diff --git a/src/uix/sema/verbs.test.ts b/src/uix/sema/verbs.test.ts index dc606ecc8..c4ee63683 100644 --- a/src/uix/sema/verbs.test.ts +++ b/src/uix/sema/verbs.test.ts @@ -167,6 +167,26 @@ describe('validateEventName', () => { expect(validateEventName('commit-frob').family).toBe('commit') }) + it('recognises multi-word canonical verbs (enter-mode, exit-mode)', () => { + // Family-verb shape with multi-word verb. + expect(validateEventName('shift-enter-mode')).toEqual({ + name: 'shift-enter-mode', + head: 'shift', + matchesCanonical: true, + variant: 'enter-mode', + family: 'shift', + verb: 'enter-mode' + }) + expect(validateEventName('shift-exit-mode').matchesCanonical).toBe(true) + // With trailing variant. + expect(validateEventName('shift-enter-mode-edit').matchesCanonical).toBe(true) + expect(validateEventName('shift-enter-mode-edit').verb).toBe('enter-mode') + // Bare verb shape — `enter-mode` alone is canonical. + expect(validateEventName('enter-mode').matchesCanonical).toBe(true) + expect(validateEventName('enter-mode').verb).toBe('enter-mode') + expect(validateEventName('enter-mode').family).toBe('shift') + }) + it('reports the existing dialog event names against the catalog', () => { const dialogEvents = [ 'open', diff --git a/src/uix/sema/verbs.ts b/src/uix/sema/verbs.ts index ddf082ead..3c808b274 100644 --- a/src/uix/sema/verbs.ts +++ b/src/uix/sema/verbs.ts @@ -156,6 +156,29 @@ const VERB_SET: ReadonlySet = new Set( Object.values(SEMA_VERBS).flatMap((verbs) => [...verbs]) ); +/** + * Find the longest canonical verb that is a prefix of `text` (followed + * by end-of-string or a dash). Required because the canon includes + * multi-word verbs (`enter-mode`, `exit-mode`); a naive "split at first + * dash" would only see `enter` / `exit` and miss the actual verb. + * + * Returns the matched verb plus whatever remains of `text` after it + * (with the separating dash consumed), or `undefined` if no verb + * prefixes `text`. Sorts by length descending so `enter-mode` beats + * `enter` when both could match. + */ +function matchLongestVerb( + text: string, + verbs: Iterable +): { verb: string; tail: string } | undefined { + const sorted = [...verbs].sort((a, b) => b.length - a.length); + for (const verb of sorted) { + if (text === verb) return { verb, tail: '' }; + if (text.startsWith(verb + '-')) return { verb, tail: text.slice(verb.length + 1) }; + } + return undefined; +} + /** * Reverse lookup: which family does a given verb belong to? * @@ -221,33 +244,34 @@ export function validateEventName(name: string): VerbValidation { const tail = dashIdx === -1 ? '' : name.slice(dashIdx + 1); const variant = dashIdx === -1 ? undefined : tail; - // Case 1: head is a canonical verb → family from VERB_TO_FAMILY. - if (VERB_SET.has(head)) { + // Case 1: name starts with a canonical verb (bare verb or verb-variant + // shape). Longest-prefix match against ALL canonical verbs so + // multi-word verbs like `enter-mode` win over `enter`. + const verbHeadMatch = matchLongestVerb(name, VERB_SET); + if (verbHeadMatch) { return { name, head, matchesCanonical: true, variant, - family: VERB_TO_FAMILY.get(head), - verb: head + family: VERB_TO_FAMILY.get(verbHeadMatch.verb), + verb: verbHeadMatch.verb }; } - // Case 2: head is a family name → tail's first segment should be a - // verb in that family. + // Case 2: head is a family name → longest-prefix match against the + // family's verbs (covers multi-word verbs like `shift.enter-mode`). if (head in SEMA_VERBS) { const family = head as SemaFamilyName; - const tailFirstDash = tail.indexOf('-'); - const verbCandidate = tailFirstDash === -1 ? tail : tail.slice(0, tailFirstDash); const familyVerbs = SEMA_VERBS[family] as readonly string[]; - const verbCanonical = verbCandidate.length > 0 && familyVerbs.includes(verbCandidate); + const verbMatch = matchLongestVerb(tail, familyVerbs); return { name, head, - matchesCanonical: verbCanonical, + matchesCanonical: verbMatch !== undefined, variant, family, - verb: verbCanonical ? verbCandidate : undefined + verb: verbMatch?.verb }; }