Skip to content

Commit 4f0d15b

Browse files
feat(mf2): Keep attributes in data model, use Map for options & attributes (unicode-org/message-format-wg#845)
1 parent 7248851 commit 4f0d15b

File tree

20 files changed

+234
-209
lines changed

20 files changed

+234
-209
lines changed

packages/mf2-fluent/src/fluent-to-message.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,13 @@ function asExpression(exp: Fluent.Expression): Expression {
119119
throw new Error(`More than one positional argument is not supported.`);
120120
}
121121
if (named.length > 0) {
122-
annotation.options = [];
122+
annotation.options = new Map();
123123
for (const { name, value } of named) {
124124
const quoted = value.type !== 'NumberLiteral';
125125
const litValue = quoted ? value.parse().value : value.value;
126-
annotation.options.push({
127-
name: name.name,
128-
value: { type: 'literal', value: litValue }
126+
annotation.options.set(name.name, {
127+
type: 'literal',
128+
value: litValue
129129
});
130130
}
131131
}
@@ -152,13 +152,13 @@ function asExpression(exp: Fluent.Expression): Expression {
152152
? `-${exp.id.name}.${exp.attribute.name}`
153153
: `-${exp.id.name}`;
154154
if (exp.arguments?.named.length) {
155-
annotation.options = [];
155+
annotation.options = new Map();
156156
for (const { name, value } of exp.arguments.named) {
157157
const quoted = value.type !== 'NumberLiteral';
158158
const litValue = quoted ? value.parse().value : value.value;
159-
annotation.options.push({
160-
name: name.name,
161-
value: { type: 'literal', value: litValue }
159+
annotation.options.set(name.name, {
160+
type: 'literal',
161+
value: litValue
162162
});
163163
}
164164
}

packages/mf2-fluent/src/message-to-fluent.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,19 @@ function functionRefToFluent(
165165
): Fluent.InlineExpression {
166166
const args = new Fluent.CallArguments();
167167
if (arg) args.positional[0] = arg;
168-
if (options) {
169-
args.named = options.map(opt => {
170-
const va = valueToFluent(ctx, opt.value);
168+
if (options?.size) {
169+
args.named = [];
170+
for (const [name, value] of options) {
171+
const va = valueToFluent(ctx, value);
171172
if (va instanceof Fluent.BaseLiteral) {
172-
const id = new Fluent.Identifier(opt.name);
173-
return new Fluent.NamedArgument(id, va);
173+
const id = new Fluent.Identifier(name);
174+
args.named.push(new Fluent.NamedArgument(id, va));
175+
} else {
176+
throw new Error(
177+
`Fluent options must have literal values (got ${va.type} for ${name})`
178+
);
174179
}
175-
throw new Error(
176-
`Fluent options must have literal values (got ${va.type} for ${opt.name})`
177-
);
178-
});
180+
}
179181
}
180182

181183
const id = ctx.functionMap[name];

packages/mf2-icu-mf1/src/mf1-to-message-data.ts

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
Expression,
44
FunctionAnnotation,
55
Message,
6-
Option,
6+
Options,
77
VariableRef,
88
Variant
99
} from 'messageformat';
@@ -76,9 +76,7 @@ function tokenToPart(
7676
if (pt.type === 'content') value += pt.value;
7777
else throw new Error(`Unsupported param type: ${pt.type}`);
7878
}
79-
annotation.options = [
80-
{ name: 'param', value: { type: 'literal', value } }
81-
];
79+
annotation.options = new Map([['param', { type: 'literal', value }]]);
8280
}
8381
return {
8482
type: 'expression',
@@ -93,12 +91,9 @@ function tokenToPart(
9391
name: 'number'
9492
};
9593
if (pluralOffset) {
96-
annotation.options = [
97-
{
98-
name: 'pluralOffset',
99-
value: { type: 'literal', value: String(pluralOffset) }
100-
}
101-
];
94+
annotation.options = new Map([
95+
['pluralOffset', { type: 'literal', value: String(pluralOffset) }]
96+
]);
10297
}
10398
return {
10499
type: 'expression',
@@ -126,22 +121,19 @@ function argToExpression({
126121
};
127122
}
128123

129-
const options: Option[] = [];
124+
const options: Options = new Map();
130125
if (pluralOffset) {
131-
options.push({
132-
name: 'pluralOffset',
133-
value: { type: 'literal', value: String(pluralOffset) }
126+
options.set('pluralOffset', {
127+
type: 'literal',
128+
value: String(pluralOffset)
134129
});
135130
}
136131
if (type === 'selectordinal') {
137-
options.push({
138-
name: 'type',
139-
value: { type: 'literal', value: 'ordinal' }
140-
});
132+
options.set('type', { type: 'literal', value: 'ordinal' });
141133
}
142134

143135
const annotation: FunctionAnnotation = { type: 'function', name: 'number' };
144-
if (options.length) annotation.options = options;
136+
if (options.size) annotation.options = options;
145137

146138
return { type: 'expression', arg, annotation };
147139
}

packages/mf2-messageformat/src/cst/expression.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,15 +277,12 @@ function parseAttribute(ctx: ParseContext, start: number): CST.Attribute {
277277
let pos = id.end;
278278
const ws = whitespaces(source, pos);
279279
let equals: CST.Syntax<'='> | undefined;
280-
let value: CST.Literal | CST.VariableRef | undefined;
280+
let value: CST.Literal | undefined;
281281
if (source[pos + ws] === '=') {
282282
pos += ws + 1;
283283
equals = { start: pos - 1, end: pos, value: '=' };
284284
pos += whitespaces(source, pos);
285-
value =
286-
source[pos] === '$'
287-
? parseVariable(ctx, pos)
288-
: parseLiteral(ctx, pos, true);
285+
value = parseLiteral(ctx, pos, true);
289286
pos = value.end;
290287
}
291288
return {

packages/mf2-messageformat/src/cst/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export interface Attribute {
186186
open: Syntax<'@'>;
187187
name: Identifier;
188188
equals?: Syntax<'='>;
189-
value?: Literal | VariableRef;
189+
value?: Literal;
190190
}
191191

192192
/** @beta */

packages/mf2-messageformat/src/data-model/format-markup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ export function formatMarkup(
1010
const source =
1111
kind === 'close' ? `/${name}` : kind === 'open' ? `#${name}` : `#${name}/`;
1212
const part: MessageMarkupPart = { type: 'markup', kind, source, name };
13-
if (options?.length) {
13+
if (options?.size) {
1414
part.options = {};
15-
for (const { name, value } of options) {
15+
for (const [name, value] of options) {
1616
let rv = resolveValue(ctx, value);
1717
if (typeof rv === 'object' && typeof rv?.valueOf === 'function') {
1818
const vv = rv.valueOf();

packages/mf2-messageformat/src/data-model/from-cst.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,16 @@ function asExpression(
9595
allowMarkup: boolean
9696
): Model.Expression | Model.Markup {
9797
if (exp.type === 'expression') {
98-
const attributes = exp.attributes.length
99-
? exp.attributes.map(asAttribute)
100-
: undefined;
10198
if (allowMarkup && exp.markup) {
10299
const cm = exp.markup;
103100
const name = asName(cm.name);
104101
const kind =
105102
cm.open.value === '/' ? 'close' : cm.close ? 'standalone' : 'open';
106103
const markup: Model.Markup = { type: 'markup', kind, name };
107-
if (cm.options.length) markup.options = cm.options.map(asOption);
108-
if (attributes) markup.attributes = attributes;
104+
if (cm.options.length) markup.options = asOptions(cm.options);
105+
if (exp.attributes.length) {
106+
markup.attributes = asAttributes(exp.attributes);
107+
}
109108
markup[cst] = exp;
110109
return markup;
111110
}
@@ -121,7 +120,7 @@ function asExpression(
121120
switch (ca.type) {
122121
case 'function':
123122
annotation = { type: 'function', name: asName(ca.name) };
124-
if (ca.options.length) annotation.options = ca.options.map(asOption);
123+
if (ca.options.length) annotation.options = asOptions(ca.options);
125124
break;
126125
case 'reserved-annotation':
127126
annotation = {
@@ -142,25 +141,38 @@ function asExpression(
142141
else expression = { type: 'expression', annotation };
143142
}
144143
if (expression) {
145-
if (attributes) expression.attributes = attributes;
144+
if (exp.attributes.length) {
145+
expression.attributes = asAttributes(exp.attributes);
146+
}
146147
expression[cst] = exp;
147148
return expression;
148149
}
149150
}
150151
throw new MessageSyntaxError('parse-error', exp.start, exp.end);
151152
}
152153

153-
const asOption = (option: CST.Option): Model.Option => ({
154-
name: asName(option.name),
155-
value: asValue(option.value),
156-
[cst]: option
157-
});
154+
function asOptions(options: CST.Option[]): Model.Options {
155+
const map: Model.Options = new Map();
156+
for (const opt of options) {
157+
const name = asName(opt.name);
158+
if (map.has(name)) {
159+
throw new MessageSyntaxError('duplicate-option-name', opt.start, opt.end);
160+
}
161+
map.set(name, asValue(opt.value));
162+
}
163+
return map;
164+
}
158165

159-
function asAttribute(attr: CST.Attribute): Model.Attribute {
160-
const name = asName(attr.name);
161-
return attr.value
162-
? { name, value: asValue(attr.value), [cst]: attr }
163-
: { name, [cst]: attr };
166+
function asAttributes(attributes: CST.Attribute[]): Model.Attributes {
167+
const map: Model.Attributes = new Map();
168+
for (const attr of attributes) {
169+
const name = asName(attr.name);
170+
if (map.has(name)) {
171+
throw new MessageSyntaxError('duplicate-attribute', attr.start, attr.end);
172+
}
173+
map.set(name, attr.value ? asValue(attr.value) : true);
174+
}
175+
return map;
164176
}
165177

166178
function asName(id: CST.Identifier): string {

packages/mf2-messageformat/src/data-model/parse.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ describe('private annotations', () => {
3535
declarations: [],
3636
pattern: [
3737
'foo ',
38-
{ type: 'expression', annotation: { type: 'priv-bar' } }
38+
{
39+
type: 'expression',
40+
annotation: { type: 'priv-bar' },
41+
attributes: new Map([['baz', true]])
42+
}
3943
]
4044
});
4145
});

packages/mf2-messageformat/src/data-model/parse.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ function expression(allowMarkup: boolean): Model.Expression | Model.Markup {
236236
pos += 1; // ':'
237237
annotation = { type: 'function', name: identifier() };
238238
const options_ = options();
239-
if (options_.length) annotation.options = options_;
239+
if (options_) annotation.options = options_;
240240
break;
241241
}
242242
case '#':
@@ -246,7 +246,7 @@ function expression(allowMarkup: boolean): Model.Expression | Model.Markup {
246246
const kind = sigil === '#' ? 'open' : 'close';
247247
markup = { type: 'markup', kind, name: identifier() };
248248
const options_ = options();
249-
if (options_.length) markup.options = options_;
249+
if (options_) markup.options = options_;
250250
break;
251251
}
252252
case '^':
@@ -267,50 +267,75 @@ function expression(allowMarkup: boolean): Model.Expression | Model.Markup {
267267
throw SyntaxError('parse-error', pos);
268268
}
269269

270-
while (source[pos] === '@') attribute();
270+
const attributes_ = attributes();
271271
if (markup?.kind === 'open' && source[pos] === '/') {
272272
markup.kind = 'standalone';
273273
pos += 1; // '/'
274274
}
275275
expect('}', true);
276276

277277
if (annotation) {
278-
return arg
278+
const exp: Model.Expression = arg
279279
? { type: 'expression', arg, annotation }
280280
: { type: 'expression', annotation };
281+
if (attributes_) exp.attributes = attributes_;
282+
return exp;
283+
}
284+
if (markup) {
285+
if (attributes_) markup.attributes = attributes_;
286+
return markup;
281287
}
282-
if (markup) return markup;
283288
if (!arg) throw SyntaxError('empty-token', start, pos);
284-
return { type: 'expression', arg };
289+
return attributes_
290+
? { type: 'expression', arg, attributes: attributes_ }
291+
: { type: 'expression', arg };
285292
}
286293

287294
/** Requires and consumes leading and trailing whitespace. */
288295
function options() {
289296
ws('/}');
290-
const options: Model.Option[] = [];
297+
const options: Model.Options = new Map();
298+
let isEmpty = true;
291299
while (pos < source.length) {
292300
const next = source[pos];
293301
if (next === '@' || next === '/' || next === '}') break;
302+
const start = pos;
294303
const name_ = identifier();
304+
if (options.has(name_)) {
305+
throw SyntaxError('duplicate-option-name', start, pos);
306+
}
295307
ws();
296308
expect('=', true);
297309
ws();
298-
options.push({ name: name_, value: value(true) });
310+
options.set(name_, value(true));
311+
isEmpty = false;
299312
ws('/}');
300313
}
301-
return options;
314+
return isEmpty ? null : options;
302315
}
303316

304-
function attribute() {
305-
pos += 1; // '@'
306-
identifier(); // name
307-
ws('=/}');
308-
if (source[pos] === '=') {
309-
pos += 1; // '='
310-
ws();
311-
value(true); // value
312-
ws('/}');
317+
function attributes() {
318+
const attributes: Model.Attributes = new Map();
319+
let isEmpty = true;
320+
while (source[pos] === '@') {
321+
const start = pos;
322+
pos += 1; // '@'
323+
const name_ = identifier();
324+
if (attributes.has(name_)) {
325+
throw SyntaxError('duplicate-attribute', start, pos);
326+
}
327+
ws('=/}');
328+
if (source[pos] === '=') {
329+
pos += 1; // '='
330+
ws();
331+
attributes.set(name_, literal(true));
332+
ws('/}');
333+
} else {
334+
attributes.set(name_, true);
335+
}
336+
isEmpty = false;
313337
}
338+
return isEmpty ? null : attributes;
314339
}
315340

316341
// eslint-disable-next-line @typescript-eslint/no-explicit-any

packages/mf2-messageformat/src/data-model/resolve-function-annotation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getValueSource, resolveValue } from './resolve-value.js';
66
import type {
77
FunctionAnnotation,
88
Literal,
9-
Option,
9+
Options,
1010
VariableRef
1111
} from './types.js';
1212

@@ -51,10 +51,10 @@ export function resolveFunctionAnnotation(
5151
}
5252
}
5353

54-
function resolveOptions(ctx: Context, options: Option[] | undefined) {
54+
function resolveOptions(ctx: Context, options: Options | undefined) {
5555
const opt: Record<string, unknown> = Object.create(null);
5656
if (options) {
57-
for (const { name, value } of options) {
57+
for (const [name, value] of options) {
5858
opt[name] = resolveValue(ctx, value);
5959
}
6060
}

0 commit comments

Comments
 (0)