diff --git a/.agent/workflows/interactive-testing.md b/.agent/workflows/interactive-testing.md index 3ee4d48d..15da7b3d 100644 --- a/.agent/workflows/interactive-testing.md +++ b/.agent/workflows/interactive-testing.md @@ -6,12 +6,12 @@ To use a NodeJS interactive session to test your Tempo library, you can use the // turbo ```bash -npx tsx -i --import ./test/repl.ts +npx tsx --conditions=development -i --harmony-temporal --import ./bin/repl.ts ``` ### Purpose -This command starts a Node.js REPL (Read-Eval-Print Loop) while pre-loading the `Tempo` class and the `Temporal` polyfill into the global scope. This allows you to try different invocations of `Tempo` directly without writing a script. +This command starts a Node.js REPL (Read-Eval-Print Loop) while pre-loading the `Tempo` class and the `Temporal` support into the global scope. This allows you to try different invocations of `Tempo` directly without writing a script. ### Usage Examples Once the REPL has started, you can run commands like: @@ -32,4 +32,4 @@ t1.add({ days: 5 }).format('plain'); ### Why this works - `npx tsx`: Uses the `tsx` runner to handle TypeScript files on the fly. - `-i`: Explicitly requests an interactive session. -- `--import ./test/repl.ts`: Loads the helper script before starting the REPL, which attaches `Tempo` to `globalThis`. \ No newline at end of file +- `--import ./bin/repl.ts`: Loads the helper script before starting the REPL, which attaches `Tempo` to `globalThis`. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d121bb..f851e788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,39 +34,4 @@ jobs: run: npm test working-directory: packages/tempo - test-parse-prefilter: - name: Test with parse.preFilter enabled - runs-on: ubuntu-latest - timeout-minutes: 30 - 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 - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - name: Install monorepo dependencies - run: npm ci - working-directory: ${{ github.workspace }} - - name: Run all tests with parse.preFilter - run: npm test - working-directory: packages/tempo - env: - TEMPO_PREFILTER_CI: 'true' - - name: Run end-to-end benchmark - run: npx tsx --conditions=development bench/bench.parse.prefilter.e2e.ts > bench-output.json 2> bench-error.log - working-directory: packages/tempo - - name: Upload benchmark output - if: always() - uses: actions/upload-artifact@v4 - with: - name: bench-parse-prefilter-e2e - path: | - packages/tempo/bench-output.json - packages/tempo/bench-error.log - - name: Validate benchmark output - run: | - node -e "const r=require('./packages/tempo/bench-output.json');if(!r.success){console.error('Benchmark failed:',r.errors);process.exit(1)}else{console.log('Benchmark passed.')}" - working-directory: ${{ github.workspace }} diff --git a/doc/main_branch_protection.md b/doc/main_branch_protection.md new file mode 100644 index 00000000..98949e2c --- /dev/null +++ b/doc/main_branch_protection.md @@ -0,0 +1,147 @@ +# Preventing Writes to the Main Branch Locally + +To prevent accidental writes (commits and pushes) to the `main` branch on your local workstation, you can use **Git Hooks**. + + +## Example: Local Hooks for Main Branch Protection +Set up local hooks to: +1. **`pre-commit`**: Prevent direct commits to the `main` branch. +2. **`pre-push`**: Prevent pushing changes to the remote `main` branch. + +### How to Override +If you genuinely need to write to `main` (e.g., for an urgent fix), you have two options: +- **Environment Variable**: Prepend the command with the override flag: + - `ALLOW_MAIN_COMMIT=true git commit -m "Urgent fix"` + - `ALLOW_MAIN_PUSH=true git push` +- **Skip Hooks**: Use the standard Git flag: + - `git commit --no-verify` + - `git push --no-verify` + +--- + +## Applying This Globally (Recommended) +If you want this protection to apply to **every repository** on your workstation, you can configure a global hooks directory. + +### 1. Create a Global Hooks Directory +Choose a location (e.g., `~/.git-hooks`) and move the hook scripts there: + +```bash +mkdir -p ~/.git-hooks +# Copy the hooks from your repository (replace /path/to/repo with your repo root or use the command below) +# Example using git to find the repo root: +cp $(git rev-parse --show-toplevel)/.git/hooks/pre-commit ~/.git-hooks/ +cp $(git rev-parse --show-toplevel)/.git/hooks/pre-push ~/.git-hooks/ +chmod +x ~/.git-hooks/* +``` + + +### 2. Configure Git Globally (with Important Warning) +Run this command to tell Git to use your new global hooks directory: + +```bash +git config --global core.hooksPath ~/.git-hooks +``` + +**⚠️ Warning:** Setting `core.hooksPath` with the `--global` flag disables all per-repository `.git/hooks/*` scripts. This will break tools that rely on per-project hooks, such as Husky, lefthook, lint-staged, and others. Any hooks defined in individual repositories will be ignored as long as the global `core.hooksPath` is set. + +#### Recommended Alternatives + +- **Chain per-repo hooks from your global hook scripts:** + - Manually update your global hook scripts (in `~/.git-hooks/`) to call any existing hooks in each repository’s `.git/hooks/` directory, so you don’t lose project-specific logic. +- **Scope the setting to individual repositories:** + - Instead of using `--global`, set the hooks path only for the current repository: + ```bash + git config core.hooksPath ~/.git-hooks + ``` + - This way, only the current repo is affected, and other repos keep their own `.git/hooks/*` scripts. + +For more details, see the [Git documentation on `core.hooksPath`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corehookspath). + +--- + +## Hook Implementation Details + +### pre-commit +This script checks the current branch before every commit. + +```bash +#!/bin/bash +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$CURRENT_BRANCH" = "main" ]; then + if [ "$ALLOW_MAIN_COMMIT" != "true" ]; then + echo "❌ ERROR: Direct commit to 'main' branch is prohibited." + exit 1 + fi +fi +``` + +### pre-push +This script checks the remote branch being pushed to. + +```bash +#!/bin/bash +while read local_ref local_sha remote_ref remote_sha +do + if [ "$remote_ref" = "refs/heads/main" ]; then + if [ "$ALLOW_MAIN_PUSH" != "true" ]; then + echo "❌ ERROR: Pushing to 'main' branch is prohibited." + exit 1 + fi + fi +done +``` + +--- + +## 🆘 I'm on 'main' and have changes, what do I do? + +If you've already made changes on `main` and the hook blocks your commit, **don't panic and don't drop your stash!** You can easily move your work to a new branch. + +### The "Magic" Command: Just Create a New Branch +Git allows you to create and switch to a new branch while keeping your uncommitted changes. + +```bash +# 1. Create and switch to a new branch +git checkout -b feature/my-cool-feature +# OR (modern syntax) +git switch -c feature/my-cool-feature + +# 2. Now you can commit normally +git add . +git commit -m "My feature changes" +``` + +### If you want to be extra safe (The Stash Method) +If you have a lot of complex changes and want to ensure `main` stays clean: + +```bash +# 1. Save your work temporarily +git stash + +# 2. Create and switch to the new branch +git checkout -b feature/my-cool-feature + +# 3. Bring your changes back +git stash pop + +# 4. Commit +git commit -am "My feature changes" +``` + +### "I accidentally committed before I added the hook!" +If you have local commits on `main` that you haven't pushed yet, you can move them to a new branch: + +```bash +# 1. Create a new branch at your current (accidental) commit +git branch feature/my-feature + +# 2. Make sure your local reference to 'origin/main' is up-to-date +git fetch origin +# 3. Reset your local 'main' back to where it should be (the remote version) +# (Fetching first ensures you don't accidentally reset to a stale origin/main reference) +git reset --hard origin/main + +# 4. Switch to your new branch to continue working +git checkout feature/my-feature +``` + diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/installation.md index 4c6c4982..05b4d7b9 100644 --- a/packages/tempo/doc/installation.md +++ b/packages/tempo/doc/installation.md @@ -8,7 +8,11 @@ `Temporal` is now at Stage 4 and is expected to land broadly in runtimes soon. To avoid needlessly inflating package size with a dependency that will increasingly become unnecessary, `Tempo` does not bundle a `Temporal` polyfill by default. -As of 13 January 2026, Chrome 144 has shipped `Temporal`, and Firefox 139 also includes native `Temporal` support, while Node.js still does not provide built-in `Temporal` globally. Please verify support in your actual target runtime(s) and add a polyfill only when needed. +As of 13 January 2026, Chrome 144 has shipped `Temporal`, and Firefox 139 also includes native `Temporal` support. + +While Node.js does not yet enable `Temporal` by default, recent versions (Node 20+) support it via the `--harmony-temporal` flag. This allows you to use `Tempo` without an external polyfill package. + +Please verify support in your actual target runtime(s) and add a polyfill only when needed. You can check at runtime with a simple guard: @@ -39,7 +43,15 @@ import { Tempo } from '@magmacomputing/tempo'; const t = new Tempo('next Friday'); ``` -### Node.js quick-start (if Temporal is not available) +### Node.js (with Native Temporal) + +If you are using Node.js 20+, you can enable native `Temporal` support without installing a polyfill: + +```bash +node --harmony-temporal my-app.js +``` + +### Node.js (with Polyfill) The polyfill import shown here is conditional guidance, not required for all environments. diff --git a/packages/tempo/doc/tempo.layout.md b/packages/tempo/doc/tempo.layout.md index 7a865dfc..d1fecea6 100644 --- a/packages/tempo/doc/tempo.layout.md +++ b/packages/tempo/doc/tempo.layout.md @@ -81,6 +81,18 @@ When crafting raw regex, the following capture groups are used by the engine: - `per`: Period string offset - `unt`: Relative unit (e.g., `days`, `weeks`) +## Prototyping with `Tempo.regexp()` + +You can use the static `Tempo.regexp()` method to "preview" how a layout string will be compiled by the engine. This is useful for testing custom regex logic before applying it to your configuration. + +```typescript +// Expands {dd}, {sep}, {mm}, etc. into a final anchored RegExp +const regex = Tempo.regexp('{dd}{sep}{mm}{sep}{yy}'); + +console.log(regex.source); +// Output (illustrative): ^((?
...)(?...)(?...)(?...)(?...))$ +``` + --- ## Professional Services diff --git a/packages/tempo/index.md b/packages/tempo/index.md index e50cff6e..83be48fb 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -556,11 +556,12 @@ function focusActiveCard() { } .tempo-btn-brand { - background-color: var(--vp-c-brand-1); + background-color: #3498db; color: white; } .tempo-btn-brand:hover { - background-color: var(--vp-c-brand-2); + background-color: #2980b9; + color: white; } .tempo-btn-alt { diff --git a/packages/tempo/package.json b/packages/tempo/package.json index bf436dff..5785a3cb 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -30,8 +30,6 @@ "**/plugin/extend/extend.*.ts", "dist/engine/engine.*.js", "src/engine/engine.*.ts", - "dist/parse/parse.*.js", - "src/parse/parse.*.ts", "dist/module/module.*.js", "src/module/module.*.ts", "dist/discrete/discrete.*.js", @@ -54,8 +52,8 @@ "default": "./dist/core.index.js" }, "#tempo/enums": { - "development": "./src/support/tempo.enum.ts", - "default": "./dist/support/tempo.enum.js" + "development": "./src/support/support.enum.ts", + "default": "./dist/support/support.enum.js" }, "#tempo/duration": { "development": "./src/module/module.duration.ts", @@ -109,10 +107,6 @@ "development": "./src/discrete/discrete.parse.ts", "default": "./dist/discrete/discrete.parse.js" }, - "#tempo/parse/*.js": { - "development": "./src/parse/*.ts", - "default": "./dist/parse/*.js" - }, "#tempo/format": { "development": "./src/discrete/discrete.format.ts", "default": "./dist/discrete/discrete.format.js" @@ -132,8 +126,8 @@ "import": "./dist/tempo.index.js" }, "./enums": { - "types": "./dist/support/tempo.enum.d.ts", - "import": "./dist/support/tempo.enum.js" + "types": "./dist/support/support.enum.d.ts", + "import": "./dist/support/support.enum.js" }, "./extend/*": { "types": "./dist/plugin/extend/extend.*.d.ts", diff --git a/packages/tempo/plan/RELEASE-D.md b/packages/tempo/plan/RELEASE-D.md index f2caf2ec..54e8017a 100644 --- a/packages/tempo/plan/RELEASE-D.md +++ b/packages/tempo/plan/RELEASE-D.md @@ -5,20 +5,21 @@ This release focuses on modularizing and refactoring the parsing and pattern-mat ## Task Breakdown & Tracking + ### Pattern Compiler + Cache Extraction -- [ ] Extract `compileRegExp`, `setPatterns`, and helpers to new module -- [ ] Integrate memoization/caching logic as needed -- [ ] Refactor engine and consumers to use new module -- [ ] Ensure compatibility with snippet/layout definitions -- [ ] Add/expand unit tests for pattern logic and cache -- [ ] Update documentation and references +- [x] Extract `compileRegExp`, `setPatterns`, and helpers to new module (PatternCompiler) +- [x] Integrate memoization/caching logic as needed (PatternCompiler cache) +- [x] Refactor engine and consumers to use new PatternCompiler module +- [x] Ensure compatibility with snippet/layout definitions +- [x] Add/expand unit tests for pattern logic and cache +- [x] Update documentation and references ### Alias Resolution Engine Extraction -- [ ] Extract alias resolution logic to new module -- [ ] Define interfaces for registration, lookup, collision -- [ ] Refactor engine and plugins to use new APIs -- [ ] Add/expand unit tests for alias/collision -- [ ] Update documentation and references +- [x] Extract alias resolution logic to new module +- [x] Define interfaces for registration, lookup, collision +- [x] Refactor engine and plugins to use new APIs +- [x] Add/expand unit tests for alias/collision +- [x] Update documentation and references ### Guard Builder Extraction (Assessment) - [ ] Identify all guard-building/token-ingestion logic diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 7745ff36..7f8ef9ff 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -16,8 +16,8 @@ import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/plugin.type.js'; import { sym, isTempo, TermError, getRuntime, Match } from '../support/support.index.js'; import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; -import { setProperty } from '#tempo/support/tempo.util.js'; -import enums from '../support/tempo.enum.js'; +import { setProperty } from '#tempo/support/support.util.js'; +import enums from '../support/support.enum.js'; import * as t from '../tempo.type.js'; import type { Tempo } from '../tempo.class.js'; diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index dab78483..5b1561f3 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -86,17 +86,14 @@ export class AliasEngine { 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); - } - + if (this.#parent instanceof AliasEngine) { 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 { + if (this.#parent) + this.#logger?.error(this.#config, "Parent engine must be an instance of AliasEngine"); + 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) diff --git a/packages/tempo/src/engine/engine.layout.ts b/packages/tempo/src/engine/engine.layout.ts index a07293d0..4ca00e4c 100644 --- a/packages/tempo/src/engine/engine.layout.ts +++ b/packages/tempo/src/engine/engine.layout.ts @@ -1,9 +1,10 @@ import { ownEntries } from '#library/primitive.library.js'; -import { Token } from '#tempo/support/tempo.symbol.js'; +import { Token } from '#tempo/support/support.symbol.js'; +import { resolveLayoutOrderPure } from './engine.resolver.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]) @@ -51,19 +52,27 @@ export function resolveLayoutClassificationOrder(layout: Record, if (preferred.length === 0) return layout; const entries = ownEntries(layout) as LayoutEntry[]; - const byName = new Map(); + const lookup = new Map(); entries.forEach(([key, value]) => { + lookup.set(key, [key, value]); const description = key.description ?? ''; - if (description) byName.set(description, [key, value]); + if (description) lookup.set(description, [key, value]); const alias = TOKEN_ALIAS.get(key); - if (alias) byName.set(alias, [key, value]); + if (alias) lookup.set(alias, [key, value]); }); const next: LayoutEntry[] = []; 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 + ? (lookup.get(name) ?? lookup.get(description) ?? (alias ? lookup.get(alias) : undefined)) + : (lookup.get(resolvedName!) ?? lookup.get(name)); + if (!entry) return; if (seen.has(entry[0])) return; seen.add(entry[0]); @@ -94,22 +103,12 @@ export function resolveLayoutOrder({ layout, monthDayLayouts, isMonthDay, layout classification ?? DEFAULT_LAYOUT_CLASS, ); - const layouts = ownEntries(ordered) as LayoutEntry[]; - let changed = false; + const entries = ownEntries(ordered) as LayoutEntry[]; + const layouts = resolveLayoutOrderPure(ordered, monthDayLayouts, isMonthDay); - monthDayLayouts.forEach(([dmy, mdy]) => { - const idx1 = layouts.findIndex(([key]) => key.description === dmy); - const idx2 = layouts.findIndex(([key]) => key.description === mdy); + if (layouts.length !== entries.length) return ordered; - if (idx1 === -1 || idx2 === -1) return; - - const swap1 = idx1 < idx2 && isMonthDay; - const swap2 = idx1 > idx2 && !isMonthDay; - if (swap1 || swap2) { - [layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]]; - changed = true; - } - }); + const changed = layouts.some((entry, idx) => entry[0] !== entries[idx][0]); if (changed) return Object.fromEntries(layouts) as Record; return ordered; diff --git a/packages/tempo/src/engine/engine.pattern.ts b/packages/tempo/src/engine/engine.pattern.ts new file mode 100644 index 00000000..aca9bb04 --- /dev/null +++ b/packages/tempo/src/engine/engine.pattern.ts @@ -0,0 +1,167 @@ +// engine.pattern.ts +// Pattern Compiler and Cache Engine for Tempo +// Responsible for snippet/layout expansion, regex compilation, and pattern caching + +import { isRegExp, isNullish, isEmpty, isString } from '#library/assertion.library.js'; +import { ownEntries, ownKeys } from '#library/primitive.library.js'; +import { Match, Snippet, Layout } from '../support/support.default.js'; +import { getSymbol, hasOwn, logWarn, logError } from '../support/support.util.js'; +import { Token } from '../support/support.symbol.js'; +import enums from '../support/support.enum.js'; +import type * as t from '../tempo.type.js'; + +const BRACES_REGEX = new RegExp(Match.braces, 'g'); + +export interface PatternCompilerOptions { + state: t.Internal.State; +} + +export class PatternCompiler { + #state: t.Internal.State; + #globalCache: Map = new Map(); + #snippetCache: WeakMap> = new WeakMap(); + + constructor(options: PatternCompilerOptions) { + this.#state = options.state; + } + + /** + * Translates {layout} into an anchored, case-insensitive RegExp. + * Includes recursive expansion of placeholders using snippet registries. + */ + compileRegExp(layout: string | RegExp, snippet?: Snippet): RegExp { + const state = this.#state; + const source = isRegExp(layout) ? layout.source : layout; + let cache: Map; + if (snippet) { + if (!this.#snippetCache.has(snippet)) { + this.#snippetCache.set(snippet, new Map()); + } + cache = this.#snippetCache.get(snippet)!; + } else { + cache = this.#globalCache; + } + if (cache.has(source)) { + return cache.get(source)!; + } + + const matcher = (source: string, d = 0): string => { + if (d > 10) { // Emit a diagnostic if recursion limit is hit (likely circular placeholder) + logWarn(this.#state.config, `[PatternCompiler] Recursion limit exceeded in matcher (d > 10) for src:`, source, `depth:`, d); + return source; + } + + if (source.startsWith('/') && source.endsWith('/')) + source = source.substring(1, source.length - 1); // remove the leading/trailing "/" + if (source.startsWith('^') && source.endsWith('$')) + source = source.substring(1, source.length - 1); // remove the leading/trailing anchors (^ $) + + return source.replace(BRACES_REGEX, (match, name) => {// iterate over "{}" pairs in the source string + const token = getSymbol(name); // get the symbol for this {name} + const customs = snippet?.[token as keyof Snippet]?.source ?? snippet?.[name as keyof Snippet]?.source; + const globals = state.parse.snippet[token as keyof Snippet]?.source ?? state.parse.snippet[name as keyof Snippet]?.source; + const stateLayout = state.parse.layout[token as keyof Layout] ?? state.parse.layout[name as keyof Layout]; + const defaultLayout = Layout[token as keyof Layout];// get resolution source (layout) + + let res = customs ?? globals ?? stateLayout ?? defaultLayout; // get the snippet/layout source + + if (isNullish(res) && name.includes('.')) { // if no definition found, try fallback + const prefix = name.split('.')[0]; // get the base token name + const pToken = getSymbol(prefix); + res = snippet?.[pToken as keyof Snippet]?.source ?? snippet?.[prefix as keyof Snippet]?.source + ?? state.parse.snippet[pToken as keyof Snippet]?.source ?? state.parse.snippet[prefix as keyof Snippet]?.source + ?? state.parse.layout[pToken as keyof Layout] ?? state.parse.layout[prefix as keyof Layout] + ?? Layout[pToken as keyof Layout]; + } + + if (res && name.includes('.')) { // wrap dotted extensions for identification + const safeName = name.replace(/\./g, '_'); + if (!res.startsWith(`(?<${safeName}>`)) + res = `(?<${safeName}>${res})`; + } + + return (isNullish(res) || res === match) // if no definition found, + ? match // return the original match + : matcher(res, d + 1); // else recurse to see if snippet contains embedded "{}" pairs + }); + }; + + try { + const expanded = matcher(source); + const compiled = new RegExp(`^(${expanded})$`, 'i'); + cache.set(source, compiled); + return compiled; + } catch (e: any) { + // Use the computed source for fallback, do not cache fallback, and log error + logError(this.#state.config, { context: 'pattern compile failed', pattern: source }, e); + return new RegExp(`^${Match.escape(source)}$`, 'i'); + } + } + + /** + * Build RegExp patterns into the state. + * Re-evaluates all snippets and layouts. + */ + setPatterns() { + this.clearCache(); + const state = this.#state; + // ensure we have our own isolated mutable containers before mutation + state.parse.snippet = { ...state.parse.snippet }; + state.parse.pattern = new Map(); + + const snippet = state.parse.snippet; + + // 1. ensure numeric snippets are current + if (enums?.NUMBER) { + const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); + const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); + + snippet[Token.nbr] = nbr; + snippet[Token.mod] = new RegExp(`((?${Match.modifier.source})?${nbr.source}? *)`); + snippet[Token.afx] = new RegExp(`((s)? (?${Match.affix.source}))?${snippet[Token.sep]?.source || ''}?`); + } + + // 2. build ignore pattern + const ignores = ownKeys(state.parse.ignore, true); + + if (!isEmpty(ignores)) { + const words = ignores + .filter(isString) + .map(w => Match.escape(w.toLowerCase())) + .join('|'); + + state.parse.ignorePattern = new RegExp(`\\b(${words})\\b`, 'gi'); + } else { + delete state.parse.ignorePattern; + } + + // 3. build the patterns + ownEntries(state.parse.layout, true).forEach(([key, layout]) => { + const symbol = getSymbol(key); + const compiled = this.compileRegExp(layout, snippet); + + state.parse.pattern.set(symbol, compiled); + }); + } + + /** + * Clear the pattern cache. + */ + clearCache() { + this.#globalCache.clear(); + // WeakMap has no clear(), so re-instantiate to drop all snippet-specific caches + this.#snippetCache = new WeakMap(); + } +} + +/** + * Functional wrapper for the PatternCompiler. + * Handles engine instantiation and pattern building for a given state. + */ +export function setPatterns(state: t.Internal.State) { + // 🛡️ Critical fix: ensure we use an OWN PatternCompiler for each state to avoid cross-pollution + if (!hasOwn(state, 'patternCompiler') || !state.patternCompiler) + state.patternCompiler = new PatternCompiler({ state }); + + state.patternCompiler.setPatterns(); +} diff --git a/packages/tempo/src/engine/engine.layout.resolver.ts b/packages/tempo/src/engine/engine.resolver.ts similarity index 100% rename from packages/tempo/src/engine/engine.layout.resolver.ts rename to packages/tempo/src/engine/engine.resolver.ts diff --git a/packages/tempo/src/parse/parse.layout.ts b/packages/tempo/src/parse/parse.layout.ts deleted file mode 100644 index ae4da410..00000000 --- a/packages/tempo/src/parse/parse.layout.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { ownEntries } from '#library/primitive.library.js'; -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; - -const TOKEN_ALIAS = new Map( - (ownEntries(Token, true) as [string, symbol][]).map(([name, key]) => [key, name]) -); - -const TOKEN_DESCRIPTION_BY_NAME = new Map( - (ownEntries(Token, true) as [string, symbol][]) - .map(([name, key]) => [name, key.description ?? ''] as const) - .filter(([, description]) => description.length > 0) -); - -export const DEFAULT_LAYOUT_CLASS: unique symbol = Symbol('default'); - -export interface ResolveLayoutOrderArgs { - layout: Record; - monthDayLayouts: t.LayoutPair[] | readonly t.LayoutPair[]; - isMonthDay: boolean; - layoutController?: LayoutController; - classification?: PropertyKey; -} - -export function createLayoutController(layout: Record): LayoutController { - return { - [DEFAULT_LAYOUT_CLASS]: getLayoutOrder(layout), - } -} - -export function resolveLayoutClassificationOrder(layout: Record, controller: LayoutController, classification: PropertyKey = DEFAULT_LAYOUT_CLASS): Record { - const preferred = controller[classification] ?? []; - if (preferred.length === 0) return layout; - - const entries = ownEntries(layout) as LayoutEntry[]; - const byName = new Map(); - entries.forEach(([key, value]) => { - const description = key.description ?? ''; - if (description) byName.set(description, [key, value]); - const alias = TOKEN_ALIAS.get(key); - if (alias) byName.set(alias, [key, value]); - }); - const next: LayoutEntry[] = []; - const seen = new Set(); - - preferred.forEach(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]); - next.push(entry); - }); - - entries.forEach(entry => { - if (!seen.has(entry[0])) next.push(entry); - }); - - const changed = next.length === entries.length && next.some((entry, idx) => entry[0] !== entries[idx][0]); - return changed ? Object.fromEntries(next) as Record : layout; -} - -export function resolveLayoutOrder({ layout, monthDayLayouts, isMonthDay, layoutController, classification }: ResolveLayoutOrderArgs): Record { - const ordered = resolveLayoutClassificationOrder( - layout, - layoutController ?? createLayoutController(layout), - classification ?? DEFAULT_LAYOUT_CLASS, - ); - - const layouts = ownEntries(ordered) as LayoutEntry[]; - let changed = false; - - monthDayLayouts.forEach(([dmy, mdy]) => { - const idx1 = layouts.findIndex(([key]) => key.description === dmy); - const idx2 = layouts.findIndex(([key]) => key.description === mdy); - - if (idx1 === -1 || idx2 === -1) return; - - const swap1 = idx1 < idx2 && isMonthDay; - const swap2 = idx1 > idx2 && !isMonthDay; - if (swap1 || swap2) { - [layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]]; - changed = true; - } - }); - - if (changed) return Object.fromEntries(layouts) as Record; - return ordered; -} - -export function getLayoutOrder(layout: Record): string[] { - return (ownEntries(layout) as LayoutEntry[]) - .map(([key]) => key.description ?? String(key)); -} diff --git a/packages/tempo/src/parse/parse.resolver.ts b/packages/tempo/src/parse/parse.resolver.ts deleted file mode 100644 index a03bcebe..00000000 --- a/packages/tempo/src/parse/parse.resolver.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ownEntries } from '#library/primitive.library.js'; -import type * as t from '../tempo.type.js'; - -export type LayoutEntry = [symbol, string]; - -export function resolveLayoutOrderPure( - layout: Record, - monthDayLayouts: t.LayoutPair[] | readonly t.LayoutPair[], - isMonthDay: boolean -): LayoutEntry[] { - const layouts = ownEntries(layout) as LayoutEntry[]; - let changed = false; - - monthDayLayouts.forEach(([dmy, mdy]) => { - const idx1 = layouts.findIndex(([key]) => key.description === dmy); - const idx2 = layouts.findIndex(([key]) => key.description === mdy); - if (idx1 === -1 || idx2 === -1) return; - const swap1 = idx1 < idx2 && isMonthDay; - const swap2 = idx1 > idx2 && !isMonthDay; - if (swap1 || swap2) { - [layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]]; - changed = true; - } - }); - - return layouts; -} diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 83023935..d974a060 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -2,7 +2,7 @@ import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from import { secureRef } from '#library/proxy.library.js'; import { sym, getRuntime, isTempo } from '#tempo/support'; -import { hasOwn } from '#tempo/support/tempo.util.js'; +import { hasOwn } from '#tempo/support/support.util.js'; import type { Tempo } from '../tempo.class.js'; import type { Plugin } from './plugin.type.js'; diff --git a/packages/tempo/src/plugin/term/term.quarter.ts b/packages/tempo/src/plugin/term/term.quarter.ts index 51a48571..b1fec7c5 100644 --- a/packages/tempo/src/plugin/term/term.quarter.ts +++ b/packages/tempo/src/plugin/term/term.quarter.ts @@ -1,5 +1,5 @@ import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; -import { COMPASS } from '../../support/tempo.enum.js'; +import { COMPASS } from '../../support/support.enum.js'; import { isNumber } from '#library/assertion.library.js'; import { asArray } from '#library'; import type { Tempo } from '../../tempo.class.js'; diff --git a/packages/tempo/src/plugin/term/term.season.ts b/packages/tempo/src/plugin/term/term.season.ts index 49024ce1..02321db6 100644 --- a/packages/tempo/src/plugin/term/term.season.ts +++ b/packages/tempo/src/plugin/term/term.season.ts @@ -1,5 +1,5 @@ import { getTermRange, defineTerm, defineRange, resolveCycleWindow } from '../term.util.js'; -import { COMPASS } from '../../support/tempo.enum.js'; +import { COMPASS } from '../../support/support.enum.js'; import type { Tempo } from '../../tempo.class.js'; /** definition of meteorological season ranges */ diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/support.default.ts similarity index 98% rename from packages/tempo/src/support/tempo.default.ts rename to packages/tempo/src/support/support.default.ts index e76aa748..ce55d52e 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -2,9 +2,9 @@ import { looseIndex } from '#library/object.library.js'; import { secure, proxify } from '#library/proxy.library.js'; import { getDateTimeFormat } from '#library/international.library.js'; -import { NUMBER, MODE, MONTH_DAY } from './tempo.enum.js'; -import { Token } from './tempo.symbol.js'; -import { IntlDefault } from './tempo.intl.js'; +import { NUMBER, MODE, MONTH_DAY } from './support.enum.js'; +import { Token } from './support.symbol.js'; +import { IntlDefault } from './support.intl.js'; import type { Options } from '../tempo.type.js'; import type { Tempo } from '../tempo.class.js'; diff --git a/packages/tempo/src/support/tempo.enum.ts b/packages/tempo/src/support/support.enum.ts similarity index 99% rename from packages/tempo/src/support/tempo.enum.ts rename to packages/tempo/src/support/support.enum.ts index d91e5776..3f43a220 100644 --- a/packages/tempo/src/support/tempo.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -1,4 +1,4 @@ -import { sym } from './tempo.symbol.js'; +import { sym } from './support.symbol.js'; import { enumify, Enum } from '#library/enumerate.library.js'; import { proxify } from '#library/proxy.library.js'; import { allDescriptors } from '#library/reflection.library.js'; diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 71d06f0c..0b68965e 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -24,13 +24,14 @@ export { PARSE, MONTH_DAY, NumericPattern -} from './tempo.enum.js'; +} from './support.enum.js'; 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, $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, logError, logWarn, logDebug } from './tempo.util.js'; -export { init, extendState } from './tempo.init.js'; \ No newline at end of file +export { sym, isTempo, Token, TermError, type TempoBrand } from './support.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 './support.symbol.js'; +export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; +export { getRuntime, TempoRuntime } from './support.runtime.js'; +export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './support.default.js'; +export { SCHEMA, getLargestUnit, logError, logWarn, logDebug } from './support.util.js'; +export { setPatterns } from '../engine/engine.pattern.js'; +export { init, extendState } from './support.init.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/support.init.ts similarity index 93% rename from packages/tempo/src/support/tempo.init.ts rename to packages/tempo/src/support/support.init.ts index 9241a92e..206cee97 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -8,11 +8,11 @@ import { asType } from '#library/type.library.js'; import { isString, isObject, isUndefined, isDefined, isRegExp } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; -import { getRuntime } from './tempo.runtime.js'; -import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay } from './tempo.util.js'; -import { sym, Token } from './tempo.symbol.js'; -import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './tempo.default.js'; -import enums, { STATE } from './tempo.enum.js'; +import { getRuntime } from './support.runtime.js'; +import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError } from './support.util.js'; +import { sym, Token } from './support.symbol.js'; +import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './support.default.js'; +import enums, { STATE } from './support.enum.js'; import * as t from '../tempo.type.js'; /** @internal Initialise a Tempo state */ @@ -141,8 +141,14 @@ export function extendState(state: t.Internal.State, options: t.Options) { if (optKey === 'snippet') { const pattern = isRegExp(v) ? v.source : String(v); // 🛡️ Security Check: Prevent catastrophic backtracking and malicious patterns - if (pattern.length > 500) throw new Error(`[Tempo#extend] Snippet pattern too long (max 500 chars).`); - if (Match.backtrack.test(pattern)) throw new Error(`[Tempo#extend] Snippet contains suspicious nested quantifiers.`); + if (pattern.length > 500) { + logError(state.config, `[Tempo#extend] Snippet pattern too long (max 500 chars).`); + return new RegExp(Match.escape(pattern)); + } + if (Match.backtrack.test(pattern)) { + logError(state.config, `[Tempo#extend] Snippet contains suspicious nested quantifiers.`); + return new RegExp(Match.escape(pattern)); + } return new RegExp(pattern); } return isRegExp(v) ? v.source : v; diff --git a/packages/tempo/src/support/tempo.intl.ts b/packages/tempo/src/support/support.intl.ts similarity index 100% rename from packages/tempo/src/support/tempo.intl.ts rename to packages/tempo/src/support/support.intl.ts diff --git a/packages/tempo/src/support/tempo.register.ts b/packages/tempo/src/support/support.register.ts similarity index 94% rename from packages/tempo/src/support/tempo.register.ts rename to packages/tempo/src/support/support.register.ts index 1bf410d8..2859f9e6 100644 --- a/packages/tempo/src/support/tempo.register.ts +++ b/packages/tempo/src/support/support.register.ts @@ -3,14 +3,14 @@ import { isEqual } from '#library/object.library.js'; import { isDefined, isObject, isSymbol, isUndefined } from '#library/assertion.library.js'; import { ownKeys } from '#library/primitive.library.js'; import { unwrap } from '#library/primitive.library.js'; -import { sym } from './tempo.symbol.js'; +import { sym } from './support.symbol.js'; import type { Property } from '#library/type.library.js'; -import { getRuntime } from './tempo.runtime.js'; -import { hasOwn, setProperty } from './tempo.util.js'; +import { getRuntime } from './support.runtime.js'; +import { hasOwn, setProperty } from './support.util.js'; // Import the live enums and their mutable state from the enum module -import { STATE, REGISTRIES, DEFAULTS } from './tempo.enum.js'; +import { STATE, REGISTRIES, DEFAULTS } from './support.enum.js'; const rt = getRuntime(); diff --git a/packages/tempo/src/support/tempo.runtime.ts b/packages/tempo/src/support/support.runtime.ts similarity index 99% rename from packages/tempo/src/support/tempo.runtime.ts rename to packages/tempo/src/support/support.runtime.ts index 3e439c65..df2fbcc7 100644 --- a/packages/tempo/src/support/tempo.runtime.ts +++ b/packages/tempo/src/support/support.runtime.ts @@ -1,4 +1,4 @@ -import { sym } from './tempo.symbol.js'; +import { sym } from './support.symbol.js'; import type { TermPlugin, Extension, Plugin } from '../plugin/plugin.type.js'; import type { Internal } from '../tempo.type.js'; diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/support.symbol.ts similarity index 59% rename from packages/tempo/src/support/tempo.symbol.ts rename to packages/tempo/src/support/support.symbol.ts index 8ef15d7c..ea0d5222 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -13,26 +13,26 @@ export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termError') as any; /** @internal unique symbols for critical internal accessors */ -/** key for Global Discovery of Tempo configuration */ export const $Tempo: unique symbol = Symbol.for('$Tempo') as any; -/** key for Reactive Plugin Registration */ export const $Register: unique symbol = Symbol.for('magmacomputing/tempo/register') as any; -/** key for Internal Interpreter Service */ export const $Interpreter: unique symbol = Symbol.for('magmacomputing/tempo/interpreter') as any; -/** key for contextual Error Logging */ export const $logError: unique symbol = Symbol.for('magmacomputing/tempo/logError') as any; -/** key for contextual Debug Logging */ export const $logDebug: unique symbol = Symbol.for('magmacomputing/tempo/logDebug') as any; -/** key for contextual Debugger */ export const $dbg: unique symbol = Symbol.for('magmacomputing/tempo/dbg') as any; -/** key for Master Guard */ export const $guard: unique symbol = Symbol.for('magmacomputing/tempo/guard') as any; -/** internal key for signaling pre-errored state */ export const $errored: unique symbol = Symbol.for('magmacomputing/tempo/errored') as any; -/** internal key for accessing private instance state */ export const $Internal: unique symbol = Symbol.for('magmacomputing/tempo/internal') as any; -/** hardened globalThis bridge key for the TempoRuntime */export const $Bridge: unique symbol = Symbol.for('magmacomputing/tempo/runtime') as any; -/** cross-bundle brand check for TempoRuntime */ export const $RuntimeBrand: unique symbol = Symbol.for('magmacomputing/tempo/runtime/brand') as any; -/** branding for explicit PropertyDescriptors */ export const $Descriptor: unique symbol = Symbol.for('magmacomputing/tempo/descriptor') as any; +/** key for Global Discovery of Tempo configuration */ export const $Tempo: unique symbol = Symbol.for('$Tempo') as any; +/** key for Reactive Plugin Registration */ export const $Register: unique symbol = Symbol.for('magmacomputing/tempo/register') as any; +/** key for Internal Interpreter Service */ export const $Interpreter: unique symbol = Symbol.for('magmacomputing/tempo/interpreter') as any; +/** key for contextual Error Logging */ export const $logError: unique symbol = Symbol.for('magmacomputing/tempo/logError') as any; +/** key for contextual Debug Logging */ export const $logDebug: unique symbol = Symbol.for('magmacomputing/tempo/logDebug') as any; +/** key for contextual Debugger */ export const $dbg: unique symbol = Symbol.for('magmacomputing/tempo/dbg') as any; +/** key for Master Guard */ export const $guard: unique symbol = Symbol.for('magmacomputing/tempo/guard') as any; +/** internal key for signaling pre-errored state */ export const $errored: unique symbol = Symbol.for('magmacomputing/tempo/errored') as any; +/** internal key for accessing private instance state */ export const $Internal: unique symbol = Symbol.for('magmacomputing/tempo/internal') as any; +/** hardened globalThis bridge key for the TempoRuntime */ export const $Bridge: unique symbol = Symbol.for('magmacomputing/tempo/runtime') as any; +/** cross-bundle brand check for TempoRuntime */ export const $RuntimeBrand: unique symbol = Symbol.for('magmacomputing/tempo/runtime/brand') as any; +/** branding for explicit PropertyDescriptors */ export const $Descriptor: unique symbol = Symbol.for('magmacomputing/tempo/descriptor') as any; -/** internal static config helper */ export const $setConfig: unique symbol = Symbol.for('magmacomputing/tempo/setConfig') as any; -/** 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; +/** internal static config helper */ export const $setConfig: unique symbol = Symbol.for('magmacomputing/tempo/setConfig') as any; +/** 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; /** @internal Tempo Symbol Registry (Local Keys) */ const local = { diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/support.util.ts similarity index 52% rename from packages/tempo/src/support/tempo.util.ts rename to packages/tempo/src/support/support.util.ts index c43beb24..859bfc30 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -1,13 +1,13 @@ import { isBoolean } from '#library/assertion.library.js'; -import { sym, Token } from './tempo.symbol.js'; +import { sym, Token } from './support.symbol.js'; import { asType } from '#library/type.library.js'; import { asArray } from '#library/coercion.library.js'; import { isSymbol, isUndefined, isDefined, isString, isRegExp, isNullish, isObject, isEmpty } from '#library/assertion.library.js'; -import { ownEntries, ownKeys } from '#library/primitive.library.js'; -import { getRuntime } from './tempo.runtime.js'; -import { Match, Snippet, Layout } from './tempo.default.js'; -import enums from './tempo.enum.js'; +import { ownEntries, ownKeys, unwrap } from '#library/primitive.library.js'; +import { getRuntime } from './support.runtime.js'; +import { Match, Snippet, Layout } from './support.default.js'; +import enums from './support.enum.js'; import type * as t from '../tempo.type.js'; /** @internal normalize layout-order options into a clean string array */ @@ -49,17 +49,30 @@ export function logDebug(config: any, ...msg: any[]) { rt.logger?.debug(config ?? rt.state?.config, ...msg); } -/** @internal return the Prototype parent of an object */ -export const proto = (obj: object) => Object.getPrototypeOf(obj); +/** @internal check if an object is a proxy */ +export const isProxy = (obj: any): boolean => !!obj && !!(obj as any)[sym.$Target]; -/** @internal test object has own property with the given key */ -export const hasOwn = (obj: object, key: PropertyKey) => Object.hasOwn(obj, key); +/** @internal check if an object has an own property (respects Proxy/Shadowing) */ +export const hasOwn = (obj: any, key: PropertyKey): boolean => { + return Object.hasOwn(unwrap(obj), key); +} + +/** @internal get the prototype of an object */ +export const proto = (obj: any): any => Object.getPrototypeOf(unwrap(obj)); + +/** @internal create a new shadowed object from a prototype */ +export function create(obj: any, name: string): T { + const prototype = proto(obj); + if (!isObject(prototype)) { + logError(null, `[Tempo#create] Failed to create shadowed object for '${name}'. Proto(obj) is null or not an object.`); + return {} as T; + } -/** @internal create an object based on a prototype */ -export const create = (obj: object, name: string): T => { - const entry = proto(obj)[name]; - if (!isObject(entry)) - throw new TypeError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); + const entry = prototype[name]; + if (!isObject(entry)) { + logError(null, `[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); + return {} as T; + } return { ...entry } as T; } @@ -115,97 +128,6 @@ export function getLargestUnit(list: any[]): string { return 'nanosecond'; } -/** @internal translates {layout} into an anchored, case-insensitive RegExp. */ -export function compileRegExp(layout: string | RegExp, state: t.Internal.State, snippet?: Snippet) { - // helper function to replace {name} placeholders with their corresponding snippets - const matcher = (source: string, d = 0): string => { - if (d > 10) return source; // prevent infinite recursion - - if (source.startsWith('/') && source.endsWith('/')) - source = source.substring(1, source.length - 1); // remove the leading/trailing "/" - if (source.startsWith('^') && source.endsWith('$')) - source = source.substring(1, source.length - 1); // remove the leading/trailing anchors (^ $) - - return source.replace(new RegExp(Match.braces, 'g'), (match, name) => { // iterate over "{}" pairs in the source string - const token = getSymbol(name); // get the symbol for this {name} - const customs = snippet?.[token as keyof Snippet]?.source ?? snippet?.[name as keyof Snippet]?.source; - const globals = state.parse.snippet[token as keyof Snippet]?.source ?? state.parse.snippet[name as keyof Snippet]?.source; - const stateLayout = state.parse.layout[token as keyof Layout] ?? state.parse.layout[name as keyof Layout]; - const defaultLayout = Layout[token as keyof Layout]; // get resolution source (layout) - - let res = customs ?? globals ?? stateLayout ?? defaultLayout; // get the snippet/layout source - - if (isNullish(res) && name.includes('.')) { // if no definition found, try fallback - const prefix = name.split('.')[0]; // get the base token name - const pToken = getSymbol(prefix); - res = snippet?.[pToken as keyof Snippet]?.source ?? snippet?.[prefix as keyof Snippet]?.source - ?? state.parse.snippet[pToken as keyof Snippet]?.source ?? state.parse.snippet[prefix as keyof Snippet]?.source - ?? state.parse.layout[pToken as keyof Layout] ?? state.parse.layout[prefix as keyof Layout] - ?? Layout[pToken as keyof Layout]; - } - - if (res && name.includes('.')) { // wrap dotted extensions for identification - const safeName = name.replace(/\./g, '_'); - if (!res.startsWith(`(?<${safeName}>`)) - res = `(?<${safeName}>${res})`; - } - - return (isNullish(res) || res === match) // if no definition found, - ? match // return the original match - : matcher(res, d + 1); // else recurse to see if snippet contains embedded "{}" pairs - }); - }; - - try { - const source = isRegExp(layout) ? layout.source : layout; - const expanded = matcher(source); - return new RegExp(`^(${expanded})$`, 'i'); - } catch (e: any) { - return new RegExp(`^${Match.escape(layout as string)}$`, 'i'); - } -} - -/** @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(); - - const snippet = state.parse.snippet; - - // 1. ensure numeric snippets are current - if (enums?.NUMBER) { - const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); // escape each key - const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); - - snippet[Token.nbr] = nbr; - snippet[Token.mod] = new RegExp(`((?${Match.modifier.source})?${nbr.source}? *)`); - snippet[Token.afx] = new RegExp(`((s)? (?${Match.affix.source}))?${snippet[Token.sep].source}?`); - } - - // 2. build ignore pattern - const ignores = ownKeys(state.parse.ignore, true); - - if (!isEmpty(ignores)) { - const words = ignores - .filter(isString) - .map(w => Match.escape(w.toLowerCase())) - .join('|'); - - state.parse.ignorePattern = new RegExp(`\\b(${words})\\b`, 'gi'); - } else { - delete state.parse.ignorePattern; - } - - // 3. build the patterns - ownEntries(state.parse.layout).forEach(([key, layout]) => { - const symbol = getSymbol(key); - const compiled = compileRegExp(layout, state, snippet); - - state.parse.pattern.set(symbol, compiled); - }); -} - /** * @internal Normalize a MonthDay configuration value against a base. * @param value The user-supplied value to normalize diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 18705c63..3004eb38 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -22,11 +22,11 @@ import { registerTerm, getTermRange } from './plugin/term.util.js'; import type { TermPlugin, Plugin } from './plugin/plugin.type.js'; 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, 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 { PatternCompiler } from './engine/engine.pattern.js'; +import { resolveMonthDay, setProperty, proto, hasOwn, normalizeLayoutOrder } from './support/support.util.js'; +import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; +import { datePattern } from './support/support.default.js'; +import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, 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' { @@ -871,7 +871,7 @@ export class Tempo { /** @internal lookup or registers a new `Symbol` for a given key. */ static getSymbol(key?: string | symbol) { if (isUndefined(key)) { - const usr = `usr.${++Tempo.#usrCount}`; // allocate a prefixed 'user' key + const usr = `usr.${++Tempo.#usrCount}`; // allocate a prefixed 'user' key return Token[usr] = Symbol(usr); // add to Symbol register } @@ -885,9 +885,13 @@ export class Tempo { return Token[key as keyof typeof Token] ?? Symbol.for(`$Tempo.${key}`); } - /** @internal translates {layout} into an anchored, case-insensitive RegExp. */ + /** translates {layout} into an anchored, case-insensitive RegExp. */ static regexp(layout: string | RegExp, snippet?: Snippet) { - return compileRegExp(layout, (this as any)[$Internal](), snippet as any); + const state = (this as any)[$Internal](); + + state.patternCompiler ??= new PatternCompiler({ state }); + + return state.patternCompiler.compileRegExp(layout, snippet as any); } /** Compares two `Tempo` instances or date-time values. */ diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 441552fe..c8a7ef89 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -7,15 +7,16 @@ * Inside `tempo.class.ts` these are accessed via `import * as t`. */ -import { sym, type TempoBrand } from '#tempo/support/tempo.symbol.js'; -import * as enums from '#tempo/support/tempo.enum.js'; +import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; +import * as enums from '#tempo/support/support.enum.js'; import type { Logify } from '#library/logify.class.js'; -import type { Snippet, Layout, Event, Period, Ignore } from '#tempo/support/tempo.default.js'; +import type { Snippet, Layout, Event, Period, Ignore } from '#tempo/support/support.default.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption } from '#library/type.library.js'; import type { TermPlugin, Plugin } from '#tempo/plugin/plugin.type.js'; -import type { Token } from '#tempo/support/tempo.symbol.js'; +import type { Token } from '#tempo/support/support.symbol.js'; import type { Tempo } from '#tempo/tempo.class.js'; import { AliasEngine } from './engine/engine.alias.js'; +import type { PatternCompiler } from './engine/engine.pattern.js'; declare global { interface globalThis { @@ -225,6 +226,7 @@ export namespace Internal { /** @internal current ZonedDateTime during parsing */ zdt?: Temporal.ZonedDateTime; /** @internal has the parse operation errored? */ errored?: boolean; /** @internal Alias engine for this Tempo instance */ aliasEngine?: AliasEngine; + /** @internal Pattern compiler for this Tempo instance */ patternCompiler?: PatternCompiler; } /** debug a Tempo instantiation */ diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index 24f4d1b6..cdef1a92 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -22,7 +22,6 @@ "#tempo/ticker": ["./plugin/extend/extend.ticker.ts"], "#tempo/engine/*.js": ["./engine/*.ts"], "#tempo/module/*.js": ["./module/*.ts"], - "#tempo/parse/*.js": ["./parse/*.ts"], "#tempo/plugin/extend/*.js": ["./plugin/extend/*.ts"], "#tempo/plugin/term/*.js": ["./plugin/term/*.ts"], "#tempo/term/*": ["./plugin/term/term.*.ts"], diff --git a/packages/tempo/test/engine/parse.layout.test.ts b/packages/tempo/test/engine/parse.layout.test.ts index a9a8779d..8af58ab8 100644 --- a/packages/tempo/test/engine/parse.layout.test.ts +++ b/packages/tempo/test/engine/parse.layout.test.ts @@ -3,7 +3,7 @@ import { createLayoutController, resolveLayoutClassificationOrder, resolveLayoutOrder, -} from '#tempo/parse/parse.layout.js'; +} from '#tempo/engine/engine.layout.js'; const makeLayout = (names: string[]) => Object.fromEntries(names.map(name => [Symbol(name), name])) as Record; @@ -11,7 +11,7 @@ const makeLayout = (names: string[]) => const orderOf = (layout: Record) => Reflect.ownKeys(layout).map(key => (key as symbol).description); -describe('engine.layout resolver', () => { +describe('engine.resolver', () => { test('no-op when no swap pair matches', () => { const layout = makeLayout(['x', 'y', 'z']); const resolved = resolveLayoutOrder({ diff --git a/packages/tempo/test/engine/pattern_compiler_optimization.test.ts b/packages/tempo/test/engine/pattern_compiler_optimization.test.ts new file mode 100644 index 00000000..72de435a --- /dev/null +++ b/packages/tempo/test/engine/pattern_compiler_optimization.test.ts @@ -0,0 +1,43 @@ +import { $Internal, TempoRuntime, init } from '#tempo/support'; +import { PatternCompiler } from '../../src/engine/engine.pattern.js'; +import { getSymbol } from '../../src/support/support.util.js'; + +describe('PatternCompiler Optimization and Safety', () => { + let state: any; + let compiler: PatternCompiler; + + beforeEach(() => { + const runtime = TempoRuntime.createScoped(); + state = init({}, false); + runtime.state = state; + compiler = new PatternCompiler({ state }); + }); + + test('should expand nested placeholders correctly using the hoisted BRACES_REGEX', () => { + // Define some snippets in the state + state.parse.snippet[getSymbol('a')] = { source: 'A{b}' }; + state.parse.snippet[getSymbol('b')] = { source: 'B{c}' }; + state.parse.snippet[getSymbol('c')] = { source: 'C' }; + + const result = compiler.compileRegExp('{a}'); + expect(result.source).toBe('^(ABC)$'); + }); + + test('should handle circular references by hitting recursion limit', () => { + // Circular reference: a -> b -> a + state.parse.snippet[getSymbol('a')] = { source: 'A{b}' }; + state.parse.snippet[getSymbol('b')] = { source: 'B{a}' }; + + // This should hit the recursion limit (d > 10) and return the partially expanded string + const result = compiler.compileRegExp('{a}'); + // Expanding {a} -> A{b} -> AB{a} -> ABA{b} ... + expect(result.source).toContain('ABABAB'); + }); + + test('should handle multiple occurrences of placeholders in a single string', () => { + state.parse.snippet[getSymbol('x')] = { source: 'X' }; + + const result = compiler.compileRegExp('{x}-{x}-{x}'); + expect(result.source).toBe('^(X-X-X)$'); + }); +}); diff --git a/packages/tempo/test/plugins/plugin_registration.test.ts b/packages/tempo/test/plugins/plugin_registration.test.ts index 631c81cf..b2314bb7 100644 --- a/packages/tempo/test/plugins/plugin_registration.test.ts +++ b/packages/tempo/test/plugins/plugin_registration.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import { getRuntime } from '#tempo/support/tempo.runtime.js'; +import { getRuntime } from '#tempo/support/support.runtime.js'; import { TickerModule } from '#tempo/ticker'; describe('Ticker Registration / Initialization', () => { diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 3605aec3..ee931ee1 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -44,7 +44,6 @@ export default defineConfig({ { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/plugin.$1.js') }, { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/$1.js') }, { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './dist/engine/$1.js') }, - { find: /^#tempo\/parse\/(.*)\.js$/, replacement: resolve(__dirname, './dist/parse/$1.js') }, { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './dist/module/$1.js') }, { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/term/$1.js') }, { find: /^#tempo\/support$/, replacement: resolve(__dirname, './dist/support/support.index.js') }, @@ -65,7 +64,6 @@ export default defineConfig({ { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/plugin.$1.ts') }, { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/$1.ts') }, { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './src/engine/$1.ts') }, - { find: /^#tempo\/parse\/(.*)\.js$/, replacement: resolve(__dirname, './src/parse/$1.ts') }, { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './src/module/$1.ts') }, { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/term/$1.ts') }, { find: /^#tempo\/support$/, replacement: resolve(__dirname, './src/support/support.index.ts') },