@lokascript/framework 4.6.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -1
- package/dist/api/create-dsl.d.ts +18 -2
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1799 -713
- package/dist/api/index.js.map +1 -1
- package/dist/api/translation-loss.d.ts +84 -0
- package/dist/api/translation-loss.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1057 -684
- package/dist/core/index.js.map +1 -1
- package/dist/core/matching/confidence-model.d.ts +54 -0
- package/dist/core/matching/confidence-model.d.ts.map +1 -0
- package/dist/core/matching/index.d.ts +12 -0
- package/dist/core/matching/index.d.ts.map +1 -0
- package/dist/core/matching/match-skeleton.d.ts +114 -0
- package/dist/core/matching/match-skeleton.d.ts.map +1 -0
- package/dist/core/matching/stock-confidence.d.ts +15 -0
- package/dist/core/matching/stock-confidence.d.ts.map +1 -0
- package/dist/core/matching/stock-reader.d.ts +63 -0
- package/dist/core/matching/stock-reader.d.ts.map +1 -0
- package/dist/core/matching/stock-reading.d.ts +44 -0
- package/dist/core/matching/stock-reading.d.ts.map +1 -0
- package/dist/core/matching/value-reader.d.ts +62 -0
- package/dist/core/matching/value-reader.d.ts.map +1 -0
- package/dist/core/pattern-matching/index.js +561 -660
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
- package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
- package/dist/core/types.d.ts +53 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/generation/index.d.ts +2 -0
- package/dist/generation/index.d.ts.map +1 -1
- package/dist/generation/index.js +593 -1
- package/dist/generation/index.js.map +1 -1
- package/dist/generation/pattern-render.d.ts +72 -0
- package/dist/generation/pattern-render.d.ts.map +1 -0
- package/dist/generation/schema-patterns.d.ts +152 -0
- package/dist/generation/schema-patterns.d.ts.map +1 -0
- package/dist/index.cjs +2016 -904
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1998 -903
- package/dist/index.js.map +1 -1
- package/dist/ir/index.js.map +1 -1
- package/dist/parsing/index.js +561 -660
- package/dist/parsing/index.js.map +1 -1
- package/dist/testing/gate/index.d.ts +8 -0
- package/dist/testing/gate/index.d.ts.map +1 -0
- package/dist/testing/gate/index.js +664 -0
- package/dist/testing/gate/index.js.map +1 -0
- package/dist/testing/gate/lint/format.d.ts +7 -0
- package/dist/testing/gate/lint/format.d.ts.map +1 -0
- package/dist/testing/gate/lint/index.d.ts +30 -0
- package/dist/testing/gate/lint/index.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
- package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/index.d.ts +14 -0
- package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
- package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
- package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
- package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
- package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
- package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
- package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
- package/dist/testing/gate/lint/types.d.ts +74 -0
- package/dist/testing/gate/lint/types.d.ts.map +1 -0
- package/dist/testing/gate/round-trip.d.ts +184 -0
- package/dist/testing/gate/round-trip.d.ts.map +1 -0
- package/package.json +6 -2
- package/src/__test__/fixtures/sql-dsl.ts +8 -2
- package/src/api/create-dsl-engine.test.ts +150 -0
- package/src/api/create-dsl.test.ts +7 -0
- package/src/api/create-dsl.ts +173 -22
- package/src/api/index.ts +1 -0
- package/src/api/translation-loss.test.ts +89 -0
- package/src/api/translation-loss.ts +128 -0
- package/src/core/index.ts +1 -0
- package/src/core/matching/confidence-model.ts +58 -0
- package/src/core/matching/index.ts +12 -0
- package/src/core/matching/match-skeleton.test.ts +179 -0
- package/src/core/matching/match-skeleton.ts +621 -0
- package/src/core/matching/stock-confidence.ts +238 -0
- package/src/core/matching/stock-reader.test.ts +265 -0
- package/src/core/matching/stock-reader.ts +205 -0
- package/src/core/matching/stock-reading.ts +681 -0
- package/src/core/matching/value-reader.ts +85 -0
- package/src/core/pattern-matching/pattern-matcher.ts +11 -876
- package/src/core/types.ts +53 -0
- package/src/generation/index.ts +2 -0
- package/src/generation/pattern-render.ts +378 -0
- package/src/generation/schema-patterns.test.ts +139 -0
- package/src/generation/schema-patterns.ts +974 -0
- package/src/testing/gate/index.ts +8 -0
- package/src/testing/gate/lint/format.ts +23 -0
- package/src/testing/gate/lint/index.ts +73 -0
- package/src/testing/gate/lint/lint.test.ts +131 -0
- package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
- package/src/testing/gate/lint/rules/index.ts +37 -0
- package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
- package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
- package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
- package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
- package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
- package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
- package/src/testing/gate/lint/types.ts +83 -0
- package/src/testing/gate/round-trip.test.ts +208 -0
- package/src/testing/gate/round-trip.ts +503 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patterns from command schemas: the generator semantic's hyperscript parser
|
|
3
|
+
* runs (domains rethink Phase 4.4, `docs-internal/DOMAINS_RETHINK.md`), moved
|
|
4
|
+
* here from `@lokascript/semantic` beside the domains' own `generatePattern`
|
|
5
|
+
* (`pattern-generator.ts`), which `createMultilingualDSL` still uses.
|
|
6
|
+
*
|
|
7
|
+
* Per command and language it builds the main pattern and its variants: the
|
|
8
|
+
* simple variant (required roles only), the omit-role, widened-type and
|
|
9
|
+
* required-role variants, an SOV language's verb-first fallback, and the
|
|
10
|
+
* value-prefix-literal variants (`RoleSpec` / `CommandSchema` document each).
|
|
11
|
+
* What differs between grammars is a hook (`SchemaPatternGrammar`); what a
|
|
12
|
+
* grammar adds on top (hyperscript's fused event-handler patterns) stays in the
|
|
13
|
+
* grammar's package.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtractionRule, LanguagePattern, PatternToken } from '../core/types';
|
|
17
|
+
import type { CommandSchema, RoleSpec } from '../schema';
|
|
18
|
+
|
|
19
|
+
/** What differs between grammars in how a schema becomes patterns. */
|
|
20
|
+
export interface SchemaPatternGrammar {
|
|
21
|
+
/**
|
|
22
|
+
* How `svoPosition` / `sovPosition` sort: `'ascending'` puts the lowest first
|
|
23
|
+
* (hyperscript's schemas, 1-based), `'descending'` the highest (a domain's
|
|
24
|
+
* schemas, as framework's `generatePattern` reads them).
|
|
25
|
+
*/
|
|
26
|
+
readonly positionOrder: 'ascending' | 'descending';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A role marker in a language profile. */
|
|
30
|
+
export interface SchemaPatternMarker {
|
|
31
|
+
readonly primary: string;
|
|
32
|
+
readonly alternatives?: string[];
|
|
33
|
+
readonly position: 'before' | 'after';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the generator reads of a language profile. */
|
|
37
|
+
export interface SchemaPatternProfile {
|
|
38
|
+
readonly code: string;
|
|
39
|
+
readonly wordOrder: string;
|
|
40
|
+
readonly keywords: Readonly<
|
|
41
|
+
Record<string, { readonly primary: string; readonly alternatives?: string[] } | undefined>
|
|
42
|
+
>;
|
|
43
|
+
readonly roleMarkers: Readonly<Partial<Record<string, SchemaPatternMarker>>>;
|
|
44
|
+
/**
|
|
45
|
+
* The language may drop its role markers colloquially (Turkish case
|
|
46
|
+
* suffixes): each marker becomes an optional group that still renders.
|
|
47
|
+
*/
|
|
48
|
+
readonly markersOptional?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The generator, bound to a grammar (`createSchemaPatternGenerator`). */
|
|
52
|
+
export interface SchemaPatternGenerator {
|
|
53
|
+
/** The main pattern for a command in a language. */
|
|
54
|
+
generatePattern(
|
|
55
|
+
schema: CommandSchema,
|
|
56
|
+
profile: SchemaPatternProfile,
|
|
57
|
+
config?: SchemaPatternConfig
|
|
58
|
+
): LanguagePattern;
|
|
59
|
+
/** The command without its optional roles; null when it has none. */
|
|
60
|
+
generateSimplePattern(
|
|
61
|
+
schema: CommandSchema,
|
|
62
|
+
profile: SchemaPatternProfile,
|
|
63
|
+
config?: SchemaPatternConfig
|
|
64
|
+
): LanguagePattern | null;
|
|
65
|
+
/** An SOV language's verb-first fallback; null for other word orders. */
|
|
66
|
+
generateVerbFirstPattern(
|
|
67
|
+
schema: CommandSchema,
|
|
68
|
+
profile: SchemaPatternProfile,
|
|
69
|
+
config?: SchemaPatternConfig
|
|
70
|
+
): LanguagePattern | null;
|
|
71
|
+
/** The main pattern and every variant the schema and config ask for. */
|
|
72
|
+
generatePatternVariants(
|
|
73
|
+
schema: CommandSchema,
|
|
74
|
+
profile: SchemaPatternProfile,
|
|
75
|
+
config?: SchemaPatternConfig
|
|
76
|
+
): LanguagePattern[];
|
|
77
|
+
/** The token(s) of one role: its value slot and marker, in the language's order. */
|
|
78
|
+
buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): PatternToken[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A generator for the grammar's schemas. */
|
|
82
|
+
export function createSchemaPatternGenerator(
|
|
83
|
+
grammar: SchemaPatternGrammar
|
|
84
|
+
): SchemaPatternGenerator {
|
|
85
|
+
return {
|
|
86
|
+
generatePattern: (schema, profile, config) => generatePattern(grammar, schema, profile, config),
|
|
87
|
+
generateSimplePattern: (schema, profile, config) =>
|
|
88
|
+
generateSimplePattern(grammar, schema, profile, config),
|
|
89
|
+
generateVerbFirstPattern: (schema, profile, config) =>
|
|
90
|
+
generateVerbFirstPattern(schema, profile, config),
|
|
91
|
+
generatePatternVariants: (schema, profile, config) =>
|
|
92
|
+
generatePatternVariants(grammar, schema, profile, config),
|
|
93
|
+
buildRoleToken,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The schema without its variant-only roles: what every pattern but a required-role variant is built from. */
|
|
98
|
+
export function withoutVariantOnlyRoles<S extends CommandSchema>(schema: S): S {
|
|
99
|
+
return schema.roles.some(r => r.variantOnly)
|
|
100
|
+
? { ...schema, roles: schema.roles.filter(r => !r.variantOnly) }
|
|
101
|
+
: schema;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Roles in the grammar's position order for the word order (a stable sort).
|
|
106
|
+
* Each order keeps the rule its generator always had for a role with no
|
|
107
|
+
* position: 99 ascending (semantic's `sortRolesByWordOrder`), 0 descending
|
|
108
|
+
* (framework's `sortRolesByWordOrder`).
|
|
109
|
+
*/
|
|
110
|
+
function sortRolesByPosition<T extends Pick<RoleSpec, 'svoPosition' | 'sovPosition'>>(
|
|
111
|
+
roles: readonly T[],
|
|
112
|
+
wordOrder: string,
|
|
113
|
+
order: SchemaPatternGrammar['positionOrder']
|
|
114
|
+
): T[] {
|
|
115
|
+
const sortKey = wordOrder === 'SOV' ? 'sovPosition' : 'svoPosition';
|
|
116
|
+
return [...roles].sort((a, b) =>
|
|
117
|
+
order === 'ascending'
|
|
118
|
+
? (a[sortKey] ?? 99) - (b[sortKey] ?? 99)
|
|
119
|
+
: (b[sortKey] ?? 0) - (a[sortKey] ?? 0)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Minimum interface for role specs that can have marker resolution.
|
|
125
|
+
* Compatible with the RoleSpec type from command-schemas.
|
|
126
|
+
*/
|
|
127
|
+
export interface RoleSpecWithMarker {
|
|
128
|
+
role: string;
|
|
129
|
+
markerOverride?: Record<string, string | undefined>;
|
|
130
|
+
markerLegacy?: Record<string, readonly string[]>;
|
|
131
|
+
markerVariants?: Record<string, readonly string[]>;
|
|
132
|
+
methodCarrier?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolved marker information for a role.
|
|
137
|
+
*/
|
|
138
|
+
export interface ResolvedMarker {
|
|
139
|
+
/** Primary marker string (may be empty string for no marker) */
|
|
140
|
+
primary: string;
|
|
141
|
+
/** Alternative markers */
|
|
142
|
+
alternatives?: string[];
|
|
143
|
+
/** Position relative to role value */
|
|
144
|
+
position: 'before' | 'after';
|
|
145
|
+
/** Whether this is an override marker */
|
|
146
|
+
isOverride: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the marker for a role in a specific language.
|
|
151
|
+
*
|
|
152
|
+
* Checks for command-specific marker overrides first,
|
|
153
|
+
* then falls back to the default marker from the language profile.
|
|
154
|
+
*
|
|
155
|
+
* @param roleSpec Role specification with optional override
|
|
156
|
+
* @param profile Language profile with role markers
|
|
157
|
+
* @returns Resolved marker info, or null if no marker
|
|
158
|
+
*/
|
|
159
|
+
export function resolveMarkerForRole(
|
|
160
|
+
roleSpec: RoleSpecWithMarker,
|
|
161
|
+
profile: SchemaPatternProfile
|
|
162
|
+
): ResolvedMarker | null {
|
|
163
|
+
// Check for command-specific marker override first
|
|
164
|
+
const overrideMarker = roleSpec.markerOverride?.[profile.code];
|
|
165
|
+
const defaultMarker = profile.roleMarkers[roleSpec.role];
|
|
166
|
+
|
|
167
|
+
if (overrideMarker !== undefined) {
|
|
168
|
+
// Use override marker (can be empty string to suppress default marker).
|
|
169
|
+
// markerLegacy entries stay accepted, so correcting an override does not
|
|
170
|
+
// stop the marker a previous release rendered from parsing.
|
|
171
|
+
const alternatives = legacyMarkerAlternatives(roleSpec, profile.code, overrideMarker);
|
|
172
|
+
return {
|
|
173
|
+
primary: overrideMarker,
|
|
174
|
+
...(alternatives && { alternatives }),
|
|
175
|
+
position: defaultMarker?.position ?? 'before',
|
|
176
|
+
isOverride: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (defaultMarker && defaultMarker.primary) {
|
|
181
|
+
const result: ResolvedMarker = {
|
|
182
|
+
primary: defaultMarker.primary,
|
|
183
|
+
position: defaultMarker.position,
|
|
184
|
+
isOverride: false,
|
|
185
|
+
};
|
|
186
|
+
if (defaultMarker.alternatives) {
|
|
187
|
+
result.alternatives = defaultMarker.alternatives;
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Alternatives an OVERRIDE marker accepts: the role's {@link RoleSpecWithMarker.markerLegacy}
|
|
197
|
+
* entries for this language, minus the override itself.
|
|
198
|
+
*
|
|
199
|
+
* Every generator that resolves a marker has its own override branch, and each
|
|
200
|
+
* one used to drop alternatives entirely — so correcting a marker silently
|
|
201
|
+
* stopped the previous one parsing. This is the single definition of what an
|
|
202
|
+
* override still accepts; call it from each of those branches.
|
|
203
|
+
*
|
|
204
|
+
* Prefer {@link schemaMarkerAlternatives}, which also merges `markerVariants`.
|
|
205
|
+
* This narrower entry point remains for callers that must exclude variants.
|
|
206
|
+
*/
|
|
207
|
+
export function legacyMarkerAlternatives(
|
|
208
|
+
roleSpec: RoleSpecWithMarker,
|
|
209
|
+
languageCode: string,
|
|
210
|
+
overrideMarker: string
|
|
211
|
+
): string[] | undefined {
|
|
212
|
+
const legacy = roleSpec.markerLegacy?.[languageCode];
|
|
213
|
+
if (!legacy?.length) return undefined;
|
|
214
|
+
const alternatives = [...new Set(legacy)].filter(a => a && a !== overrideMarker);
|
|
215
|
+
return alternatives.length ? alternatives : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Every schema-declared alternative a role's marker accepts in this language:
|
|
220
|
+
* `markerLegacy` ∪ `markerVariants`, minus the marker itself.
|
|
221
|
+
*
|
|
222
|
+
* **The single definition — call it from every branch of every generator.** The
|
|
223
|
+
* two fields used to sit on DIFFERENT branches: the override branch read only
|
|
224
|
+
* `markerLegacy`, the profile-default branch only `markerVariants`. So giving a
|
|
225
|
+
* language a `markerOverride` silently deleted its `markerVariants`, and giving
|
|
226
|
+
* it `markerVariants` did nothing at all wherever an override existed. Both
|
|
227
|
+
* halves were live: tr `set`'s dative allomorphs (`e|a|ye|ya`) were declared as
|
|
228
|
+
* variants but dropped by the tr override, so `… doğru ya ayarla` parsed only
|
|
229
|
+
* inside an event handler — the one generator that happened to merge both.
|
|
230
|
+
*
|
|
231
|
+
* `markerVariants` are held out for a role with a {@link RoleSpecWithMarker.methodCarrier}:
|
|
232
|
+
* there they are not synonyms but distinct command shapes recorded into another
|
|
233
|
+
* role (`put`'s `into|before|after` → `method`), and merging them as synonyms is
|
|
234
|
+
* what broke `put-before`/`put-after` in 23 languages at once. That flag is the
|
|
235
|
+
* discriminator because it is set on exactly the role whose variants carry
|
|
236
|
+
* meaning — pinned by `schema-consistency.test.ts`, which fails if a new
|
|
237
|
+
* `markerVariants` appears without that decision being made.
|
|
238
|
+
*/
|
|
239
|
+
export function schemaMarkerAlternatives(
|
|
240
|
+
roleSpec: RoleSpecWithMarker,
|
|
241
|
+
languageCode: string,
|
|
242
|
+
marker: string
|
|
243
|
+
): string[] | undefined {
|
|
244
|
+
const legacy = roleSpec.markerLegacy?.[languageCode] ?? [];
|
|
245
|
+
const variants = roleSpec.methodCarrier ? [] : (roleSpec.markerVariants?.[languageCode] ?? []);
|
|
246
|
+
const alternatives = [...new Set([...legacy, ...variants])].filter(a => a && a !== marker);
|
|
247
|
+
return alternatives.length ? alternatives : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Configuration for pattern generation.
|
|
252
|
+
*/
|
|
253
|
+
export interface SchemaPatternConfig {
|
|
254
|
+
/** Base priority for generated patterns (higher = checked first) */
|
|
255
|
+
basePriority?: number;
|
|
256
|
+
/** Whether to generate simple patterns (without optional roles) */
|
|
257
|
+
generateSimpleVariants?: boolean;
|
|
258
|
+
/** Whether to generate alternative keyword patterns */
|
|
259
|
+
generateAlternatives?: boolean;
|
|
260
|
+
/**
|
|
261
|
+
* Whether to generate verb-first fallback patterns for SOV languages
|
|
262
|
+
* (`トグル .active` alongside the canonical `.active を トグル`). Default on.
|
|
263
|
+
* These are strictly lower priority than the canonical patterns, so native
|
|
264
|
+
* SOV order always wins; this only accepts bilingual/code-switched input.
|
|
265
|
+
*/
|
|
266
|
+
generateVerbFirstVariants?: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const defaultConfig: SchemaPatternConfig = {
|
|
270
|
+
basePriority: 100,
|
|
271
|
+
generateSimpleVariants: true,
|
|
272
|
+
generateAlternatives: true,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Generate a pattern for a command in a specific language.
|
|
277
|
+
*/
|
|
278
|
+
function generatePattern(
|
|
279
|
+
grammar: SchemaPatternGrammar,
|
|
280
|
+
schema: CommandSchema,
|
|
281
|
+
profile: SchemaPatternProfile,
|
|
282
|
+
config: SchemaPatternConfig = defaultConfig
|
|
283
|
+
): LanguagePattern {
|
|
284
|
+
const id = `${schema.action}-${profile.code}-generated`;
|
|
285
|
+
const priority = config.basePriority ?? 100;
|
|
286
|
+
|
|
287
|
+
// Get keyword translation
|
|
288
|
+
const keyword = profile.keywords[schema.action];
|
|
289
|
+
if (!keyword) {
|
|
290
|
+
throw new Error(`No keyword translation for '${schema.action}' in ${profile.code}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Build tokens based on word order
|
|
294
|
+
const tokens = buildTokens(grammar, schema, profile, keyword);
|
|
295
|
+
|
|
296
|
+
// Build extraction rules with defaults for optional roles
|
|
297
|
+
// This ensures defaults are applied even when optional groups don't match
|
|
298
|
+
const extraction = buildExtractionRulesWithDefaults(schema, profile);
|
|
299
|
+
|
|
300
|
+
// Build template format string (for documentation)
|
|
301
|
+
const format = buildFormatString(grammar, schema, profile, keyword);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
id,
|
|
305
|
+
language: profile.code,
|
|
306
|
+
command: schema.action,
|
|
307
|
+
priority,
|
|
308
|
+
template: {
|
|
309
|
+
format,
|
|
310
|
+
tokens,
|
|
311
|
+
},
|
|
312
|
+
extraction,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Generate a simple variant pattern (without optional roles).
|
|
318
|
+
*/
|
|
319
|
+
function generateSimplePattern(
|
|
320
|
+
grammar: SchemaPatternGrammar,
|
|
321
|
+
schema: CommandSchema,
|
|
322
|
+
profile: SchemaPatternProfile,
|
|
323
|
+
config: SchemaPatternConfig = defaultConfig
|
|
324
|
+
): LanguagePattern | null {
|
|
325
|
+
// Only generate simple variant if there are optional roles
|
|
326
|
+
const optionalRoles = schema.roles.filter(r => !r.required);
|
|
327
|
+
if (optionalRoles.length === 0) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const requiredRoles = schema.roles.filter(r => r.required);
|
|
332
|
+
const simpleSchema: CommandSchema = {
|
|
333
|
+
...schema,
|
|
334
|
+
roles: requiredRoles,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const pattern = generatePattern(grammar, simpleSchema, profile, config);
|
|
338
|
+
|
|
339
|
+
// Adjust for simple variant
|
|
340
|
+
return {
|
|
341
|
+
...pattern,
|
|
342
|
+
id: `${schema.action}-${profile.code}-generated-simple`,
|
|
343
|
+
priority: (config.basePriority ?? 100) - 5, // Lower priority than full pattern (was -10)
|
|
344
|
+
extraction: buildExtractionRulesWithDefaults(schema, profile),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Generate a verb-first fallback pattern for an SOV language.
|
|
350
|
+
*
|
|
351
|
+
* Canonical SOV order is patient-then-verb (`.active を トグル`). Bilingual and
|
|
352
|
+
* code-switching authors often write the English/loanword verb first
|
|
353
|
+
* (`トグル .active`). This emits `[verb] [required role(s), UNMARKED]` at a lower
|
|
354
|
+
* priority so the canonical pattern always wins for native input — the
|
|
355
|
+
* verb-first form only matches when the input genuinely starts with the verb.
|
|
356
|
+
*
|
|
357
|
+
* Kept deliberately conservative: only commands with a single required role
|
|
358
|
+
* (patient-focused: toggle/add/remove/show/hide/…) get this fallback, which
|
|
359
|
+
* keeps the unmarked match unambiguous. Returns null otherwise.
|
|
360
|
+
*/
|
|
361
|
+
function generateVerbFirstPattern(
|
|
362
|
+
schema: CommandSchema,
|
|
363
|
+
profile: SchemaPatternProfile,
|
|
364
|
+
config: SchemaPatternConfig = defaultConfig
|
|
365
|
+
): LanguagePattern | null {
|
|
366
|
+
if (profile.wordOrder !== 'SOV') return null;
|
|
367
|
+
|
|
368
|
+
const requiredRoles = schema.roles.filter(r => r.required);
|
|
369
|
+
if (requiredRoles.length !== 1) return null;
|
|
370
|
+
|
|
371
|
+
const keyword = profile.keywords[schema.action];
|
|
372
|
+
if (!keyword) return null;
|
|
373
|
+
|
|
374
|
+
const verbToken: PatternToken = keyword.alternatives
|
|
375
|
+
? { type: 'literal', value: keyword.primary, alternatives: keyword.alternatives }
|
|
376
|
+
: { type: 'literal', value: keyword.primary };
|
|
377
|
+
|
|
378
|
+
// Verb first, then the required role(s) with NO marker (the marker is what
|
|
379
|
+
// disambiguates roles in canonical SOV; verb-first input typically omits it).
|
|
380
|
+
// A valuePrefixLiteral (rolePrefixLiteralVariants clones only) still precedes
|
|
381
|
+
// its role's value — the SOV transformer renders `移動 url "/page" に`
|
|
382
|
+
// verb-first, which is exactly this shape (trailing particle tolerated).
|
|
383
|
+
const roleTokens: PatternToken[] = requiredRoles.flatMap(r => {
|
|
384
|
+
const prefix = r.valuePrefixLiteral?.[profile.code];
|
|
385
|
+
const words = wordsFor(r, profile.code);
|
|
386
|
+
const roleToken: PatternToken = {
|
|
387
|
+
type: 'role',
|
|
388
|
+
role: r.role,
|
|
389
|
+
optional: false,
|
|
390
|
+
expectedTypes: r.expectedTypes,
|
|
391
|
+
...(words ? { words } : {}),
|
|
392
|
+
};
|
|
393
|
+
return prefix ? [{ type: 'literal', value: prefix } as PatternToken, roleToken] : [roleToken];
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
id: `${schema.action}-${profile.code}-generated-verb-first`,
|
|
398
|
+
language: profile.code,
|
|
399
|
+
command: schema.action,
|
|
400
|
+
// Strictly below the canonical (basePriority) and simple (basePriority - 5)
|
|
401
|
+
// patterns so native order is always preferred.
|
|
402
|
+
priority: (config.basePriority ?? 100) - 20,
|
|
403
|
+
template: {
|
|
404
|
+
format: `${keyword.primary} <${requiredRoles[0].role}>`,
|
|
405
|
+
tokens: [verbToken, ...roleTokens],
|
|
406
|
+
},
|
|
407
|
+
extraction: buildExtractionRulesWithDefaults(schema, profile),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Generate all pattern variants for a command in a language.
|
|
413
|
+
*/
|
|
414
|
+
function generatePatternVariants(
|
|
415
|
+
grammar: SchemaPatternGrammar,
|
|
416
|
+
schema: CommandSchema,
|
|
417
|
+
profile: SchemaPatternProfile,
|
|
418
|
+
config: SchemaPatternConfig = defaultConfig
|
|
419
|
+
): LanguagePattern[] {
|
|
420
|
+
const patterns: LanguagePattern[] = [];
|
|
421
|
+
// A variant-only role is in the required-role variants alone.
|
|
422
|
+
const full = schema;
|
|
423
|
+
schema = withoutVariantOnlyRoles(schema);
|
|
424
|
+
|
|
425
|
+
// Main pattern
|
|
426
|
+
patterns.push(generatePattern(grammar, schema, profile, config));
|
|
427
|
+
|
|
428
|
+
// Simple variant (without optional roles)
|
|
429
|
+
if (config.generateSimpleVariants) {
|
|
430
|
+
const simple = generateSimplePattern(grammar, schema, profile, config);
|
|
431
|
+
if (simple) {
|
|
432
|
+
patterns.push(simple);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Omit-role variants: an EXTRA pattern per schema-listed role with that
|
|
437
|
+
// role (and its marker phrase) removed entirely. Covers a REQUIRED role
|
|
438
|
+
// whose phrase is legitimately absent in valid hyperscript (transition's
|
|
439
|
+
// goal: `transition *max-height over 300ms`, slide-toggle) — making the
|
|
440
|
+
// role optional inside the main pattern is the verified skippable-group
|
|
441
|
+
// re-binding regression (see the transition goal NOTE); a separate variant
|
|
442
|
+
// has no skippable group at all. Priority sits below the simple variant so
|
|
443
|
+
// any text carrying the role phrase always prefers the fuller patterns.
|
|
444
|
+
for (const omitted of schema.omitRoleVariants ?? []) {
|
|
445
|
+
const variantSchema: CommandSchema = {
|
|
446
|
+
...schema,
|
|
447
|
+
roles: schema.roles.filter(r => r.role !== omitted),
|
|
448
|
+
};
|
|
449
|
+
const variant = generatePattern(grammar, variantSchema, profile, config);
|
|
450
|
+
patterns.push({
|
|
451
|
+
...variant,
|
|
452
|
+
id: `${schema.action}-${profile.code}-generated-no-${omitted}`,
|
|
453
|
+
priority: (config.basePriority ?? 100) - 8,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Widened-type variants: an EXTRA pattern per entry whose named role accepts a
|
|
458
|
+
// wider set of value types. Deliberately BELOW every command pattern, so it is
|
|
459
|
+
// inert on any span a real command can claim — see the schema field's doc for
|
|
460
|
+
// the ko `transition opacity to 0` collision that rules out widening the main
|
|
461
|
+
// pattern in place.
|
|
462
|
+
for (const widen of schema.widenTypeVariants ?? []) {
|
|
463
|
+
if (widen.excludeLanguages?.includes(profile.code)) continue;
|
|
464
|
+
const cloneSchema: CommandSchema = {
|
|
465
|
+
...schema,
|
|
466
|
+
roles: schema.roles.map(r =>
|
|
467
|
+
r.role === widen.role ? { ...r, expectedTypes: [...widen.types] } : r
|
|
468
|
+
),
|
|
469
|
+
};
|
|
470
|
+
const main = generatePattern(grammar, cloneSchema, profile, config);
|
|
471
|
+
patterns.push({
|
|
472
|
+
...main,
|
|
473
|
+
id: `${schema.action}-${profile.code}-generated-${widen.idSuffix}`,
|
|
474
|
+
priority: (config.basePriority ?? 100) - (widen.priorityDelta ?? 15),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Required-role variants: inert unless the role's marker is present, so they
|
|
479
|
+
// may outrank the command's other patterns (see the schema field).
|
|
480
|
+
for (const v of full.requiredRoleVariants ?? []) {
|
|
481
|
+
const variantSchema: CommandSchema = {
|
|
482
|
+
...full,
|
|
483
|
+
roles: full.roles
|
|
484
|
+
.filter(r => !v.omit?.includes(r.role))
|
|
485
|
+
.map(r => {
|
|
486
|
+
const required = r.role === v.role ? { ...r, required: true } : r;
|
|
487
|
+
return r.role === v.widen?.role
|
|
488
|
+
? { ...required, expectedTypes: [...v.widen.types] }
|
|
489
|
+
: required;
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
const variant = generatePattern(grammar, variantSchema, profile, config);
|
|
493
|
+
patterns.push({
|
|
494
|
+
...variant,
|
|
495
|
+
id: `${schema.action}-${profile.code}-generated-${v.idSuffix}`,
|
|
496
|
+
priority: (config.basePriority ?? 100) + (v.priorityDelta ?? 5),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Verb-first fallback for SOV languages (lower priority than the above).
|
|
501
|
+
if (config.generateVerbFirstVariants !== false) {
|
|
502
|
+
const verbFirst = generateVerbFirstPattern(schema, profile, config);
|
|
503
|
+
if (verbFirst) {
|
|
504
|
+
patterns.push(verbFirst);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Value-prefix-literal variants: an EXTRA pattern per entry with a REQUIRED
|
|
509
|
+
// literal keyword immediately before the named role's value (`go [to] url
|
|
510
|
+
// {destination}`). Higher priority than the counterpart pattern, but inert
|
|
511
|
+
// unless the keyword is present — base patterns above stay byte-identical,
|
|
512
|
+
// so forms riding on them (`go back`) cannot regress. The matched keyword is
|
|
513
|
+
// recorded as a fixed-value literal in the variant's methodCarrier role.
|
|
514
|
+
for (const v of schema.rolePrefixLiteralVariants ?? []) {
|
|
515
|
+
const literal = v.literal[profile.code];
|
|
516
|
+
if (!literal) continue;
|
|
517
|
+
const { rolePrefixLiteralVariants: _omitted, ...baseSchema } = schema;
|
|
518
|
+
const cloneSchema: CommandSchema = {
|
|
519
|
+
...baseSchema,
|
|
520
|
+
roles: schema.roles.map(r =>
|
|
521
|
+
r.role === v.role ? { ...r, valuePrefixLiteral: { [profile.code]: literal } } : r
|
|
522
|
+
),
|
|
523
|
+
};
|
|
524
|
+
const delta = v.priorityDelta ?? 5;
|
|
525
|
+
const carrier: Record<string, ExtractionRule> = v.methodCarrier
|
|
526
|
+
? { [v.methodCarrier]: { value: literal } }
|
|
527
|
+
: {};
|
|
528
|
+
const main = generatePattern(grammar, cloneSchema, profile, config);
|
|
529
|
+
patterns.push({
|
|
530
|
+
...main,
|
|
531
|
+
id: `${schema.action}-${profile.code}-generated-${v.idSuffix}`,
|
|
532
|
+
priority: (config.basePriority ?? 100) + delta,
|
|
533
|
+
extraction: { ...main.extraction, ...carrier },
|
|
534
|
+
});
|
|
535
|
+
if (config.generateVerbFirstVariants !== false) {
|
|
536
|
+
const verbFirstUrl = generateVerbFirstPattern(cloneSchema, profile, config);
|
|
537
|
+
if (verbFirstUrl) {
|
|
538
|
+
patterns.push({
|
|
539
|
+
...verbFirstUrl,
|
|
540
|
+
id: `${schema.action}-${profile.code}-generated-verb-first-${v.idSuffix}`,
|
|
541
|
+
priority: (config.basePriority ?? 100) - 20 + delta,
|
|
542
|
+
extraction: { ...verbFirstUrl.extraction, ...carrier },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return patterns;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Build pattern tokens based on word order.
|
|
553
|
+
*/
|
|
554
|
+
function buildTokens(
|
|
555
|
+
grammar: SchemaPatternGrammar,
|
|
556
|
+
schema: CommandSchema,
|
|
557
|
+
profile: SchemaPatternProfile,
|
|
558
|
+
keyword: { primary: string; alternatives?: string[] }
|
|
559
|
+
): PatternToken[] {
|
|
560
|
+
const tokens: PatternToken[] = [];
|
|
561
|
+
|
|
562
|
+
// Get verb token
|
|
563
|
+
const verbToken: PatternToken = keyword.alternatives
|
|
564
|
+
? { type: 'literal', value: keyword.primary, alternatives: keyword.alternatives }
|
|
565
|
+
: { type: 'literal', value: keyword.primary };
|
|
566
|
+
|
|
567
|
+
// Get role tokens sorted by position. In SOV a role with `sovSlot: 'postVerb'`
|
|
568
|
+
// follows the verb (a domain's summarize format: `content 要約 として format`).
|
|
569
|
+
const postVerbRoles =
|
|
570
|
+
profile.wordOrder === 'SOV' ? schema.roles.filter(r => r.sovSlot === 'postVerb') : [];
|
|
571
|
+
const roleTokens = buildRoleTokens(
|
|
572
|
+
grammar,
|
|
573
|
+
postVerbRoles.length
|
|
574
|
+
? { ...schema, roles: schema.roles.filter(r => r.sovSlot !== 'postVerb') }
|
|
575
|
+
: schema,
|
|
576
|
+
profile
|
|
577
|
+
);
|
|
578
|
+
const postVerbTokens = postVerbRoles.length
|
|
579
|
+
? buildRoleTokens(grammar, { ...schema, roles: postVerbRoles }, profile)
|
|
580
|
+
: [];
|
|
581
|
+
|
|
582
|
+
// Arrange based on word order
|
|
583
|
+
switch (profile.wordOrder) {
|
|
584
|
+
case 'SVO':
|
|
585
|
+
// Verb first, then roles in order
|
|
586
|
+
tokens.push(verbToken);
|
|
587
|
+
tokens.push(...roleTokens);
|
|
588
|
+
break;
|
|
589
|
+
|
|
590
|
+
case 'SOV':
|
|
591
|
+
// Roles first (reversed for SOV), then verb
|
|
592
|
+
tokens.push(...roleTokens);
|
|
593
|
+
tokens.push(verbToken);
|
|
594
|
+
tokens.push(...postVerbTokens);
|
|
595
|
+
break;
|
|
596
|
+
|
|
597
|
+
case 'VSO':
|
|
598
|
+
// Verb first, then subject, then object
|
|
599
|
+
tokens.push(verbToken);
|
|
600
|
+
tokens.push(...roleTokens);
|
|
601
|
+
break;
|
|
602
|
+
|
|
603
|
+
default:
|
|
604
|
+
// Default to SVO
|
|
605
|
+
tokens.push(verbToken);
|
|
606
|
+
tokens.push(...roleTokens);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return collapseAdjacentLiterals(tokens);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Collapse adjacent identical literal tokens into one.
|
|
614
|
+
*
|
|
615
|
+
* For the event-handler trigger (`on`) schema, a handful of languages use the
|
|
616
|
+
* same word for the trigger keyword (the `on` verb) and the event role's marker —
|
|
617
|
+
* bn `তে`, hi `पर`, th `เมื่อ`. `buildTokens` then emits it twice (`<event> তে তে`),
|
|
618
|
+
* which the renderer prints verbatim; on re-parse the duplicate pairs with the
|
|
619
|
+
* following token as a phantom command (e.g. `তে আমি` → a spurious `on me`). No
|
|
620
|
+
* trigger pattern needs to match the same literal twice in a row, so fold the
|
|
621
|
+
* duplicate, preserving any `alternatives` from either side. Verified to affect
|
|
622
|
+
* only the `on` patterns of bn/hi/th — no other command/language has adjacent
|
|
623
|
+
* duplicate literals. See MULTILINGUAL_BEHAVIORS_PLAN.md Phase 2.
|
|
624
|
+
*/
|
|
625
|
+
function collapseAdjacentLiterals(tokens: PatternToken[]): PatternToken[] {
|
|
626
|
+
const out: PatternToken[] = [];
|
|
627
|
+
for (const tok of tokens) {
|
|
628
|
+
const prev = out[out.length - 1];
|
|
629
|
+
if (prev && prev.type === 'literal' && tok.type === 'literal' && prev.value === tok.value) {
|
|
630
|
+
const alts = [...(prev.alternatives ?? []), ...(tok.alternatives ?? [])];
|
|
631
|
+
if (alts.length) out[out.length - 1] = { ...prev, alternatives: [...new Set(alts)] };
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
out.push(tok);
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Build tokens for roles.
|
|
641
|
+
*/
|
|
642
|
+
function buildRoleTokens(
|
|
643
|
+
grammar: SchemaPatternGrammar,
|
|
644
|
+
schema: CommandSchema,
|
|
645
|
+
profile: SchemaPatternProfile
|
|
646
|
+
): PatternToken[] {
|
|
647
|
+
const tokens: PatternToken[] = [];
|
|
648
|
+
|
|
649
|
+
// Sort roles by position using shared utility
|
|
650
|
+
const sortedRoles = sortRolesByPosition(schema.roles, profile.wordOrder, grammar.positionOrder);
|
|
651
|
+
|
|
652
|
+
for (const roleSpec of sortedRoles) {
|
|
653
|
+
const roleToken = buildRoleToken(roleSpec, profile);
|
|
654
|
+
|
|
655
|
+
if (!roleSpec.required) {
|
|
656
|
+
// Wrap optional roles in a group
|
|
657
|
+
tokens.push({
|
|
658
|
+
type: 'group',
|
|
659
|
+
optional: true,
|
|
660
|
+
tokens: roleToken,
|
|
661
|
+
});
|
|
662
|
+
} else {
|
|
663
|
+
tokens.push(...roleToken);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return tokens;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Build token(s) for a single role.
|
|
672
|
+
*
|
|
673
|
+
* Exported so the fused event-handler generators can build a wrapped command's
|
|
674
|
+
* optional-role slots from the very same code the standalone command patterns
|
|
675
|
+
* use — a hand-rolled copy there is what let the two drift apart in the first
|
|
676
|
+
* place (see `appendRemainingOptionalRoles` in event-handlers-sov.ts).
|
|
677
|
+
*/
|
|
678
|
+
/** The words a role reads in `language` (`RoleSpec.words`: its `'*'` list and the language's own). */
|
|
679
|
+
function wordsFor(roleSpec: RoleSpec, language: string): readonly string[] | undefined {
|
|
680
|
+
if (!roleSpec.words) return undefined;
|
|
681
|
+
const words = [...(roleSpec.words['*'] ?? []), ...(roleSpec.words[language] ?? [])];
|
|
682
|
+
return words.length > 0 ? words : undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): PatternToken[] {
|
|
686
|
+
const tokens: PatternToken[] = [];
|
|
687
|
+
|
|
688
|
+
// Check for command-specific marker override first
|
|
689
|
+
const overrideMarker = roleSpec.markerOverride?.[profile.code];
|
|
690
|
+
const defaultMarker = profile.roleMarkers[roleSpec.role];
|
|
691
|
+
|
|
692
|
+
// A role whose renderOverride is the empty string parses WITH its marker but
|
|
693
|
+
// renders WITHOUT it (e.g. fetch's `from`: `fetch from /api` must parse, yet
|
|
694
|
+
// canonical _hyperscript rejects `fetch from "/api"` while accepting
|
|
695
|
+
// `fetch "/api"`). Tag the emitted marker literal so the renderer skips it;
|
|
696
|
+
// the pattern matcher ignores the flag, so parsing is unchanged.
|
|
697
|
+
const suppressMarker = roleSpec.renderOverride?.[profile.code] === '';
|
|
698
|
+
const words = wordsFor(roleSpec, profile.code);
|
|
699
|
+
|
|
700
|
+
// Role value token
|
|
701
|
+
const roleValueToken: PatternToken = {
|
|
702
|
+
type: 'role',
|
|
703
|
+
role: roleSpec.role,
|
|
704
|
+
optional: !roleSpec.required,
|
|
705
|
+
expectedTypes: roleSpec.expectedTypes,
|
|
706
|
+
// The matcher reads this too — a `'keyword'`-shaped slot holds a fixed
|
|
707
|
+
// keyword phrase (`using view transition`) and must not be skipped by the
|
|
708
|
+
// trailing-slot verb guard.
|
|
709
|
+
...(roleSpec.valueShape !== undefined ? { valueShape: roleSpec.valueShape } : {}),
|
|
710
|
+
// A greedy role reads up to the next marker the pattern owes (a domain's
|
|
711
|
+
// SQL condition); its value reader does the reading.
|
|
712
|
+
...(roleSpec.greedy ? { greedy: true } : {}),
|
|
713
|
+
// A role with a word list reads only one of its words.
|
|
714
|
+
...(words ? { words } : {}),
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// Required literal immediately before the role VALUE (rolePrefixLiteralVariants
|
|
718
|
+
// clones only): `[to] url {destination}`, `url {destination} に`. Deliberately
|
|
719
|
+
// NOT an optional group — the variant must only match when the keyword is
|
|
720
|
+
// present, so base patterns keep winning everywhere else.
|
|
721
|
+
const prefixLiteral = roleSpec.valuePrefixLiteral?.[profile.code];
|
|
722
|
+
const pushPrefixed = (): void => {
|
|
723
|
+
if (prefixLiteral) tokens.push({ type: 'literal', value: prefixLiteral });
|
|
724
|
+
tokens.push(roleValueToken);
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// Use override marker if available, otherwise use default
|
|
728
|
+
if (overrideMarker !== undefined) {
|
|
729
|
+
// Command-specific marker override. Multi-word markers (e.g. "partials in",
|
|
730
|
+
// "with title") split into one literal token per word so the matcher
|
|
731
|
+
// consumes them sequentially. A per-command markerOptional wraps each word
|
|
732
|
+
// in an optional group (`go to /page` and `go back` both parse — the
|
|
733
|
+
// render side drops the preposition, so the parse side can't require it).
|
|
734
|
+
const markerWords = overrideMarker ? overrideMarker.split(/\s+/).filter(Boolean) : [];
|
|
735
|
+
// Where the profile carries no RoleMarker for this role there is nothing to
|
|
736
|
+
// take a side from, and 'before' is wrong in a verb-final language: ja
|
|
737
|
+
// rendered `opacity を に 0` and bn `opacity কে তে 0`, the override marker
|
|
738
|
+
// stranded AHEAD of its own value. Default to 'after' for SOV.
|
|
739
|
+
//
|
|
740
|
+
// EXEMPT a PASSTHROUGH override — one whose marker is the same string en
|
|
741
|
+
// uses (`scope`, `manner`). Those are English words carried verbatim into
|
|
742
|
+
// every language, so they keep English's prepositional side; without the
|
|
743
|
+
// exemption ja/ko/qu lose `set`'s scope.
|
|
744
|
+
const isPassthroughOverride =
|
|
745
|
+
overrideMarker !== undefined && overrideMarker === roleSpec.markerOverride?.en;
|
|
746
|
+
const position =
|
|
747
|
+
roleSpec.markerPositionOverride?.[profile.code] ??
|
|
748
|
+
roleSpec.markerPosition ??
|
|
749
|
+
defaultMarker?.position ??
|
|
750
|
+
(profile.wordOrder === 'SOV' && !isPassthroughOverride ? 'after' : 'before');
|
|
751
|
+
const optionalMarker = roleSpec.markerOptional?.[profile.code] === true;
|
|
752
|
+
// Single-word overrides also accept every schema-declared alternative
|
|
753
|
+
// (markerLegacy ∪ markerVariants), so correcting an override's marker does
|
|
754
|
+
// not stop the previous one parsing, and declaring an override does not
|
|
755
|
+
// silently delete that language's variants. schemaMarkerAlternatives holds
|
|
756
|
+
// variants out for a methodCarrier role (`put`'s before/after) — see its doc.
|
|
757
|
+
const pushWord = (word: string): void => {
|
|
758
|
+
const alternatives =
|
|
759
|
+
markerWords.length === 1
|
|
760
|
+
? (schemaMarkerAlternatives(roleSpec, profile.code, word) ?? [])
|
|
761
|
+
: [];
|
|
762
|
+
const literal: PatternToken = {
|
|
763
|
+
type: 'literal',
|
|
764
|
+
value: word,
|
|
765
|
+
...(alternatives.length ? { alternatives } : {}),
|
|
766
|
+
...(suppressMarker ? { renderSuppress: true } : {}),
|
|
767
|
+
};
|
|
768
|
+
tokens.push(optionalMarker ? { type: 'group', optional: true, tokens: [literal] } : literal);
|
|
769
|
+
};
|
|
770
|
+
if (position === 'before') {
|
|
771
|
+
for (const word of markerWords) pushWord(word);
|
|
772
|
+
pushPrefixed();
|
|
773
|
+
} else {
|
|
774
|
+
pushPrefixed();
|
|
775
|
+
for (const word of markerWords) pushWord(word);
|
|
776
|
+
}
|
|
777
|
+
} else if (defaultMarker) {
|
|
778
|
+
// When the profile allows colloquial marker-dropping, the marker becomes an
|
|
779
|
+
// optional group so `.active değiştir` parses as well as `.active i değiştir`.
|
|
780
|
+
// The marked form still matches the marker literal directly (higher
|
|
781
|
+
// confidence); the unmarked form falls back through the optional group.
|
|
782
|
+
// The schema's markerVariants/markerLegacy merge in as extra alternatives:
|
|
783
|
+
// the transformer can render a role with a particle the profile default
|
|
784
|
+
// doesn't cover (zh 把 before go's destination in `go back`).
|
|
785
|
+
const asMarker = (): PatternToken => {
|
|
786
|
+
const alternatives = [
|
|
787
|
+
...new Set([
|
|
788
|
+
...(defaultMarker.alternatives ?? []),
|
|
789
|
+
...(schemaMarkerAlternatives(roleSpec, profile.code, defaultMarker.primary) ?? []),
|
|
790
|
+
]),
|
|
791
|
+
].filter(a => a !== defaultMarker.primary);
|
|
792
|
+
return {
|
|
793
|
+
type: 'literal',
|
|
794
|
+
value: defaultMarker.primary,
|
|
795
|
+
...(alternatives.length ? { alternatives } : {}),
|
|
796
|
+
...(suppressMarker ? { renderSuppress: true } : {}),
|
|
797
|
+
};
|
|
798
|
+
};
|
|
799
|
+
const pushMarker = (marker: PatternToken): void => {
|
|
800
|
+
if (!profile.markersOptional && !roleSpec.markerOptional?.[profile.code]) {
|
|
801
|
+
tokens.push(marker);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// A profile-wide `markersOptional` says the marker may be DROPPED
|
|
805
|
+
// colloquially, not that it is absent from canonical output — so the group
|
|
806
|
+
// still renders. A per-role `markerOptional` is the opposite: the render
|
|
807
|
+
// side genuinely omits that marker (`go to /page` vs `go back`), so it
|
|
808
|
+
// stays render-optional too.
|
|
809
|
+
tokens.push({
|
|
810
|
+
type: 'group',
|
|
811
|
+
optional: true,
|
|
812
|
+
...(profile.markersOptional && !roleSpec.markerOptional?.[profile.code]
|
|
813
|
+
? { renderRequired: true }
|
|
814
|
+
: {}),
|
|
815
|
+
tokens: [marker],
|
|
816
|
+
});
|
|
817
|
+
};
|
|
818
|
+
// A schema's own side for the role (a domain's `markerPosition`) outranks the profile's.
|
|
819
|
+
if ((roleSpec.markerPosition ?? defaultMarker.position) === 'before') {
|
|
820
|
+
// Preposition: "on #button"
|
|
821
|
+
if (defaultMarker.primary) {
|
|
822
|
+
pushMarker(asMarker());
|
|
823
|
+
}
|
|
824
|
+
pushPrefixed();
|
|
825
|
+
} else {
|
|
826
|
+
// Postposition/particle: "#button に"
|
|
827
|
+
pushPrefixed();
|
|
828
|
+
pushMarker(asMarker());
|
|
829
|
+
}
|
|
830
|
+
} else {
|
|
831
|
+
// No marker, just the role value
|
|
832
|
+
pushPrefixed();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return tokens;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// =============================================================================
|
|
839
|
+
// Extraction Rules Building
|
|
840
|
+
// =============================================================================
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Build extraction rules for a pattern.
|
|
844
|
+
*/
|
|
845
|
+
function buildExtractionRules(
|
|
846
|
+
schema: CommandSchema,
|
|
847
|
+
profile: SchemaPatternProfile
|
|
848
|
+
): Record<string, ExtractionRule> {
|
|
849
|
+
const rules: Record<string, ExtractionRule> = {};
|
|
850
|
+
|
|
851
|
+
for (const roleSpec of schema.roles) {
|
|
852
|
+
// Check for command-specific marker override first
|
|
853
|
+
const overrideMarker = roleSpec.markerOverride?.[profile.code];
|
|
854
|
+
const defaultMarker = profile.roleMarkers[roleSpec.role];
|
|
855
|
+
|
|
856
|
+
if (roleSpec.valuePrefixLiteral?.[profile.code]) {
|
|
857
|
+
// The prefix literal sits immediately before the value on every surface,
|
|
858
|
+
// so it is the extraction anchor (push/replace's proven `url` shape) —
|
|
859
|
+
// it wins over the role's own marker (`to`), which may be optional/absent.
|
|
860
|
+
rules[roleSpec.role] = { marker: roleSpec.valuePrefixLiteral[profile.code] };
|
|
861
|
+
} else if (overrideMarker !== undefined) {
|
|
862
|
+
// Use the override marker, accepting the schema's declared alternatives
|
|
863
|
+
// so a corrected marker does not stop the previous one parsing and an
|
|
864
|
+
// override does not delete that language's variants (mirrors
|
|
865
|
+
// buildRoleToken; single-word overrides only, as a multi-word marker has
|
|
866
|
+
// no single token to swap).
|
|
867
|
+
if (!overrideMarker) {
|
|
868
|
+
rules[roleSpec.role] = {};
|
|
869
|
+
} else {
|
|
870
|
+
const isSingleWord = !/\s/.test(overrideMarker.trim());
|
|
871
|
+
const markerAlternatives = isSingleWord
|
|
872
|
+
? (schemaMarkerAlternatives(roleSpec, profile.code, overrideMarker) ?? [])
|
|
873
|
+
: [];
|
|
874
|
+
rules[roleSpec.role] = markerAlternatives.length
|
|
875
|
+
? { marker: overrideMarker, markerAlternatives }
|
|
876
|
+
: { marker: overrideMarker };
|
|
877
|
+
}
|
|
878
|
+
} else if (defaultMarker && defaultMarker.primary) {
|
|
879
|
+
// Merge the schema's declared alternatives (mirrors buildRoleToken)
|
|
880
|
+
const markerAlternatives = [
|
|
881
|
+
...new Set([
|
|
882
|
+
...(defaultMarker.alternatives ?? []),
|
|
883
|
+
...(schemaMarkerAlternatives(roleSpec, profile.code, defaultMarker.primary) ?? []),
|
|
884
|
+
]),
|
|
885
|
+
].filter(a => a !== defaultMarker.primary);
|
|
886
|
+
rules[roleSpec.role] = markerAlternatives.length
|
|
887
|
+
? { marker: defaultMarker.primary, markerAlternatives }
|
|
888
|
+
: { marker: defaultMarker.primary };
|
|
889
|
+
} else {
|
|
890
|
+
rules[roleSpec.role] = {};
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return rules;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Build extraction rules with defaults for optional roles.
|
|
899
|
+
*/
|
|
900
|
+
function buildExtractionRulesWithDefaults(
|
|
901
|
+
schema: CommandSchema,
|
|
902
|
+
profile: SchemaPatternProfile
|
|
903
|
+
): Record<string, ExtractionRule> {
|
|
904
|
+
const baseRules = buildExtractionRules(schema, profile);
|
|
905
|
+
const rules: Record<string, ExtractionRule> = {};
|
|
906
|
+
|
|
907
|
+
// Copy base rules and add defaults for optional roles
|
|
908
|
+
for (const roleSpec of schema.roles) {
|
|
909
|
+
const baseRule = baseRules[roleSpec.role] || {};
|
|
910
|
+
|
|
911
|
+
if (!roleSpec.required && roleSpec.default) {
|
|
912
|
+
rules[roleSpec.role] = { ...baseRule, default: roleSpec.default };
|
|
913
|
+
} else {
|
|
914
|
+
rules[roleSpec.role] = baseRule;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return rules;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// =============================================================================
|
|
922
|
+
// Format String Building
|
|
923
|
+
// =============================================================================
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Build a human-readable format string for documentation.
|
|
927
|
+
*/
|
|
928
|
+
function buildFormatString(
|
|
929
|
+
grammar: SchemaPatternGrammar,
|
|
930
|
+
schema: CommandSchema,
|
|
931
|
+
profile: SchemaPatternProfile,
|
|
932
|
+
keyword: { primary: string }
|
|
933
|
+
): string {
|
|
934
|
+
const parts: string[] = [];
|
|
935
|
+
|
|
936
|
+
// Sort roles by position using shared utility
|
|
937
|
+
const sortedRoles = sortRolesByPosition(schema.roles, profile.wordOrder, grammar.positionOrder);
|
|
938
|
+
|
|
939
|
+
// Build role parts
|
|
940
|
+
const roleParts = sortedRoles.map(roleSpec => {
|
|
941
|
+
// Use shared marker resolution utility
|
|
942
|
+
const resolved = resolveMarkerForRole(roleSpec, profile);
|
|
943
|
+
let part = '';
|
|
944
|
+
|
|
945
|
+
if (resolved && resolved.primary) {
|
|
946
|
+
// Has a marker
|
|
947
|
+
if (resolved.position === 'before') {
|
|
948
|
+
part = `${resolved.primary} {${roleSpec.role}}`;
|
|
949
|
+
} else {
|
|
950
|
+
part = `{${roleSpec.role}} ${resolved.primary}`;
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
// No marker
|
|
954
|
+
part = `{${roleSpec.role}}`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return roleSpec.required ? part : `[${part}]`;
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// Arrange based on word order
|
|
961
|
+
switch (profile.wordOrder) {
|
|
962
|
+
case 'SVO':
|
|
963
|
+
case 'VSO':
|
|
964
|
+
parts.push(keyword.primary, ...roleParts);
|
|
965
|
+
break;
|
|
966
|
+
case 'SOV':
|
|
967
|
+
parts.push(...roleParts, keyword.primary);
|
|
968
|
+
break;
|
|
969
|
+
default:
|
|
970
|
+
parts.push(keyword.primary, ...roleParts);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return parts.join(' ');
|
|
974
|
+
}
|