diff --git a/AGENTS.md b/AGENTS.md index a24762ab25..72cfb9cd01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,7 +163,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan ### Code conventions - **New Hypervel-owned code must be Laravel-style** — Design new packages and public surfaces as if they were first-party Laravel packages ported to and enhanced for Hypervel. APIs, naming, class responsibilities, code patterns, and directory structure must be ergonomic, intuitive, and immediately familiar to Laravel developers, while internals remain coroutine-safe and optimized for Hypervel's long-lived Swoole runtime and high-performance requirements. -- **Modern PHP 8.4+ with full typing** — use constructor property promotion, readonly properties, enums, match expressions, named arguments, and attributes where they fit. Every file declares `strict_types=1`; parameters, return types, and properties are natively typed wherever PHP and the inherited API permit (e.g. `resource` cannot be represented as a native PHP type). +- **Modern PHP 8.4+ with full typing** — use constructor property promotion, readonly properties, enums, match expressions, named arguments, and attributes where they fit. Every file declares `strict_types=1`; parameters, return types, and properties are natively typed wherever PHP and the inherited API permit (e.g. `resource` cannot be represented as a native PHP type). PHP does not allow return types on `__construct()` or `__destruct()`. - **Newly written classes use dependency injection** — inject contracts (e.g. `Repository $config`, `CacheRepository $cache`) via constructor or method injection rather than helpers, facades, or `new` for framework services. Dependencies become explicit in signatures and tests swap them in directly, without facade-mocking machinery. Fall back to `Container::getInstance()->make(...)` only where injection isn't possible — static contexts and traits, like the testing package's Concerns. Helpers (`config()`, `cache()`) are fine in non-class contexts such as route and config files. - **Never convert ported code to dependency injection** — ported code keeps its upstream facade, helper, and instantiation style. Converting it restructures classes and breaks 1:1 upstream mergeability. - **Import classes, don't use FQCNs** — always add a `use` statement and reference the short name. The only exceptions are places where FQCNs genuinely make more sense, such as middleware arrays and similar config-style identifier lists. diff --git a/composer.json b/composer.json index 1e48f837f9..142acd3576 100644 --- a/composer.json +++ b/composer.json @@ -289,7 +289,7 @@ "require-dev": { "ably/ably-php": "^1.0", "algolia/algoliasearch-client-php": "^4.0", - "brianium/paratest": "^7.19", + "brianium/paratest": "^7.24", "composer/semver": "^3.4", "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index d0005a0f6b..ce1a50693d 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** None. `pagination` is complete; detail plan `2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md`. -- **Ledger entries required for the active work:** None. The completed Pagination work is recorded under `Complete Pagination correctness, current parity, and query contracts`, with its cross-package findings also recorded at their owning package entries. -- **Pending revalidation carried into the active work:** None. The completed View audit owns `ComponentAttributeBag::data()`; Translation's `__()` conditional type remains routed to its active owner audit. +- **Active package or work unit:** None. `translation` is complete; detail plan `2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md`. +- **Ledger entries required for the active work:** None. The completed Translation work is recorded under `Complete Translation correctness, current parity, and worker lifecycles`, with its cross-package findings recorded at their owning package entries. +- **Pending revalidation carried into the active work:** None. The completed View audit owns `ComponentAttributeBag::data()`; the completed Translation audit owns `__()`'s conditional type. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1053,7 +1053,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events`, `bus`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | | `macroable-03` | `macroable` | `cookie`, `log`, and `notifications` (revalidation complete); later full `jwt` audit | `Complete Macroable callable and test-state handling`; finding `macroable-03` | | `auth-01` | `support`, `auth` | `auth` (revalidation complete) | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | @@ -1188,7 +1188,12 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `view-24` | `foundation` | `foundation` and `view` (revalidation complete) | `Complete View correctness, lifecycle, and current parity`; finding `view-24` | | `view-37` | `view` | `view` (revalidation complete), `foundation`, `testbench`, and `testing` (targeted corrections complete); later full `testbench` and `testing` audits | `Complete View correctness, lifecycle, and current parity`; finding `view-37` | | `view-38` | `view` | `view` (revalidation complete), `boost` (targeted correction complete); later full `boost` audit | `Complete View correctness, lifecycle, and current parity`; finding `view-38` | -| `translation-10` | `translation` | `view` (sibling revalidation complete); later full `translation` audit | `Complete View correctness, lifecycle, and current parity`; finding `translation-10` | +| `translation-10` | `translation` | `translation` and `view` (sibling revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `translation-10` | +| `translation-02` | `contracts`, `translation` | `auth`, `foundation`, `support`, `translation`, and `validation` (revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `translation-02` | +| `translation-09` | `foundation`, `translation` | `foundation` and `translation` (revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `translation-09` | +| `routing-26` | `routing` | `foundation` and `routing` (targeted correction complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `routing-26` | +| `session-25` | `session` | `foundation` and `session` (targeted correction complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `session-25` | +| `view-42` | `view` | `foundation` and `view` (revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `view-42` | ## Package checklist @@ -1277,7 +1282,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `validation` - [x] `routing` - [x] `view` -- [ ] `translation` +- [x] `translation` - [x] `pagination` - [ ] `socialite` - [ ] `sanctum` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 078dfd28fd..81ba4bc9cb 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1833,7 +1833,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Carried findings:** `pagination-01` and `pagination-02` are revalidated across every supported cursor item shape: missing/null order values still fail, and pivot values retain their truthful mixed domain. - **Architecture and worker ownership:** Paginator instances remain operation-local. Cursor construction owns the flat-parameter invariant across decoded, direct, generated, and userland cursors; malformed decoded arrays retain the existing null result. Three paginator request resolvers and the cursor resolver read only the current `RequestContext`; the lazy view resolver alone captures the worker container and honors rebinding. Those five resolvers and two view names remain explicit worker-static boot/test configuration reset by the existing subscriber. No paginator service, request context slot, lock, registry, or retained request object was added. - **Important rejected concerns:** Do not add cursor signing/encryption, strict base64 mode, recursive payload validation, a generic query-bindability validator, a JSON helper or wrapper exception, a generic reset framework, eager view resolution, broader View/Factory contracts, an `onEachSide` interface/property promise, concrete `UrlWindow` dependency, global PHPStan ignore, uniform per-page normalization, or Scout paginator generics unsupported by engine contracts. Arrays are the only cursor parameter shape that the query builder silently changes; enums and dates are valid bindings, while unsupported objects already fail loudly. A local `UrlWindow` intersection is required because changing the public contract breaks Laravel compatibility and interface mocks, while narrowing the stored property rejects valid assignments at higher analysis levels. -- **Upstream inventory and owner routes:** #59699 contributes the malformed-cursor source/tests; #60968 contributes zero-per-page and unconditional cursor-item indexing; all 18 files from #60586 were checked against current source. Collections random types and Validation defaults were already current; Pagination, Support Lottery, and Routing land here. View's `ComponentAttributeBag::data()` type is complete in the merged View audit, while Translation's `__()` conditional type remains routed to its active owner. +- **Upstream inventory and owner routes:** #59699 contributes the malformed-cursor source/tests; #60968 contributes zero-per-page and unconditional cursor-item indexing; all 18 files from #60586 were checked against current source. Collections random types and Validation defaults were already current; Pagination, Support Lottery, and Routing land here. View's `ComponentAttributeBag::data()` type is complete in the merged View audit, and Translation's `__()` conditional type is complete in the Translation audit. - **Cross-package revalidation:** Completed Collections, Support, Database, Routing, and Scout are revalidated by `collections-15`, `support-32`, `support-33`, `database-24`, `database-25`, `routing-25`, and `scout-41`. Sanctum and API Client receive only `sanctum-02` and `api-client-01`; their later full package audits remain open. No consumer-local workaround or duplicate normalization was added. - **Regression and static coverage:** Counterfactual tests cover raw and encoded malformed cursors, every invalid envelope shape, flat/nested/empty decoded arrays, direct array construction, generation from an array-valued ordered item, mixed cursor and pivot values, backed enums, float-ordered SQLite paging, integer query keys, explicit zero across direct/Query/Eloquent paths, Scout engine inputs, one-read request ownership, concurrent isolation, lazy view rebinding, JSON failures and escape flags, keyed cursor pages, zero per-page, every static slot, provider discovery, relation cursor objects, concrete/generic paginator results, and conditional/iterator types. `src` analysis pins the local `UrlWindow` structural read; focused type fixtures cover Pagination, Database builders/relations, Routing, and Lottery. - **Performance and compatibility:** Four request resolvers remove a duplicate context check and container/request resolution. Added recurring work is one allocation-free, non-recursive scan of the typically one-to-three values at Cursor construction, one required `Collection::values()` normalization per cursor page measured below a microsecond at representative sizes, one integer `max()` per Scout pagination call, and one negligible lazy-resolver return verification. JSON and URL serialization remain one pass; no query, network round trip, lock, yield, retry, polling loop, unbounded cache, or retained worker memory is added. Laravel APIs are preserved or widened except that array-valued cursor parameters now fail before the query builder can silently change them, invalid explicit pages now fail closed before ambient resolver or engine behavior, and Query Builder's cursor per-page parameter is narrowed from nullable to its already-required `int`; explicit null already threw one frame deeper, so valid calls and runtime work are unchanged while the failure moves to the call site. @@ -1874,3 +1874,31 @@ Append package entries in checklist order. Keep each entry compact but complete - **Laravel-facing result:** Supported View and Blade method names, named arguments, provider extension points, protected compiler hooks, component syntax, facades, configuration, and render behavior are compatible or restored to current Laravel. `newComponentHash()` remains facade-exposed and supports Laravel's immediate seed-then-compile sequence; a seed no longer survives an unrelated intervening compile pass. Comma-bearing supported directives now compile by top-level arguments, and multi-key inline `@json` arrays retain HTML-safe default flags. Alias-first `Blade::component()` and render-before-store View section content remain the two documented public Hypervel differences. The removed `getParentPlaceholder()` was a superseded Hypervel divergence, not a current Laravel API. - **Validation and review:** Changed test files and the combined View, Integration/View, Foundation View, Mail View, facade, and metadata coverage are green. Facades and split metadata have been regenerated and checked. The prior authoritative `composer fix` gate passed formatting, both PHPStan configurations, the complete parallel suite, Testbench package mode, and dogfood. The compile-state follow-up passes the complete View group, both PHPStan configurations, targeted formatting, and `git diff --check`; independent follow-up review verified the final tree and signed off with no remaining finding. - **Assessment:** Every accepted View finding is implemented at its owning boundary without a local workaround, speculative abstraction, stale compatibility path, meaningful hot-path regression, or unintended Laravel API removal. No View TODO, deferred defect, or open workflow step remains. + +### Complete Translation correctness, current parity, and worker lifecycles + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh caller/callee and hot-path self-review, and independent code review are signed off. The work corrected every verified prior-audit finding and the same-family issues found while tracing them across Translation, Contracts, Foundation, Validation, Auth, Routing, Session, View, Support facade metadata, package metadata, tests, and localization documentation. The detailed design is recorded in [`2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md`](2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md). + +| Findings | Final decision | +|---|---| +| `translation-02` | Make the Translator contract truthful and complete with `array|string` retrieval, typed string/array accessors, fallback accessors, migrated string-only framework consumers, regenerated Lang metadata, and conditional `__()` metadata. | +| `routing-26`, `session-25`, `view-42` | Restore conditional helper metadata for Redirector/response, SessionManager/value/null, and View Factory/View results; remove the wrong Session Store-contract annotation. | +| `translation-03`, `translation-04` | Use one paired-delimiter numeric selector grammar, preserve malformed conditions literally, reject accidental wildcard-only matches, and cast only modulo operands for PHP 8.4-safe pluralization. | +| `translation-05` | Reject separator and exact dot-segment locales at both eager Translator assignment and the FileLoader trust boundary while preserving dot-bearing locale names. | +| `translation-06`, `translation-07`, `translation-14` | Treat only JSON `null` as missing, preserve falsey JSON values and explicitly keyed empty PHP arrays, reject invalid roots and non-null scalar top-level values with path/key diagnostics, and recursively replace only string leaves in mixed arrays. | +| `translation-08` | Document coroutine-local current locale and worker-shared boot configuration on every affected public mutator and in canonical user guidance. | +| `translation-09` | Construct and dispatch `LocaleUpdated` only when targeted listeners exist, preserving mutation and event ordering through canonical typed services. | +| `translation-10` | Narrow `stringable()` to its real closure-or-class-string surface and fail immediately when a class registration omits its handler. | +| `translation-11` | Complete loader namespace shapes and fallback typing. | +| `translation-12` | Resolve provider dependencies once through canonical typed services, remove duplicate defaults and assignment, and delete the false direct Container dependency. | +| `translation-13` | Restore repository test bases, local fixture ownership, normal coroutine execution, deterministic concurrent isolation, current typing, and Hypervel-owned fixture prose. | + +- **Architecture and worker ownership:** Translator and FileLoader remain worker-lifetime singletons. Loaded immutable definitions, fallback locale, selector, callbacks, namespaces, and paths remain worker-shared boot configuration; current locale remains coroutine-local. Concurrent first loads may perform the same immutable read and write the same cache entry, so no lock, request-scoped Translator, invalidation registry, watcher, or extra context slot is added. +- **Cross-package revalidation:** Contracts owns the complete public Translator surface. Translation owns retrieval, selectors, loader boundaries, worker-state warnings, and `__()` metadata. Foundation owns typed locale service resolution, optional locale events, request-local/config separation, FormRequest's string-only message, and helper conditional metadata attributed to Routing, Session, and View. Validation and Auth use typed string retrieval only where their own APIs require strings and retain `get()` where arrays remain valid. Support's generated Lang facade reflects the final contract. The View sibling of `translation-10` remains complete, and `support-02` namespace `"0"` behavior remains intact. +- **Correctness and parity:** Valid Laravel translation APIs, named arguments, protected extension points, and plural behavior remain compatible. Hypervel deliberately rejects non-null scalar top-level JSON values instead of returning values outside `array|string`; `null` remains the untranslated sentinel. Explicitly keyed empty PHP arrays remain values rather than falling through to another locale, so string-only consumers reject them consistently with other arrays instead of treating them as missing. Mixed array leaves are preserved instead of being coerced, invalid plural labels and mismatched delimiters remain literal, invalid locale paths fail before filesystem access, and callable shapes that could never register correctly now fail at the native boundary. Effective locale setters do not mutate process-global configuration. +- **Important rejected concerns:** Do not add a selector object, locale-rule service, request-scoped Translator, loaded-definition lock, cache invalidator, file watcher, JSON schema layer, scalar coercion, compatibility callable wrapper, generic event helper, per-consumer wrong-type matrix, or duplicate replacement path. Retain deployment/worker reload as the language-file invalidation boundary. +- **Regression coverage:** Focused tests prove typed retrieval and consumer behavior; valid, malformed, wildcard, decimal, signed, and modulo plural branches; every locale entry path and dot-bearing locale; JSON root/value diagnostics, falsey values, null sentinel fallback, and mixed-array replacement; optional-event ordering; provider ownership; facade drift; fallback accessors; strict callable registration; integration fixture ownership; and concurrent locale/missing-key isolation. A targeted static probe proves every supported branch of `__()`, `redirect()`, `session()`, and `view()`. +- **Performance and complexity:** Cached translation hits retain their existing filesystem and cache behavior. Added locale checks run only on assignment or load, JSON validation walks each decoded file once, mixed-array recursion runs only when replacements are non-empty, and typed accessors add one result-type check. Listener detection avoids event allocation and dispatch when unused, while provider changes reduce startup resolutions. No request path gains a lock, retry, polling, extra I/O, serialization layer, network round trip, unbounded retained state, or meaningful hot-path regression. +- **Validation and review:** Every changed test and affected Translation, Integration Translation, Foundation, Auth, and Validation suite is green, including random-order integration coverage. The static contract probe, facade generation/drift test, dogfood dependency resolution, formatting, both PHPStan configurations, `git diff --check`, and the authoritative `composer fix` gate passed. Review independently reproduced the JSON sentinel and scalar rejection through a real filesystem, verified every conditional-helper inference, and signed off with no remaining finding. +- **Owner handoff observations:** `AGENTS.md` still points to `docs/ai/differences-vs-laravel.md` as current although that guide is marked for deletion. `tests/Support/SupportUriTest.php` still contains old versioned Hypervel documentation URLs as inert parser fixtures. Neither affects Translation behavior, and neither was changed in this work unit. +- **Assessment:** Translation is contract-complete, coroutine-safe, worker-lifecycle-aware, failure-truthful, and current at the audited Laravel surface. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale compatibility path, meaningful performance regression, or unresolved defect. diff --git a/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md b/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md new file mode 100644 index 0000000000..0b3b63fdc3 --- /dev/null +++ b/docs/plans/2026-08-06-0925-translation-correctness-current-parity-and-worker-lifecycles.md @@ -0,0 +1,740 @@ +# Translation correctness, current parity, and worker lifecycles + +## Objective + +Bring Translation to current Laravel behavior where that behavior is correct, fix the verified +selector, locale-path, JSON, type, and event defects, and document the state model required by +Hypervel's long-lived workers. The result keeps one worker-lifetime Translator and loader, shared +loaded definitions, coroutine-local current locale, and boot-only fallback and callback +configuration. + +This is a corrections plan, not a fresh package-wide audit. It incorporates every verified item +from the prior Translation audit and every same-family issue found while validating those items. +Supported Laravel APIs, named arguments, protected extension points, and conventional package +behavior remain compatible unless an approved correction below explicitly changes them. + +## Evidence baseline + +- Hypervel baseline: `30ba7559a4` on `audit/translation-correctness-lifecycle-parity`. +- Laravel reference: local `examples/laravel/framework`, branch `13.x`, at `1a7816b370`. +- Baseline focused verification is green for `tests/Translation`, + `tests/Integration/Translation`, and `tests/Foundation/FoundationApplicationTest.php`. +- `Hypervel\Translation\Translator` is the only implementation of + `Hypervel\Contracts\Translation\Translator` in `src/`. +- The public `trans()` helper returns that contract when called without a key, so concrete-only + methods do not provide a statically usable helper API. +- The adjacent `__()`, `redirect()`, `session()`, and `view()` helpers have runtime branches matching + current Laravel's conditional return metadata. Hypervel omits three annotations and gives + `session()` an information-free annotation naming the Store contract instead of SessionManager. +- Direct and facade caller traces found twelve string-only framework uses of `get()`: eight in + Translation, Validation, and Foundation, plus four Auth notification subject/action values. + Five notification line values and six Validation/Translator lookups deliberately remain + array-capable. +- `translator`, `translation.loader`, `events`, `config`, and `files` already have canonical + contract or concrete aliases in `Foundation\Application`; the planned `make()` calls resolve + the existing singletons rather than creating parallel instances. +- Broad searches found no repository or Laravel use of `[*,*]`. Its present catch-all behavior is + accidental PHP string comparison, not a documented plural condition. +- Broad searches likewise found no repository or Laravel condition with mismatched `{...]` or + `[...]}` delimiters, although both currently select as live conditions. +- The seven Translation mutators listed below change state retained on the shared Translator or + loader. `setLocale()` is different: Hypervel already stores its effective value in + `CoroutineContext`. + +| Reference | Current surface checked | Decision | +|---|---|---| +| Laravel #60443 | typed Translator accessors and tests | Port `string()` / `array()` and make Hypervel's public contract statically complete. | +| Laravel #58367 / #58648 | plural-condition grammar and tests | Port the intended numeric grammar, then close the verified residual invalid-label defect. | +| Laravel #59174 / #59268 | PHP 8.4 float modulo handling | Port only the final operand-level casts; do not cast the whole plural count. | +| Laravel locale hardening commits `c248521f5` / `e78d24f3` | Translator locale validation | Preserve dot-bearing locale names while rejecting exact dot segments at the filesystem boundary. | +| Laravel #59913 / #59688 | loader namespace shapes and empty-array comparison | Port the metadata and strict comparison, then distinguish an empty loaded group from an explicitly keyed empty value. | +| Hypervel runtime traces | JSON values, protected replacements, Application events, worker state | Fix at the existing owning methods without a new service, cache, registry, parser, or lock. | + +## Anti-overengineering rules + +The following wording is retained verbatim from the core audit plan. Its principle numbering is +also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this +plan” refers to that plan's **Established remediation vocabulary** section. + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Architecture and retained boundaries + +- Translator and FileLoader remain worker-lifetime singletons. Loaded translation definitions, + selector, fallback, callbacks, namespaces, and loader paths remain shared for the worker + generation. +- Current locale remains coroutine-local through `CoroutineContext`. No request-scoped Translator, + config mutation, or locale service is introduced. +- Configuration supplies construction defaults. `app.locale` and `app.fallback_locale` are not + live mirrors of effective Translator state. +- Two coroutines may perform the same first immutable group read before either caches it. Their + identical writes are benign; no lock or per-request loaded cache is warranted. +- Language files are deployment-time inputs. Worker reload remains their invalidation boundary; + no watcher, LRU, realpath cache, or invalidation registry is added. +- `Loader::addPath()` remains a Hypervel contract extension implemented by every loader. +- `translation-01` namespace `"0"` behavior and `support-02` replacement-value enum handling are + preserved. Translation identifiers remain string APIs. +- `docs/ai/differences-vs-laravel.md` remains untouched because its first line forbids new entries + while it awaits deletion. Its stale references in `AGENTS.md` remain unchanged because their + correct replacement depends on the owner's pending keep-or-delete decision. + +### Reported observations outside this work unit + +Preserve these in the owner handoff without changing the cited references or fixture here: + +- `AGENTS.md` still describes `docs/ai/differences-vs-laravel.md` as current and instructs keeping + it consistent, contradicting the guide's first-line deletion banner added by `83f7ad8a7`. +- `tests/Support/SupportUriTest.php` contains `https://hypervel.org/docs/11.x/...` as arbitrary URI + parser inputs from past mechanical de-branding. They have no functional effect and do not belong + in a Translation work unit. + +## Findings and final decisions + +| ID | Category | Severity | Final decision | +|---|---|---:|---| +| `translation-02` | Laravel API / contract parity | Improvement | Add typed `string()` / `array()` retrieval, make the Translator contract's existing `get()` signature truthful and complete, route string-only framework consumers through the typed boundary, and restore conditional `__()` metadata. | +| `routing-26` | Helper type parity | Minor | Describe `redirect()`'s Redirector-or-response result conditionally. | +| `session-25` | Helper type defect | Minor | Replace the information-free, wrong-contract `session()` metadata with its Manager/value/null conditional. | +| `view-42` | Helper type parity | Minor | Describe `view()`'s supported Factory-or-View result conditionally. | +| `translation-03` | Plural-condition defect | Major | Use one exact numeric grammar with paired delimiters for extraction and stripping; preserve invalid bracketed content and reject accidental `[*,*]`. | +| `translation-04` | PHP 8.4 pluralization defect | Major | Cast only modulo operands, retain float exact/range behavior, and replace the vacuous deprecation test. | +| `translation-05` | Filesystem boundary defect | Major | Reject separators and exact `.` / `..` locales before filesystem access while allowing dot-bearing locale names. | +| `translation-06` | Falsey value defect | Minor | Treat only `null` as a missing JSON translation so `''`, `'0'`, and `[]` remain real values. | +| `translation-07` | JSON diagnostics defect | Minor | Reject non-array JSON roots and non-null scalar top-level translation values at the file boundary with path-bearing diagnostics. | +| `translation-08` | Worker-lifecycle documentation | Major | Mark shared mutators Boot-only and document effective locale and fallback ownership. | +| `translation-09` | Optional-event overhead | Improvement | Guard `LocaleUpdated` construction and dispatch with `hasListeners()` and use typed canonical resolution. | +| `translation-10` | Public callable type defect | Minor | Narrow `stringable()` to its real closure-or-class-string surface and reject an inert class registration immediately. | +| `translation-11` | Type / upstream metadata parity | Minor | Complete loader map shapes and the fallback type. | +| `translation-12` | Provider / package ownership cleanup | Minor | Use typed `make()` resolution, one config resolution, no duplicate defaults or promoted assignment, and remove the unused direct Container dependency. | +| `translation-13` | Test ownership and isolation | Minor | Replace process-global fixture communication, restore inherited coroutine execution, correct bases/types, and remove stale fixture code. | +| `translation-14` | Array translation defect | Major | Make the replacement boundary array-capable so JSON and PHP-file arrays preserve mixed leaves, explicitly keyed empty arrays remain values, and `get(): array|string` is honored consistently. | + +## Approval surface before implementation + +The implementation proceeds only after the owner has reviewed these intentional public or +behavioral corrections: + +1. Add `string()`, `array()`, `getFallback()`, and `setFallback()` to the public Translator + contract; correct `get()` to return `array|string` and accept `$fallback`; migrate direct and + facade consumers whose own API requires a string; regenerate Lang. An array at those sites will + throw the accessor's key-specific `InvalidArgumentException` instead of a later, call-site- + dependent `TypeError`. +2. Widen protected `Translator::makeReplacements()` from `string` to `array|string`, including its + conditional return type. Preserve non-string values inside arrays instead of following + Laravel's inconsistent PHP-file path, which coerces values and emits a PHP deprecation for + null. This is more permissive and matches `array`, but it is still a protected + extension-point and observable behavior change. +3. Narrow `Translator::stringable()` from `callable|string` to `Closure|string` and throw when a + class string is registered without a handler. Invokable objects and callable arrays were + accepted by the native type but failed inside the method; function strings registered an inert + null entry. +4. Reject unsupported plural labels `[*]`, `[1,]`, punctuation labels, and `[*,*]` as literal text, + and require paired condition delimiters. `[*,*]`, `{1]`, and `[1}` currently select despite + having no repository or Laravel consumer; `[*,*]` does so only because PHP compares every + tested number as greater than the string `'*'`. +5. Correct the locale traversal and scalar-JSON defects shared by current Laravel rather than + preserving them for parity. +6. Record the intentional Laravel locale/config difference: effective current locale is + request-local, fallback is a boot-only shared Translator setting, and neither setter mutates + process-global config. + +## Implementation + +### 1. Correct selector grammar, float handling, and return types + +Change `src/translation/src/MessageSelector.php` first because Translator's conditional +replacement return depends on `choose(): string`. + +Compose one compile-time pattern for the four supported condition forms: numeric, +numeric-to-numeric, numeric-to-wildcard, and wildcard-to-numeric. Signed integers and decimals +include `.5` and `1.`: + +```php +private const string NUMERIC = '-?(?:\d+(?:\.\d*)?|\.\d+)'; + +private const string CONDITION = '(?:' . self::NUMERIC + . '|(?:' . self::NUMERIC . '|\*),' . self::NUMERIC + . '|' . self::NUMERIC . ',\*)'; + +private const string CONDITION_PATTERN = '/^(?|\{(' . self::CONDITION . ')\}|\[(' . self::CONDITION . ')\])(.*)/s'; +``` + +- Use the composed constant in `extractFromString()` and `stripConditions()`. The branch-reset + group keeps the condition in capture 1 and value in capture 2 for either paired delimiter; + stripping returns capture 2 only when the full leading condition matches. +- Normalize admitted numeric operands explicitly and use strict comparisons. No `is_numeric()` + guard remains because the pattern makes it unreachable. +- Reject `[*,*]`; allowing both wildcards would require a special branch and would convert today's + accidental all-number match into a non-negative match after numeric casting. Preserve paired + cross-style forms such as `[1]` and `{1,2}`; delimiter style remains a documentation convention, + not a new compatibility restriction. +- Narrow `choose(): string`, `extract(): ?string`, and `extractFromString(): ?string`. +- Apply Laravel's final operand-level `(int)` casts only around `%`; keep the original float for + exact and relational comparisons. Convert loose equality to evidence-based strict comparison: + original numeric values compare as floats, modulo results as integers. +- Re-evaluate the Arabic `@phpstan-ignore smallerOrEqual.alwaysTrue` after the strict conversion and + remove it if it no longer suppresses a current finding. +- Do not add a parser object, secondary regex, precision branch above 2^53, locale rule service, or + whole-input cast. + +Update `tests/Translation/TranslationMessageSelectorTest.php` in the same slice: + +- extend `Hypervel\Tests\TestCase`, type every revised method and provider, and keep the current + matrix; +- port Laravel's Markdown and signed-range cases; +- prove valid decimals, signed endpoints, and one-sided wildcard ranges; +- prove `[?]`, `[-]`, `[.]`, `[*]`, `[,]`, `[1,]`, `[*,*]`, `{1]`, and `[1}` remain literal text, + asserting the complete malformed segment survives condition stripping; +- replace the vacuous `[2,*]` float-deprecation test with a plain Polish plural string that reaches + a modulo branch while deprecations are promoted, and retain an English `1.5` plural regression. + +Run this test file immediately. + +### 2. Complete the Translator contract and centralize array replacements + +Update `src/contracts/src/Translation/Translator.php` and +`src/translation/src/Translator.php` together. + +The contract gains the universal surface actually used by the framework and helper: + +```php +public function get( + string $key, + array $replace = [], + ?string $locale = null, + bool $fallback = true +): array|string; + +public function string( + string $key, + array $replace = [], + ?string $locale = null, + bool $fallback = true +): string; + +public function array( + string $key, + array $replace = [], + ?string $locale = null, + bool $fallback = true +): array; + +public function getFallback(): string; + +public function setFallback(string $fallback): void; +``` + +`setFallback()` carries the same Boot-only shared-state warning on the contract and implementation. +Port Laravel's current typed accessor behavior and exception messages immediately after `get()` in +upstream order. + +Complete the conditional metadata in `src/foundation/src/helpers.php` for `__()`, `redirect()`, +`session()`, and `view()`. Use the real Manager on `session()`'s null branch and null for its array +branch. Add imports for `Redirector` and `SessionManager`, remove the stale Session Store-contract +import, use existing View and RedirectResponse imports, and normalize the redirect native union to +the same short names. Also use the already-imported `CookieJar` in its conditional annotation. Do +not change runtime branches or normalize correct FQCN annotations on unrelated helpers. + +Use the typed accessor everywhere the framework itself promises a string: + +- call `string()` from `Translator::choice()` and + `PotentiallyTranslatedString::translate()`; +- call `string()` for the four leaf-message lookups in + `src/validation/src/Concerns/FormatsMessages.php` and for the zero-error summary in + `src/validation/src/ValidationException.php`; +- call `string()` for FormRequest's unknown-field message and remove its now-redundant + `@var string` assertion; +- change only the subject and action text in Auth's `ResetPassword` and `VerifyEmail` + notifications to `Lang::string()`. + +Keep `get()` where arrays are supported: `Translator::has()`, Validation's bulk custom-message and +attribute lookups, the `AnyOf`, `Can`, and `Enum` rule messages, and all five Auth notification +lines. `SimpleMessage::line()` deliberately formats array values, while its `subject()` and +`action()` parameters are strings. If an exact custom validation-message key resolves to an array, +fail through `string()` rather than skipping it and hiding the invalid configuration. Do not +mechanically convert helpers or other `array|string` consumers. + +Make one existing protected method own both string and array replacement: + +```php +/** + * Make the place-holder replacements on a line. + * + * @return ($line is array ? array : string) + */ +protected function makeReplacements(array|string $line, array $replace): array|string +{ + if (empty($replace)) { + return $line; + } + + if (is_array($line)) { + foreach ($line as $key => $value) { + if (is_array($value) || is_string($value)) { + $line[$key] = $this->makeReplacements($value, $replace); + } + } + + return $line; + } + + // Existing string replacement path. +} +``` + +This preserves integer and string keys, order, non-string scalar types, object identity, and the +caller's original array. Only strings can contain placeholders, so no scalar coercion or leaf +validation pass is added. + +- Keep `empty($replace)` first so arrays without replacements return untouched and incur no walk. +- Replace the JSON terminal truthiness fallback with `makeReplacements($line ?? $key, $replace)`. +- Collapse `getLine()`'s string and non-empty-array branches into one call to + `makeReplacements()` and remove its separate `array_walk_recursive()` implementation. Keep an + empty loaded group as no line, while an explicitly keyed empty PHP or JSON array remains a real + `[]` translation and does not fall through to the fallback locale. +- Keep string-only consumers fail-fast for every array-valued key through `string()`, including + empty arrays. +- Keep a top-level non-null, non-string JSON value invalid: nested mixed values are covered by the + promised array result, while `get(): array|string` cannot represent a top-level scalar. Preserve + `null` as the missing-translation sentinel. Do not widen the return, coerce the value, or + special-case `has()`. +- Narrow `$fallback` from `?string` to `string`. +- Change `stringable()` to `Closure|string`, document the string as `class-string`, and throw + `InvalidArgumentException` at registration when a class string has no handler. Use + `@param class-string|Closure $class`. Do not check class existence or normalize other callable + shapes. +- Document `$stringableHandlers` as `array` after the null registration path + is removed. +- Add Boot-only warnings to `handleMissingKeysUsing()`, `determineLocalesUsing()`, `stringable()`, + `addLines()`, `addNamespace()`, `addPath()`, and `addJsonPath()`, each naming the shared worker + state it changes. Use the same shared-instance wording for `setSelector()` and `setLoaded()`, and + tell `setFallback()` callers to use `setLocale()` for request-local overrides. Do not warn + coroutine-local `setLocale()` or directly instantiated loader mutators. + +Update `tests/Translation/TranslationTranslatorTest.php`: + +- port the four typed accessor success/failure tests; +- cover `''`, `'0'`, empty JSON and PHP arrays, fallback suppression, and `has()` agreement; +- prove `choice()` on an array-valued key fails through the typed string accessor; +- exercise the same nested mixed-leaf array through JSON and normal groups, with and without + replacements, proving string replacement plus preservation of integer, float, boolean, null, + object identity, keys, order, and unchanged caller input; +- cover valid closure registration, class-string plus handler, immediate missing-handler failure, + and native rejection of callable arrays and invokable objects; +- type every test method and `getLoader(): Loader`; +- prove the real Translator's fallback defaults to `''` and round-trips through its public getter + and setter; +- remove the inert third Translator constructor argument; +- de-brand the enum and URL fixture prose without inventing Hypervel version claims: + `The release shipped in :month 2025`, `Stay tuned for version :version`, + `:person gets excited about every new release`, and `https://hypervel.org/docs`. + +Run this test file immediately after the contract and Translator slice. + +Update only the affected strict test doubles: + +- allow `string()` on the shared translator mock in + `tests/Foundation/FoundationFormRequestTest.php` while retaining `get()` for array-capable + Validator metadata; +- add `string()` stubs beside the `get()` stubs in the two failing current-password cases in + `tests/Validation/ValidationValidatorTest.php`. The two passing cases render no message and + remain unchanged. Add `: void` to the revised test method. + +Do not add matching expectations to the three `get()->never()` cases or bare Validation Factory +mocks. Unstubbed calls already fail on those strict mocks. Existing real-Translator tests in +Validation own valid `FormatsMessages`, `ValidationException`, and +`PotentiallyTranslatedString` behavior; do not duplicate the accessor's wrong-type matrix at every +consumer. + +`ResetPasswordNotificationTest` already builds the mail message and exercises both migrated Auth +calls. Add one direct mail-message case to `VerifyEmailNotificationTest` that asserts its subject +and action text, covering the corresponding valid path without another wrong-type test. + +### 3. Protect FileLoader's path and JSON boundaries + +Update `src/translation/src/FileLoader.php`: + +- at the beginning of public `load()`, before any filesystem call, reject a locale containing `/` + or `\`, or exactly equal to `.` or `..`, with the existing + `InvalidArgumentException('Invalid characters present in locale.')` message; +- use the identical literal predicate in `Translator::setLocale()` for eager feedback while + continuing to allow `en.UTF-8`; +- replace the JSON decode guard with only `! is_array($decoded)`, which subsumes decoded null and + syntax failures and retains the existing path-bearing RuntimeException; +- reject each decoded top-level value that is neither null, a string, nor an array with a path- and + key-bearing RuntimeException; nested arrays may retain mixed leaves, while null remains the + missing-translation sentinel; +- remove the redundant assignment to the promoted `$files` property; +- document `$hints` and `namespaces()` as `array`. +- Link the identical locale predicates in `FileLoader::load()` and `Translator::setLocale()` with + reciprocal comments rather than extracting a validator for a two-condition check. + +Update `src/contracts/src/Translation/Loader.php` and `src/translation/src/ArrayLoader.php` with the +same namespace-map return shape. Do not add locale or JSON schema abstractions. + +Update `tests/Translation/TranslationFileLoaderTest.php`: + +- extend `Hypervel\Tests\TestCase`, type revised methods and scalar JSON providers, and use the + imported `RuntimeException`; +- assert separator and exact-dot locales throw before `exists()`, `get()`, or `getRequire()`; +- assert `en.UTF-8` reaches the expected JSON and PHP paths; +- drive valid scalar JSON values (`1`, `true`, `false`, string, and `null`) plus malformed JSON + through the existing diagnostic, while arrays including numeric keys remain accepted. +- prove a non-null scalar top-level translation value reports its file and key before reaching + Translator; +- prove a null value loads successfully, then crosses the real FileLoader-to-Translator boundary + as a missing key for both `get()` and `has()`. + +Add Translator-level coverage using FileLoader plus a filesystem mock for explicit, fallback, and +custom-resolver invalid locales, and cover both constructor/configured and direct setter validation. +This proves every supported entry path reaches the trust-boundary check without duplicating +validation in `get()` or `choice()`. + +Run FileLoader and Translator tests after the slice. + +### 4. Correct Application locale resolution and optional events + +Update the locale block in `src/foundation/src/Application.php` to resolve the canonical contracts: + +```php +$translator = $this->make(TranslatorContract::class); + +$previous = $translator->getLocale(); +$translator->setLocale($locale); + +$events = $this->make(DispatcherContract::class); + +if ($events->hasListeners(LocaleUpdated::class)) { + $events->dispatch(new LocaleUpdated($locale, $previous)); +} +``` + +- Resolve each dependency once per method. +- Apply `make(TranslatorContract::class)` to `getLocale()`, `getFallbackLocale()`, `setLocale()`, and + `setFallbackLocale()`; use `make(DispatcherContract::class)` only in `setLocale()`. +- Keep locale assignment before event-dispatcher resolution, listener detection, and event + dispatch. This preserves the existing mutation ordering and synchronous event when a listener + exists. +- Add a concise source WHY at the Laravel config-mutation omission: process-global config supplies + boot defaults, while effective current locale is request-local and fallback belongs to the + shared Translator. +- Do not skip same-locale events, defer dispatch, cache listener state in Application, or introduce + another locale service. + +Update the locale tests in `tests/Foundation/FoundationApplicationTest.php`: + +- mock `Contracts\Translation\Translator` and `Contracts\Events\Dispatcher`, not `stdClass`; +- expect `hasListeners(LocaleUpdated::class)` before dispatch in the listener case; +- add the no-listener case and prove no dispatch occurs; +- prove both setters change Translator state without changing `app.locale` or + `app.fallback_locale`, with concise matching `REMOVED:` comments for Laravel's omitted config + writes; +- use global expectation ordering to pin previous-locale lookup and mutation before listener + detection and dispatch; +- type every revised test method. + +Run the focused Foundation locale tests, then the whole FoundationApplicationTest file. + +### 5. Clean provider ownership, metadata, and generated facade + +Update `src/translation/src/TranslationServiceProvider.php`: + +- resolve `ConfigRepository`, Translation `Loader`, and `Filesystem` through `make()`; +- resolve config once, use `string('app.locale')` and `string('app.fallback_locale')` without + duplicate `'en'` defaults, and keep config as the construction seed to avoid recursive + Translator resolution; +- retain the existing canonical singleton keys and path precedence. + +Remove only the unused direct `hypervel/container` requirement from +`src/translation/composer.json`. Support continues to own the provider parent's Container +dependency. + +Require ParaTest `^7.24` in the root and dogfood manifests and restore PHPUnit `^13.0.3` in the +root, Testbench, and dogfood manifests. ParaTest 7.24 is the first released version that supports +and requires PHPUnit 13.3, so raising the runner floor structurally excludes the incompatible +ParaTest 7.23 / PHPUnit 13.3 pair without a PHPUnit ceiling. Keep the wider PHPUnit constraint +truthful for standalone Testbench installs, where ParaTest does not own the minimum version. Use +`composer require --dev` for the root manifest and local ignored lock; edit the two lockless +manifests directly. Do not patch vendor code, require an unreleased branch, add runtime +compatibility checks, or commit an ignored lockfile. + +Because PHPUnit 13.3 is a new minor release and Hypervel integrates with PHPUnit internals, verify +the resolved versions and rerun the full checkpoint with the new runner. Run dogfood from a fresh +unlocked install to prove its standalone dependency graph selects the same compatible pair. + +After source signatures and annotations are final, run +`composer facade "Hypervel\\Support\\Facades\\Lang"`. Inspect the generated +`src/support/src/Facades/Lang.php` and retain only the expected Translation API changes, including +typed accessors and `string|\Closure` `stringable()` metadata. Investigate any other generated diff +instead of accepting it mechanically. Run +`./vendor/bin/phpunit --no-progress tests/FacadeDocumenter/FacadeDocblocksTest.php`; its repository- +wide discovery and non-empty assertion own facade drift detection. + +### 6. Restore normal test ownership and coroutine coverage + +Update `tests/Integration/Translation/TranslatorTest.php`: + +- replace `$_SERVER` callback communication with local variables captured by reference; +- remove the now-unneeded setup/teardown cleanup and redundant `handleMissingKeysUsing(null)` calls; +- type revised methods and provider shapes; +- replace `Taylor | Laravel` with `Taylor | Hypervel`. + +Create `tests/Translation/CoroutineIsolationTest.php` and move only the two concurrency tests plus +`YieldingTranslationLoader` from TranslationTranslatorTest: + +- inherit the normal test coroutine; do not opt out or call `run()`; +- call `parallel()` directly for both tests; +- run two locale tasks concurrently, yield after each mutation, and assert both isolated child + locales plus the unchanged parent locale. Never assert inside a bare `Coroutine::create()` child, + because it reports and discards throwables instead of propagating them to PHPUnit; +- retain the yielding loader delays that force the missing-key interleaving; +- prove child locale mutation does not change the parent locale and missing-key suppression does + not hide a concurrent callback. + +Do not add a package TestCase, per-test cleanup registry, `RunTestsInCoroutine` trait, or source +cleanup for container-owned test state. + +Clarify the full-typing convention in `AGENTS.md`: PHP does not allow return types on +`__construct()` or `__destruct()`. + +Run the new isolation file immediately, then the complete Translation and Integration Translation +focused suites, including one random-order integration run. + +### 7. Make localization documentation canonical and READMEs thin + +Update `src/boost/docs/localization.md` in Laravel-docs prose: + +- add a short typed-values subsection under Retrieving Translation Strings using + `trans()->string()` and `trans()->array()`, explaining that the methods throw when a key has the + wrong value type; +- explain in Configuring the Locale that `app.locale` and `app.fallback_locale` provide each + worker's initial values, `App::setLocale()` changes the locale for the current request, and + `App::currentLocale()` reads the effective value; +- add an important warning that fallback locale is shared by a worker and + `App::setFallbackLocale()` is intended for application boot; +- keep examples task-oriented and avoid implementation terms where they do not help users. + +Rewrite `src/translation/README.md` as the required thin package surface: header, existing badge, +`Documentation: https://hypervel.org/docs/localization`, a concise `Differences From Laravel` +section describing `Translator::setLocale()` as coroutine-local, `Translator::setFallback()` as +boot-only worker-shared state, and JSON top-level values as strings, arrays, or the null +missing-translation sentinel, then `Ported from: https://github.com/laravel/framework`. + +Add the matching concise locale/config difference to `src/foundation/README.md`, move its existing +upstream line after `Differences From Laravel` to satisfy README ordering, and do not duplicate the +localization guide's usage detail. + +Do not add anything to `docs/ai/differences-vs-laravel.md`. + +### 8. Finish audit bookkeeping only after verification and review + +Update `docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md` after code review: + +- mark Translation complete and link this detail plan; +- add concise ledger rows for `translation-02` through `translation-14`, merging rows only where + they have the same owner and final implementation; +- record on `translation-14` that mixed values are valid inside the promised array result while a + top-level non-null, non-string JSON value remains invalid under `get(): array|string`; +- record Auth, Foundation, Validation, and Contracts as revalidated consumers where appropriate; +- carry the two report-only observations above into the owner handoff without editing their files; +- preserve concurrently landed audit entries and expected ledger ordering. + +Do not copy the pre-consensus `.tmp` findings file into tracked documentation. This plan is the +final design record. + +## File ownership + +| Owner | Files | +|---|---| +| Translation source | `src/translation/src/{Translator,MessageSelector,FileLoader,ArrayLoader,TranslationServiceProvider,PotentiallyTranslatedString}.php` | +| Contracts | `src/contracts/src/Translation/{Translator,Loader}.php` | +| Foundation | `src/foundation/src/Application.php`, `src/foundation/src/Http/FormRequest.php`, `src/foundation/src/helpers.php` | +| Validation consumers | `src/validation/src/Concerns/FormatsMessages.php`, `src/validation/src/ValidationException.php` | +| Auth facade consumers | `src/auth/src/Notifications/{ResetPassword,VerifyEmail}.php` | +| Generated facade | `src/support/src/Facades/Lang.php` | +| Standards | `AGENTS.md` | +| Package metadata | `composer.json`, `src/testbench/composer.json`, `src/translation/composer.json`, `dogfood/testbench-package/composer.json` | +| Tests | `tests/Translation/{TranslationTranslatorTest,TranslationMessageSelectorTest,TranslationFileLoaderTest,CoroutineIsolationTest}.php`, `tests/Integration/Translation/TranslatorTest.php`, `tests/Auth/VerifyEmailNotificationTest.php`, `tests/Foundation/{FoundationApplication,FoundationFormRequest}Test.php`, `tests/Validation/ValidationValidatorTest.php` | +| User documentation | `src/boost/docs/localization.md` | +| Thin package records | `src/translation/README.md`, `src/foundation/README.md` | +| Audit record | this plan and the core audit plan | + +## Verification + +### Targeted cadence + +Run each new or changed test file immediately after its owning source slice: + +```shell +./vendor/bin/phpunit --no-progress tests/Translation/TranslationMessageSelectorTest.php +./vendor/bin/phpunit --no-progress tests/Translation/TranslationTranslatorTest.php +./vendor/bin/phpunit --no-progress tests/Translation/TranslationFileLoaderTest.php +./vendor/bin/phpunit --no-progress tests/Translation/CoroutineIsolationTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Translation/TranslatorTest.php +./vendor/bin/phpunit --no-progress tests/FacadeDocumenter/FacadeDocblocksTest.php +./vendor/bin/phpunit --no-progress tests/Auth/VerifyEmailNotificationTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/FoundationApplicationTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/FoundationFormRequestTest.php +./vendor/bin/phpunit --no-progress tests/Validation/ValidationValidatorTest.php +``` + +Then run the focused package and random-order checks: + +```shell +./vendor/bin/phpunit --no-progress tests/Translation tests/Integration/Translation +./vendor/bin/phpunit --no-progress --order-by=random tests/Integration/Translation/TranslatorTest.php +./vendor/bin/phpunit --no-progress tests/Auth +./vendor/bin/phpunit --no-progress tests/Foundation +./vendor/bin/phpunit --no-progress tests/Validation +``` + +### Static public-contract proof + +Create a temporary ignored `.tmp` PHP file that calls both methods through the helper contract: + +```php +$message = trans()->string('messages.welcome'); +$options = trans()->array('messages.options'); +$value = trans()->get('messages.welcome', [], null, false); +$fallback = trans()->getFallback(); +trans()->setFallback('en'); + +function dumpTranslationHelperTypes(?string $maybeNullKey): void +{ + \PHPStan\dumpType(__('messages.welcome')); + \PHPStan\dumpType(__(null)); + \PHPStan\dumpType(__($maybeNullKey)); + \PHPStan\dumpType(redirect()); + \PHPStan\dumpType(redirect('/home')); + \PHPStan\dumpType(session()); + \PHPStan\dumpType(session('key')); + \PHPStan\dumpType(session(['key' => 'value'])); + \PHPStan\dumpType(view()); + \PHPStan\dumpType(view('welcome')); +} +``` + +Run targeted PHPStan on that file and confirm every dumped type, then delete it. Also run a targeted +php-cs-fixer dry-run on `src/foundation/src/helpers.php`. Do not add a committed test fixture or +change PHPStan paths; `tests/` is intentionally excluded. + +### Checkpoint gate + +After the coherent implementation and documentation slices are complete, run `composer fix` once. +It owns the full formatter, PHPStan, parallel suite, and Testbench checks. If it fails, correct with +targeted checks and resume from the failed script entry as described in `AGENTS.md`. + +Before review, also: + +- regenerate the explicitly named Lang facade after its source signatures and annotations are + final, inspect its diff, and run the repository-wide facade drift test; +- search all `src/` for Translator contract implementers and extensions again; +- search all `src/` for direct and facade `get()` consumers again and confirm every remaining use + is intentionally array-capable; +- search Translation source/tests for stale Laravel-branded fixture strings; +- verify the two locale predicates are textually identical; +- verify every affected worker-lifetime mutator has exactly one useful Boot-only warning; +- verify no `docs/ai/differences-vs-laravel.md` edit exists; +- inspect every changed file and `git diff --check`. + +## Performance accounting + +- Existing cached translation hits keep the same filesystem and cache behavior. No lock, context + slot, cache layer, or per-request Translator is added. +- Selector work retains the same regex operations per inspected segment while replacing two + drifting patterns with one compile-time constant. Numeric casts replace implicit coercions in + existing branches. +- FileLoader validation adds constant-time string checks only when a group is loaded; cached + Translator hits still return before FileLoader. `setLocale()` adds only two strict dot-segment + comparisons to its existing separator check. JSON value validation walks each decoded top-level + entry once while the file is loaded, never on cached translation hits. +- Array recursion runs only for array-valued translations with non-empty replacements. Arrays with + no replacements now return earlier than the existing recursive walk. +- Each migrated string-only lookup adds one `is_string()` check after the same underlying `get()`. + It adds no filesystem access, container resolution, cache entry, or success-path allocation. +- The no-listener locale path adds one cached `hasListeners()` lookup and removes event allocation + and dispatch. The listener path retains its event and ordering. +- Provider construction resolves config once instead of twice. All provider changes are worker + startup work. +- Remaining type declarations, tests, dependency constraints, metadata, and documentation add no runtime + overhead. ParaTest and PHPUnit changes affect development and test execution only. + +## Completion criteria + +- All `translation-02` through `translation-14` decisions are implemented at their owning boundary. +- Typed helper access works through the contract and generated facade, not only the concrete class. +- `__()`, `redirect()`, `session()`, and `view()` expose their truthful conditional result types to + static analysis without changing runtime behavior. +- Framework-owned string-only consumers use the typed accessor; array-capable Validation, rule, + and notification-line consumers retain `get()` deliberately. +- Valid plural conditions retain current behavior; invalid bracketed content remains intact; + float modulo branches emit no PHP 8.4 deprecation and English `1.5` stays plural. +- No invalid locale reaches a filesystem method through direct loader, explicit locale, fallback, + resolver, configured locale, or setter paths; `en.UTF-8` remains valid. +- JSON falsey string values and arrays round-trip consistently, explicitly keyed empty PHP arrays + do not fall through to another locale, null remains the missing-translation sentinel, invalid + roots and non-null scalar top-level values get named diagnostics, and array replacement has one + implementation. +- Effective current locale is request-local; fallback and documented mutators are clearly boot-only; + config remains unchanged by effective locale setters. +- Optional locale events allocate and dispatch only for active listeners. +- Translation tests use repository base cases, normal coroutine execution, local fixture state, + current typing, and Hypervel-branded data. +- User guidance lives in localization.md; both READMEs remain thin and correctly ordered. +- The full-typing rule explicitly excludes return types on constructors and destructors. +- Root and dogfood resolve released ParaTest 7.24 with PHPUnit 13.3 or later; standalone Testbench + retains its truthful PHPUnit 13 support floor without a vendor patch or committed dogfood + lockfile. +- The owner handoff retains the stale AGENTS references and Support URI fixture observations as + report-only items. +- No stale code, duplicate replacement path, compatibility shim, dead guard, rejected mechanism, + misleading comment, or superseded documentation remains. +- Targeted tests, the static contract probe, facade drift detection, `composer fix`, self-review, and + peer code review are green before the audit ledger is closed. diff --git a/dogfood/testbench-package/composer.json b/dogfood/testbench-package/composer.json index b46404fa96..a87c0c24d4 100644 --- a/dogfood/testbench-package/composer.json +++ b/dogfood/testbench-package/composer.json @@ -30,7 +30,7 @@ "hypervel/support": "0.4.x-dev" }, "require-dev": { - "brianium/paratest": "^7.19", + "brianium/paratest": "^7.24", "fakerphp/faker": "^1.24", "hypervel/components": "0.4.x-dev", "hypervel/testbench": "0.4.x-dev", diff --git a/src/auth/src/Notifications/ResetPassword.php b/src/auth/src/Notifications/ResetPassword.php index e418cf5065..62a780e9fe 100644 --- a/src/auth/src/Notifications/ResetPassword.php +++ b/src/auth/src/Notifications/ResetPassword.php @@ -73,9 +73,9 @@ public function toMail(mixed $notifiable): MailMessage protected function buildMailMessage(string $url): MailMessage { return (new MailMessage) - ->subject(Lang::get('Reset your password')) + ->subject(Lang::string('Reset your password')) ->line(Lang::get('You are receiving this email because we received a password reset request for your account.')) - ->action(Lang::get('Reset Password'), $url) + ->action(Lang::string('Reset Password'), $url) ->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => $this->expireMinutes])) ->line(Lang::get('If you did not request a password reset, no further action is required.')); } diff --git a/src/auth/src/Notifications/VerifyEmail.php b/src/auth/src/Notifications/VerifyEmail.php index 0c205f8d7f..907d8ecf9e 100644 --- a/src/auth/src/Notifications/VerifyEmail.php +++ b/src/auth/src/Notifications/VerifyEmail.php @@ -56,9 +56,9 @@ public function toMail(mixed $notifiable): MailMessage protected function buildMailMessage(string $url): MailMessage { return (new MailMessage) - ->subject(Lang::get('Verify your email address')) + ->subject(Lang::string('Verify your email address')) ->line(Lang::get('Please click the button below to verify your email address.')) - ->action(Lang::get('Verify Email Address'), $url) + ->action(Lang::string('Verify Email Address'), $url) ->line(Lang::get('If you did not create an account, no further action is required.')); } diff --git a/src/boost/docs/localization.md b/src/boost/docs/localization.md index 6aa7a899ba..f61c3270d5 100644 --- a/src/boost/docs/localization.md +++ b/src/boost/docs/localization.md @@ -8,6 +8,7 @@ - [Using Short Keys](#using-short-keys) - [Using Translation Strings as Keys](#using-translation-strings-as-keys) - [Retrieving Translation Strings](#retrieving-translation-strings) + - [Typed Translation Values](#typed-translation-values) - [Replacing Parameters in Translation Strings](#replacing-parameters-in-translation-strings) - [Pluralization](#pluralization) - [Overriding Package Language Files](#overriding-package-language-files) @@ -52,9 +53,9 @@ php artisan lang:publish ### Configuring the Locale -The default language for your application is stored in the `config/app.php` configuration file's `locale` configuration option, which is typically set using the `APP_LOCALE` environment variable. You are free to modify this value to suit the needs of your application. +The default language for your application is stored in the `config/app.php` configuration file's `locale` configuration option, which is typically set using the `APP_LOCALE` environment variable. This value provides the initial locale when each application worker starts. You are free to modify this value to suit the needs of your application. -You may also configure a "fallback language", which will be used when the default language does not contain a given translation string. Like the default language, the fallback language is also configured in the `config/app.php` configuration file, and its value is typically set using the `APP_FALLBACK_LOCALE` environment variable. +You may also configure a "fallback language", which will be used when the current language does not contain a given translation string. Like the default language, the fallback language is also configured in the `config/app.php` configuration file, and its value is typically set using the `APP_FALLBACK_LOCALE` environment variable. This value provides the initial fallback locale for each worker. You may modify the default language for a single HTTP request at runtime using the `setLocale` method provided by the `App` facade: @@ -72,10 +73,15 @@ Route::get('/greeting/{locale}', function (string $locale) { }); ``` +The locale set by this method applies only to the current request. + +> [!IMPORTANT] +> The fallback locale is shared by all requests handled by a worker. Therefore, the `App::setFallbackLocale` method should only be called during application boot, such as from the `boot` method of a service provider. + #### Determining the Current Locale -You may use the `currentLocale` and `isLocale` methods on the `App` facade to determine the current locale or check if the locale is a given value: +You may use the `currentLocale` and `isLocale` methods on the `App` facade to retrieve the effective locale for the current request or check if the locale is a given value: ```php use Hypervel\Support\Facades\App; @@ -192,6 +198,19 @@ If you are using the [Blade templating engine](/docs/{{version}}/blade), you may {{ __('messages.welcome') }} ``` + +### Typed Translation Values + +Translation values may contain strings or arrays. When you know which type a translation should contain, you may use the `string` or `array` method on the translator returned by the `trans` helper: + +```php +$message = trans()->string('messages.welcome'); + +$options = trans()->array('messages.options'); +``` + +If the translation value is not the expected type, an `InvalidArgumentException` will be thrown. + ### Replacing Parameters in Translation Strings diff --git a/src/contracts/src/Translation/Loader.php b/src/contracts/src/Translation/Loader.php index dd2374bda5..13e5040785 100644 --- a/src/contracts/src/Translation/Loader.php +++ b/src/contracts/src/Translation/Loader.php @@ -28,6 +28,8 @@ public function addPath(string $path): void; /** * Get an array of all the registered namespaces. + * + * @return array */ public function namespaces(): array; } diff --git a/src/contracts/src/Translation/Translator.php b/src/contracts/src/Translation/Translator.php index b0accd4591..022ec7a60e 100644 --- a/src/contracts/src/Translation/Translator.php +++ b/src/contracts/src/Translation/Translator.php @@ -11,7 +11,19 @@ interface Translator /** * Get the translation for a given key. */ - public function get(string $key, array $replace = [], ?string $locale = null): mixed; + public function get(string $key, array $replace = [], ?string $locale = null, bool $fallback = true): array|string; + + /** + * Get the specified string translation value. + */ + public function string(string $key, array $replace = [], ?string $locale = null, bool $fallback = true): string; + + /** + * Get the specified array translation value. + * + * @return array + */ + public function array(string $key, array $replace = [], ?string $locale = null, bool $fallback = true): array; /** * Get a translation according to an integer value. @@ -19,12 +31,26 @@ public function get(string $key, array $replace = [], ?string $locale = null): m public function choice(string $key, array|Countable|float|int $number, array $replace = [], ?string $locale = null): string; /** - * Get the default locale being used. + * Get the current locale being used. */ public function getLocale(): string; /** - * Set the default locale. + * Set the current locale. */ public function setLocale(string $locale): void; + + /** + * Get the fallback locale being used. + */ + public function getFallback(): string; + + /** + * Set the fallback locale being used. + * + * Boot-only. The fallback is shared by the worker's Translator instance and + * affects every subsequent translation lookup in that worker. Use + * setLocale() for a current-request locale override. + */ + public function setFallback(string $fallback): void; } diff --git a/src/foundation/README.md b/src/foundation/README.md index d3bbf90365..7177f1f9e1 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -3,8 +3,6 @@ Foundation for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/foundation) -Ported from: https://github.com/laravel/framework - ## Differences From Laravel Hypervel's HTTP kernel contract includes the complete middleware stack, group, @@ -12,6 +10,8 @@ alias, and priority management surface because framework and package providers configure middleware through that contract. Custom HTTP kernels must implement the same surface, and its mutators are intended for application boot. +The application locale setters do not change the `app.locale` or `app.fallback_locale` configuration values. `App::setLocale()` applies only to the current request, while `App::setFallbackLocale()` is intended for application boot and changes the fallback shared by the worker. + Laravel's deprecated `Middleware::validateCsrfTokens()` alias is intentionally not ported. Configure request-forgery protection with `preventRequestForgery()`. The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. @@ -19,3 +19,5 @@ The default `dev` server process runs `php artisan watch` so the Watcher package Laravel's default Pail process is omitted because Hypervel has no Pail-equivalent command. Application logging remains controlled by the application's logging configuration. Laravel's optional Whoops exception renderer is omitted. Hypervel's built-in renderer provides framework-aware query details and Blade source mapping while applications may still bind a custom `ExceptionRenderer` implementation. + +Ported from: https://github.com/laravel/framework diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 4a4950e3f1..6ae3548852 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -10,10 +10,12 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract; use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Foundation\CachesConfiguration; use Hypervel\Contracts\Foundation\CachesRoutes; use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Events\EventServiceProvider; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Events\LocaleUpdated; @@ -1248,7 +1250,7 @@ public function providerIsLoaded(string $provider): bool */ public function getLocale(): string { - return $this['translator']->getLocale(); + return $this->make(TranslatorContract::class)->getLocale(); } /** @@ -1272,7 +1274,7 @@ public function currentLocale(): string */ public function getFallbackLocale(): string { - return $this['translator']->getFallback(); + return $this->make(TranslatorContract::class)->getFallback(); } /** @@ -1280,11 +1282,18 @@ public function getFallbackLocale(): string */ public function setLocale(string $locale): void { - $previous = $this['translator']->getLocale(); + // Config supplies the boot defaults; the Translator owns the request-local + // current locale and worker-shared fallback thereafter. + $translator = $this->make(TranslatorContract::class); - $this['translator']->setLocale($locale); + $previous = $translator->getLocale(); + $translator->setLocale($locale); - $this['events']->dispatch(new LocaleUpdated($locale, $previous)); + $events = $this->make(DispatcherContract::class); + + if ($events->hasListeners(LocaleUpdated::class)) { + $events->dispatch(new LocaleUpdated($locale, $previous)); + } } /** @@ -1295,7 +1304,7 @@ public function setLocale(string $locale): void */ public function setFallbackLocale(string $fallbackLocale): void { - $this['translator']->setFallback($fallbackLocale); + $this->make(TranslatorContract::class)->setFallback($fallbackLocale); } /** diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 77bf167696..64382e93ef 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -270,8 +270,7 @@ protected function validateNoUnknownFields(Validator $validator): void foreach (array_keys(Arr::dot($input)) as $inputKey) { if (! isset($knownFields[$inputKey])) { - /** @var string $message */ - $message = $validator->getTranslator()->get('validation.prohibited', [ + $message = $validator->getTranslator()->string('validation.prohibited', [ 'attribute' => str_replace('_', ' ', $inputKey), ]); diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index f2f4d68a94..cce56cffbb 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -14,7 +14,6 @@ use Hypervel\Contracts\Cookie\Factory as CookieFactory; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Routing\UrlGenerator as UrlGeneratorContract; -use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Contracts\Support\Responsable; @@ -32,7 +31,9 @@ use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Log\LogManager; use Hypervel\Queue\CallQueuedClosure; +use Hypervel\Routing\Redirector; use Hypervel\Routing\Router; +use Hypervel\Session\SessionManager; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\Date; @@ -375,7 +376,7 @@ function context($key = null, $default = null) /** * Create a new cookie instance. * - * @return ($name is null ? \Hypervel\Cookie\CookieJar : Cookie) + * @return ($name is null ? CookieJar : Cookie) */ function cookie(?string $name = null, ?string $value = null, int $minutes = 0, ?string $path = null, ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = null): CookieJar|Cookie { @@ -666,8 +667,10 @@ function public_path(string $path = ''): string if (! function_exists('redirect')) { /** * Get an instance of the redirector or create a redirect response. + * + * @return ($to is null ? Redirector : RedirectResponse) */ - function redirect(?string $to = null, int $status = 302, array $headers = [], ?bool $secure = null): \Hypervel\Routing\Redirector|\Hypervel\Http\RedirectResponse + function redirect(?string $to = null, int $status = 302, array $headers = [], ?bool $secure = null): Redirector|RedirectResponse { if (is_null($to)) { return app('redirect'); @@ -853,7 +856,7 @@ function secure_url(string $path, mixed $parameters = []): string * * If an array is passed as the key, we will assume you want to set an array of values. * - * @return mixed|SessionContract + * @return ($key is null ? SessionManager : ($key is string ? mixed : null)) */ function session(array|string|null $key = null, mixed $default = null): mixed { @@ -951,6 +954,8 @@ function trans_choice(string $key, Countable|array|float|int $number, array $rep if (! function_exists('__')) { /** * Translate the given message. + * + * @return ($key is null ? null : array|string) */ function __(?string $key = null, array $replace = [], ?string $locale = null): array|string|null { @@ -1013,6 +1018,8 @@ function validator(?array $data = null, array $rules = [], array $messages = [], if (! function_exists('view')) { /** * Get the evaluated view contents for the given view. + * + * @return ($view is null ? ViewFactory : ViewContract) */ function view(?string $view = null, array|Arrayable $data = [], array $mergeData = []): ViewFactory|ViewContract { diff --git a/src/support/src/Facades/Lang.php b/src/support/src/Facades/Lang.php index 93b85ce8a1..6d65baeeae 100644 --- a/src/support/src/Facades/Lang.php +++ b/src/support/src/Facades/Lang.php @@ -8,6 +8,8 @@ * @method static bool hasForLocale(string $key, string|null $locale = null) * @method static bool has(string $key, string|null $locale = null, bool $fallback = true) * @method static array|string get(string $key, array $replace = [], string|null $locale = null, bool $fallback = true) + * @method static string string(string $key, array $replace = [], string|null $locale = null, bool $fallback = true) + * @method static array array(string $key, array $replace = [], string|null $locale = null, bool $fallback = true) * @method static string choice(string $key, \Countable|array|int|float $number, array $replace = [], string|null $locale = null) * @method static void addLines(array $lines, string $locale, string $namespace = '*') * @method static void load(string $namespace, string $group, string $locale) @@ -26,7 +28,7 @@ * @method static string getFallback() * @method static void setFallback(string $fallback) * @method static void setLoaded(array $loaded) - * @method static void stringable(callable|string $class, callable|null $handler = null) + * @method static void stringable(string|\Closure $class, callable|null $handler = null) * @method static void flushState() * @method static void setParsedKey(string $key, array $parsed) * @method static void flushParsedKeys() diff --git a/src/translation/README.md b/src/translation/README.md index 575542ea44..72ad27808e 100644 --- a/src/translation/README.md +++ b/src/translation/README.md @@ -1,4 +1,14 @@ Translation for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/translation) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/translation) + +Documentation: https://hypervel.org/docs/localization + +## Differences From Laravel + +- `Translator::setLocale()` changes the locale only for the current coroutine and does not affect other concurrent requests in the worker. +- `Translator::setFallback()` changes the fallback shared by the worker and is intended for application boot. +- `Translator` rejects JSON translation files whose top-level values are not strings or arrays, naming the file and key. A `null` value is allowed and means the key is untranslated. + +Ported from: https://github.com/laravel/framework diff --git a/src/translation/composer.json b/src/translation/composer.json index 4e5a190b06..c0f5d422dd 100644 --- a/src/translation/composer.json +++ b/src/translation/composer.json @@ -31,7 +31,6 @@ "require": { "php": "^8.4", "hypervel/collections": "^0.4", - "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/filesystem": "^0.4", @@ -52,4 +51,4 @@ "dev-main": "0.4-dev" } } -} \ No newline at end of file +} diff --git a/src/translation/src/ArrayLoader.php b/src/translation/src/ArrayLoader.php index a79c36b6af..1478597a02 100644 --- a/src/translation/src/ArrayLoader.php +++ b/src/translation/src/ArrayLoader.php @@ -58,6 +58,8 @@ public function addMessages(string $locale, string $group, array $messages, ?str /** * Get an array of all the registered namespaces. + * + * @return array */ public function namespaces(): array { diff --git a/src/translation/src/FileLoader.php b/src/translation/src/FileLoader.php index d642fa780c..42319151e5 100644 --- a/src/translation/src/FileLoader.php +++ b/src/translation/src/FileLoader.php @@ -7,6 +7,8 @@ use Hypervel\Contracts\Translation\Loader; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; +use Hypervel\Support\Str; +use InvalidArgumentException; use RuntimeException; class FileLoader implements Loader @@ -23,20 +25,18 @@ class FileLoader implements Loader /** * All of the namespace hints. + * + * @var array */ protected array $hints = []; /** * Create a new file loader instance. - * - * @param Filesystem $files the filesystem instance */ public function __construct( protected Filesystem $files, array|string $path ) { - $this->files = $files; - $this->paths = is_string($path) ? [$path] : $path; } @@ -45,6 +45,11 @@ public function __construct( */ public function load(string $locale, string $group, ?string $namespace = null): array { + // Mirrors the eager check in Translator::setLocale(); keep both predicates identical. + if (Str::contains($locale, ['/', '\\']) || $locale === '.' || $locale === '..') { + throw new InvalidArgumentException('Invalid characters present in locale.'); + } + if ($group === '*' && $namespace === '*') { return $this->loadJsonPaths($locale); } @@ -114,10 +119,18 @@ protected function loadJsonPaths(string $locale): array if ($this->files->exists($full = "{$path}/{$locale}.json")) { $decoded = json_decode($this->files->get($full), true); - if (is_null($decoded) || json_last_error() !== JSON_ERROR_NONE) { + if (! is_array($decoded)) { throw new RuntimeException("Translation file [{$full}] contains an invalid JSON structure."); } + foreach ($decoded as $key => $value) { + if ($value !== null && ! is_string($value) && ! is_array($value)) { + throw new RuntimeException( + "Translation file [{$full}] contains an invalid value for key [{$key}]. Translation values must be strings or arrays." + ); + } + } + $output = array_merge($output, $decoded); } @@ -135,6 +148,8 @@ public function addNamespace(string $namespace, string $hint): void /** * Get an array of all the registered namespaces. + * + * @return array */ public function namespaces(): array { diff --git a/src/translation/src/MessageSelector.php b/src/translation/src/MessageSelector.php index 68e1bdb08d..64aaffaf55 100644 --- a/src/translation/src/MessageSelector.php +++ b/src/translation/src/MessageSelector.php @@ -8,10 +8,18 @@ class MessageSelector { + private const string NUMERIC = '-?(?:\d+(?:\.\d*)?|\.\d+)'; + + private const string CONDITION = '(?:' . self::NUMERIC + . '|(?:' . self::NUMERIC . '|\*),' . self::NUMERIC + . '|' . self::NUMERIC . ',\*)'; + + private const string CONDITION_PATTERN = '/^(?|\{(' . self::CONDITION . ')\}|\[(' . self::CONDITION . ')\])(.*)/s'; + /** * Select a proper translation string based on the given number. */ - public function choose(string $line, float|int $number, string $locale): mixed + public function choose(string $line, float|int $number, string $locale): string { $segments = explode('|', $line); @@ -33,7 +41,7 @@ public function choose(string $line, float|int $number, string $locale): mixed /** * Extract a translation string using inline conditions. */ - private function extract(array $segments, float|int $number): mixed + private function extract(array $segments, float|int $number): ?string { foreach ($segments as $part) { if (! is_null($line = $this->extractFromString($part, $number))) { @@ -47,9 +55,9 @@ private function extract(array $segments, float|int $number): mixed /** * Get the translation string if the condition matches. */ - private function extractFromString(string $part, float|int $number): mixed + private function extractFromString(string $part, float|int $number): ?string { - preg_match('/^[\{\[]([^\[\]\{\}]*)[\}\]](.*)/s', $part, $matches); + preg_match(self::CONDITION_PATTERN, $part, $matches); if (count($matches) !== 3) { return null; @@ -62,18 +70,18 @@ private function extractFromString(string $part, float|int $number): mixed if (str_contains($condition, ',')) { [$from, $to] = explode(',', $condition, 2); - if ($to === '*' && $number >= $from) { - return $value; + if ($to === '*') { + return $number >= (float) $from ? $value : null; } - if ($from === '*' && $number <= $to) { - return $value; - } - if ($number >= $from && $number <= $to) { - return $value; + + if ($from === '*') { + return $number <= (float) $to ? $value : null; } + + return $number >= (float) $from && $number <= (float) $to ? $value : null; } - return $condition == $number ? $value : null; + return (float) $condition === (float) $number ? $value : null; } /** @@ -82,7 +90,7 @@ private function extractFromString(string $part, float|int $number): mixed private function stripConditions(array $segments): array { return (new Collection($segments)) - ->map(fn ($part) => preg_replace('/^[\{\[]([^\[\]\{\}]*)[\}\]]/', '', $part)) + ->map(fn ($part) => preg_replace(self::CONDITION_PATTERN, '$2', $part)) ->all(); } @@ -293,7 +301,7 @@ public function getPluralIndex(string $locale, float|int $number): int case 'ur_PK': case 'zu': case 'zu_ZA': - return ($number == 1) ? 0 : 1; + return ((float) $number === 1.0) ? 0 : 1; case 'am': case 'am_ET': case 'bh': @@ -322,7 +330,7 @@ public function getPluralIndex(string $locale, float|int $number): int case 'wa': case 'wa_BE': case 'xbr': - return (($number == 0) || ($number == 1)) ? 0 : 1; + return (((float) $number === 0.0) || ((float) $number === 1.0)) ? 0 : 1; case 'be': case 'be_BY': case 'bs': @@ -337,39 +345,39 @@ public function getPluralIndex(string $locale, float|int $number): int case 'sr_RS': case 'uk': case 'uk_UA': - return (($number % 10 == 1) && ($number % 100 != 11)) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2); + return (((int) $number % 10 === 1) && ((int) $number % 100 !== 11)) ? 0 : ((((int) $number % 10 >= 2) && ((int) $number % 10 <= 4) && (((int) $number % 100 < 10) || ((int) $number % 100 >= 20))) ? 1 : 2); case 'cs': case 'cs_CZ': case 'sk': case 'sk_SK': - return ($number == 1) ? 0 : ((($number >= 2) && ($number <= 4)) ? 1 : 2); + return ((float) $number === 1.0) ? 0 : ((($number >= 2) && ($number <= 4)) ? 1 : 2); case 'ga': case 'ga_IE': - return ($number == 1) ? 0 : (($number == 2) ? 1 : 2); + return ((float) $number === 1.0) ? 0 : (((float) $number === 2.0) ? 1 : 2); case 'lt': case 'lt_LT': - return (($number % 10 == 1) && ($number % 100 != 11)) ? 0 : ((($number % 10 >= 2) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2); + return (((int) $number % 10 === 1) && ((int) $number % 100 !== 11)) ? 0 : ((((int) $number % 10 >= 2) && (((int) $number % 100 < 10) || ((int) $number % 100 >= 20))) ? 1 : 2); case 'sl': case 'sl_SI': - return ($number % 100 == 1) ? 0 : (($number % 100 == 2) ? 1 : ((($number % 100 == 3) || ($number % 100 == 4)) ? 2 : 3)); + return ((int) $number % 100 === 1) ? 0 : (((int) $number % 100 === 2) ? 1 : ((((int) $number % 100 === 3) || ((int) $number % 100 === 4)) ? 2 : 3)); case 'mk': case 'mk_MK': - return ($number % 10 == 1) ? 0 : 1; + return ((int) $number % 10 === 1) ? 0 : 1; case 'mt': case 'mt_MT': - return ($number == 1) ? 0 : ((($number == 0) || (($number % 100 > 1) && ($number % 100 < 11))) ? 1 : ((($number % 100 > 10) && ($number % 100 < 20)) ? 2 : 3)); + return ((float) $number === 1.0) ? 0 : ((((float) $number === 0.0) || (((int) $number % 100 > 1) && ((int) $number % 100 < 11))) ? 1 : ((((int) $number % 100 > 10) && ((int) $number % 100 < 20)) ? 2 : 3)); case 'lv': case 'lv_LV': - return ($number == 0) ? 0 : ((($number % 10 == 1) && ($number % 100 != 11)) ? 1 : 2); + return ((float) $number === 0.0) ? 0 : ((((int) $number % 10 === 1) && ((int) $number % 100 !== 11)) ? 1 : 2); case 'pl': case 'pl_PL': - return ($number == 1) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 12) || ($number % 100 > 14))) ? 1 : 2); + return ((float) $number === 1.0) ? 0 : ((((int) $number % 10 >= 2) && ((int) $number % 10 <= 4) && (((int) $number % 100 < 12) || ((int) $number % 100 > 14))) ? 1 : 2); case 'cy': case 'cy_GB': - return ($number == 1) ? 0 : (($number == 2) ? 1 : ((($number == 8) || ($number == 11)) ? 2 : 3)); + return ((float) $number === 1.0) ? 0 : (((float) $number === 2.0) ? 1 : ((((float) $number === 8.0) || ((float) $number === 11.0)) ? 2 : 3)); case 'ro': case 'ro_RO': - return ($number == 1) ? 0 : ((($number == 0) || (($number % 100 > 0) && ($number % 100 < 20))) ? 1 : 2); + return ((float) $number === 1.0) ? 0 : ((((float) $number === 0.0) || (((int) $number % 100 > 0) && ((int) $number % 100 < 20))) ? 1 : 2); case 'ar': case 'ar_AE': case 'ar_BH': @@ -390,7 +398,7 @@ public function getPluralIndex(string $locale, float|int $number): int case 'ar_SY': case 'ar_TN': case 'ar_YE': - return ($number == 0) ? 0 : (($number == 1) ? 1 : (($number == 2) ? 2 : ((($number % 100 >= 3) && ($number % 100 <= 10)) ? 3 : ((($number % 100 >= 11) && ($number % 100 <= 99)) ? 4 : 5)))); // @phpstan-ignore smallerOrEqual.alwaysTrue + return ((float) $number === 0.0) ? 0 : (((float) $number === 1.0) ? 1 : (((float) $number === 2.0) ? 2 : ((((int) $number % 100 >= 3) && ((int) $number % 100 <= 10)) ? 3 : ((((int) $number % 100 >= 11) && ((int) $number % 100 <= 99)) ? 4 : 5)))); // @phpstan-ignore smallerOrEqual.alwaysTrue default: return 0; } diff --git a/src/translation/src/PotentiallyTranslatedString.php b/src/translation/src/PotentiallyTranslatedString.php index aedffa36b0..f10ee87dc3 100644 --- a/src/translation/src/PotentiallyTranslatedString.php +++ b/src/translation/src/PotentiallyTranslatedString.php @@ -32,13 +32,13 @@ public function __construct( */ public function translate(array $replace = [], ?string $locale = null): static { - $this->translation = $this->translator->get($this->string, $replace, $locale); + $this->translation = $this->translator->string($this->string, $replace, $locale); return $this; } /** - * Translates the string based on a count. + * Translate the string based on a count. */ public function translateChoice(array|Countable|float|int $number, array $replace = [], ?string $locale = null): static { diff --git a/src/translation/src/TranslationServiceProvider.php b/src/translation/src/TranslationServiceProvider.php index 010688f328..fc7772e763 100644 --- a/src/translation/src/TranslationServiceProvider.php +++ b/src/translation/src/TranslationServiceProvider.php @@ -4,6 +4,9 @@ namespace Hypervel\Translation; +use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Translation\Loader; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\ServiceProvider; class TranslationServiceProvider extends ServiceProvider @@ -16,14 +19,15 @@ public function register(): void $this->registerLoader(); $this->app->singleton('translator', function ($app) { - $loader = $app['translation.loader']; + $config = $app->make(ConfigRepository::class); + $loader = $app->make(Loader::class); $trans = new Translator( $loader, - $app->make('config')->string('app.locale', 'en') + $config->string('app.locale') ); - $trans->setFallback($app->make('config')->string('app.fallback_locale', 'en')); + $trans->setFallback($config->string('app.fallback_locale')); return $trans; }); @@ -36,7 +40,7 @@ protected function registerLoader(): void { $this->app->singleton('translation.loader', function ($app) { return new FileLoader( - $app['files'], + $app->make(Filesystem::class), [ dirname(__DIR__) . DIRECTORY_SEPARATOR . 'lang', $app->langPath(), diff --git a/src/translation/src/Translator.php b/src/translation/src/Translator.php index 437432fbd1..05d64ea248 100644 --- a/src/translation/src/Translator.php +++ b/src/translation/src/Translator.php @@ -36,7 +36,7 @@ class Translator extends NamespacedItemResolver implements TranslatorContract /** * The fallback locale used by the translator. */ - protected ?string $fallback = ''; + protected string $fallback = ''; /** * The array of loaded translation groups. @@ -57,6 +57,8 @@ class Translator extends NamespacedItemResolver implements TranslatorContract /** * The custom rendering callbacks for stringable objects. + * + * @var array */ protected array $stringableHandlers = []; @@ -157,7 +159,45 @@ public function get(string $key, array $replace = [], ?string $locale = null, bo // If the line doesn't exist, we will return back the key which was requested as // that will be quick to spot in the UI if language keys are wrong or missing // from the application's language files. Otherwise we can return the line. - return $this->makeReplacements($line ?: $key, $replace); + return $this->makeReplacements($line ?? $key, $replace); + } + + /** + * Get the specified string translation value. + * + * @throws InvalidArgumentException + */ + public function string(string $key, array $replace = [], ?string $locale = null, bool $fallback = true): string + { + $value = $this->get($key, $replace, $locale, $fallback); + + if (! is_string($value)) { + throw new InvalidArgumentException( + sprintf('Translation value for key [%s] must be a string, %s given.', $key, gettype($value)) + ); + } + + return $value; + } + + /** + * Get the specified array translation value. + * + * @return array + * + * @throws InvalidArgumentException + */ + public function array(string $key, array $replace = [], ?string $locale = null, bool $fallback = true): array + { + $value = $this->get($key, $replace, $locale, $fallback); + + if (! is_array($value)) { + throw new InvalidArgumentException( + sprintf('Translation value for key [%s] must be an array, %s given.', $key, gettype($value)) + ); + } + + return $value; } /** @@ -165,7 +205,7 @@ public function get(string $key, array $replace = [], ?string $locale = null, bo */ public function choice(string $key, array|Countable|float|int $number, array $replace = [], ?string $locale = null): string { - $line = $this->get( + $line = $this->string( $key, [], $locale = $this->localeForChoice($key, $locale) @@ -207,29 +247,36 @@ protected function getLine(string $namespace, string $group, string $locale, ?st $line = Arr::get($this->loaded[$namespace][$group][$locale], $item); - if (is_string($line)) { + // Loaders return an empty array for missing or empty groups, so only a + // requested item may use an empty array as its translation value. + if (is_string($line) || (is_array($line) && ($line !== [] || $item !== null))) { return $this->makeReplacements($line, $replace); } - if (is_array($line) && count($line) > 0) { - array_walk_recursive($line, function (&$value, $key) use ($replace) { - $value = $this->makeReplacements($value, $replace); - }); - - return $line; - } return null; } /** * Make the place-holder replacements on a line. + * + * @return ($line is array ? array : string) */ - protected function makeReplacements(string $line, array $replace): string + protected function makeReplacements(array|string $line, array $replace): array|string { if (empty($replace)) { return $line; } + if (is_array($line)) { + foreach ($line as $key => $value) { + if (is_array($value) || is_string($value)) { + $line[$key] = $this->makeReplacements($value, $replace); + } + } + + return $line; + } + $shouldReplace = []; foreach ($replace as $key => $value) { @@ -262,6 +309,9 @@ protected function makeReplacements(string $line, array $replace): string /** * Add translation lines to the given locale. + * + * Boot-only. The lines are stored on the shared Translator instance and + * affect every subsequent lookup in the worker. */ public function addLines(array $lines, string $locale, string $namespace = '*'): void { @@ -345,6 +395,9 @@ protected function withoutMissingKeyHandling(callable $callback): mixed /** * Register a callback that is responsible for handling missing translation keys. + * + * Boot-only. The callback is stored on the shared Translator instance and + * affects every subsequent missing-key lookup in the worker. */ public function handleMissingKeysUsing(?callable $callback): static { @@ -355,6 +408,9 @@ public function handleMissingKeysUsing(?callable $callback): static /** * Add a new namespace to the loader. + * + * Boot-only. The namespace is added to the shared loader and affects every + * subsequent translation lookup in the worker. */ public function addNamespace(string $namespace, string $hint): void { @@ -363,6 +419,9 @@ public function addNamespace(string $namespace, string $hint): void /** * Add a new path to the loader. + * + * Boot-only. The path is added to the shared loader and affects every + * subsequent translation lookup in the worker. */ public function addPath(string $path): void { @@ -371,6 +430,9 @@ public function addPath(string $path): void /** * Add a new JSON path to the loader. + * + * Boot-only. The path is added to the shared loader and affects every + * subsequent translation lookup in the worker. */ public function addJsonPath(string $path): void { @@ -404,7 +466,10 @@ protected function localeArray(?string $locale): array } /** - * Specify a callback that should be invoked to determined the applicable locale array. + * Specify a callback that should be invoked to determine the applicable locale array. + * + * Boot-only. The callback is stored on the shared Translator instance and + * affects every subsequent locale resolution in the worker. */ public function determineLocalesUsing(callable $callback): void { @@ -426,8 +491,8 @@ public function getSelector(): MessageSelector /** * Set the message selector instance. * - * Boot-only. The selector is held on the singleton Translator and used for - * every choice() call across all coroutines. + * Boot-only. The selector is stored on the shared Translator instance and + * affects every subsequent choice() call in the worker. */ public function setSelector(MessageSelector $selector): void { @@ -443,7 +508,7 @@ public function getLoader(): Loader } /** - * Get the default locale being used. + * Get the current locale being used. */ public function locale(): string { @@ -451,7 +516,7 @@ public function locale(): string } /** - * Get the default locale being used. + * Get the current locale being used. */ public function getLocale(): string { @@ -459,13 +524,14 @@ public function getLocale(): string } /** - * Set the default locale. + * Set the current locale. * * @throws InvalidArgumentException */ public function setLocale(string $locale): void { - if (Str::contains($locale, ['/', '\\'])) { + // Mirrors the trust-boundary check in FileLoader::load(); keep both predicates identical. + if (Str::contains($locale, ['/', '\\']) || $locale === '.' || $locale === '..') { throw new InvalidArgumentException('Invalid characters present in locale.'); } @@ -483,9 +549,9 @@ public function getFallback(): string /** * Set the fallback locale being used. * - * Boot-only. The fallback is shared across all coroutines on the singleton - * Translator; per-request use races and affects every concurrent lookup. - * For per-request locale overrides use setLocale(), which is Context-scoped. + * Boot-only. The fallback is shared by the worker's Translator instance and + * affects every subsequent translation lookup in that worker. Use + * setLocale() for a current-request locale override. */ public function setFallback(string $fallback): void { @@ -495,9 +561,8 @@ public function setFallback(string $fallback): void /** * Set the loaded translation groups. * - * Boot-only. Overwrites the singleton Translator's loaded-translation cache - * used across all coroutines; per-request use races and breaks concurrent - * lookups. + * Boot-only. The groups are stored on the shared Translator instance and + * affect every subsequent translation lookup in the worker. */ public function setLoaded(array $loaded): void { @@ -506,8 +571,13 @@ public function setLoaded(array $loaded): void /** * Add a handler to be executed in order to format a given class to a string during translation replacements. + * + * Boot-only. The handler is stored on the shared Translator instance and + * affects every subsequent replacement in the worker. + * + * @param class-string|Closure $class */ - public function stringable(callable|string $class, ?callable $handler = null): void + public function stringable(Closure|string $class, ?callable $handler = null): void { if ($class instanceof Closure) { [$class, $handler] = [ @@ -516,6 +586,10 @@ public function stringable(callable|string $class, ?callable $handler = null): v ]; } + if ($handler === null) { + throw new InvalidArgumentException('A handler must be provided when registering a stringable class.'); + } + $this->stringableHandlers[$class] = $handler; } diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php index 2162567f28..5be34cb2ee 100644 --- a/src/validation/src/Concerns/FormatsMessages.php +++ b/src/validation/src/Concerns/FormatsMessages.php @@ -62,7 +62,7 @@ protected function getMessage(string $attribute, string $rule): string // messages out of the translator service for this validation rule. $key = "validation.{$lowerRule}"; - if ($key !== ($value = $this->translator->get($key))) { + if ($key !== ($value = $this->translator->string($key))) { return $value; } @@ -150,7 +150,7 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra protected function getCustomMessageFromTranslator(array|string $keys): string { foreach (Arr::wrap($keys) as $key) { - if (($message = $this->translator->get($key)) !== $key) { + if (($message = $this->translator->string($key)) !== $key) { return $message; } @@ -204,7 +204,7 @@ protected function getSizeMessage(string $attribute, string $rule): string $key = "validation.{$lowerRule}.{$type}"; - return $this->translator->get($key); + return $this->translator->string($key); } /** @@ -465,7 +465,7 @@ public function getDisplayableValue(string $attribute, mixed $value): string $key = "validation.values.{$attribute}.{$value}"; - if (($line = $this->translator->get($key)) !== $key) { + if (($line = $this->translator->string($key)) !== $key) { return $line; } diff --git a/src/validation/src/ValidationException.php b/src/validation/src/ValidationException.php index 136ff4f026..2af00106ce 100644 --- a/src/validation/src/ValidationException.php +++ b/src/validation/src/ValidationException.php @@ -59,7 +59,7 @@ protected static function summarize(ValidatorContract $validator): string $messages = $validator->errors()->all(); if (! count($messages)) { - return $validator->getTranslator()->get('The given data was invalid.'); + return $validator->getTranslator()->string('The given data was invalid.'); } $message = array_shift($messages); diff --git a/tests/Auth/VerifyEmailNotificationTest.php b/tests/Auth/VerifyEmailNotificationTest.php index 618f621e31..a54582a795 100644 --- a/tests/Auth/VerifyEmailNotificationTest.php +++ b/tests/Auth/VerifyEmailNotificationTest.php @@ -39,6 +39,14 @@ public function testVerificationUrlUsesFallbackWhenNestedSettingIsOmitted(): voi $this->assertSame(now()->addMinutes(60)->getTimestamp(), $this->expiresAt($url)); } + public function testMailMessageUsesTranslatedStringMetadata(): void + { + $mail = (new VerifyEmail)->toMail(new VerifyEmailNotifiableStub); + + $this->assertSame('Verify your email address', $mail->subject); + $this->assertSame('Verify Email Address', $mail->actionText); + } + /** * Read the expiration timestamp from a signed URL. */ diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index aa699faed0..1a26e6c62d 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Foundation\FoundationApplicationTest; use Hypervel\Config\Repository; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; @@ -22,7 +24,6 @@ use Mockery as m; use Psr\Log\LoggerInterface; use RuntimeException; -use stdClass; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -41,32 +42,59 @@ protected function tearDown(): void } } - public function testSetLocaleSetsLocaleAndFiresLocaleChangedEvent() + public function testSetLocaleSetsLocaleAndFiresLocaleChangedEvent(): void { - $trans = m::mock(stdClass::class); - $trans->shouldReceive('getLocale')->once()->andReturn('bar'); - $trans->shouldReceive('setLocale')->once()->with('foo'); - $events = m::mock(stdClass::class); - $events->shouldReceive('dispatch')->once()->with(m::on(function (LocaleUpdated $event) { + $translator = m::mock(TranslatorContract::class); + $translator->shouldReceive('getLocale')->once()->andReturn('bar')->globally()->ordered(); + $translator->shouldReceive('setLocale')->once()->with('foo')->globally()->ordered(); + $events = m::mock(DispatcherContract::class); + $events->shouldReceive('hasListeners')->once()->with(LocaleUpdated::class)->andReturn(true)->globally()->ordered(); + $events->shouldReceive('dispatch')->once()->with(m::on(function (LocaleUpdated $event): bool { return $event->locale === 'foo' && $event->previousLocale === 'bar'; - })); + }))->globally()->ordered(); + $config = new Repository(['app' => ['locale' => 'en']]); $app = new Application; - $app->singleton('translator', fn () => $trans); + $app->singleton('translator', fn () => $translator); + $app->singleton('events', fn () => $events); + $app->instance('config', $config); + + $app->setLocale('foo'); + + // REMOVED: Effective current locale is request-local and does not mutate worker-shared config. + $this->assertSame('en', $config->string('app.locale')); + } + + public function testSetLocaleDoesNotDispatchWhenLocaleEventHasNoListeners(): void + { + $translator = m::mock(TranslatorContract::class); + $translator->shouldReceive('getLocale')->once()->andReturn('bar'); + $translator->shouldReceive('setLocale')->once()->with('foo'); + $events = m::mock(DispatcherContract::class); + $events->shouldReceive('hasListeners')->once()->with(LocaleUpdated::class)->andReturn(false); + $events->shouldReceive('dispatch')->never(); + + $app = new Application; + $app->singleton('translator', fn () => $translator); $app->singleton('events', fn () => $events); $app->setLocale('foo'); } - public function testSetFallbackLocaleSetsTranslatorFallback() + public function testSetFallbackLocaleSetsTranslatorFallback(): void { - $trans = m::mock(stdClass::class); - $trans->shouldReceive('setFallback')->once()->with('fr'); + $translator = m::mock(TranslatorContract::class); + $translator->shouldReceive('setFallback')->once()->with('fr'); + $config = new Repository(['app' => ['fallback_locale' => 'en']]); $app = new Application; - $app->singleton('translator', fn () => $trans); + $app->singleton('translator', fn () => $translator); + $app->instance('config', $config); $app->setFallbackLocale('fr'); + + // REMOVED: The effective fallback belongs to the worker-shared Translator after boot. + $this->assertSame('en', $config->string('app.fallback_locale')); } public function testLoggerInterfaceResolvesAfterFacadesAreRegisteredBeforeConfiguredProviders() @@ -93,13 +121,24 @@ public function testLoggerInterfaceResolvesAfterFacadesAreRegisteredBeforeConfig $this->assertNotInstanceOf(Log::class, $logger); } - public function testGetFallbackLocaleReadsFromTranslator() + public function testGetLocaleReadsFromTranslator(): void + { + $translator = m::mock(TranslatorContract::class); + $translator->shouldReceive('getLocale')->once()->andReturn('en'); + + $app = new Application; + $app->singleton('translator', fn () => $translator); + + $this->assertSame('en', $app->getLocale()); + } + + public function testGetFallbackLocaleReadsFromTranslator(): void { - $trans = m::mock(stdClass::class); - $trans->shouldReceive('getFallback')->once()->andReturn('en'); + $translator = m::mock(TranslatorContract::class); + $translator->shouldReceive('getFallback')->once()->andReturn('en'); $app = new Application; - $app->singleton('translator', fn () => $trans); + $app->singleton('translator', fn () => $translator); $this->assertSame('en', $app->getFallbackLocale()); } diff --git a/tests/Foundation/FoundationFormRequestTest.php b/tests/Foundation/FoundationFormRequestTest.php index 8254a53720..4efa7c8c9c 100644 --- a/tests/Foundation/FoundationFormRequestTest.php +++ b/tests/Foundation/FoundationFormRequestTest.php @@ -658,6 +658,7 @@ protected function createValidationFactory(Container $container): ValidationFact { $translator = m::mock(Translator::class)->shouldReceive('get') ->zeroOrMoreTimes()->andReturn('error') + ->shouldReceive('string')->zeroOrMoreTimes()->andReturn('error') ->shouldReceive('choice')->zeroOrMoreTimes()->andReturn('error')->getMock(); return new ValidationFactory($translator, $container); diff --git a/tests/Integration/Translation/TranslatorTest.php b/tests/Integration/Translation/TranslatorTest.php index d487d39b78..e275bcad50 100644 --- a/tests/Integration/Translation/TranslatorTest.php +++ b/tests/Integration/Translation/TranslatorTest.php @@ -12,33 +12,13 @@ class TranslatorTest extends TestCase { - protected function setUp(): void - { - parent::setUp(); - - unset( - $_SERVER['__missing_translation_key'], - $_SERVER['__missing_translation_key_locale'], - ); - } - - protected function tearDown(): void - { - unset( - $_SERVER['__missing_translation_key'], - $_SERVER['__missing_translation_key_locale'], - ); - - parent::tearDown(); - } - protected function defineEnvironment(ApplicationContract $app): void { $app['translator']->addNamespace('tests', __DIR__ . '/Fixtures/lang'); $app['translator']->addJsonPath(__DIR__ . '/Fixtures/lang'); } - public function testItCanGetFromLocaleForJson() + public function testItCanGetFromLocaleForJson(): void { $this->assertSame('30 Days', $this->app['translator']->get('30 Days')); @@ -47,7 +27,7 @@ public function testItCanGetFromLocaleForJson() $this->assertSame('30 jours', $this->app['translator']->get('30 Days')); } - public function testItCanCheckLanguageExistsHasFromLocaleForJson() + public function testItCanCheckLanguageExistsHasFromLocaleForJson(): void { $this->assertTrue($this->app['translator']->has('1 Day')); $this->assertTrue($this->app['translator']->hasForLocale('1 Day')); @@ -60,23 +40,27 @@ public function testItCanCheckLanguageExistsHasFromLocaleForJson() $this->assertTrue($this->app['translator']->hasForLocale('30 Days')); } - public function testItCanCheckKeyExistsWithoutTriggeringHandleMissingKeys() + public function testItCanCheckKeyExistsWithoutTriggeringHandleMissingKeys(): void { - $this->app['translator']->handleMissingKeysUsing(function ($key) { - $_SERVER['__missing_translation_key'] = $key; + $missingKey = null; + + $this->app['translator']->handleMissingKeysUsing(function (string $key) use (&$missingKey): void { + $missingKey = $key; }); $this->assertFalse($this->app['translator']->has('Foo Bar')); - $this->assertFalse(isset($_SERVER['__missing_translation_key'])); + $this->assertNull($missingKey); $this->assertFalse($this->app['translator']->hasForLocale('Foo Bar', 'nl')); - $this->assertFalse(isset($_SERVER['__missing_translation_key'])); + $this->assertNull($missingKey); } - public function testItCanHandleMissingKeysUsingCallback() + public function testItCanHandleMissingKeysUsingCallback(): void { - $this->app['translator']->handleMissingKeysUsing(function ($key) { - $_SERVER['__missing_translation_key'] = $key; + $missingKey = null; + + $this->app['translator']->handleMissingKeysUsing(function (string $key) use (&$missingKey): string { + $missingKey = $key; return 'callback key'; }); @@ -84,43 +68,41 @@ public function testItCanHandleMissingKeysUsingCallback() $key = $this->app['translator']->get('some missing key'); $this->assertSame('callback key', $key); - $this->assertSame('some missing key', $_SERVER['__missing_translation_key']); - - $this->app['translator']->handleMissingKeysUsing(null); + $this->assertSame('some missing key', $missingKey); } - public function testItCanHandleMissingKeysNoReturn() + public function testItCanHandleMissingKeysNoReturn(): void { - $this->app['translator']->handleMissingKeysUsing(function ($key) { - $_SERVER['__missing_translation_key'] = $key; + $missingKey = null; + + $this->app['translator']->handleMissingKeysUsing(function (string $key) use (&$missingKey): void { + $missingKey = $key; }); $key = $this->app['translator']->get('some missing key'); $this->assertSame('some missing key', $key); - $this->assertSame('some missing key', $_SERVER['__missing_translation_key']); - - $this->app['translator']->handleMissingKeysUsing(null); + $this->assertSame('some missing key', $missingKey); } - public function testItReturnsCorrectLocaleForMissingKeys() + public function testItReturnsCorrectLocaleForMissingKeys(): void { - $this->app['translator']->handleMissingKeysUsing(function ($key, $replacements, $locale) { - $_SERVER['__missing_translation_key_locale'] = $locale; + $missingLocale = null; + + $this->app['translator']->handleMissingKeysUsing(function (string $key, array $replacements, string $locale) use (&$missingLocale): void { + $missingLocale = $locale; }); $this->app['translator']->get('some missing key', [], 'ht'); - $this->assertSame('ht', $_SERVER['__missing_translation_key_locale']); - - $this->app['translator']->handleMissingKeysUsing(null); + $this->assertSame('ht', $missingLocale); } - public function testFileValidationDoesNotAttemptToTranslateAlreadyTranslatedMessages() + public function testFileValidationDoesNotAttemptToTranslateAlreadyTranslatedMessages(): void { $keysLookedUp = []; - $this->app['translator']->handleMissingKeysUsing(function ($key) use (&$keysLookedUp) { + $this->app['translator']->handleMissingKeysUsing(function (string $key) use (&$keysLookedUp): void { $keysLookedUp[] = $key; }); @@ -132,12 +114,10 @@ public function testFileValidationDoesNotAttemptToTranslateAlreadyTranslatedMess $validator->fails(); $this->assertNotContains('The file field must be a file of type: txt.', $keysLookedUp); - - $this->app['translator']->handleMissingKeysUsing(null); } #[DataProvider('greetingChoiceDataProvider')] - public function testItCanHandleChoice(int $count, string $expected, ?string $locale = null) + public function testItCanHandleChoice(int $count, string $expected, ?string $locale = null): void { if ($locale !== null) { $this->app->setLocale($locale); @@ -152,13 +132,13 @@ public function testItCanHandleChoice(int $count, string $expected, ?string $loc } #[DataProvider('greetingChoiceDataProvider')] - public function testItCanHandleChoiceWithChoiceSeparatorInReplaceString(int $count, string $expected, ?string $locale = null) + public function testItCanHandleChoiceWithChoiceSeparatorInReplaceString(int $count, string $expected, ?string $locale = null): void { if ($locale !== null) { $this->app->setLocale($locale); } - $name = 'Taylor | Laravel'; + $name = 'Taylor | Hypervel'; $this->assertSame( strtr($expected, [':name' => $name, ':count' => $count]), @@ -166,6 +146,9 @@ public function testItCanHandleChoiceWithChoiceSeparatorInReplaceString(int $cou ); } + /** + * @return array + */ public static function greetingChoiceDataProvider(): array { return [ diff --git a/tests/Translation/CoroutineIsolationTest.php b/tests/Translation/CoroutineIsolationTest.php new file mode 100644 index 0000000000..50a5963821 --- /dev/null +++ b/tests/Translation/CoroutineIsolationTest.php @@ -0,0 +1,75 @@ +handleMissingKeysUsing(function (string $key) use (&$missingKeys): string { + $missingKeys[] = $key; + + return "missing:{$key}"; + }); + + [$hasMissingKey, $translatedMissingKey] = parallel([ + fn (): bool => $translator->has('messages.first', 'en'), + function () use ($translator): string { + usleep(2500); + + return $translator->get('messages.second', [], 'fr'); + }, + ]); + + $this->assertFalse($hasMissingKey); + $this->assertSame('missing:messages.second', $translatedMissingKey); + $this->assertSame(['messages.second'], $missingKeys); + } + + public function testLocaleMutationIsIsolatedBetweenConcurrentCoroutines(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + [$firstLocale, $secondLocale] = parallel([ + function () use ($translator): string { + $translator->setLocale('fr'); + usleep(5000); + + return $translator->getLocale(); + }, + function () use ($translator): string { + $translator->setLocale('de'); + usleep(5000); + + return $translator->getLocale(); + }, + ]); + + $this->assertSame('fr', $firstLocale); + $this->assertSame('de', $secondLocale); + $this->assertSame('en', $translator->getLocale()); + } +} + +class YieldingTranslationLoader extends ArrayLoader +{ + public function load(string $locale, string $group, ?string $namespace = null): array + { + if ($locale === 'en' && $group === '*') { + usleep(5000); + } + + return parent::load($locale, $group, $namespace); + } +} diff --git a/tests/Translation/TranslationFileLoaderTest.php b/tests/Translation/TranslationFileLoaderTest.php index 9bc0caa959..8e03a05e9b 100644 --- a/tests/Translation/TranslationFileLoaderTest.php +++ b/tests/Translation/TranslationFileLoaderTest.php @@ -5,14 +5,16 @@ namespace Hypervel\Tests\Translation; use Hypervel\Filesystem\Filesystem; +use Hypervel\Tests\TestCase; use Hypervel\Translation\FileLoader; +use InvalidArgumentException; use Mockery as m; -use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class TranslationFileLoaderTest extends TestCase { - public function testLoadMethodLoadsTranslationsFromAddedPath() + public function testLoadMethodLoadsTranslationsFromAddedPath(): void { $files = m::mock(Filesystem::class); $loader = new FileLoader($files, __DIR__); @@ -27,7 +29,7 @@ public function testLoadMethodLoadsTranslationsFromAddedPath() $this->assertEquals(['foo' => 'bar', 'baz' => 'backagesplash'], $loader->load('en', 'messages')); } - public function testLoadMethodHandlesMissingAddedPath() + public function testLoadMethodHandlesMissingAddedPath(): void { $files = m::mock(Filesystem::class); $loader = new FileLoader($files, __DIR__); @@ -41,7 +43,7 @@ public function testLoadMethodHandlesMissingAddedPath() $this->assertEquals(['foo' => 'bar'], $loader->load('en', 'messages')); } - public function testLoadMethodOverwritesExistingKeysFromAddedPath() + public function testLoadMethodOverwritesExistingKeysFromAddedPath(): void { $files = m::mock(Filesystem::class); $loader = new FileLoader($files, __DIR__); @@ -56,7 +58,7 @@ public function testLoadMethodOverwritesExistingKeysFromAddedPath() $this->assertEquals(['foo' => 'baz'], $loader->load('en', 'messages')); } - public function testLoadMethodLoadsTranslationsFromMultipleAddedPaths() + public function testLoadMethodLoadsTranslationsFromMultipleAddedPaths(): void { $files = m::mock(Filesystem::class); $loader = new FileLoader($files, __DIR__); @@ -75,7 +77,7 @@ public function testLoadMethodLoadsTranslationsFromMultipleAddedPaths() $this->assertEquals(['foo' => 'bar', 'baz' => 'backagesplash', 'qux' => 'quux'], $loader->load('en', 'messages')); } - public function testLoadMethodWithoutNamespacesProperlyCallsLoader() + public function testLoadMethodWithoutNamespacesProperlyCallsLoader(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $files->shouldReceive('exists')->once()->with(__DIR__ . '/en/foo.php')->andReturn(true); @@ -84,7 +86,7 @@ public function testLoadMethodWithoutNamespacesProperlyCallsLoader() $this->assertEquals(['messages'], $loader->load('en', 'foo', null)); } - public function testLoadMethodWithoutNamespacesProperlyCallsLoaderWithMultiplePaths() + public function testLoadMethodWithoutNamespacesProperlyCallsLoaderWithMultiplePaths(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), [__DIR__, __DIR__ . '/second']); $files->shouldReceive('exists')->once()->with(__DIR__ . '/en/foo.php')->andReturn(true); @@ -95,7 +97,7 @@ public function testLoadMethodWithoutNamespacesProperlyCallsLoaderWithMultiplePa $this->assertEquals(['messages' => 'second'], $loader->load('en', 'foo', null)); } - public function testLoadMethodWithNamespacesProperlyCallsLoader() + public function testLoadMethodWithNamespacesProperlyCallsLoader(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $files->shouldReceive('exists')->once()->with('bar/en/foo.php')->andReturn(true); @@ -106,7 +108,7 @@ public function testLoadMethodWithNamespacesProperlyCallsLoader() $this->assertEquals(['foo' => 'bar'], $loader->load('en', 'foo', 'namespace')); } - public function testLoadMethodWithNamespacesProperlyCallsLoaderWithMultiplePaths() + public function testLoadMethodWithNamespacesProperlyCallsLoaderWithMultiplePaths(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), [__DIR__, __DIR__ . '/second']); $files->shouldReceive('exists')->once()->with('test-namespace-dir/en/foo.php')->andReturn(true); @@ -118,7 +120,7 @@ public function testLoadMethodWithNamespacesProperlyCallsLoaderWithMultiplePaths $this->assertEquals(['foo' => 'bar'], $loader->load('en', 'foo', 'namespace')); } - public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOverrides() + public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOverrides(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $files->shouldReceive('exists')->once()->with('bar/en/foo.php')->andReturn(true); @@ -130,7 +132,7 @@ public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOver $this->assertEquals(['foo' => 'override', 'baz' => 'boom'], $loader->load('en', 'foo', 'namespace')); } - public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOverridesWithMultiplePaths() + public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOverridesWithMultiplePaths(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), [__DIR__, __DIR__ . '/second']); $files->shouldReceive('exists')->once()->with('test-namespace-dir/en/foo.php')->andReturn(true); @@ -144,7 +146,7 @@ public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOver $this->assertEquals(['foo' => 'override-2', 'baz' => 'boom-2'], $loader->load('en', 'foo', 'namespace')); } - public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOverridesWithMultiplePathsWithMissingKey() + public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOverridesWithMultiplePathsWithMissingKey(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), [__DIR__, __DIR__ . '/second']); $files->shouldReceive('exists')->once()->with('test-namespace-dir/en/foo.php')->andReturn(true); @@ -158,7 +160,44 @@ public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOver $this->assertEquals(['foo' => 'override', 'baz' => 'boom-2'], $loader->load('en', 'foo', 'namespace')); } - public function testEmptyArraysReturnedWhenFilesDontExist() + #[DataProvider('invalidLocaleProvider')] + public function testInvalidLocalesAreRejectedBeforeFilesystemAccess(string $locale): void + { + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->never(); + $files->shouldReceive('get')->never(); + $files->shouldReceive('getRequire')->never(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid characters present in locale.'); + + (new FileLoader($files, __DIR__))->load($locale, 'messages'); + } + + public static function invalidLocaleProvider(): array + { + return [ + 'forward slash' => ['en/US'], + 'backslash' => ['en\US'], + 'current directory' => ['.'], + 'parent directory' => ['..'], + ]; + } + + public function testDotBearingLocalesReachPhpAndJsonTranslationPaths(): void + { + $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); + + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.UTF-8/messages.php')->andReturn(true); + $files->shouldReceive('getRequire')->once()->with(__DIR__ . '/en.UTF-8/messages.php')->andReturn(['foo' => 'bar']); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.UTF-8.json')->andReturn(true); + $files->shouldReceive('get')->once()->with(__DIR__ . '/en.UTF-8.json')->andReturn('{"foo":"bar"}'); + + $this->assertSame(['foo' => 'bar'], $loader->load('en.UTF-8', 'messages')); + $this->assertSame(['foo' => 'bar'], $loader->load('en.UTF-8', '*', '*')); + } + + public function testEmptyArraysReturnedWhenFilesDontExist(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $files->shouldReceive('exists')->once()->with(__DIR__ . '/en/foo.php')->andReturn(false); @@ -167,7 +206,7 @@ public function testEmptyArraysReturnedWhenFilesDontExist() $this->assertEquals([], $loader->load('en', 'foo', null)); } - public function testEmptyArraysReturnedWhenFilesDontExistForNamespacedItems() + public function testEmptyArraysReturnedWhenFilesDontExistForNamespacedItems(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $files->shouldReceive('getRequire')->never(); @@ -175,7 +214,7 @@ public function testEmptyArraysReturnedWhenFilesDontExistForNamespacedItems() $this->assertEquals([], $loader->load('en', 'foo', 'bar')); } - public function testLoadMethodForJSONProperlyCallsLoader() + public function testLoadMethodForJSONProperlyCallsLoader(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(true); @@ -184,7 +223,16 @@ public function testLoadMethodForJSONProperlyCallsLoader() $this->assertEquals(['foo' => 'bar'], $loader->load('en', '*', '*')); } - public function testLoadMethodForJSONProperlyCallsLoaderForMultiplePaths() + public function testLoadMethodForJsonAcceptsArraysWithNumericKeys(): void + { + $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(true); + $files->shouldReceive('get')->once()->with(__DIR__ . '/en.json')->andReturn('["first","second"]'); + + $this->assertSame(['first', 'second'], $loader->load('en', '*', '*')); + } + + public function testLoadMethodForJSONProperlyCallsLoaderForMultiplePaths(): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $loader->addJsonPath(__DIR__ . '/another'); @@ -197,20 +245,59 @@ public function testLoadMethodForJSONProperlyCallsLoaderForMultiplePaths() $this->assertEquals(['foo' => 'bar', 'baz' => 'backagesplash'], $loader->load('en', '*', '*')); } - public function testLoadMethodThrowExceptionWhenProvideInvalidJSON() + #[DataProvider('invalidJsonRootProvider')] + public function testLoadMethodThrowsForInvalidJsonRoots(string $json): void { $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); $loader->addJsonPath(__DIR__ . '/invalid'); - $invalidJsonString = '.{"foo":"cricket", "baz": "football"}'; $files->shouldReceive('exists')->once()->with(__DIR__ . '/invalid/en.json')->andReturn(true); - $files->shouldReceive('get')->once()->with(__DIR__ . '/invalid/en.json')->andReturn($invalidJsonString); + $files->shouldReceive('get')->once()->with(__DIR__ . '/invalid/en.json')->andReturn($json); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Translation file [' . __DIR__ . '/invalid/en.json] contains an invalid JSON structure.' + ); + + $loader->load('en', '*', '*'); + } + + public static function invalidJsonRootProvider(): array + { + return [ + 'integer' => ['1'], + 'true' => ['true'], + 'false' => ['false'], + 'string' => ['"translation"'], + 'null' => ['null'], + 'malformed' => ['.{"foo":"bar"}'], + ]; + } + + public function testLoadMethodRejectsScalarJsonTranslationValues(): void + { + $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(true); + $files->shouldReceive('get')->once()->with(__DIR__ . '/en.json')->andReturn('{"unread":0}'); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Translation file [' . __DIR__ . '/en.json] contains an invalid value for key [unread]. Translation values must be strings or arrays.' + ); + $loader->load('en', '*', '*'); } - public function testAllRegisteredNamespaceReturnProperly() + public function testLoadMethodAllowsNullJsonTranslationValues(): void + { + $loader = new FileLoader($files = m::mock(Filesystem::class), __DIR__); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(true); + $files->shouldReceive('get')->once()->with(__DIR__ . '/en.json')->andReturn('{"untranslated":null}'); + + $this->assertSame(['untranslated' => null], $loader->load('en', '*', '*')); + } + + public function testAllRegisteredNamespaceReturnProperly(): void { $loader = new FileLoader(m::mock(Filesystem::class), __DIR__); $loader->addNamespace('namespace', 'foo'); @@ -218,7 +305,7 @@ public function testAllRegisteredNamespaceReturnProperly() $this->assertEquals(['namespace' => 'foo', 'namespace2' => 'bar'], $loader->namespaces()); } - public function testAllAddedJsonPathsReturnProperly() + public function testAllAddedJsonPathsReturnProperly(): void { $loader = new FileLoader(m::mock(Filesystem::class), __DIR__); $path1 = __DIR__ . '/another'; @@ -228,7 +315,7 @@ public function testAllAddedJsonPathsReturnProperly() $this->assertEquals([$path1, $path2], $loader->jsonPaths()); } - public function testAllAddedPathsReturnProperly() + public function testAllAddedPathsReturnProperly(): void { $loader = new FileLoader(m::mock(Filesystem::class), __DIR__); $path1 = __DIR__ . '/another'; diff --git a/tests/Translation/TranslationMessageSelectorTest.php b/tests/Translation/TranslationMessageSelectorTest.php index 86848f613a..750b4ee9d7 100644 --- a/tests/Translation/TranslationMessageSelectorTest.php +++ b/tests/Translation/TranslationMessageSelectorTest.php @@ -4,21 +4,25 @@ namespace Hypervel\Tests\Translation; +use ErrorException; +use Hypervel\Tests\TestCase; use Hypervel\Translation\MessageSelector; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; class TranslationMessageSelectorTest extends TestCase { #[DataProvider('chooseTestData')] - public function testChoose($expected, $id, $number) + public function testChoose(string $expected, string $id, float|int $number): void { $selector = new MessageSelector; - $this->assertEquals($expected, $selector->choose($id, $number, 'en')); + $this->assertSame($expected, $selector->choose($id, $number, 'en')); } - public static function chooseTestData() + /** + * @return array + */ + public static function chooseTestData(): array { return [ ['first', 'first', 1], @@ -37,6 +41,8 @@ public static function chooseTestData() ['', '{0}first|{1}', 1], ['first', '{1.3}first|{2.3}second', 1.3], ['second', '{1.3}first|{2.3}second', 2.3], + ['first', '{1.}first|{2.}second', 1], + ['second', '{-.5}first|{.5}second', .5], ['first line', '{1}first line|{2}second', 1], @@ -54,17 +60,69 @@ public static function chooseTestData() ['first', '[*,4]first|[5,*]second', 1], ['second', '[5,*]first|[*,4]second', 1], ['second', '[5,*]first|[*,4]second', 0], + ['first', '[-1.5,-.5]first|[.5,1.]second', -1], + ['second', '[-1.5,-.5]first|[.5,1.]second', .5], ['first', '{0}first|[1,3]second|[4,*]third', 0], ['second', '{0}first|[1,3]second|[4,*]third', 1], ['third', '{0}first|[1,3]second|[4,*]third', 9], + ['first', '[*,-1]first|{0}second|[1,*]third', -4], + ['first', '[*,-1] first|{0} second|[1,*] third', -4], + ['second', '[*,-1]first|{0}second|[1,*]third', 0], + ['second', '[*,-1] first|{0} second|[1,*] third', 0], + ['third', '[*,-1]first|{0}second|[1,*]third', 9], + ['first', '[-5,-1]first|{0}second|[1,*]third', -4], + ['first', 'first|second|third', 1], ['second', 'first|second|third', 9], ['second', 'first|second|third', 0], ['first', '{0} first | { 1 } second', 0], ['first', '[4,*]first | [1,3]second', 100], + + ['[?]first', '[?]first', 1], + ['[-]first', '[-]first', 1], + ['[.]first', '[.]first', 1], + ['[*]first', '[*]first', 1], + ['[,]first', '[,]first', 1], + ['[1,]first', '[1,]first', 1], + ['[*,*]first', '[*,*]first', 1], + ['{1]first', '{1]first', 1], + ['[1}first', '[1}first', 1], + + ['[first](//example.com)', '[first](//example.com)|[second](//test.com)', 1], + ['[second](//test.com)', '[first](//example.com)|[second](//test.com)', 2], + ['[first](//example.com)', '{0}[first](//example.com)|{1}[second](//test.com)', 0], + ['[second](//test.com)', '{0}[first](//example.com)|{1}[second](//test.com)', 1], + ['[first](//example.com)', '{0}[first](//example.com)|[2,*][second](//test.com)', 0], + ['[first](//example.com)', '{0}[first](//example.com)|[2,*][second](//test.com)', 1], + ['[second](//test.com)', '{0}[first](//example.com)|[2,*][second](//test.com)', 10], + ['[first](//example.com)', '{0}[first](//example.com)|{2.3}[second](//test.com)', 0], + ['[first](//example.com)', '{0}[first](//example.com)|{2.3}[second](//test.com)', 1], + ['[second](//test.com)', '{0}[first](//example.com)|{2.3}[second](//test.com)', 2.3], ]; } + + public function testChooseWithFloatDoesNotTriggerDeprecation(): void + { + $selector = new MessageSelector; + + set_error_handler(static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }, E_DEPRECATED); + + try { + $this->assertSame('few', $selector->choose('one|few|many', 2.75, 'pl')); + } finally { + restore_error_handler(); + } + } + + public function testChoosePluralizesFloats(): void + { + $selector = new MessageSelector; + + $this->assertSame('plural', $selector->choose('singular|plural', 1.5, 'en')); + } } diff --git a/tests/Translation/TranslationTranslatorTest.php b/tests/Translation/TranslationTranslatorTest.php index f91fb6d23a..e4ef963818 100644 --- a/tests/Translation/TranslationTranslatorTest.php +++ b/tests/Translation/TranslationTranslatorTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Translation; use Hypervel\Contracts\Translation\Loader; -use Hypervel\Coroutine\Coroutine; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; @@ -13,24 +13,23 @@ use Hypervel\Tests\Translation\Fixtures\Enums\Baz; use Hypervel\Tests\Translation\Fixtures\Enums\Foo; use Hypervel\Translation\ArrayLoader; +use Hypervel\Translation\FileLoader; use Hypervel\Translation\MessageSelector; use Hypervel\Translation\Translator; +use InvalidArgumentException; use Mockery as m; - -use function Hypervel\Coroutine\parallel; -use function Hypervel\Coroutine\run; +use stdClass; +use TypeError; class TranslationTranslatorTest extends TestCase { - protected bool $runTestsInCoroutine = false; - - public function testHasMethodReturnsFalseWhenReturnedTranslationIsNull() + public function testHasMethodReturnsFalseWhenReturnedTranslationIsNull(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('bar'))->willReturn('foo'); $this->assertFalse($translator->has('foo', 'bar')); - $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en', 'sp'])->getMock(); + $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('bar'))->willReturn('bar'); $this->assertTrue($translator->has('foo', 'bar')); @@ -53,7 +52,7 @@ public function testHasMethodReturnsFalseWhenReturnedTranslationIsNull() $this->assertFalse($translator->hasForLocale('foo')); } - public function testGetMethodProperlyLoadsAndRetrievesItem() + public function testGetMethodProperlyLoadsAndRetrievesItem(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -63,7 +62,7 @@ public function testGetMethodProperlyLoadsAndRetrievesItem() $this->assertSame('foo', $translator->get('foo::bar.foo')); } - public function testGetMethodProperlyLoadsAndRetrievesArrayItem() + public function testGetMethodProperlyLoadsAndRetrievesArrayItem(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -73,7 +72,61 @@ public function testGetMethodProperlyLoadsAndRetrievesArrayItem() $this->assertSame('foo', $translator->get('foo::bar.foo')); } - public function testGetMethodForNonExistingReturnsSameKey() + public function testStringMethodProperlyLoadsAndRetrievesStringItem(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['baz' => 'breeze :foo']); + + $this->assertSame('breeze bar', $translator->string('foo::bar.baz', ['foo' => 'bar'], 'en')); + } + + public function testStringMethodThrowsExceptionForArrayItem(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['baz' => ['breeze']]); + $this->expectExceptionObject(new InvalidArgumentException('Translation value for key [foo::bar.baz] must be a string, array given.')); + + $translator->string('foo::bar.baz', [], 'en'); + } + + public function testArrayMethodProperlyLoadsAndRetrievesArrayItem(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['baz' => ['breeze :foo']]); + + $this->assertSame(['breeze bar'], $translator->array('foo::bar.baz', ['foo' => 'bar'], 'en')); + } + + public function testEmptyArrayItemDoesNotFallBackWhileEmptyGroupRemainsMissing(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->setFallback('lv'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['empty' => []]); + $translator->getLoader()->shouldReceive('load')->with('lv', 'bar', 'foo')->andReturn(['empty' => ['fallback']]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'missing', 'foo')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('lv', 'missing', 'foo')->andReturn([]); + + $this->assertSame([], $translator->get('foo::bar.empty')); + $this->assertTrue($translator->has('foo::bar.empty')); + $this->assertSame([], $translator->array('foo::bar.empty')); + $this->assertSame('foo::missing', $translator->get('foo::missing')); + } + + public function testArrayMethodThrowsExceptionForStringItem(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'bar', 'foo')->andReturn(['baz' => 'breeze']); + $this->expectExceptionObject(new InvalidArgumentException('Translation value for key [foo::bar.baz] must be an array, string given.')); + + $translator->array('foo::bar.baz', [], 'en'); + } + + public function testGetMethodForNonExistingReturnsSameKey(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -84,7 +137,7 @@ public function testGetMethodForNonExistingReturnsSameKey() $this->assertSame('foo::unknown.bar', $translator->get('foo::unknown.bar')); } - public function testTransMethodProperlyLoadsAndRetrievesItemWithHTMLInTheMessage() + public function testTransMethodProperlyLoadsAndRetrievesItemWithHTMLInTheMessage(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -92,7 +145,7 @@ public function testTransMethodProperlyLoadsAndRetrievesItemWithHTMLInTheMessage $this->assertSame('breeze

test

', $translator->get('foo.bar', [], 'en')); } - public function testGetMethodProperlyLoadsAndRetrievesItemWithCapitalization() + public function testGetMethodProperlyLoadsAndRetrievesItemWithCapitalization(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -101,7 +154,7 @@ public function testGetMethodProperlyLoadsAndRetrievesItemWithCapitalization() $this->assertSame('foo', $translator->get('foo::bar.foo')); } - public function testGetMethodProperlyLoadsAndRetrievesItemWithLongestReplacementsFirst() + public function testGetMethodProperlyLoadsAndRetrievesItemWithLongestReplacementsFirst(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -111,7 +164,7 @@ public function testGetMethodProperlyLoadsAndRetrievesItemWithLongestReplacement $this->assertSame('foo', $translator->get('foo::bar.foo')); } - public function testGetMethodProperlyLoadsAndRetrievesItemForFallback() + public function testGetMethodProperlyLoadsAndRetrievesItemForFallback(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->setFallback('lv'); @@ -122,7 +175,18 @@ public function testGetMethodProperlyLoadsAndRetrievesItemForFallback() $this->assertSame('foo', $translator->get('foo::bar.foo')); } - public function testGetDoesNotCallGetLineTwiceForMissingKeyWhenLocaleMatchesFallback() + public function testFallbackLocaleCanBeReadAndChanged(): void + { + $translator = new Translator($this->getLoader(), 'en'); + + $this->assertSame('', $translator->getFallback()); + + $translator->setFallback('lv'); + + $this->assertSame('lv', $translator->getFallback()); + } + + public function testGetDoesNotCallGetLineTwiceForMissingKeyWhenLocaleMatchesFallback(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['getLine'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->setFallback('en'); @@ -133,7 +197,7 @@ public function testGetDoesNotCallGetLineTwiceForMissingKeyWhenLocaleMatchesFall $translator->get('messages.test', [], 'en'); } - public function testGetMethodProperlyLoadsAndRetrievesItemForGlobalNamespace() + public function testGetMethodProperlyLoadsAndRetrievesItemForGlobalNamespace(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -141,7 +205,7 @@ public function testGetMethodProperlyLoadsAndRetrievesItemForGlobalNamespace() $this->assertSame('breeze bar', $translator->get('foo.bar', ['foo' => 'bar'])); } - public function testChoiceMethodProperlyLoadsAndRetrievesItemForAnInt() + public function testChoiceMethodProperlyLoadsAndRetrievesItemForAnInt(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get', 'localeForChoice'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('en'))->willReturn('line'); @@ -152,7 +216,7 @@ public function testChoiceMethodProperlyLoadsAndRetrievesItemForAnInt() $translator->choice('foo', 10, ['replace']); } - public function testChoiceMethodProperlyLoadsAndRetrievesItemForAFloat() + public function testChoiceMethodProperlyLoadsAndRetrievesItemForAFloat(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get', 'localeForChoice'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->expects($this->once())->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('en'))->willReturn('line'); @@ -163,7 +227,7 @@ public function testChoiceMethodProperlyLoadsAndRetrievesItemForAFloat() $translator->choice('foo', 1.2, ['replace']); } - public function testChoiceMethodProperlyCountsCollectionsAndLoadsAndRetrievesItem() + public function testChoiceMethodProperlyCountsCollectionsAndLoadsAndRetrievesItem(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get', 'localeForChoice'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->expects($this->exactly(2))->method('get')->with($this->equalTo('foo'), $this->equalTo([]), $this->equalTo('en'))->willReturn('line'); @@ -178,7 +242,7 @@ public function testChoiceMethodProperlyCountsCollectionsAndLoadsAndRetrievesIte $translator->choice('foo', $values, ['replace']); } - public function testChoiceMethodProperlySelectsLocaleForChoose() + public function testChoiceMethodProperlySelectsLocaleForChoose(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get', 'hasForLocale'])->setConstructorArgs([$this->getLoader(), 'cs'])->getMock(); $translator->setFallback('en'); @@ -190,7 +254,7 @@ public function testChoiceMethodProperlySelectsLocaleForChoose() $translator->choice('foo', 10, ['replace']); } - public function testChoiceMethodProperlyUsesCustomCountReplacement() + public function testChoiceMethodProperlyUsesCustomCountReplacement(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get', 'localeForChoice'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); $translator->expects($this->once())->method('get')->with($this->equalTo(':count foos'), $this->equalTo([]), $this->equalTo('en'))->willReturn('{1} :count foos|[2,*] :count foos'); @@ -201,42 +265,139 @@ public function testChoiceMethodProperlyUsesCustomCountReplacement() $this->assertEquals('1,234 foos', $translator->choice(':count foos', 1234, ['count' => '1,234'])); } - public function testGetJson() + public function testChoiceRequiresAStringTranslation(): void + { + $loader = (new ArrayLoader)->addMessages('en', 'messages', ['items' => ['one', 'many']]); + $translator = new Translator($loader, 'en'); + + $this->expectExceptionObject(new InvalidArgumentException('Translation value for key [messages.items] must be a string, array given.')); + + $translator->choice('messages.items', 2); + } + + public function testGetJson(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['foo' => 'one']); $this->assertSame('one', $translator->get('foo')); } - public function testGetJsonReplaces() + public function testGetJsonPreservesFalseyValuesAndHasAgreement(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([ + 'empty' => '', + 'zero' => '0', + 'items' => [], + ]); + + $this->assertSame('', $translator->get('empty')); + $this->assertSame('0', $translator->get('zero')); + $this->assertSame([], $translator->get('items')); + $this->assertTrue($translator->has('empty')); + $this->assertTrue($translator->has('zero')); + $this->assertTrue($translator->has('items')); + } + + public function testNullJsonTranslationValueIsTreatedAsMissing(): void + { + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(true); + $files->shouldReceive('get')->once()->with(__DIR__ . '/en.json')->andReturn('{"untranslated":null}'); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en/untranslated.php')->andReturn(false); + + $translator = new Translator(new FileLoader($files, __DIR__), 'en'); + + $this->assertSame('untranslated', $translator->get('untranslated')); + $this->assertFalse($translator->has('untranslated')); + } + + public function testGetPreservesMixedArrayLeavesForJsonTranslations(): void + { + $object = new stdClass; + $line = [ + 'message' => 'Hello :name', + 7 => 42, + 'nested' => [ + 'message' => 'Welcome :name', + 'float' => 1.5, + 'boolean' => false, + 'null' => null, + 'object' => $object, + ], + ]; + $expected = $line; + $expected['message'] = 'Hello Taylor'; + $expected['nested']['message'] = 'Welcome Taylor'; + + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['payload' => $line]); + + $this->assertSame($line, $translator->get('payload')); + $result = $translator->get('payload', ['name' => 'Taylor']); + $this->assertSame($expected, $result); + $this->assertSame($object, $result['nested']['object']); + $this->assertSame('Hello :name', $line['message']); + } + + public function testGetPreservesMixedArrayLeavesForGroupedTranslations(): void + { + $object = new stdClass; + $line = [ + 'message' => 'Hello :name', + 7 => 42, + 'nested' => [ + 'message' => 'Welcome :name', + 'float' => 1.5, + 'boolean' => false, + 'null' => null, + 'object' => $object, + ], + ]; + $expected = $line; + $expected['message'] = 'Hello Taylor'; + $expected['nested']['message'] = 'Welcome Taylor'; + + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); + $translator->getLoader()->shouldReceive('load')->once()->with('en', 'messages', '*')->andReturn(['payload' => $line]); + + $this->assertSame($line, $translator->get('messages.payload')); + $result = $translator->get('messages.payload', ['name' => 'Taylor']); + $this->assertSame($expected, $result); + $this->assertSame($object, $result['nested']['object']); + $this->assertSame('Hello :name', $line['message']); + } + + public function testGetJsonReplaces(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['foo :i:c :u' => 'bar :i:c :u']); $this->assertSame('bar onetwo three', $translator->get('foo :i:c :u', ['i' => 'one', 'c' => 'two', 'u' => 'three'])); } - public function testGetJsonHasAtomicReplacements() + public function testGetJsonHasAtomicReplacements(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['Hello :foo!' => 'Hello :foo!']); $this->assertSame('Hello baz:bar!', $translator->get('Hello :foo!', ['foo' => 'baz:bar', 'bar' => 'abcdef'])); } - public function testGetJsonReplacesForAssociativeInput() + public function testGetJsonReplacesForAssociativeInput(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['foo :i :c' => 'bar :i :c']); $this->assertSame('bar eye see', $translator->get('foo :i :c', ['i' => 'eye', 'c' => 'see'])); } - public function testGetJsonPreservesOrder() + public function testGetJsonPreservesOrder(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn(['to :name I give :greeting' => ':greeting :name']); $this->assertSame('Greetings David', $translator->get('to :name I give :greeting', ['name' => 'David', 'greeting' => 'Greetings'])); } - public function testGetJsonForNonExistingJsonKeyLooksForRegularKeys() + public function testGetJsonForNonExistingJsonKeyLooksForRegularKeys(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -244,7 +405,7 @@ public function testGetJsonForNonExistingJsonKeyLooksForRegularKeys() $this->assertSame('one', $translator->get('foo.bar')); } - public function testGetJsonForNonExistingJsonKeyLooksForRegularKeysAndReplace() + public function testGetJsonForNonExistingJsonKeyLooksForRegularKeysAndReplace(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -252,7 +413,7 @@ public function testGetJsonForNonExistingJsonKeyLooksForRegularKeysAndReplace() $this->assertSame('one two', $translator->get('foo.bar', ['message' => 'two'])); } - public function testGetJsonForNonExistingReturnsSameKey() + public function testGetJsonForNonExistingReturnsSameKey(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -260,7 +421,7 @@ public function testGetJsonForNonExistingReturnsSameKey() $this->assertSame('Foo that bar', $translator->get('Foo that bar')); } - public function testGetJsonForNonExistingReturnsSameKeyAndReplaces() + public function testGetJsonForNonExistingReturnsSameKeyAndReplaces(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -268,7 +429,7 @@ public function testGetJsonForNonExistingReturnsSameKeyAndReplaces() $this->assertSame('foo baz', $translator->get('foo :message', ['message' => 'baz'])); } - public function testEmptyFallbacks() + public function testEmptyFallbacks(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader()->shouldReceive('load')->once()->with('en', '*', '*')->andReturn([]); @@ -301,7 +462,60 @@ public function testGetJsonReplacesWithStringable(): void ); } - public function testGetJsonReplacesWithEnums() + public function testGetJsonReplacesWithRegisteredStringableClass(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $translator->getLoader() + ->shouldReceive('load') + ->once() + ->with('en', '*', '*') + ->andReturn(['test' => 'the date is :date']); + + $translator->stringable( + CarbonImmutable::class, + fn (CarbonImmutable $carbon): string => $carbon->format('jS M Y') + ); + + $this->assertSame( + 'the date is 1st Jan 1970', + $translator->get('test', ['date' => CarbonImmutable::createFromTimestamp(0)]) + ); + } + + public function testStringableClassRequiresAHandler(): void + { + $translator = new Translator($this->getLoader(), 'en'); + + $this->expectException(InvalidArgumentException::class); + + $translator->stringable(CarbonImmutable::class); + } + + public function testStringableRejectsCallableArrays(): void + { + $translator = new Translator($this->getLoader(), 'en'); + + $this->expectException(TypeError::class); + + $translator->stringable([$translator, 'get']); + } + + public function testStringableRejectsInvokableObjects(): void + { + $translator = new Translator($this->getLoader(), 'en'); + $stringable = new class { + public function __invoke(): string + { + return 'formatted'; + } + }; + + $this->expectException(TypeError::class); + + $translator->stringable($stringable); + } + + public function testGetJsonReplacesWithEnums(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->getLoader() @@ -309,28 +523,28 @@ public function testGetJsonReplacesWithEnums() ->once() ->with('en', '*', '*') ->andReturn([ - 'string_backed_enum' => 'Laravel 12 was released in :month 2025', - 'int_backed_enum' => 'Stay tuned for Laravel v:version', - 'unit_enum' => ':person gets excited about every new Laravel release', + 'string_backed_enum' => 'The release shipped in :month 2025', + 'int_backed_enum' => 'Stay tuned for version :version', + 'unit_enum' => ':person gets excited about every new release', ]); $this->assertSame( - 'Laravel 12 was released in February 2025', + 'The release shipped in February 2025', $translator->get('string_backed_enum', ['month' => Baz::February]) ); $this->assertSame( - 'Stay tuned for Laravel v13', + 'Stay tuned for version 13', $translator->get('int_backed_enum', ['version' => Bar::Thirteen]) ); $this->assertSame( - 'Hosni gets excited about every new Laravel release', + 'Hosni gets excited about every new release', $translator->get('unit_enum', ['person' => Foo::Hosni]) ); } - public function testTagReplacements() + public function testTagReplacements(): void { $translator = new Translator($this->getLoader(), 'en'); @@ -338,17 +552,17 @@ public function testTagReplacements() $translator->getLoader()->shouldReceive('load')->once()->with('en', 'We have some nice documentation', '*')->andReturn([]); $this->assertSame( - 'We have some nice documentation', + 'We have some nice documentation', $translator->get( 'We have some nice documentation', [ - 'docs-link' => fn ($children) => "{$children}", + 'docs-link' => fn ($children) => "{$children}", ] ) ); } - public function testTagReplacementsHandleMultipleOfSameTag() + public function testTagReplacementsHandleMultipleOfSameTag(): void { $translator = new Translator($this->getLoader(), 'en'); @@ -366,7 +580,7 @@ public function testTagReplacementsHandleMultipleOfSameTag() ); } - public function testDetermineLocalesUsingMethod() + public function testDetermineLocalesUsingMethod(): void { $translator = new Translator($this->getLoader(), 'en'); $translator->determineLocalesUsing(function ($locales) { @@ -380,38 +594,69 @@ public function testDetermineLocalesUsingMethod() $this->assertSame('foo', $translator->get('foo')); } - public function testMissingKeyHandlingIsIsolatedPerCoroutine() + public function testConfiguredInvalidLocaleIsRejectedBeforeFilesystemAccess(): void { - $translator = new Translator(new YieldingTranslationLoader, 'en'); - $missingKeys = []; + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->never(); - $translator->handleMissingKeysUsing(function (string $key) use (&$missingKeys): string { - $missingKeys[] = $key; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid characters present in locale.'); - return "missing:{$key}"; - }); + new Translator(new FileLoader($files, __DIR__), '.'); + } - $results = null; + public function testExplicitInvalidLocaleIsRejectedBeforeFilesystemAccess(): void + { + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->never(); + $translator = new Translator(new FileLoader($files, __DIR__), 'en'); - run(function () use ($translator, &$results): void { - $results = parallel([ - fn (): bool => $translator->has('messages.first', 'en'), - function () use ($translator): string { - usleep(2500); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid characters present in locale.'); - return $translator->get('messages.second', [], 'fr'); - }, - ]); - }); + $translator->get('messages.welcome', [], 'en/US'); + } + + public function testInvalidFallbackLocaleIsRejectedBeforeItsFilesystemAccess(): void + { + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(false); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en/messages.php')->andReturn(false); + $translator = new Translator(new FileLoader($files, __DIR__), 'en'); + $translator->setFallback('../fr'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid characters present in locale.'); + + $translator->get('messages.welcome'); + } + + public function testInvalidLocaleFromResolverIsRejectedBeforeItsFilesystemAccess(): void + { + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/en.json')->andReturn(false); + $translator = new Translator(new FileLoader($files, __DIR__), 'en'); + $translator->determineLocalesUsing(static fn (array $locales): array => ['en\US']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid characters present in locale.'); + + $translator->get('messages.welcome'); + } - [$hasMissingKey, $translatedMissingKey] = $results; + public function testSetLocaleRejectsInvalidLocaleImmediately(): void + { + $loader = m::mock(Loader::class); + $loader->shouldReceive('load')->never(); + $translator = new Translator($loader, 'en'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid characters present in locale.'); - $this->assertFalse($hasMissingKey); - $this->assertSame('missing:messages.second', $translatedMissingKey); - $this->assertSame(['messages.second'], $missingKeys); + $translator->setLocale('..'); } - public function testMissingKeyCallbackDoesNotRecurse() + public function testMissingKeyCallbackDoesNotRecurse(): void { $translator = new Translator(new ArrayLoader, 'en'); $calls = 0; @@ -426,7 +671,7 @@ public function testMissingKeyCallbackDoesNotRecurse() $this->assertSame(1, $calls); } - public function testFlushStateClearsMacros() + public function testFlushStateClearsMacros(): void { Translator::macro('translationStaticStateProbe', static fn (): string => 'ok'); @@ -437,39 +682,13 @@ public function testFlushStateClearsMacros() $this->assertFalse(Translator::hasMacro('translationStaticStateProbe')); } - public function testSetLocale() - { - $translator = new Translator($this->getLoader(), 'en'); - - run(function () use ($translator) { - Coroutine::create(function () use ($translator) { - $translator->setLocale('fr'); - $this->assertSame('fr', $translator->getLocale()); - }); - }); - - $this->assertSame('en', $translator->getLocale()); - } - - public function testDoubleUnderscoreHelperReturnsNullWhenKeyIsNull() + public function testDoubleUnderscoreHelperReturnsNullWhenKeyIsNull(): void { $this->assertNull(__(null)); } - protected function getLoader() + protected function getLoader(): Loader { return m::mock(Loader::class); } } - -class YieldingTranslationLoader extends ArrayLoader -{ - public function load(string $locale, string $group, ?string $namespace = null): array - { - if ($locale === 'en' && $group === '*') { - usleep(5000); - } - - return parent::load($locale, $group, $namespace); - } -} diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index a3be9352c4..eb7c1ac161 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -1383,7 +1383,7 @@ public function testValidateArrayKeys() $this->assertFalse($v->passes()); } - public function testValidateCurrentPassword() + public function testValidateCurrentPassword(): void { // Fails when user is not logged in. $auth = m::mock(Guard::class); @@ -1398,6 +1398,7 @@ public function testValidateCurrentPassword() $trans = $this->getTranslator(); $trans->shouldReceive('get')->andReturnArg(0); + $trans->shouldReceive('string')->andReturnArg(0); $v = new Validator($trans, ['password' => 'foo'], ['password' => 'current_password']); $v->setContainer($container); @@ -1422,6 +1423,7 @@ public function testValidateCurrentPassword() $trans = $this->getTranslator(); $trans->shouldReceive('get')->andReturnArg(0); + $trans->shouldReceive('string')->andReturnArg(0); $v = new Validator($trans, ['password' => 'foo'], ['password' => 'current_password']); $v->setContainer($container);