diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edc9095..30d121b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: name: Test with parse.preFilter enabled runs-on: ubuntu-latest timeout-minutes: 30 - if: github.ref == 'refs/heads/release-c-layout-order-planner' || github.event.pull_request.base.ref == 'main' + if: (github.event_name == 'push' || github.event_name == 'pull_request') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release/D' || github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'release/D') steps: - uses: actions/checkout@v4 - name: Set up Node.js diff --git a/package-lock.json b/package-lock.json index 568f75e..6e26a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.8.0", + "version": "2.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.8.0", + "version": "2.9.1", "workspaces": [ "packages/*" ], @@ -1058,9 +1058,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1075,9 +1072,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1092,9 +1086,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1109,9 +1100,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1126,9 +1114,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1143,9 +1128,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1160,9 +1142,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1177,9 +1156,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1194,9 +1170,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1211,9 +1184,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1228,9 +1198,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1245,9 +1212,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1262,9 +1226,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4834,7 +4795,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.8.0", + "version": "2.9.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -4853,14 +4814,14 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.8.0", + "version": "2.9.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.8.0", + "@magmacomputing/library": "2.9.1", "@rollup/plugin-alias": "^6.0.0", "cross-env": "^7.0.3", "magic-string": "^0.30.21", diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 82ad0fd..34610d0 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -12,7 +12,8 @@ export default defineConfig({ base: '/magma/', title: "Tempo", description: "The Professional Date-Time Library for Temporal", - srcDir: './doc', + srcDir: '.', + srcExclude: ['**/plan/**', '**/archive/**', '**/bench/**', '**/scratch/**', 'CHANGELOG.md'], markdown: { math: true }, diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 13a7081..10d538a 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.9.0] - 2026-05-06 + +### Added +- **Centralized Alias Architecture**: Finalized the migration to a unified `AliasEngine`. All event and period aliases are now managed through a centralized registry, providing a single source of truth across global and local contexts. +- **Rich Alias Results**: Alias resolution now returns a structured `AliasResult` object containing exhaustive metadata, including the source (global/local), type (Event/Period), and specific resolution flags. +- **Hardened Clock Snapping**: Standardized the resolution path for clock-like aliases (e.g. `8:00`). The engine now ensures absolute sub-second precision clearing (milliseconds, microseconds, and nanoseconds) when snapping to a time-string alias. +- **Optimized Lifecycle Monitoring**: Implemented a version counter in the `AliasEngine`. Mutation operations now trigger a version increment, allowing `Tempo` instances to efficiently detect registry changes and rebuild internal regex patterns without expensive deep-cloning. + +### Changed +- **Parser Context Consolidation**: Extracted the "host" facade construction from the main parsing loop into a dedicated `getResolutionContext` helper, improving maintainability and reducing Parser complexity. +- **Decoupled Term Registration**: Refactored `Tempo.extend` and term-based alias registration to bypass legacy raw registries, while maintaining backward compatibility via a mirrored metadata view. + +### Fixed +- **Documentation Server Stability**: Resolved VitePress 404 errors by correcting the `srcDir` configuration and implemented `srcExclude` to prevent build failures from dead links in non-documentation folders. + ## [2.8.0] - 2026-04-30 diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 0ab63a1..0aa0529 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -90,6 +90,7 @@ For granular "Lite" builds, see the [Full Installation Guide](https://magmacompu ## ✨ Why Tempo? * **🏗️ Future Standard**: Built natively on the TC39 `Temporal` proposal. Inherit the reliability of the future standard. * **🗣️ Natural Language**: Resolve complex terms like `#quarter.last` or "two days ago" with zero configuration. +* **🧠 Functional Aliases**: Extend the parser with custom logic using a powerful resolution context for relative date math. * **🔄 Cycle Persistence**: Shift by semantic terms (Quarters, Seasons) while preserving your relative day-of-period offset. * **⚡ Zero-Cost Parsing**: Lazy evaluation and smart matching ensure instantiation overhead is near-zero. * **🛡️ Monorepo Resilient**: Built for stability in complex environments with proxy-protected registries. diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index 2f3ebdc..ff377c6 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -130,7 +130,19 @@ The **Guarded-Lazy** strategy ensures that even with hundreds of custom plugins, 1. **Longest-Token Matching**: To prevent partial matching (e.g., matching `qtr` inside `quarter`), the guard uses a "Scan-and-Consume" loop that prioritizes the longest available token. 2. **Unified Wordlist**: The guard automatically ingests all registered Terms, Timezones, Month names, and Custom Events into a single high-speed lookup Set. 3. **High-Speed Gatekeeper**: By avoiding complex backtracking regexes, the gatekeeper provides predictable $O(1)$ performance even as the plugin list grows. -4. **Auto-Lazy**: Valid inputs that pass the guard automatically switch the instance to `mode: 'defer'`, deferring the full $O(N)$ parse work until a property is actually read. +4. **Versioned Registry (v2.9.0)**: To avoid redundant wordlist rebuilding, the Guard now monitors a `#version` counter on the alias registry. The wordlist is only rebuilt when a mutation actually occurs. +5. **Auto-Lazy**: Valid inputs that pass the guard automatically switch the instance to `mode: 'defer'`, deferring the full $O(N)$ parse work until a property is actually read. + +--- + +## 🧩 Centralized Alias Management (v2.9.0) +As of **v2.9.0**, Tempo has consolidated all Event and Period alias logic into a dedicated **`AliasEngine`**. + +### How it works: +- **Hierarchical Registry**: Aliases are managed in a prototype-aware chain. A local `Tempo` instance can have its own private aliases that shadow global ones, all while sharing the same underlying resolution logic. +- **Rich Metadata**: Every resolution returns a structured `AliasResult`, providing the Parser with immediate knowledge of the alias's origin (global vs local), type, and clock-snapping requirements. +- **Clock Snapping**: Time-based aliases (e.g. `8:00`) are automatically "snapped" to absolute precision, clearing sub-second drift (ms, us, ns) during the resolution phase. +- **Decoupled Registration**: By moving away from legacy raw objects, the registry is now protected against accidental mutation and supports efficient, version-aware monitoring. ### 📈 Validation & Performance The efficiency of the Master Guard and the success of the Zero-Cost objective have been validated via local benchmarking: diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index f1c2831..3489b6b 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,6 +1,23 @@ # 📜 Version 2.x History +## [v2.9.0] - 2026-05-06 +### 🏗️ Alias Architecture Stabilization +- **Unified Alias Engine**: Completed the architectural migration to the `AliasEngine`, centralizing all registration and resolution logic for Events and Periods. +- **Rich Metadata Results**: Enhanced the resolution engine to return structured `AliasResult` objects, providing better traceability and granular control over alias-driven parses. +- **Hardened Clock Snapping**: Finalized the standardization of clock-like alias resolution, ensuring consistent sub-second precision clearing for all static and functional time aliases. +- **Performance Optimized Monitoring**: Introduced a lightweight version-tracking system to detect registry mutations, enabling `O(1)` change detection during the Tempo lifecycle. + +### ⚙️ Parser & Registry Refinement +- **Consolidated Host Context**: Modularized the construction of the functional alias "pseudo-Tempo" context, improving separation of concerns in the parsing pipeline. +- **Legacy Decoupling**: Successfully decoupled term-based alias registration from internal raw objects, moving toward a more encapsulated and secure registry design. + +### 📚 Documentation Fixes +- **VitePress 404 Patch**: Corrected root directory resolution to fix "404 Not Found" errors on the landing page. +- **Build Resilience**: Implemented targeted folder exclusion to prevent build-time dead-link errors in non-public directories. + +--- + ## [v2.8.0] - 2026-04-30 ### 🚨 Immutability System Refined - The project evaluated mutation-throwing Proxies for all immutable objects, but reverted to using `Object.freeze` for stability and compatibility. diff --git a/packages/tempo/doc/tempo.modularity.md b/packages/tempo/doc/tempo.modularity.md index 07a2065..7699644 100644 --- a/packages/tempo/doc/tempo.modularity.md +++ b/packages/tempo/doc/tempo.modularity.md @@ -57,7 +57,7 @@ Adds support for semantic terms like `qtr`, `szn`, `zdc`, and `per`. There are t #### 1. The Side-Effect (Standard Activation) Fastest way to enable all standard terms in a Core environment. ```typescript -import '@magmacomputing/tempo/term/standard'; // One-line activation +import '@magmacomputing/tempo/term'; // One-line activation ``` #### 2. The Explicit Module (Uniform Sync) diff --git a/packages/tempo/doc/tempo.parse.md b/packages/tempo/doc/tempo.parse.md index f6a814e..d4ec7c8 100644 --- a/packages/tempo/doc/tempo.parse.md +++ b/packages/tempo/doc/tempo.parse.md @@ -111,7 +111,37 @@ Tempo.init({ const t = new Tempo('party'); ``` +### 🧠 Functional Alias Context +When you use a function as an alias value, Tempo provides a powerful **Resolution Context** (the `this` binding). This context mimics a lightweight Tempo instance, allowing you to perform relative date math during resolution. + +Available methods in the context: +* **`this.add(duration)`**: Add a duration to the current anchor. +* **`this.subtract(duration)`**: Subtract a duration. +* **`this.with(values)`**: Set specific fields (year, month, day, etc.). +* **`this.set(input)`**: Recursively parse another string or value relative to the anchor. +* **`this.toNow()`**: Get the current system time. +* **`this.toDateTime()`**: Get the current anchor as a native `Temporal.ZonedDateTime`. +* **`this.hh`, `this.mi`, `this.ss`**: Accessors for current time units. + +#### Example: Complex Functional Alias +```typescript +Tempo.init({ + event: { + // Resolve "bedtime" to 10pm on the same day + 'bedtime': function() { + return this.with({ hour: 22, minute: 0, second: 0 }); + }, + // Resolve "meeting" to 2 hours after whatever was just parsed + 'meeting': function() { + return this.add({ hours: 2 }); + } + } +}); +``` + --- ## 🛡️ Performance: The Master Guard -Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass, ensuring that parsing remains $O(1)$ relative to the number of plugins you have active. +Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass. + +In version **2.9.0**, the Master Guard has been optimized with a **Versioned Registry**. The engine now tracks a `#version` counter on the alias registry, ensuring that the guard's pattern list is only rebuilt when a mutation actually occurs. This provides near-instant validation for high-volume parsing tasks. diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/tempo.term.md index e1a2bf6..59c28db 100644 --- a/packages/tempo/doc/tempo.term.md +++ b/packages/tempo/doc/tempo.term.md @@ -42,7 +42,7 @@ Hemisphere-aware: southern-hemisphere configs shift the quarter boundaries by si const t = new Tempo('15-Feb-2025'); t.term.qtr // → 'Q1' -t.term.quarter // → { key: 'Q1', day: 1, month: 1, fiscal: 2025, sphere: 'North' } +t.term.quarter // → { key: 'Q1', day: 1, month: 1, fiscal: 2025, sphere: 'north' } ``` ```ts @@ -54,17 +54,14 @@ t.term.qtr // → 'Q3' (southern hemisphere) ### `szn` / `season` — Meteorological Seasons Maps the current date to the appropriate meteorological season. -Hemisphere-aware (northern / southern boundaries differ), and the full `season` scope additionally includes the corresponding **Chinese season** for the date. +Hemisphere-aware (northern / southern boundaries differ). ```ts const t = new Tempo('01-Jul-2025'); t.term.szn // → 'Winter' (northern hemisphere) t.term.season -// → { key: 'Winter', day: 22, month: 12, symbol: 'Snowflake', sphere: 'North' } - -t.term.season.CN -// → { key: 'Summer', symbol: 'Sun', ... } +// → { key: 'Winter', day: 22, month: 12, symbol: 'Snowflake', sphere: 'north' } ``` ```ts @@ -132,7 +129,7 @@ In **Tempo Full**, all standard terms are enabled by default. In **Tempo Core**, ### 1. Standard Activation (Recommended) The fastest way to enable all built-in terms (`qtr`, `szn`, `zdc`, `per`). ```typescript -import '@magmacomputing/tempo/term/standard'; // One-line side-effect activation +import '@magmacomputing/tempo/term'; // One-line side-effect activation ``` ### 2. Explicit Module (Uniform Sync) @@ -210,6 +207,14 @@ export const MySeasonTerm = defineTerm({ A `Range` object must include a `key` and any subset of the date-time fields below. `getTermRange` sorts ranges in descending chronological order and returns the **first range whose boundary the instance has reached or passed**. +### 🔄 Sync to Alias Engine +When a term plugin defines `ranges` with string-based `key` values, Tempo automatically synchronizes these keys with the internal **Alias Engine**. + +* **Period Scopes**: Ranges defined in a `period` scope (like `midnight` or `morning`) are registered as **Period Aliases**. +* **Event Scopes**: Ranges defined in an `event` scope are registered as **Event Aliases**. + +This synchronization happens during `Tempo.extend()` and `Tempo.init()`, ensuring that any named range boundaries are immediately available for use in the natural-language parsing engine. For example, if you define a custom term with a range key `"bedtime"`, you can immediately create a new instance using `new Tempo('bedtime')`. + ```ts type Range = { key: PropertyKey; // identifier returned when keyOnly = true diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 374ed6d..bf436df 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -24,8 +24,8 @@ "type": "module", "sideEffects": [ "**/tempo.index.js", - "**/plugin/term/standard.index.js", - "**/plugin/term/standard.index.ts", + "**/plugin/term/term.index.js", + "**/plugin/term/term.index.ts", "**/plugin/extend/extend.*.js", "**/plugin/extend/extend.*.ts", "dist/engine/engine.*.js", @@ -69,10 +69,6 @@ "development": "./src/plugin/extend/extend.ticker.ts", "default": "./dist/plugin/extend/extend.ticker.js" }, - "#tempo/term/standard": { - "development": "./src/plugin/term/standard.index.ts", - "default": "./dist/plugin/term/standard.index.js" - }, "#tempo/term": { "development": "./src/plugin/term/term.index.ts", "default": "./dist/plugin/term/term.index.js" @@ -147,10 +143,6 @@ "types": "./dist/module/module.*.d.ts", "import": "./dist/module/module.*.js" }, - "./term/standard": { - "types": "./dist/plugin/term/standard.index.d.ts", - "import": "./dist/plugin/term/standard.index.js" - }, "./term/*": { "types": "./dist/plugin/term/term.*.d.ts", "import": "./dist/plugin/term/term.*.js" @@ -252,4 +244,4 @@ "doc": "doc", "test": "test" } -} +} \ No newline at end of file diff --git a/packages/tempo/plan/alias-migration-phase2.md b/packages/tempo/plan/alias-migration-phase2.md new file mode 100644 index 0000000..d506b06 --- /dev/null +++ b/packages/tempo/plan/alias-migration-phase2.md @@ -0,0 +1,51 @@ +# Alias Migration: Phase 2 - Full Resolution Engine + +This document outlines the remaining tasks to complete the migration from legacy alias management to the centralized `AliasEngine` architecture. The goal is to move all interpretation and mutation logic out of the Parser and into the Engine. + +## 1. Consolidate Resolution Context (The "Host" Object) +Currently, `discrete.parse.ts` manually constructs a "pseudo-Tempo" `host` object to pass into functional aliases. This logic should be standardized and moved to a helper. + +- [x] Create a `getResolutionContext(state, dateTime)` helper in `support` or `AliasEngine`. +- [x] Ensure the context provides `add`, `subtract`, `with`, `set`, and time-unit accessors. +- [x] Remove the manual host construction from `discrete.parse.ts`. + +## 2. Hardened Clock Snapping +Aliases that resolve to a time-string (`hh:mm[:ss]`) currently have two different paths depending on whether they are static or functional. + +- [x] **Standardize Paths**: Both static and functional aliases should trigger the "snap" path if they match `Match.clock`. +- [x] **Fix Precision Leak**: Ensure that snapping to a clock-time clears ALL sub-second components (ms, us, ns) from the anchor. +- [x] **Support High-Precision**: Update the snapping logic to support `hh:mm:ss.ffffff` patterns natively. +- [x] **Engine-Level Detection**: Move the `Match.clock` test into `AliasEngine.resolveAlias`. + +## 3. Rich Alias Results +Instead of returning a raw `string | number`, the `AliasEngine` should return a structured result object. + +- [x] Define `AliasResult` interface: + ```typescript + interface AliasResult { + value: string; + key: string; // The original baseName (e.g., 'noon') + type: 'evt' | 'per'; + source: 'global' | 'local'; + isClock: boolean; // True if it matched Match.clock + isFunction: boolean; + } + ``` +- [x] Update `resolveAlias` to return this structure. + +## 4. Parser Cleanup +With the Engine handling the "what" and "how" of resolution, the Parser can focus on the "when". + +- [x] Refactor `parseGroups` in `discrete.parse.ts` to consume the new `AliasResult`. +- [x] Remove manual string-splitting and mutation logic from the Parser. +- [x] Leverage the `source` metadata from the result instead of manually parsing regex group names (like `evt1_0`). +- [x] Extract `host` context construction to a helper. + +## 5. Lifecycle & Monitoring +- [x] Implement `AliasEngine.getVersion()` or similar to allow `Tempo` class to detect registry changes without deep-cloning. +- [x] Audit `tempo.class.ts` for any remaining direct access to `parse.event` or `parse.period`. + +--- + +> [!IMPORTANT] +> **Priority 1**: Hardening the clock-snapping logic and fixing the sub-second precision leak. diff --git a/packages/tempo/plan/alias.registration.md b/packages/tempo/plan/alias.registration.md new file mode 100644 index 0000000..5f256fa --- /dev/null +++ b/packages/tempo/plan/alias.registration.md @@ -0,0 +1,68 @@ +tempo.class will invoke $setEvents() as part of the global, sandbox and instance setup. +events will be an array of [eventName, eventTarget] +where eventName is a plain string or a regex-like string (e.g. "xmas( )?eve"), and eventTarget is a string or Function that returns a string. +the eventTarget will name the date-string (e.g. "25-Dec") that should be interpreted when this eventName is detected by the parse-engine. + +$setEvents will then run "events=ownEntries();" on the list of Events it has been provided (most likely just the Default, but could be more from global-discovery, localStorage, etc.). + +If there are no events (which can happen), $setEvents exits... nothing to do. + +If there are events, it should + check if there is an 'own' shape.aliasEngine, else allocate a 'new AliasEngine(...)" +the new aliasEngine should contain a reference to it's parent object... nothing for global, global for sandbox, global-or-sandbox for instances. +This hierarchy is important for Event resolution (see below). +each new aliasEngine should calculate it's own 'depth'... that is, + global => 0, + sandbox => 1+ (increasing for each sandbox created from another sandbox), + instance => 1 (if direct child of global) or => 2+ (if direct child of a sandbox) + +$setEvents should then call aliasEngine.clear('event')... not sure if this is absolutely necessary, but couldn't hurt. + +$setevents should then call "const groups = aliasEngine.registerEvents(events);" +to pass control to the shape's aliasEngine instance. + +That instance will go through the 'events' array, and for each: + stash some related information into the Engine's instance so we can track + ### a sequential number to be allocated on an Event + ### the baseName + ### the eventTarget + ### the eventName (? not sure if this is needed ?) + a Set on the instance will track calc'd 'baseName' + it will also output a 'warn' if it detects that a baseName has already been used (whether in the current events-array or up the proto-chain). + +Once the registration process is complete, it should return a regex-like string back to the caller in tempo.class. +The string will contain (from lowest to highest in the proto-chain) a calculated named-group regex source, with "(?eventTarget)" + the section will be "{depth}evt{index}" where depth is the aliasEngine's instance depth (0, 1, 2, etc.) and index is the sequential number that was assigned to an Event. + For example, passing in ['xmas','25-Dec'] from the global shape will have the registration return "((?<0evt1>xmas))" + For example, later passing in [bday; '20-May'] from an instance shape will have the registration return "((?<1evt1>bday)|(?<0evt1>xmas))" + +When assembling the string to be returned (pipe-delimited named-group regex-source), the registration should: + ensure lower-depth regex-sources are returned prior to higher-depth + ensure that if a lower-depth is marked as a 'collision', then any higher-depth with that same baseName will be excluded + +To use a Period as an example, assuming an instance wants to override a global definition of 'noon': + "new Tempo('noon': {period: {noon:'11:00'}}); + We would expect the depth for the Tempo-instance to be '1' (direct child of global shape) + We would expect the index to be '1' (first Period alias detected) + We would expect the registerEvents to return "((?<1per1>noon)|... the rest of the global Periods *except* where baseName is 'noon')" + +When the calculated alias-string is returned to tempo.class, it will then update it's shadow the definition of the parent's snippets for Token.evt and Token.per. +tempo.class then calls setPatterns which will build the actual patterns (based on the current Layouts / Snippets) + +## Event Resolution +when the parsing engine detects a match against the patterns, and it finds a named-group with the pattern evt or per, then it knows it has an alias to de-reference. + +It will find the aliasEngine that is associated with the current tempo-level being parse (global, sandbox, instance). + +It will then invoke that aliasEngine's instance's method resolveEvent (or resolvePeriod) by passing in the named-group and the 'this' reference. + +The aliasEngine will decode the 'depth' from the alias argument (the leading digits before the 'evt' or 'per' portion of the string), and travel up the proto-chain til it finds the correct instance that matches that depth. + +The aliasEngine will then decode the 'index' from the alias argument (the trailing digit after the 'evt' or 'per' portion of the string) + +That resolved instance will lookup its own registry of Aliases for the index of the eventTarget. + +If the retrieved eventTarget is a string, it will return it to the parsing engine. +If the retrieved eventTarget is a Function, it will invoke the function (binding the 'this' context), and invoke a .toString() on the result before passing it back to the eventTarget;' + +* what to do if the alias resolution is cyclic ? diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 59090fe..7745ff3 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -1,6 +1,6 @@ import '#library/temporal.polyfill.js'; import { asType } from '#library/type.library.js'; -import { isNull, isString, isObject, isFunction, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; +import { isNull, isString, isObject, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; import { asArray, asInteger } from '#library/coercion.library.js'; import { isNumeric } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; @@ -21,6 +21,33 @@ import enums from '../support/tempo.enum.js'; import * as t from '../tempo.type.js'; import type { Tempo } from '../tempo.class.js'; +/** + * Provide a lightweight host context that mimics a Tempo instance for functional alias handlers. + * @internal + */ +function getResolutionContext(state: any, dateTime: Temporal.ZonedDateTime, resolvingKeys: Set) { + const TempoClass = getRuntime().modules['Tempo']; + return { + add: (val: any) => dateTime.add(val), + subtract: (val: any) => dateTime.subtract(val), + with: (val: any) => dateTime.with(val), + set: (val: any, opt?: any) => { + const res = _ParseEngine.conform(state, val, dateTime, true, resolvingKeys); + return (TempoClass as any)?.from(isZonedDateTime(res.value) ? res.value : dateTime, { ...state.config, ...opt }); + }, + toNow: () => instant().toZonedDateTimeISO(state.config.timeZone).withCalendar(state.config.calendar), + toDateTime: () => dateTime, + get hh() { return dateTime.hour }, + get mi() { return dateTime.minute }, + get ss() { return dateTime.second }, + get yy() { return dateTime.year }, + get mm() { return dateTime.month }, + get dd() { return dateTime.day }, + [sym.$Identity]: true, + config: state.config + }; +} + /** * Internal Parse Engine Implementation */ @@ -131,7 +158,7 @@ const _ParseEngine = { const { timeZone: tz2, calendar: cal2 } = state.config; const [targetTz, targetCal] = getTemporalIds(tz2, cal2); - const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal, (m) => _ParseEngine.result(state, m), state.config.timeStamp); + const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal, (m) => _ParseEngine.result(state, m), state.config.timeStamp, state.config); dateTime = dt; if (timeZone && state) state.config.timeZone = timeZone; @@ -291,13 +318,16 @@ const _ParseEngine = { if (isEmpty(groups)) continue; - const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key)) || Object.values(groups).includes('now'); + const hasTime = Object.keys(groups) + .some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key) || (Match.named.test(key) && key.endsWith('tm'))) || Object.values(groups).includes('now'); _ParseEngine.result(state, { match: symKey.description, value: trim, groups: { ...groups } }); dateTime = parseZone(groups, dateTime, state.config); dateTime = _ParseEngine.parseGroups(state, groups, dateTime, isAnchored, resolvingKeys); - dateTime = parseWeekday(groups, dateTime, (TempoClass as any)?.[sym.$dbg], state.config); - dateTime = parseDate(groups, dateTime, (TempoClass as any)?.[sym.$dbg], state.config, state.parse["pivot"]); + if (state.errored) return arg; + + dateTime = parseWeekday(groups, dateTime, state.config); + dateTime = parseDate(groups, dateTime, state.config, state.parse["pivot"]); dateTime = parseTime(groups, dateTime); const isChanged = !dateTime.toPlainTime().equals(anchorTime); @@ -325,7 +355,6 @@ const _ParseEngine = { /** resolve {event} | {period} to their date | time values (mutates groups) */ parseGroups(state: t.Internal.State, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime { - const TempoClass = getRuntime().modules['Tempo']; const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -336,135 +365,77 @@ const _ParseEngine = { const isRoot = state.parseDepth === 1; if (isRoot) state.matches = []; - try { - const resolved = new Set(); - let pending: string[]; - - while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { - const key = pending[0]; + const TempoClass = getRuntime().modules['Tempo']; + const aliasEngine = state.aliasEngine ?? (TempoClass as any)?.[sym.$Internal]?.().aliasEngine; + try { + for (const key of ownKeys(groups)) { if (key === 'slk') { const slk = groups[key]; const result = resolveTermMutation(TempoClass, state as any, 'set', slk, undefined, dateTime); + if (result === null) { state.errored = true; - resolved.add(key); delete groups[key]; break; } + dateTime = result; - resolved.add(key); delete groups[key]; continue; } - const isEvent = Match.event.test(key); - const isGlobal = key.startsWith('g'); - const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; - const idx = isNamed ? -1 : +(key.match(/\d+$/)?.[0] ?? -1); - - if (isNamed) { - resolved.add(key); + if (Match.named.test(key)) { // remove structural markers delete groups[key]; continue; } - const globalParse = isGlobal ? (TempoClass as any)?.[sym.$Internal]?.().parse : undefined; - const src = isGlobal - ? (isEvent ? globalParse?.event : globalParse?.period) - : (isEvent ? state.parse.event : state.parse.period); - const entry = ownEntries(src, true)[idx]; + const register = aliasEngine?.getAlias(key); + if (!register) continue; - if (!entry) { - resolved.add(key); - delete groups[key]; - continue; - } - - const aliasKey = entry[0] as string; + const aliasKey = register.name; if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); - resolved.add(key); delete groups[key]; continue; } resolvingKeys.add(aliasKey); - resolved.add(key); - - const definition = entry[1]; - const isFn = isFunction(definition); - let res: string = ''; - if (isFn) { - // Provide a lightweight host context that mimics a Tempo instance for the handler - const host = { - add: (val: any) => { - return dateTime.add(val); - }, - subtract: (val: any) => { - return dateTime.subtract(val); - }, - with: (val: any) => dateTime.with(val), - set: (val: any, opt?: any) => { - const res = _ParseEngine.conform(state, val, dateTime, true, resolvingKeys); - return (TempoClass as any)?.from(isZonedDateTime(res.value) ? res.value : dateTime, { ...state.config, ...opt }); - }, - toNow: () => instant().toZonedDateTimeISO(state.config.timeZone).withCalendar(state.config.calendar), - toDateTime: () => dateTime, - get hh() { return dateTime.hour }, - get mi() { return dateTime.minute }, - get ss() { return dateTime.second }, - get yy() { return dateTime.year }, - get mm() { return dateTime.month }, - get dd() { return dateTime.day }, - [sym.$Identity]: true, - config: state.config - }; - - const result = (definition as Function).call(host); - if (isString(result) && /^(?:[01]?\d|2[0-3]):[0-5]\d$/.test(result)) { - const [hourStr, minuteStr] = result.split(':'); - const hour = Number(hourStr); - const minute = Number(minuteStr); - dateTime = dateTime.with({ hour, minute, second: 0, millisecond: 0 }); - res = ''; - } else if (isTempo(result)) { - dateTime = (result as any).toDateTime(); - } else if (isZonedDateTime(result)) { - dateTime = result as Temporal.ZonedDateTime; - } else if (isObject(result) && isFunction((result as any).toDateTime)) { - dateTime = (result as any).toDateTime(); - } else { - res = isString(result) || isNumeric(result) ? String(result) : ''; - } - state.zdt = dateTime; - } else { - res = (definition as string); - } + + const host = getResolutionContext(state, dateTime, resolvingKeys); + const res = aliasEngine?.resolveAlias(key as any, host); + if (!res) continue; try { - const type = isEvent ? 'Event' : 'Period'; - const pat = (isEvent ? 'dt' : 'tm'); - const resolveVal = isFn ? res : definition; - const source = isGlobal ? 'global' : 'local'; - _ParseEngine.result(state, { type, value: entry[0] as any, match: pat, source, groups: { [key]: resolveVal as string } }); - - // Protect against recursive re-evaluation of same alias - if (!isEmpty(res) && res !== String(groups[key])) { + const mapped = ({ + evt: { type: 'Event', pat: 'dt' }, + per: { type: 'Period', pat: 'tm' } + } as const)[res.type as 'evt' | 'per']; + + if (!mapped) + throw new Error(`[ParseEngine] Unexpected AliasType: ${res.type}`); + + const { type, pat } = mapped; + + _ParseEngine.result(state, { type, value: res.key as any, match: pat, source: res.source, groups: { [key]: res.value } }); + + // If it resolved to a new string, we re-parse it + if (!isEmpty(res.value) && res.value !== String(groups[key])) { const resolving = new Set(resolvingKeys); - resolving.add(aliasKey); + resolving.add(res.key); // Explicitly propagate anchor for recursive parse const prevAnchor: any = state.anchor; state.anchor = dateTime; - const resMatch = _ParseEngine.parseLayout(state, res, dateTime, true, resolving); + const resMatch = _ParseEngine.parseLayout(state, res.value, dateTime, true, resolving); state.anchor = prevAnchor; if (resMatch.type === 'Temporal.ZonedDateTime') dateTime = resMatch.value; } } finally { + state.zdt = dateTime; delete groups[key]; } } diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index cf5329d..dab7848 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -2,185 +2,261 @@ // Alias Resolution Engine for Tempo // Responsible for event/period alias mapping, collision detection, and snippet rebinding -import { asType } from '#library/type.library.js'; +/** + * AliasEngine Collision Policy: + * + * If an Event and a Period alias share the same base word (e.g., 'xmas'), + * a warning is logged and only the Event alias is included in the regex pattern for parsing. + * This ensures deterministic parsing and avoids ambiguous matches. + * + * Both aliases may still be registered and resolved, but only the Event alias will be matched + * during parsing if a collision occurs. This is a best-effort approach and not entirely risk-free; + * users should avoid such collisions when possible. + */ + +import type { Nullable } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; +import { isDefined, isFunction, isZonedDateTime } from '#library/assertion.library.js'; +import { Match } from '#tempo/support'; +import { ownEntries } from '#library/primitive.library.js'; +import * as t from '../tempo.type.js'; export type AliasTarget = string | number | Function +type AliasType = 'evt' | 'per'; +type AliasKey = `${AliasType}${number}_${number}`; +type State = Record + +export interface AliasResult { + value: string; + key: string; // The normalized baseWord (e.g. 'noon') + type: AliasType; + source: 'global' | 'local'; + isClock: boolean; + isFunction: boolean; +} export interface AliasEngineOptions { - parent?: AliasEngine | undefined; - logger?: Logify | undefined; + parent?: Nullable; + logger?: Nullable; + config?: Nullable; +} +interface Registry { // information about each registered alias + key?: AliasKey; + name: string; + target: AliasTarget; + type: AliasType; + baseWord: string; + collision?: boolean; + depth: number; } export class AliasEngine { - #parentEngine?: AliasEngineOptions["parent"]; + static aliasPattern = /^(evt|per)(\d+)_(\d+)$/; + static #idCounter = 0; + + static #getBaseWord(s: string): string { + return s + .toLowerCase() + .replace(/\[[^\]]*\]\?/g, '') + .replace(/.\?/g, '') + .replace(/[^a-z0-9]/g, ''); + } + + #parent?: AliasEngineOptions["parent"]; #logger?: AliasEngineOptions["logger"]; + #config?: AliasEngineOptions["config"]; - constructor(options: AliasEngineOptions = {}) { - this.#parentEngine = options.parent; + #depth: number; // the depth of this engine in the proto chain + #count: Record; // count of aliases registered at this level (used for indexing) + #state: State; // object that holds alias mappings, collisions, and registry for this engine + #words: Record; // object of base words for collision detection + #id: number; + #version = 0; + + get depth() { + return this.#depth + } + get id() { return this.#id } + get parent() { return this.#parent } + getWords() { return this.#words } + + constructor(options = {} as AliasEngineOptions) { + this.#parent = options.parent ?? null; this.#logger = options.logger; + this.#config = options.config; + this.#id = AliasEngine.#idCounter++; + + if (this.#parent) { + if (!(this.#parent instanceof AliasEngine)) { + const msg = "Parent engine must be an instance of AliasEngine"; + this.#logger?.error(this.#config, msg); + throw new TypeError(msg); + } + + this.#depth = this.#parent.#depth + 1; + this.#state = Object.create(this.#parent.#state); // create a new state object that inherits from the parent engine's state + this.#words = Object.create(this.#parent.#words); // create a new words object that inherits from the parent engine's words for collision detection + } else { + this.#depth = 0; + this.#state = Object.create(null); // initialize an empty state for the root engine (no parent) + this.#words = Object.create(null); // initialize an empty words object for the root engine (no parent) + } + + this.#count = { evt: 0, per: 0 }; } /** - * Detect likely overlap between two alias keys/patterns (moved from Tempo) + * Register aliases and return a regex string representing the full lineage of aliases up the proto chain. + * Ensures that shadowed/collided baseNames are excluded from parent levels. + * Note that we track collisions across both Event and Period aliases in the same #words object, since they can + * collide with each other (e.g. "on" could be an event alias and a period alias, + * which would cause confusion and unintended matches). */ - static isAliasCollision(a: string, b: string): boolean { - const left = a.trim().toLowerCase(); - const right = b.trim().toLowerCase(); + registerAliases(type: AliasType, events: [string, AliasTarget][]) { + for (const [name, target] of events) { + const index = (this.#count[type]++); + const aliasKey = `${type}${this.#depth}_${index}` as AliasKey; - if (!left || !right) return false; - if (left === right) return true; + const baseWord = AliasEngine.#getBaseWord(name); + const existingKey = this.#words[baseWord]; + const existing = existingKey ? this.getAlias(existingKey) : undefined; + const shouldOverwrite = !(existing?.type === 'evt' && type === 'per'); - // Extract the 'core' characters to determine if they conceptually target the same word - const getBaseWord = (s: string) => s - .replace(/\[[^\]]*\]\?/g, '') // remove optional character classes (e.g. [ -]?) - .replace(/.\?/g, '') // remove optional single characters (e.g. s?) - .replace(/[^a-z0-9]/g, ''); // remove all non-alphanumeric characters (regex metachars, spaces, hyphens) + if (this.#logger && baseWord in this.#words) { + this.#logger.warn(this.#config, + `[AliasEngine] Collision detected for ${type} alias "${name}". ${shouldOverwrite ? 'Overwriting' : 'Preserving'} existing alias.` + ); + } - const baseLeft = getBaseWord(left); - const baseRight = getBaseWord(right); + if (shouldOverwrite) { + this.#words[baseWord] = aliasKey; + } - if (!baseLeft || !baseRight) return false; + this.#state[aliasKey] = { + name, // plain string or regex-like string + target, // string, number, or function + type, // 'evt' or 'per' + baseWord, // used for collision detection + collision: Boolean(existingKey), + depth: this.#depth, + } + } - return baseLeft === baseRight; + this.#version++; + return this.getPatterns(type); } - #eventMap: Map = new Map(); - #periodMap: Map = new Map(); - #eventCollisions: Map = new Map(); - #periodCollisions: Map = new Map(); + /** + * Build regex patterns for this engine and all parent engines, excluding shadowed/collided baseNames. + * This ensures that if an alias is shadowed by a child engine, + * it won't be included in the regex patterns of the parent engine, + * preventing unintended matches and preserving the expected behavior of alias resolution. + */ + getPatterns(type: AliasType, seenBaseNames = new Set(), winnerLookup?: Record): string | undefined { + const patterns: string[] = []; + const winners = winnerLookup ?? this.#words; - // Event alias management - registerEventAlias(name: string, target: AliasTarget): void { - this.#registerAliasWithCollision(name, target, this.#eventMap, this.#eventCollisions, 'event'); - } + for (const [alias, register] of ownEntries(this.#state)) { + if (register.type === type && !seenBaseNames.has(register.baseWord)) { + // Check for cross-type collision priority (Events win over Periods) + if (type === 'per') { + const winnerKey = winners[register.baseWord]; + const winner = this.getAlias(winnerKey); + if (winner && winner.type === 'evt') { + continue; // Skip period if an event is using the same baseWord + } + } - registerEvents(events: [string, AliasTarget][]): void { - for (const [name, target] of events) - this.registerEventAlias(name, target); - } - resolveEventAlias(name: string, thisArg?: any) { - return this.#resolveAlias(name, this.#eventMap, thisArg); - } - hasEventAlias(name: string): boolean { - return this.#eventMap.has(name); - } - getAllEventAliases(): Record { - return Object.fromEntries(this.#eventMap.entries()); - } - detectEventCollisions(): Record { - return Object.fromEntries(this.#eventCollisions.entries()); - } + seenBaseNames.add(register.baseWord); + patterns.push(`(?<${alias}>${Match.safeAlias(register.name)})`); + } + } - // Period alias management - registerPeriodAlias(name: string, target: AliasTarget): void { - this.#registerAliasWithCollision(name, target, this.#periodMap, this.#periodCollisions, 'period'); - } + if (this.#parent) { + const parentPatterns = this.#parent.getPatterns(type, seenBaseNames, winners); + if (parentPatterns) patterns.push(parentPatterns); + } - registerPeriods(periods: [string, AliasTarget][]): void { - for (const [name, target] of periods) - this.registerPeriodAlias(name, target); - } - resolvePeriodAlias(name: string, thisArg?: any) { - return this.#resolveAlias(name, this.#periodMap, thisArg); + return patterns.join('|'); } - hasPeriodAlias(name: string): boolean { - return this.#periodMap.has(name); - } - getAllPeriodAliases(): Record { - return Object.fromEntries(this.#periodMap.entries()); + + getVersion(): number { + return this.#version + (this.#parent?.getVersion() ?? 0); } - detectPeriodCollisions(): Record { - return Object.fromEntries(this.#periodCollisions.entries()); + + hasAlias(name: string, type?: AliasType) { + const baseWord = AliasEngine.#getBaseWord(name); + const key = this.#words[baseWord]; + return !key + ? false + : type + ? this.#state[key].type === type + : true } - // Shared logic - #registerAliasWithCollision( - name: string, - target: AliasTarget, - map: Map, - collisions: Map, - type: 'event' | 'period' - ) { - let collisionDetected = false; - // Check for local collisions using isAliasCollision - for (const [existingName, existingTarget] of map.entries()) { - if ( - existingTarget !== target && - AliasEngine.isAliasCollision(existingName, name) - ) { - const existing = collisions.get(existingName) || []; - collisions.set( - existingName, - Array.from(new Set([...existing, target, existingTarget])) - ); - collisionDetected = true; - } - } + resolveAlias(name: AliasKey, thisArg?: any): AliasResult | undefined { + const register = this.getAlias(name); + if (!register) return undefined; - // Check for parent collisions using isAliasCollision - let parent = this.#parentEngine; - while (parent) { - const parentAliases = type === 'event' ? parent.getAllEventAliases() : parent.getAllPeriodAliases(); - for (const [parentName, parentTarget] of Object.entries(parentAliases)) { - if ( - parentTarget !== target && - AliasEngine.isAliasCollision(parentName, name) - ) { - const parentCollisions = collisions.get(parentName) || []; - collisions.set( - parentName, - Array.from(new Set([...parentCollisions, target, parentTarget])) - ); - collisionDetected = true; - } - } - // Access parent's parent engine via a public way if possible, or keep it private if safe - // Since we're in the same class, we can still use #parentEngine for the chain - parent = parent.#parentEngine; - } + let value = ''; + const isFn = isFunction(register.target); - if (collisionDetected && this.#logger) { - this.#logger.warn( - `[AliasEngine] Potential Collision detected for ${type} alias "${name}". Multiple definitions found. This may shadow or overwrite an existing alias.` - ); + if (isFunction(register.target)) { + const result = register.target.call(thisArg); + value = isDefined(result) ? result.toString() : ''; + } else { + value = register.target.toString(); } - map.set(name, target); + return { + value, + key: register.baseWord, + type: register.type, + source: register.depth === 0 ? 'global' : 'local', + isClock: Match.clock.test(value), + isFunction: isFn + }; } - #resolveAlias(name: string, map: Map, thisArg?: any): string | number { - let currentEngine: AliasEngine | undefined = this; - const isEvent = map === this.#eventMap; + getAlias(key: string): Registry | undefined { + return this.#state[key as AliasKey] ?? this.#parent?.getAlias(key); + } - while (currentEngine) { - const { type, value } = asType(map.get(name)); - switch (type) { - case 'Function': - return value.call(thisArg); + getAliases(type?: AliasType, recurse = false) { + const aliases = [] as Registry[]; - case 'String': - case 'Number': - return value; + ownEntries(this.#state) + .filter(([_, register]) => !type || register.type === type) + .forEach(([key, register]) => { + aliases.push(Object.assign({}, { key }, register)); + }); - default: - currentEngine = currentEngine.#parentEngine; - if (currentEngine) - map = isEvent ? currentEngine.#eventMap : currentEngine.#periodMap; - } - } + if (recurse && this.#parent) + aliases.push(...this.#parent.getAliases(type, true)); - return name; + return aliases; } - clear(type?: 'event' | 'period'): void { - if (!type || type === 'event') { - this.#eventMap.clear(); - this.#eventCollisions.clear(); + clear(type: AliasType) { + this.#count[type] = 0; + + for (const registry in this.#state) { + if (this.#state[registry as AliasKey].type === type) { + delete this.#state[registry as AliasKey]; + } } - if (!type || type === 'period') { - this.#periodMap.clear(); - this.#periodCollisions.clear(); + + // Rebuild #words and re-calculate counts from remaining state + this.#words = Object.create(this.#parent ? (this.#parent as any).#words : null); + this.#count = { evt: 0, per: 0 }; + + for (const [key, register] of ownEntries(this.#state)) { + this.#words[register.baseWord] = key; + this.#count[register.type]++; } + + this.#version++; } + } + diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index f2f164e..9289d01 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -1,4 +1,4 @@ -import { isTempo, Match } from '#tempo/support'; +import { isTempo, logError } from '#tempo/support'; import { isNumeric, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; import type { TemporalObject, TypeValue } from '#library/type.library.js'; import type { Tempo } from '#tempo/tempo.class.js'; @@ -15,7 +15,8 @@ export function compose( targetTz: string, targetCal: string, onResult?: (match: any) => void, - unit: t.Internal.TimeStamp = 'ms' + unit: t.Internal.TimeStamp = 'ms', + config?: any ): { dateTime: Temporal.ZonedDateTime, timeZone?: string | undefined } { let temporal: TemporalObject | Tempo = today; let timeZone: string | undefined; @@ -37,18 +38,11 @@ export function compose( temporal = zdt; onResult?.({ type, value: str, match: 'iso8601' }); } catch (err) { - if (Match.date.test(value)) { - try { - temporal = Temporal.PlainDate.from(value); - break; - } catch { /* ignore and fallback */ } - } - try { - temporal = Temporal.PlainDateTime.from(value); + temporal = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }); } catch (err2) { - // security check: do not let native Date take a guess on garbage strings - throw new Error(`Cannot parse Date: "${value}"`); + logError(config, `[Tempo#composer] Unrecognized or invalid ISO 8601 string: "${value}"`); + return { dateTime: today }; } } break; @@ -80,8 +74,11 @@ export function compose( case 'Number': { - if (Number.isNaN(value) || !Number.isFinite(value)) - throw new RangeError(`Invalid Tempo number: ${value}`); + if (Number.isNaN(value) || !Number.isFinite(value)) { + logError(config, `Invalid Tempo number: ${value}`); + temporal = today; + break; + } // If it's an integer and we're in 'ms' mode, treat as milliseconds if (unit === 'ms' && Number.isInteger(value)) { @@ -149,8 +146,10 @@ export function compose( dateTime = temporal.toDateTime().withCalendar(targetCal); break; - default: - throw new Error(`Cannot convert ${type} (value: ${String(temporal)}) to ZonedDateTime`); + default: { + logError(config, `Cannot convert ${type} (value: ${String(temporal)}) to ZonedDateTime`); + return { dateTime: today }; + } } return { dateTime, timeZone }; diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 1faf895..acac968 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -2,7 +2,7 @@ import '#library/temporal.polyfill.js'; import { isString, isEmpty, isUndefined, isDefined, isTemporal, isInstant } from '#library/assertion.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match, enums, isTempo } from '#tempo/support'; +import { Match, enums, isTempo, logError, logWarn } from '#tempo/support'; import * as t from '../tempo.type.js'; /** @@ -112,7 +112,7 @@ export function parseModifier({ mod, adjust, offset, period }: Lexer.GroupModifi * * @returns ZonedDateTime with computed date-offset */ -export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, logger: any, config: any): Temporal.ZonedDateTime { +export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, config: any): Temporal.ZonedDateTime { const { wkd, mod, nbr = '1', sfx, afx, ...rest } = groups as Lexer.GroupWkd; if (isUndefined(wkd)) return dateTime; @@ -121,7 +121,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, return dateTime; if (!isEmpty(mod) && !isEmpty(sfx)) { - logger.warn(config, `Cannot provide both a modifier '${mod}' and suffix '${sfx}'`); + logWarn(config, `Cannot provide both a modifier '${mod}' and suffix '${sfx}'`); return dateTime; } @@ -130,7 +130,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, const offset = (enums.WEEKDAY as any)[weekday] ?? (enums.WEEKDAYS as any)[weekday]; if (!Number.isFinite(offset)) { - logger.error(config, `Invalid weekday token: "${wkd}"`); + logError(config, `Invalid weekday token: "${wkd}"`); return dateTime; } @@ -146,7 +146,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, } /** resolve a date pattern match */ -export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, logger: any, config: any, pivot: number = 75): Temporal.ZonedDateTime { +export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, config: any, pivot: number = 75): Temporal.ZonedDateTime { const { mod, nbr = '1', afx, unt } = groups as Lexer.GroupDate; // Normalize yy, mm, dd: treat empty string as missing @@ -158,7 +158,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, lo return dateTime; if (!isEmpty(mod) && !isEmpty(afx)) { - logger.warn(config, `Cannot provide both a modifier '${mod}' and suffix '${afx}'`); + logWarn(config, `Cannot provide both a modifier '${mod}' and suffix '${afx}'`); return dateTime; } @@ -211,7 +211,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, lo delete groups["afx"]; if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { - logger.error(config, `Invalid Date components: year=${year}, month=${month}, day=${day}`); + logError(config, `Invalid Date components: year=${year}, month=${month}, day=${day}`); return dateTime; } diff --git a/packages/tempo/src/parse/parse.layout.ts b/packages/tempo/src/parse/parse.layout.ts index aae3df2..ae4da41 100644 --- a/packages/tempo/src/parse/parse.layout.ts +++ b/packages/tempo/src/parse/parse.layout.ts @@ -3,7 +3,7 @@ import { Token } from '#tempo/support/tempo.symbol.js'; import type * as t from '../tempo.type.js'; export type LayoutEntry = [symbol, string]; -export type LayoutController = Record; +export type LayoutController = Record; const TOKEN_ALIAS = new Map( (ownEntries(Token, true) as [string, symbol][]).map(([name, key]) => [key, name]) @@ -47,8 +47,15 @@ export function resolveLayoutClassificationOrder(layout: Record, const seen = new Set(); preferred.forEach(name => { - const resolvedName = TOKEN_DESCRIPTION_BY_NAME.get(name) ?? name; - const entry = byName.get(resolvedName) ?? byName.get(name); + const isSym = typeof name === 'symbol'; + const description = isSym ? (name.description ?? '') : ''; + const alias = isSym ? TOKEN_ALIAS.get(name) : undefined; + + const resolvedName = !isSym ? (TOKEN_DESCRIPTION_BY_NAME.get(name) ?? name) : undefined; + const entry = isSym + ? (byName.get(description) ?? (alias ? byName.get(alias) : undefined)) + : (byName.get(resolvedName!) ?? byName.get(name)); + if (!entry) return; if (seen.has(entry[0])) return; seen.add(entry[0]); diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term.util.ts index cccfffe..c44f88b 100644 --- a/packages/tempo/src/plugin/term.util.ts +++ b/packages/tempo/src/plugin/term.util.ts @@ -1,5 +1,5 @@ -import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; -import { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/assertion.library.js'; +import { toZonedDateTime, toInstant, getTemporalIds, instant } from '#library/temporal.library.js'; +import { isDefined, isFunction, isString, isUndefined, isNumber, isZonedDateTime } from '#library/assertion.library.js'; import { secure } from '#library/proxy.library.js'; import { sortKey, byKey } from '#library/array.library.js'; import { sym, TermError, SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; @@ -50,7 +50,9 @@ export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | num const chronological = sortKey([...list], 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'); if (chronological.length === 0) return undefined; - const zdt = anchor ?? (tempo as any).toDateTime(); + let zdt = anchor ?? (tempo as any).toDateTime(); + if (!isZonedDateTime(zdt)) + zdt = instant().toZonedDateTimeISO((tempo as any).config?.timeZone ?? 'UTC'); // determine the largest unit defined in the range list, and use the unit above it as rollover const unit = getLargestUnit(list); @@ -69,11 +71,13 @@ export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | num obj[u] = (i <= 2) ? 1 : 0; } else { const fallback = (anchor as any)[u]; - obj[u] = isNumber(fallback) ? fallback : (i <= 2 ? 1 : 0); + obj[u] = (isNumber(fallback) && !Number.isNaN(fallback)) ? fallback : (i <= 2 ? 1 : 0); } } // @ts-ignore - const resZdt = toZonedDateTime({ ...obj, timeZone: anchor.timeZoneId, calendar: anchor.calendarId }); + const [timeZone, calendar] = getTemporalIds(anchor.timeZoneId, anchor.calendarId); + const resZdt = toZonedDateTime({ ...obj, timeZone, calendar }); + // @ts-ignore return new (getHost(tempo))(resZdt, (tempo as any).config); } diff --git a/packages/tempo/src/plugin/term/standard.index.ts b/packages/tempo/src/plugin/term/standard.index.ts deleted file mode 100644 index 93bf5c4..0000000 --- a/packages/tempo/src/plugin/term/standard.index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Tempo } from '../../tempo.class.js'; -import { onRegistryReset } from '../../support/tempo.register.js'; -import { TermsModule } from './term.index.js'; - -// Side-effect: Automatically register all standard terms -Tempo.extend(TermsModule); - -// Resilience: Ensure terms are restored after a registry reset -onRegistryReset(() => { - Tempo.extend(TermsModule); -}); diff --git a/packages/tempo/src/plugin/term/term.index.ts b/packages/tempo/src/plugin/term/term.index.ts index 3f3eb63..685f296 100644 --- a/packages/tempo/src/plugin/term/term.index.ts +++ b/packages/tempo/src/plugin/term/term.index.ts @@ -1,12 +1,11 @@ import { defineModule } from '../plugin.util.js' import { getRuntime, onRegistryReset } from '#tempo/support'; +import { Tempo } from '../../tempo.class.js'; import { QuarterTerm } from './term.quarter.js' import { SeasonTerm } from './term.season.js' import { ZodiacTerm } from './term.zodiac.js' import { TimelineTerm } from './term.timeline.js' -import type { Tempo } from '../../tempo.class.js'; - /** collection of built-in terms for initial registration */ export const StandardTerms = [QuarterTerm, SeasonTerm, ZodiacTerm, TimelineTerm]; export { defineTerm, defineRange, getTermRange } from '../term.util.js'; @@ -20,3 +19,11 @@ export const TermsModule = defineModule({ TempoClass.extend(StandardTerms); }, }); + +// Side-effect: Automatically register all standard terms when this barrel is imported +Tempo.extend(TermsModule); + +// Resilience: Ensure terms are restored after a registry reset +onRegistryReset(() => { + Tempo.extend(TermsModule); +}); diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index c21a92e..71d06f0 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -28,9 +28,9 @@ export { export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './tempo.symbol.js'; -export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $buildGuard, $IsBase, $Identity, $Logify, $Discover, $ImmutableSkip } from './tempo.symbol.js'; +export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $Logify, $Discover, $ImmutableSkip } from './tempo.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; -export { SCHEMA, getLargestUnit, setPatterns } from './tempo.util.js'; +export { SCHEMA, getLargestUnit, setPatterns, logError, logWarn, logDebug } from './tempo.util.js'; export { init, extendState } from './tempo.init.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 1e32b8c..e76aa74 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -17,11 +17,13 @@ const bracket_content = /[^\]]+/; export const Match = proxify({ /** match all {} pairs, if they start with a word char */ braces: /{([#]?[\w]+(?:\.[\w]+)*)}/g, /** named capture-group, if it starts with a letter */ captures: /\(\?<([a-zA-Z][\w]*)>(.*?)(?[0-9]+)$|^g?dt$/, - /** period */ period: /^([gl])?per(?[0-9]+)$|^g?tm$/, + /** event */ event: /^evt\d+_\d+$/, + /** period */ period: /^per\d+_\d+$/, + /** structural */ named: /^g?dt$|^g?tm$/, /** two digit year */ twoDigit: /^[0-9]{2}$/, - /** date */ date: /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, - /** time */ time: /^[0-9]{2}:[0-9]{2}(:[0-9]{2})?$/, + /** date (ISO 8601) */ date: /^(?:[+-][0-9]{6}|[0-9]{4})-?(?:0[1-9]|1[0-2])-?(?:0[1-9]|[12][0-9]|3[01])$/, + /** time (HH:mm[:ss]) */ time: /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/, + /** clock (HH:mm[:ss][.ffffff]) */ clock: /^(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?(?:\.\d{1,9})?$/, /** separator characters (/ - . , T) */ separator: /[T\/\-\.\s,]/, /** modifier characters (+-<>=) */ modifier: /[\+\-\<\>][\=]?|this|next|prev|last/, /** offset post keywords (ago|hence) */ affix: /ago|hence|from now/, @@ -34,6 +36,8 @@ export const Match = proxify({ /** anchored version for shifter resolution */ slick: /^(?#[\w]+|[\w]+)\.(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, /** extracted value-only version of a slick shifter */ slickValue: /^(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, /** escape special regex characters in a string */ escape: (str: string) => String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + /** escape only dangerous quantifiers and anchors to prevent backtracking/injection while allowing basic regex */ + safeAlias: (str: string) => String(str).replace(/[*+{}!^$\\]/g, '\\$&'), /** numeric-only string detection */ numeric: /^\s*[-+]?\d+(\.\d+)?\s*$/, /** match suspicious nested quantifiers (backtracking) */ backtrack: /(\(.*\)\+|\(.*\)\*|\(.*\)\{.*\})/, }, true, false); @@ -115,7 +119,7 @@ export type Layout = typeof Layout export const Event = looseIndex()({ 'new.?years? ?eve': '31 Dec', 'nye': '31 Dec', - 'new.?years?( ?day)?': '01 Jan', + 'new.?years?(?: ?day)?': '01 Jan', 'ny': '01 Jan', 'christmas ?eve': '24 Dec', 'christmas': '25 Dec', @@ -198,5 +202,12 @@ export const Default = secure({ /** regional date-parsing configuration */ monthDay: MONTH_DAY, /** internationalization configuration */ intl: IntlDefault, - /** parse planner configuration (layoutOrder, etc.) */ planner: { layoutOrder: [], preFilter: false }, + /** parse planner configuration (layoutOrder, etc.) */ planner: { + layoutOrder: [ + Token.hms, Token.dmy6, Token.mdy6, Token.ymd6, Token.wkd, + Token.dt, Token.tm, Token.dtm, Token.tmd, Token.dmy, Token.mdy, Token.ymd, + Token.off, Token.rel + ], + preFilter: false + }, } as Options) diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index 5d15b76..9241a92 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -28,7 +28,7 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }) as t.Internal.State; // 1. Establish the base parsing state - state.parse = markConfig({ + const parseState: t.Internal.Parse = { token: Token, result: [], snippet: Object.assign({}, baseState?.parse.snippet ?? Snippet), @@ -36,16 +36,36 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int event: Object.assign({}, baseState?.parse.event ?? Event), period: Object.assign({}, baseState?.parse.period ?? Period), ignore: baseState ? { ...baseState.parse.ignore } : Object.fromEntries(asArray(Ignore).map(w => [w, w])), - monthDay: resolveMonthDay(baseState?.parse.monthDay ?? {}, Default.monthDay as any), - planner: { - layoutOrder: asArray(baseState?.parse.planner?.layoutOrder ?? (Default.planner?.layoutOrder ?? (Default as any).layoutOrder)), - preFilter: Boolean(baseState?.parse.planner?.preFilter ?? (Default.planner?.preFilter ?? (Default as any).preFilter)), + monthDay: baseState ? { + ...baseState.parse.monthDay, + locales: [...asArray(baseState.parse.monthDay.locales)], + layouts: [...asArray(baseState.parse.monthDay.layouts)], + timezones: { ...baseState.parse.monthDay.timezones }, + ...(baseState.parse.monthDay.resolvedLocales ? { + resolvedLocales: baseState.parse.monthDay.resolvedLocales.map((l: any) => ({ ...l, timeZones: [...l.timeZones] })) + } : {}) + } : resolveMonthDay({}, Default.monthDay as any), + planner: baseState ? { + ...(baseState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(baseState.parse.planner.layoutOrder)] } : {}), + ...(isDefined(baseState.parse.planner.preFilter) ? { preFilter: Boolean(baseState.parse.planner.preFilter) } : {}), + } : { + layoutOrder: [...asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder)], + preFilter: Boolean(Default.planner?.preFilter ?? (Default as any).preFilter), }, pivot: (baseState?.parse.pivot ?? Default.pivot) as any, mode: (baseState?.parse.mode ?? Default.mode) as any, lazy: false, pattern: new Map(baseState?.parse.pattern), - }); + ...(baseState ? { + ...(isDefined(baseState.parse.isAnchored) ? { isAnchored: baseState.parse.isAnchored } : {}), + ...(isDefined(baseState.parse.anchor) ? { anchor: baseState.parse.anchor } : {}), + ...(isDefined(baseState.parse.format) ? { format: baseState.parse.format } : {}), + ...(isDefined(baseState.parse.term) ? { term: baseState.parse.term } : {}), + ...(isDefined(baseState.parse.guard) ? { guard: baseState.parse.guard } : {}), + } : {}) + }; + + state.parse = markConfig(parseState); // 2. Establish the base configuration options const configDefaults = Object.fromEntries(Object.entries(Default).filter(([key]) => enums.CONFIG.has(key))); diff --git a/packages/tempo/src/support/tempo.runtime.ts b/packages/tempo/src/support/tempo.runtime.ts index 752848b..3e439c6 100644 --- a/packages/tempo/src/support/tempo.runtime.ts +++ b/packages/tempo/src/support/tempo.runtime.ts @@ -45,6 +45,8 @@ export class TempoRuntime { /** persistent global configuration state — mirrors Tempo.#global */ state?: Internal.State | undefined; + /** centralized diagnostic logger — shared across all Tempo modules */ + logger?: any | undefined; /** cache for next-available 'usr' Token key */ usrCount: number = 0; diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index dccd025..8ef15d7 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -30,6 +30,7 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr /** internal static discovery helper */ export const $setDiscovery: unique symbol = Symbol.for('magmacomputing/tempo/setDiscovery') as any; /** internal static event builder */ export const $setEvents: unique symbol = Symbol.for('magmacomputing/tempo/setEvents') as any; /** internal static period builder */ export const $setPeriods: unique symbol = Symbol.for('magmacomputing/tempo/setPeriods') as any; +/** internal static alias builder */ export const $setAliases: unique symbol = Symbol.for('magmacomputing/tempo/setAliases') as any; /** internal static guard builder */ export const $buildGuard: unique symbol = Symbol.for('magmacomputing/tempo/buildGuard') as any; /** internal static base class marker */ export const $IsBase: unique symbol = Symbol.for('magmacomputing/tempo/isBase') as any; @@ -37,7 +38,7 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr const local = { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, - $setEvents, $setPeriods, $buildGuard, $IsBase + $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase } as const; /** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index d0aa5d4..c43beb2 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -28,8 +28,25 @@ export const setProperty = (target: object, key: PropertyKey, value: T) => { /** @internal set multiple mutable, enumerable properties on a target */ export const setProperties = (target: object, properties: Record) => { - ownEntries(properties) - .forEach(([key, value]) => setProperty(target, key, value)); + ownEntries(properties).forEach(([key, value]) => setProperty(target, key, value)); +} + +/** @internal Centralized Error Logger — retrieves the shared Logify instance from the TempoRuntime */ +export function logError(config: any, ...msg: any[]) { + const rt = getRuntime(); + rt.logger?.error(config ?? rt.state?.config, ...msg); +} + +/** @internal Centralized Warning Logger — retrieves the shared Logify instance from the TempoRuntime */ +export function logWarn(config: any, ...msg: any[]) { + const rt = getRuntime(); + rt.logger?.warn(config ?? rt.state?.config, ...msg); +} + +/** @internal Centralized Debug Logger — retrieves the shared Logify instance from the TempoRuntime */ +export function logDebug(config: any, ...msg: any[]) { + const rt = getRuntime(); + rt.logger?.debug(config ?? rt.state?.config, ...msg); } /** @internal return the Prototype parent of an object */ @@ -148,13 +165,11 @@ export function compileRegExp(layout: string | RegExp, state: t.Internal.State, } } - - /** @internal build RegExp patterns into the state */ export function setPatterns(state: t.Internal.State) { // ensure we have our own isolated mutable containers before mutation state.parse.snippet = { ...state.parse.snippet }; - state.parse.pattern = new Map(state.parse.pattern); + state.parse.pattern = new Map(); const snippet = state.parse.snippet; @@ -189,7 +204,6 @@ export function setPatterns(state: t.Internal.State) { state.parse.pattern.set(symbol, compiled); }); - } /** @@ -233,7 +247,7 @@ export function resolveMonthDay(value: t.MonthDay | boolean = {}, base: t.MonthD return { locale: intl.baseName, timeZones: tzs_intl.length > 0 ? tzs_intl : fallback - }; + } }); return { @@ -243,5 +257,5 @@ export function resolveMonthDay(value: t.MonthDay | boolean = {}, base: t.MonthD layouts: layoutsList as any, timezones: tzs, resolvedLocales - }; + } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6ba4a1a..18705c6 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -25,8 +25,8 @@ import { AliasEngine } from './engine/engine.alias.js'; import { resolveMonthDay } from './support/tempo.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './parse/parse.layout.js'; import { datePattern } from './support/tempo.default.js'; -import { setProperty, proto, hasOwn, create, compileRegExp, setPatterns, normalizeLayoutOrder } from './support/tempo.util.js'; -import { sym, markConfig, TermError, getRuntime, init, extendState, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $buildGuard, $IsBase, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; +import { setProperty, proto, hasOwn, compileRegExp, setPatterns, normalizeLayoutOrder } from './support/tempo.util.js'; +import { sym, markConfig, TermError, getRuntime, init, extendState, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) declare module '#library/type.library.js' { @@ -140,99 +140,78 @@ export class Tempo { * {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested. * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ - // TODO: check all Layouts which reference "{evt}" and update them - static [$setEvents](shape: Internal.State) { - const events = ownEntries(shape.parse.event, true); - if (isLocal(shape) && !hasOwn(shape.parse, 'event') && !hasOwn(shape.parse.monthDay, 'active')) - return; // no local change needed - - // Use the correct alias engine: static for global, instance for local, and assign parentEngine for locals + static [$setAliases](shape: Internal.State, kind: 'evt' | 'per', token: any, provided?: [string, any][]) { + const field = kind === 'evt' ? 'event' : 'period'; const parent = proto(shape); - const engine = hasOwn(shape, 'aliasEngine') - ? shape.aliasEngine! - : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); - engine.clear('event'); - engine.registerEvents(events); - - const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') - const groups = events - .map(([pat, _], idx) => `(?<${src}evt${idx}>${pat})`) // assign a number to the pattern - .join('|') // make an 'Or' pattern for the event-keys - - if (groups) { - const protoEvt = proto(shape.parse.snippet)[Token.evt]?.source; - if (!isLocal(shape) || groups !== protoEvt) { - if (isLocal(shape) && !hasOwn(shape.parse, 'snippet')) - shape.parse.snippet = create(shape.parse, 'snippet'); + const parentMap = parent.parse?.[field] ?? {}; - setProperty(shape.parse.snippet, Token.evt, new RegExp(groups)); - } - } else { - // If no groups, ensure we don't have a stale or empty regex that could cause issues - if (hasOwn(shape.parse.snippet, Token.evt)) { - delete shape.parse.snippet[Token.evt as any]; - } - } - - const isMonthDay = Boolean(shape.parse.monthDay.active); - const protoDt = proto(shape.parse.layout)[Token.dt] as string; - const targetDt = isMonthDay ? datePattern.mdy : datePattern.dmy; - - if (!isLocal(shape) || targetDt !== protoDt) { - if (isLocal(shape) && !hasOwn(shape.parse, 'layout')) - shape.parse.layout = create(shape.parse, 'layout'); + // Identify local additions or overrides + const entries = provided ?? ownEntries(shape.parse[field] as any, true).filter(([k, v]) => { + return !(k in parentMap) || (shape.parse[field] as any)[k as string] !== parentMap[k as string]; + }); - setProperty(shape.parse.layout, Token.dt, targetDt); + // Sync legacy registry if provided directly + if (provided) { + if (!hasOwn(shape.parse, field)) (shape.parse as any)[field] = { ...(shape.parse as any)[field] }; + provided.forEach(([k, v]) => { + if (!hasOwn(shape.parse[field] as any, k)) (shape.parse[field] as any)[k as string] = v; + }); } - } - /** - * {tm} is a layout that combines time-related snippets (hh, mi, ss, ff, mer -or- per) into a pattern against which a string can be tested. - * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period - */ - // TODO: check all Layouts which reference "{per}" and update them - static [$setPeriods](shape: Internal.State) { - const periods = ownEntries(shape.parse.period, true); - if (isLocal(shape) && !hasOwn(shape.parse, 'period')) - return; // no local change needed + // If no local aliases, inherit the parent's engine (via prototype) and exit + if (entries.length === 0 && !hasOwn(shape, 'aliasEngine')) + return; // Use the correct alias engine: static for global, instance for local - const parent = proto(shape); - const engine = hasOwn(shape, 'aliasEngine') - ? shape.aliasEngine! - : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); - engine.clear('period'); - engine.registerPeriods(periods); - - const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') - const groups = periods - .map(([pat, _], idx) => `(?<${src}per${idx}>${pat})`) // {pattern} is the 1st element of the tuple - .join('|') // make an 'or' pattern for the period-keys + let engine = shape.aliasEngine; + + // If we have local aliases to register, we MUST have a local engine to avoid polluting the parent + if (!hasOwn(shape, 'aliasEngine')) { + engine = shape.aliasEngine = new AliasEngine({ + parent: parent.aliasEngine, + logger: Tempo.#dbg, + config: shape.config + }); + } + const groups = engine!.registerAliases(kind, entries as any); if (groups) { - const protoPer = proto(shape.parse.snippet)[Token.per]?.source; - if (!isLocal(shape) || groups !== protoPer) { - if (isLocal(shape) && !hasOwn(shape.parse, 'snippet')) - shape.parse.snippet = create(shape.parse, 'snippet'); + const protoRegex = parent.parse?.snippet?.[token]?.source; + if (groups !== protoRegex) { + if (!hasOwn(shape.parse, 'snippet')) + shape.parse.snippet = { ...shape.parse.snippet }; - setProperty(shape.parse.snippet, Token.per, new RegExp(groups)); + setProperty(shape.parse.snippet, token, new RegExp(groups)); } } else { - // If no groups, ensure we don't have a stale or empty regex that could cause issues - if (hasOwn(shape.parse.snippet, Token.per)) { - delete shape.parse.snippet[Token.per as any]; + if (hasOwn(shape.parse.snippet, token)) { + if (!hasOwn(shape.parse, 'snippet')) + shape.parse.snippet = { ...shape.parse.snippet }; + + delete (shape.parse.snippet as any)[token]; } } } + static [$setEvents](shape: Internal.State, provided?: [string, any][], rebuild = true) { + (this as any)[$setAliases](shape, 'evt', Token.evt, provided); + if (rebuild) setPatterns(shape); + } + + static [$setPeriods](shape: Internal.State, provided?: [string, any][], rebuild = true) { + (this as any)[$setAliases](shape, 'per', Token.per, provided); + if (rebuild) setPatterns(shape); + } + /** try to infer hemisphere using the timezone's daylight-savings setting */ static #setSphere = (shape: Internal.State, options: t.Options) => { if (isDefined(options.sphere)) return options.sphere; const tz = options.timeZone; if (isDefined(tz)) { - if (String(tz).toLowerCase() === 'utc') return undefined; - const sphere = getHemisphere(String(tz)); + const resolvedTz = shape.config.timeZone; + if (String(resolvedTz).toLowerCase() === 'utc') return undefined; + const sphere = getHemisphere(String(resolvedTz)); if (isDefined(sphere)) return sphere; } @@ -273,6 +252,11 @@ export class Tempo { const isMonthDay = shape.parse.monthDay.isExplicit ? shape.parse.monthDay.active! : Tempo.#isMonthDay(shape); shape.parse.monthDay.active = isMonthDay; + // ensure Token.dt matches the local monthDay preference + const dt = isMonthDay ? datePattern.mdy : datePattern.dmy; + if (shape.parse.layout[Token.dt] !== dt) + shape.parse.layout = { ...shape.parse.layout, [Token.dt]: dt }; + const layoutController = (shape.parse.planner.layoutOrder?.length ?? 0) > 0 ? { [DEFAULT_LAYOUT_CLASS]: [...shape.parse.planner.layoutOrder!] } : undefined; @@ -333,8 +317,8 @@ export class Tempo { } Tempo.#swapLayout(shape); - if (isDefined(shape.parse.event)) (this as any)[$setEvents](shape); - if (isDefined(shape.parse.period)) (this as any)[$setPeriods](shape); + if (isDefined(shape.parse.event)) (this as any)[$setEvents](shape, undefined, false); + if (isDefined(shape.parse.period)) (this as any)[$setPeriods](shape, undefined, false); setPatterns(shape); } @@ -438,8 +422,7 @@ export class Tempo { ...Object.keys(enums.DURATION), ...Object.keys(enums.DURATIONS), ...Object.keys(enums.TIMEZONE), - ...ownKeys((this as any)[$Internal]().parse.event), - ...ownKeys((this as any)[$Internal]().parse.period), + ...((this as any)[$Internal]().aliasEngine?.getAliases(undefined, true).map((a: any) => a.name) ?? []), ...ownKeys((this as any)[$Internal]().parse.ignore), ...ownKeys((this as any)[$Internal]().parse.snippet), ...ownKeys((this as any)[$Internal]().parse.layout), @@ -594,18 +577,34 @@ export class Tempo { registerTerm(config); - // 3. sync with parser registries + // 1a. sync with alias engine if (config.scope && config.ranges) { - const target = config.scope === 'period' ? (this as any)[sym.$Internal]().parse.period : (config.scope === 'event' ? (this as any)[sym.$Internal]().parse.event : undefined); - if (target) { + const type = config.scope === 'period' ? 'per' : (config.scope === 'event' ? 'evt' : undefined); + if (type) { + const aliases: [string, any][] = []; + const monthKeys = Tempo.MONTH.keys(); config.ranges.forEach(r => { - if (r.key && !target[r.key]) { - const val = isDefined(r.hour) ? `${r.hour}:${pad(r.minute ?? 0)}` : (r.month ? `${pad(r.day ?? 1)} ${Tempo.MONTH.keys()[r.month - 1]}` : undefined); - if (val) target[r.key] = val; + if (r.key) { + let val: string | undefined; + if (isDefined(r.hour)) { + if (Number.isInteger(r.hour) && r.hour >= 0 && r.hour <= 23) { + val = `${r.hour}:${pad(r.minute ?? 0)}`; + } + } else if (r.month) { + if (Number.isInteger(r.month) && r.month >= 1 && r.month <= 12) { + val = `${pad(r.day ?? 1)} ${monthKeys[r.month - 1]}`; + } + } + + if (val) aliases.push([r.key, val]); } }); - if (config.scope === 'period') (this as any)[$setPeriods]((this as any)[sym.$Internal]()); - if (config.scope === 'event') (this as any)[$setEvents]((this as any)[sym.$Internal]()); + + if (aliases.length > 0) { + const state = (this as any)[$Internal](); + if (type === 'per') (this as any)[$setPeriods](state, aliases); + else if (type === 'evt') (this as any)[$setEvents](state, aliases); + } } } } @@ -750,6 +749,7 @@ export class Tempo { const rt = getRuntime(); rt.state = undefined; // force fresh state const state = init(); + (state as any)._count = 0; if ((this as any)[sym.$IsBase]) { Tempo.#global = state; } else { @@ -760,7 +760,7 @@ export class Tempo { const parse = state.parse; parse.pattern ??= new Map(); parse.monthDay = resolveMonthDay(Default.monthDay, Tempo.MONTH_DAY); - parse.planner.layoutOrder = asArray((Default.planner?.layoutOrder ?? (Default as any).layoutOrder) as t.Options['parseOrder']) as string[]; + parse.planner.layoutOrder = asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder); parse.planner.preFilter = Boolean(Default.planner?.preFilter ?? (Default as any).preFilter); parse.pivot ??= Default.pivot as any; parse.mode ??= Default.mode as any; @@ -1030,6 +1030,7 @@ export class Tempo { }); Tempo.init(); // synchronously initialize the library + getRuntime().logger = Tempo.#dbg; } /** constructor tempo */ #tempo?: t.DateTime; @@ -1066,7 +1067,7 @@ export class Tempo { */ [$Internal]() { const self: Tempo = unwrap(this); - return { + const out = { get zdt() { return self.#zdt }, set zdt(val: any) { self.#zdt = val }, get errored() { return self.#errored }, @@ -1084,16 +1085,21 @@ export class Tempo { get now() { return self.#now }, config: self.#local.config, parse: self.#local.parse, + aliasEngine: self.#local.aliasEngine, + _id: (self.#local as any)._id, + tempoInstance: self, CONFIG: enums.CONFIG, PARSE: enums.PARSE, ZONED_DATE_TIME: enums.ZONED_DATE_TIME } + + return out; } /** allow for auto-convert of Tempo to BigInt, Number or String */ [Symbol.toPrimitive](hint?: 'string' | 'number' | 'default') { switch (hint) { - case 'string': return this.toString(); // ISO 8601 string + case 'string': return this.toString(); // iso 8601 string case 'number': return this.epoch.ms; // Unix epoch (milliseconds) default: return this.nano; // Unix epoch (nanoseconds) } @@ -1108,7 +1114,6 @@ export class Tempo { return 'Tempo'; // hard-coded to avoid minification mangling } - /** * Instantiates a new `Tempo` object with configuration only. * @@ -1323,9 +1328,9 @@ export class Tempo { } /** 4-digit year (e.g., 2024) */ get yy() { return this.toDateTime().year } - /** 4-digit ISO week-numbering year */ get yw() { return this.toDateTime().yearOfWeek } + /** 4-digit iso week-numbering year */ get yw() { return this.toDateTime().yearOfWeek } /** Month number: Jan=1, Dec=12 */ get mm() { return this.toDateTime().month as t.mm } - /** ISO week number of the year */ get ww() { return this.toDateTime().weekOfYear as t.ww } + /** iso week number of the year */ get ww() { return this.toDateTime().weekOfYear as t.ww } /** Day of the month (1-31) */ get dd() { return this.toDateTime().day } /** Day of the month (alias for `dd`) */ get day() { return this.toDateTime().day } /** Hour of the day (0-23) */ get hh() { return this.toDateTime().hour as t.hh } @@ -1342,7 +1347,7 @@ export class Tempo { /** Full month name (e.g., 'January') */ get mon() { return Tempo.MONTHS.keyOf(this.toDateTime().month as t.Month) } /** Short weekday name (e.g., 'Mon') */ get www() { return Tempo.WEEKDAY.keyOf(this.toDateTime().dayOfWeek as t.Weekday) } /** Full weekday name (e.g., 'Monday') */ get wkd() { return Tempo.WEEKDAYS.keyOf(this.toDateTime().dayOfWeek as t.Weekday) } - /** ISO weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday } + /** iso weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday } /** Nanoseconds since Unix epoch (BigInt) */ get nano() { return this.toDateTime().epochNanoseconds } /** `true` if the underlying date-time is valid. */ get isValid() { return this.#resolve(zdt => !this.#errored && isZonedDateTime(zdt)); } @@ -1450,28 +1455,44 @@ export class Tempo { /** the current system time localized to this instance. */toNow() { return instant().toZonedDateTimeISO(this.tz).withCalendar(this.cal) } /** the date-time as a standard `Date` object. */ toDate() { return new Date(this.toDateTime().round({ smallestUnit: enums.ELEMENT.ms }).epochMilliseconds) } - /**ISO8601 string representation of the date-time. */ + /** Custom JSON serialization for `JSON.stringify`. */ toJSON() { return { ...this.#local.config, value: this.toString() } } + /** iso8601 string representation of the date-time. */ toString() { return (this.isValid && !this.#errored) ? this.toPlainDateTime().toString({ calendarName: 'never' }) : String(this.#tempo ?? ''); } - /** Custom JSON serialization for `JSON.stringify`. */ - toJSON() { return { ...this.#local.config, value: this.toString() } } - /** setup local 'config' and 'parse' rules (prototype-linked to global) */ #setLocal(options: t.Options = {}) { const classState = (this.constructor as any)[$Internal](); + this.#local = Object.create(classState); + (this.#local as any)._id = (this.constructor as any)[$Internal]()._count++; + const self = unwrap(this); this.#local.config = markConfig(Object.create(classState.config)); Object.assign(this.#local.config, { scope: 'local' }); this.#local.parse = markConfig(Object.create(classState.parse)); - this.#local.parse.planner = { ...classState.parse.planner }; // clone the planner object + this.#local.parse.event = { ...classState.parse.event }; + this.#local.parse.period = { ...classState.parse.period }; + this.#local.parse.snippet = { ...classState.parse.snippet }; + this.#local.parse.planner = { + ...(classState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(classState.parse.planner.layoutOrder)] } : {}), + ...(isDefined(classState.parse.planner.preFilter) ? { preFilter: Boolean(classState.parse.planner.preFilter) } : {}), + }; + this.#local.parse.monthDay = { + ...classState.parse.monthDay, + locales: [...asArray(classState.parse.monthDay.locales)], + layouts: [...asArray(classState.parse.monthDay.layouts)], + timezones: { ...classState.parse.monthDay.timezones }, + ...(classState.parse.monthDay.resolvedLocales ? { + resolvedLocales: classState.parse.monthDay.resolvedLocales.map((l: any) => ({ ...l, timeZones: [...l.timeZones] })) + } : {}) + }; setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); Object.defineProperty(this.#local, 'tempoInstance', { // Link this instance to its state for static alias access - value: this, + value: self, writable: false, configurable: true, enumerable: false diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index e40460c..441552f 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -153,7 +153,7 @@ export interface IntlOptions { } export interface PlannerOptions { - /** preferred parse-order of layouts */ layoutOrder?: string[]; + /** preferred parse-order of layouts */ layoutOrder?: (string | symbol)[]; /** enable parse planner pre-filtering */ preFilter?: boolean; } diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index 28bb4fa..fd42d4f 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -15,48 +15,48 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => // Simulate a global state const globalShape = {} as { aliasEngine: AliasEngine }; globalShape.aliasEngine = new AliasEngine({ logger }); - globalShape.aliasEngine.registerEventAlias('globalEvt', 'globalValue'); + globalShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'globalValue'] ]); // Simulate a sandbox state inheriting from global const sandboxShape = Object.create(globalShape); sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine, logger }); - sandboxShape.aliasEngine.registerEventAlias('sandboxEvt', 'sandboxValue'); + sandboxShape.aliasEngine.registerAliases('evt', [ ['sandboxEvt', 'sandboxValue'] ]); // Simulate a local/instance state inheriting from sandbox const localShape = Object.create(sandboxShape); localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine, logger }); - localShape.aliasEngine.registerEventAlias('localEvt', 'localValue'); + localShape.aliasEngine.registerAliases('evt', [ ['localEvt', 'localValue'] ]); it('resolves local, sandbox, and global aliases in correct order', () => { // Local should resolve its own - expect(localShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localValue'); + expect(localShape.aliasEngine.resolveAlias('evt2_0')?.value).toBe('localValue'); // Local should resolve sandbox - expect(localShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxValue'); + expect(localShape.aliasEngine.resolveAlias('evt1_0')?.value).toBe('sandboxValue'); // Local should resolve global - expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(localShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); // Sandbox should not see local - expect(sandboxShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localEvt'); + expect(sandboxShape.aliasEngine.resolveAlias('evt2_0')).toBeUndefined(); // Sandbox should resolve its own and global - expect(sandboxShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxValue'); - expect(sandboxShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(sandboxShape.aliasEngine.resolveAlias('evt1_0')?.value).toBe('sandboxValue'); + expect(sandboxShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); // Global should only resolve its own - expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); - expect(globalShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxEvt'); - expect(globalShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localEvt'); + expect(globalShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); + expect(globalShape.aliasEngine.resolveAlias('evt1_0')).toBeUndefined(); + expect(globalShape.aliasEngine.resolveAlias('evt2_0')).toBeUndefined(); }); it('collision detection traverses the prototype chain', () => { (logger.warn as any).mockClear(); // Register a colliding alias in local - localShape.aliasEngine.registerEventAlias('globalEvt', 'localShadow'); + localShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'localShadow'] ]); // Should warn about collision with global expect(logger.warn).toHaveBeenCalled(); - expect((logger.warn as any).mock.calls[0][0]).toMatch(/Collision detected/i); + expect((logger.warn as any).mock.calls[0][1]).toMatch(/Collision detected/i); - expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('localShadow'); - expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(localShape.aliasEngine.resolveAlias('evt2_1')?.value).toBe('localShadow'); + expect(globalShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); (logger.warn as any).mockReset(); }); diff --git a/packages/tempo/test/core/alias-engine.mock.test.ts b/packages/tempo/test/core/alias-engine.mock.test.ts new file mode 100644 index 0000000..4841575 --- /dev/null +++ b/packages/tempo/test/core/alias-engine.mock.test.ts @@ -0,0 +1,52 @@ +import { AliasEngine } from '../../src/engine/engine.alias.js'; + +describe('AliasEngine', () => { + it('assigns correct prefixes and group names for root and children', () => { + const root = new AliasEngine(); + const child1 = new AliasEngine({ parent: root }); + const child2 = new AliasEngine({ parent: root }); + root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); + child1.registerAliases('evt', [ ['child1Event', 'child1Value'] ]); + child2.registerAliases('evt', [ ['child2Event', 'child2Value'] ]); + expect(root.getAliases('evt')[0].key).toBe('evt0_0'); + expect(child1.getAliases('evt')[0].key).toBe('evt1_0'); + expect(child2.getAliases('evt')[0].key).toBe('evt1_0'); + }); + + it('returns correct lineage from registerEvents/registerPeriods', () => { + const root = new AliasEngine(); + const events = [ ['a', 'A'], ['b', 'B'] ] as [string, string][]; + const periods = [ ['x', 'X'], ['y', 'Y'] ] as [string, string][]; + const eventPattern = root.registerAliases('evt', events); + const periodPattern = root.registerAliases('per', periods); + const eventLineage = root.getAliases('evt'); + const periodLineage = root.getAliases('per'); + expect(eventPattern).toBe('(?a)|(?b)'); + expect(periodPattern).toBe('(?x)|(?y)'); + + expect(eventLineage[0].key).toBe('evt0_0'); + expect(eventLineage[1].key).toBe('evt0_1'); + expect(periodLineage[0].key).toBe('per0_0'); + expect(periodLineage[1].key).toBe('per0_1'); + }); + + it('resolves aliases up the proto chain', () => { + const root = new AliasEngine(); + const child = new AliasEngine({ parent: root }); + root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); + child.registerAliases('evt', [ ['childEvent', 'childValue'] ]); + expect(child.resolveAlias('evt0_0')?.value).toBe('rootValue'); + expect(child.resolveAlias('evt1_0')?.value).toBe('childValue'); + }); + + it('clears aliases correctly', () => { + const root = new AliasEngine(); + root.registerAliases('evt', [ ['e1', 'v1'] ]); + root.registerAliases('per', [ ['p1', 'v2'] ]); + root.clear('evt'); + expect(root.getAliases('evt').length).toBe(0); + expect(root.getAliases('per').length).toBe(1); + root.clear('per'); + expect(root.getAliases('per').length).toBe(0); + }); +}); diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts index a24b8ed..621b921 100644 --- a/packages/tempo/test/core/alias-engine.test.ts +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -1,103 +1,114 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; -import { Logify } from '#library/logify.class.js'; +import type { Logify } from '#library/logify.class.js'; -// Simple logger mock -const logger = new Logify({ debug: true }); -// const logger = { -// warn: vi.fn(), -// }; - -beforeEach(() => { - // logger.warn.mockClear(); -}); +// Use a real Logify logger, but spy on console.warn +const logger = { + warn: (config: any, msg: string) => console.warn(msg, config), + debug: () => { }, + error: () => { }, + log: () => { }, + info: () => { }, + trace: () => { }, +} as unknown as Logify; describe('AliasEngine', () => { it('registers and resolves string and function aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - expect(engine.resolveEventAlias('foo')).toBe('bar'); - engine.registerPeriodAlias('noon', function () { return '12:00'; }); - expect(engine.resolvePeriodAlias('noon')).toBe('12:00'); + engine.registerAliases('evt', [['foo', 'bar']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); + engine.registerAliases('per', [['noon', function () { return '12:00'; }]]); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); + expect(engine.resolveAlias('per0_0')?.isClock).toBe(true); }); it('supports parent/child shadowing and fallback', () => { const globalEngine = new AliasEngine({ logger }); - globalEngine.registerEventAlias('foo', 'bar'); + globalEngine.registerAliases('evt', [['foo', 'bar']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - expect(localEngine.resolveEventAlias('foo')).toBe('bar'); - localEngine.registerEventAlias('foo', 'baz'); - expect(localEngine.resolveEventAlias('foo')).toBe('baz'); - expect(globalEngine.resolveEventAlias('foo')).toBe('bar'); + // Local should resolve parent's alias before shadowing + expect(localEngine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(localEngine.resolveAlias('evt0_0')?.source).toBe('global'); + // After shadowing, local resolves its own, parent still resolves its own + localEngine.registerAliases('evt', [['foo', 'baz']]); + expect(localEngine.resolveAlias('evt1_0')?.value).toBe('baz'); + expect(localEngine.resolveAlias('evt1_0')?.source).toBe('local'); + expect(globalEngine.resolveAlias('evt0_0')?.value).toBe('bar'); }); it('warns on local/global collision', () => { + const warnSpy = vi.spyOn(console, 'warn'); const globalEngine = new AliasEngine({ logger }); - globalEngine.registerPeriodAlias('noon', '12:00'); + globalEngine.registerAliases('evt', [['xmas', '25-Dec']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerPeriodAlias('noon', '11:00'); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('noon')); + localEngine.registerAliases('evt', [['xmas', '24-Dec']]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + warnSpy.mockRestore(); }); it('warns on local collision', () => { + const warnSpy = vi.spyOn(console, 'warn'); const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - engine.registerEventAlias('foo', 'baz'); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('foo')); + engine.registerAliases('evt', [['xmas', '25-Dec'], ['xmas', '24-Dec']]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + warnSpy.mockRestore(); }); it('registers and resolves batch aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEvents([ - ['a', '1'], - ['b', '2'], - ]); - expect(engine.resolveEventAlias('a')).toBe('1'); - expect(engine.resolveEventAlias('b')).toBe('2'); + engine.registerAliases('evt', [['foo', 'bar'], ['baz', 'qux']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(engine.resolveAlias('evt0_1')?.value).toBe('qux'); }); it('clears only events or periods', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - engine.registerPeriodAlias('noon', '12:00'); - engine.clear('event'); - expect(engine.resolveEventAlias('foo')).toBe('foo'); - expect(engine.resolvePeriodAlias('noon')).toBe('12:00'); - engine.clear('period'); - expect(engine.resolvePeriodAlias('noon')).toBe('noon'); + engine.registerAliases('evt', [['foo', 'bar']]); + engine.registerAliases('per', [['noon', '12:00']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); + + engine.clear('evt'); + // After clearing, the alias key should not resolve + expect(engine.resolveAlias('evt0_0')).toBeUndefined(); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); + engine.clear('per'); + expect(engine.resolveAlias('per0_0')).toBeUndefined(); }); it('handles regex-like collision heuristics', () => { - const globalEngine = new AliasEngine({ logger }); - globalEngine.registerPeriodAlias('noon', '12:00'); - const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerPeriodAlias('([after[ -]?])?noon', '11:00'); - - // This should warn, even if not a perfect regex match - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('noon')); + const warnSpy = vi.spyOn(console, 'warn'); + const engine = new AliasEngine({ logger }); + engine.registerAliases('evt', [['xmas( )?eve', '24-Dec'], ['xmas eve', '24-Dec']]); + // Should treat "xmas eve" and "xmas( )?eve" as same base word + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + warnSpy.mockRestore(); }); it('does not warn on non-colliding aliases', () => { + const warnSpy = vi.spyOn(console, 'warn'); const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - engine.registerEventAlias('baz', 'qux'); - expect(console.warn).not.toHaveBeenCalled(); + engine.registerAliases('evt', [['xmas', '25-Dec'], ['bday', '20-May']]); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); }); it('resolves to parent after clear', () => { const globalEngine = new AliasEngine({ logger }); - globalEngine.registerEventAlias('foo', 'bar'); + globalEngine.registerAliases('evt', [['foo', 'bar']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerEventAlias('foo', 'baz'); - expect(localEngine.resolveEventAlias('foo')).toBe('baz'); - localEngine.clear('event'); - expect(localEngine.resolveEventAlias('foo')).toBe('bar'); + localEngine.registerAliases('evt', [['foo', 'baz']]); + expect(localEngine.resolveAlias('evt1_0')?.value).toBe('baz'); + + localEngine.clear('evt'); + // Should resolve back to parent's alias after clearing local + expect(localEngine.resolveAlias('evt0_0')?.value).toBe('bar'); }); it('handles empty/optional/edge-case aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('', 'empty'); - expect(engine.resolveEventAlias('')).toBe('empty'); - engine.registerEventAlias('?', 'question'); - expect(engine.resolveEventAlias('?')).toBe('question'); + engine.registerAliases('evt', [['', 'empty'], ['foo', '']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('empty'); + expect(engine.resolveAlias('evt0_1')?.value).toBe(''); + expect(engine.resolveAlias('non-existent' as any)).toBeUndefined(); }); }); diff --git a/packages/tempo/test/core/constructor.core.test.ts b/packages/tempo/test/core/constructor.core.test.ts index e22a01b..91f8919 100644 --- a/packages/tempo/test/core/constructor.core.test.ts +++ b/packages/tempo/test/core/constructor.core.test.ts @@ -22,7 +22,7 @@ describe('Tempo Core', () => { it('should fail-fast (strict) if input fails Master Guard', () => { // 'Hello World' fails the guard, so it attempts immediate parsing and throws - expect(() => new Tempo('Hello World')).toThrow(/Cannot parse Date/); + expect(() => new Tempo('Hello World')).toThrow(/invalid ISO 8601 string/); }); }); diff --git a/packages/tempo/test/core/tempo_guard.test.ts b/packages/tempo/test/core/tempo_guard.test.ts index 04a63e7..76ad331 100644 --- a/packages/tempo/test/core/tempo_guard.test.ts +++ b/packages/tempo/test/core/tempo_guard.test.ts @@ -7,7 +7,7 @@ describe('Master Guard Extension', () => { it('should rebuild the guard after extension via Discovery', () => { // 1. Initially, '$$$apple$$$' should FAIL the guard and throw immediately - expect(() => new Tempo('$$$apple$$$')).toThrow(/Cannot parse Date: "\$\$\$apple\$\$\$"/); + expect(() => new Tempo('$$$apple$$$')).toThrow(/Unrecognized or invalid ISO 8601 string: \"\$\$\$apple\$\$\$\"/); // 2. Extend with a custom term '$$$apple$$$' via Discovery object Tempo.extend({ @@ -23,12 +23,12 @@ describe('Master Guard Extension', () => { // expect(t.parse.lazy).toBe(true); // 4. Accessing a property should now trigger parsing and throw - expect(() => t.yy).toThrow(/Cannot parse Date: "\$\$\$apple\$\$\$"/); + expect(() => t.yy).toThrow(/Unrecognized or invalid ISO 8601 string: \"\$\$\$apple\$\$\$\"/); }); it('should rebuild the guard after direct extension', () => { // 1. '@@@banana@@@' fails initially - expect(() => new Tempo('@@@banana@@@')).toThrow(/Cannot parse Date: "@@@banana@@@"/); + expect(() => new Tempo('@@@banana@@@')).toThrow(/Unrecognized or invalid ISO 8601 string: \"@@@banana@@@\"/); // 2. Extend directly Tempo.extend({ diff --git a/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts b/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts index 46f1505..dc0a5de 100644 --- a/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts +++ b/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts @@ -7,7 +7,7 @@ describe('parse prefilter numeric safety constraints', () => { test('keeps integer-like BigInt nanosecond string as early escape', () => { expect(() => new Tempo('1715900000000000000n', { timeZone: 'UTC' })) - .toThrow(/Cannot parse Date/i); + .toThrow(/Unrecognized or invalid ISO 8601 string: \"1715900000000000000n\"/i); }); test('number input with less than 8 digits still rejects safely', () => { diff --git a/packages/tempo/test/plugins/fiscal-cycle.core.test.ts b/packages/tempo/test/plugins/fiscal-cycle.core.test.ts index 203097a..c1c41ca 100644 --- a/packages/tempo/test/plugins/fiscal-cycle.core.test.ts +++ b/packages/tempo/test/plugins/fiscal-cycle.core.test.ts @@ -1,8 +1,8 @@ import { Tempo } from '#tempo/core'; import '#tempo/parse'; +import '#tempo/term'; import '#tempo/mutate'; import { FormatModule } from '#tempo/format'; -import '#tempo/term/standard'; Tempo.extend(FormatModule); diff --git a/packages/tempo/test/plugins/term-dispatch.core.test.ts b/packages/tempo/test/plugins/term-dispatch.core.test.ts index 697d035..cfb17bb 100644 --- a/packages/tempo/test/plugins/term-dispatch.core.test.ts +++ b/packages/tempo/test/plugins/term-dispatch.core.test.ts @@ -2,7 +2,7 @@ import { Tempo } from '#tempo/core'; import '#tempo/parse'; import '#tempo/mutate'; import '#tempo/format'; -import '#tempo/term/standard'; +import '#tempo/term'; describe('Term Dispatch Refactor', () => { it('should set term by index (#quarter: 2)', () => { diff --git a/packages/tempo/test/plugins/ticker.patterns.test.ts b/packages/tempo/test/plugins/ticker.patterns.test.ts index b6f55d0..4c2c3db 100644 --- a/packages/tempo/test/plugins/ticker.patterns.test.ts +++ b/packages/tempo/test/plugins/ticker.patterns.test.ts @@ -114,7 +114,7 @@ describe(`${label}`, () => { // @ts-ignore expect(() => Tempo.ticker(Infinity)).toThrow(/Invalid Tempo number: Infinity/); // @ts-ignore - expect(() => Tempo.ticker('not a number')).toThrow(/Cannot parse Date: "not a number"/); + expect(() => Tempo.ticker('not a number')).toThrow(/Unrecognized or invalid ISO 8601 string: "not a number"/); }); }); diff --git a/packages/tempo/test/plugins/ticker.term.core.test.ts b/packages/tempo/test/plugins/ticker.term.core.test.ts index a292972..cc1f788 100644 --- a/packages/tempo/test/plugins/ticker.term.core.test.ts +++ b/packages/tempo/test/plugins/ticker.term.core.test.ts @@ -1,6 +1,6 @@ import { Tempo } from '#tempo/core'; import '#tempo/parse'; -import '#tempo/term/standard'; +import '#tempo/term'; import { MutateModule } from '#tempo/mutate'; import { TickerModule } from '#tempo/ticker'; diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 049ae87..3605aec 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ alias: isDist ? [ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, - { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './dist/plugin/term/standard.index.js') }, { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/module/module.duration.js') }, { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/discrete/discrete.$1.js') }, { find: /^#tempo\/discrete$/, replacement: resolve(__dirname, './dist/discrete/discrete.index.js') }, @@ -56,7 +55,6 @@ export default defineConfig({ ] : [ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, - { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './src/plugin/term/standard.index.ts') }, { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') }, { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/module/module.duration.ts') },