packages/template-compiler/src/attribute-pattern.ts
import type { Constructable, IRegistry, } from '@aurelia/kernel';
import { IContainer, registrableMetadataKey, emptyArray, getResourceKeyFor, resolve } from '@aurelia/kernel';
import { createInterface, objectFreeze, singletonRegistration } from './utilities';
import { ErrorNames, createMappedError } from './errors';
export interface AttributePatternDefinition<T extends string = string> {
pattern: T;
symbols: string;
}
export interface ICharSpec {
chars: string;
repeat: boolean;
isSymbol: boolean;
isInverted: boolean;
has(char: string): boolean;
equals(other: ICharSpec): boolean;
}
export class CharSpec implements ICharSpec {
public has: (char: string) => boolean;
public constructor(
public chars: string,
public repeat: boolean,
public isSymbol: boolean,
public isInverted: boolean,
) {
if (isInverted) {
switch (chars.length) {
case 0:
this.has = this._hasOfNoneInverse;
break;
case 1:
this.has = this._hasOfSingleInverse;
break;
default:
this.has = this._hasOfMultipleInverse;
}
} else {
switch (chars.length) {
case 0:
this.has = this._hasOfNone;
break;
case 1:
this.has = this._hasOfSingle;
break;
default:
this.has = this._hasOfMultiple;
}
}
}
public equals(other: ICharSpec): boolean {
return this.chars === other.chars
&& this.repeat === other.repeat
&& this.isSymbol === other.isSymbol
&& this.isInverted === other.isInverted;
}
/** @internal */
private _hasOfMultiple(char: string): boolean {
return this.chars.includes(char);
}
/** @internal */
private _hasOfSingle(char: string): boolean {
return this.chars === char;
}
/** @internal */
private _hasOfNone(_char: string): boolean {
return false;
}
/** @internal */
private _hasOfMultipleInverse(char: string): boolean {
return !this.chars.includes(char);
}
/** @internal */
private _hasOfSingleInverse(char: string): boolean {
return this.chars !== char;
}
/** @internal */
private _hasOfNoneInverse(_char: string): boolean {
return true;
}
}
export class Interpretation {
public parts: readonly string[] = emptyArray;
public get pattern(): string | null {
const value = this._pattern;
if (value === '') {
return null;
} else {
return value;
}
}
public set pattern(value: string | null) {
if (value == null) {
this._pattern = '';
this.parts = emptyArray;
} else {
this._pattern = value;
this.parts = this._partsRecord[value];
}
}
/** @internal */
private _pattern: string = '';
/** @internal */
private readonly _currentRecord: Record<string, string> = {};
/** @internal */
private readonly _partsRecord: Record<string, string[]> = {};
public append(pattern: string, ch: string): void {
const currentRecord = this._currentRecord;
if (currentRecord[pattern] === undefined) {
currentRecord[pattern] = ch;
} else {
currentRecord[pattern] += ch;
}
}
public next(pattern: string): void {
const currentRecord = this._currentRecord;
let partsRecord: Interpretation['_partsRecord'];
if (currentRecord[pattern] !== undefined) {
partsRecord = this._partsRecord;
if (partsRecord[pattern] === undefined) {
partsRecord[pattern] = [currentRecord[pattern]];
} else {
partsRecord[pattern].push(currentRecord[pattern]);
}
currentRecord[pattern] = undefined!;
}
}
}
class AttrParsingState {
private readonly _nextStates: AttrParsingState[] = [];
private readonly _patterns: string[];
public _types: SegmentTypes | null = null;
public _isEndpoint: boolean = false;
public get _pattern(): string | null {
return this._isEndpoint ? this._patterns[0] : null;
}
public constructor(
public charSpec: ICharSpec,
...patterns: string[]
) {
this._patterns = patterns;
}
public findChild(charSpec: ICharSpec): AttrParsingState {
const nextStates = this._nextStates;
const len = nextStates.length;
let child: AttrParsingState = null!;
let i = 0;
for (; i < len; ++i) {
child = nextStates[i];
if (charSpec.equals(child.charSpec)) {
return child;
}
}
return null!;
}
public append(charSpec: ICharSpec, pattern: string): AttrParsingState {
const patterns = this._patterns;
if (!patterns.includes(pattern)) {
patterns.push(pattern);
}
let state = this.findChild(charSpec);
if (state == null) {
state = new AttrParsingState(charSpec, pattern);
this._nextStates.push(state);
if (charSpec.repeat) {
state._nextStates.push(state);
}
}
return state;
}
public findMatches(ch: string, interpretation: Interpretation): AttrParsingState[] {
// TODO: reuse preallocated arrays
const results = [];
const nextStates = this._nextStates;
const len = nextStates.length;
let childLen = 0;
let child: AttrParsingState = null!;
let i = 0;
let j = 0;
for (; i < len; ++i) {
child = nextStates[i];
if (child.charSpec.has(ch)) {
results.push(child);
childLen = child._patterns.length;
j = 0;
if (child.charSpec.isSymbol) {
for (; j < childLen; ++j) {
interpretation.next(child._patterns[j]);
}
} else {
for (; j < childLen; ++j) {
interpretation.append(child._patterns[j], ch);
}
}
}
}
return results;
}
}
export interface ISegment {
text: string;
eachChar(callback: (spec: CharSpec) => void): void;
}
/** @internal */
class StaticSegment implements ISegment {
private readonly _len: number;
private readonly _specs: CharSpec[];
public constructor(
public text: string,
) {
const len = this._len = text.length;
const specs = this._specs = [] as CharSpec[];
let i = 0;
for (; len > i; ++i) {
specs.push(new CharSpec(text[i], false, false, false));
}
}
public eachChar(callback: (spec: CharSpec) => void): void {
const len = this._len;
const specs = this._specs;
let i = 0;
for (; len > i; ++i) {
callback(specs[i]);
}
}
}
/** @internal */
class DynamicSegment implements ISegment {
public text: string = 'PART';
private readonly _spec: CharSpec;
public constructor(symbols: string) {
this._spec = new CharSpec(symbols, true, false, true);
}
public eachChar(callback: (spec: CharSpec) => void): void {
callback(this._spec);
}
}
/** @internal */
class SymbolSegment implements ISegment {
private readonly _spec: CharSpec;
public constructor(
public text: string,
) {
this._spec = new CharSpec(text, false, true, false);
}
public eachChar(callback: (spec: CharSpec) => void): void {
callback(this._spec);
}
}
export class SegmentTypes {
public statics: number = 0;
public dynamics: number = 0;
public symbols: number = 0;
}
export interface ISyntaxInterpreter {
add(defs: AttributePatternDefinition[]): void;
interpret(name: string): Interpretation;
}
export const ISyntaxInterpreter = /*@__PURE__*/createInterface<ISyntaxInterpreter>('ISyntaxInterpreter', x => x.singleton(SyntaxInterpreter));
/**
* The default implementation of @see {ISyntaxInterpreter}.
*/
export class SyntaxInterpreter implements ISyntaxInterpreter {
/** @internal */
public _rootState: AttrParsingState = new AttrParsingState(null!);
/** @internal */
private readonly _initialStates: AttrParsingState[] = [this._rootState];
// todo: this only works if this method is ever called only once
public add(defs: AttributePatternDefinition[]): void {
defs = defs.slice(0).sort((d1, d2) => d1.pattern > d2.pattern ? 1 : -1);
const ii = defs.length;
let currentState: AttrParsingState;
let def: AttributePatternDefinition;
let pattern: string;
let types: SegmentTypes;
let segments: ISegment[];
let len: number;
let charSpecCb: (ch: ICharSpec) => void;
let i = 0;
let j: number;
while (ii > i) {
currentState = this._rootState;
def = defs[i];
pattern = def.pattern;
types = new SegmentTypes();
segments = this._parse(def, types);
len = segments.length;
charSpecCb = (ch: ICharSpec) => currentState = currentState.append(ch, pattern);
for (j = 0; len > j; ++j) {
segments[j].eachChar(charSpecCb);
}
currentState._types = types;
currentState._isEndpoint = true;
++i;
}
}
public interpret(name: string): Interpretation {
const interpretation = new Interpretation();
const len = name.length;
let states = this._initialStates;
let i = 0;
let state: AttrParsingState;
for (; i < len; ++i) {
states = this._getNextStates(states, name.charAt(i), interpretation);
if (states.length === 0) {
break;
}
}
states = states.filter(isEndpoint);
if (states.length > 0) {
states.sort(sortEndpoint);
state = states[0];
if (!state.charSpec.isSymbol) {
interpretation.next(state._pattern!);
}
interpretation.pattern = state._pattern;
}
return interpretation;
}
/** @internal */
private _getNextStates(states: AttrParsingState[], ch: string, interpretation: Interpretation): AttrParsingState[] {
// TODO: reuse preallocated arrays
const nextStates: AttrParsingState[] = [];
let state: AttrParsingState = null!;
const len = states.length;
let i = 0;
for (; i < len; ++i) {
state = states[i];
nextStates.push(...state.findMatches(ch, interpretation));
}
return nextStates;
}
/** @internal */
private _parse(def: AttributePatternDefinition, types: SegmentTypes): ISegment[] {
const result = [];
const pattern = def.pattern;
const len = pattern.length;
const symbols = def.symbols;
let i = 0;
let start = 0;
let c = '';
while (i < len) {
c = pattern.charAt(i);
if (symbols.length === 0 || !symbols.includes(c)) {
if (i === start) {
if (c === 'P' && pattern.slice(i, i + 4) === 'PART') {
start = i = (i + 4);
result.push(new DynamicSegment(symbols));
++types.dynamics;
} else {
++i;
}
} else {
++i;
}
} else if (i !== start) {
result.push(new StaticSegment(pattern.slice(start, i)));
++types.statics;
start = i;
} else {
result.push(new SymbolSegment(pattern.slice(start, i + 1)));
++types.symbols;
start = ++i;
}
}
if (start !== i) {
result.push(new StaticSegment(pattern.slice(start, i)));
++types.statics;
}
return result;
}
}
function isEndpoint(a: AttrParsingState) {
return a._isEndpoint;
}
function sortEndpoint(a: AttrParsingState, b: AttrParsingState) {
// both a and b are endpoints
// compare them based on the number of static, then dynamic & symbol fragments
const aTypes = a._types!;
const bTypes = b._types!;
if (aTypes.statics !== bTypes.statics) {
return bTypes.statics - aTypes.statics;
}
if (aTypes.dynamics !== bTypes.dynamics) {
return bTypes.dynamics - aTypes.dynamics;
}
if (aTypes.symbols !== bTypes.symbols) {
return bTypes.symbols - aTypes.symbols;
}
return 0;
}
export class AttrSyntax {
public constructor(
public rawName: string,
public rawValue: string,
public target: string,
public command: string | null,
public parts: readonly string[] | null = null
) { }
}
export type IAttributePattern<T extends string = string> = Record<T, (rawName: string, rawValue: string, parts: readonly string[]) => AttrSyntax>;
export const IAttributePattern = /*@__PURE__*/createInterface<IAttributePattern>('IAttributePattern');
export interface IAttributeParser {
registerPattern(patterns: AttributePatternDefinition[], Type: Constructable<IAttributePattern>): void;
parse(name: string, value: string): AttrSyntax;
}
export const IAttributeParser = /*@__PURE__*/createInterface<IAttributeParser>('IAttributeParser', x => x.singleton(AttributeParser));
/**
* The default implementation of the @see IAttributeParser interface
*/
export class AttributeParser implements IAttributeParser {
/** @internal */
private readonly _cache: Record<string, Interpretation> = {};
/**
* A 2 level record with the same key on both levels.
* Just a trick to maintain `this` + have simple lookup + support multi patterns per class definition
*
* @internal
*/
private readonly _patterns: Record<string, { patternType: Constructable<IAttributePattern>; pattern?: IAttributePattern }> = {};
/** @internal */
private readonly _interpreter: ISyntaxInterpreter;
/** @internal */
private _initialized: boolean = false;
/** @internal */
private readonly _allDefinitions: AttributePatternDefinition[] = [];
/** @internal */
private readonly _container: IContainer;
public constructor() {
this._interpreter = resolve(ISyntaxInterpreter);
this._container = resolve(IContainer);
}
public registerPattern(patterns: AttributePatternDefinition[], Type: Constructable<IAttributePattern>): void {
if (this._initialized) throw createMappedError(ErrorNames.attribute_pattern_already_initialized);
const $patterns = this._patterns;
for (const { pattern } of patterns) {
if ($patterns[pattern] != null) throw createMappedError(ErrorNames.attribute_pattern_duplicate, pattern);
$patterns[pattern] = { patternType: Type };
}
this._allDefinitions.push(...patterns);
}
/** @internal */
private _initialize(): void {
this._interpreter.add(this._allDefinitions);
const _container = this._container;
for (const [, value] of Object.entries(this._patterns)) {
value.pattern = _container.get(value.patternType);
}
}
public parse(name: string, value: string): AttrSyntax {
// Optimization Idea: move the initialization to an AppTask
if (!this._initialized) {
this._initialize();
this._initialized = true;
}
let interpretation = this._cache[name];
if (interpretation == null) {
interpretation = this._cache[name] = this._interpreter.interpret(name);
}
const pattern = interpretation.pattern;
if (pattern == null) {
return new AttrSyntax(name, value, name, null, null);
} else {
return this._patterns[pattern].pattern![pattern](name, value, interpretation.parts as string[]);
}
}
}
export interface AttributePatternKind {
readonly name: string;
create<const K extends AttributePatternDefinition, P extends Constructable<IAttributePattern<K['pattern']>> = Constructable<IAttributePattern<K['pattern']>>>(patternDefs: K[], Type: P): IRegistry;
}
/**
* Decorator to be used on attr pattern classes
*/
export function attributePattern<const K extends AttributePatternDefinition>(...patternDefs: K[]): <T extends Constructable<IAttributePattern<K['pattern']>>>(target: T, context: ClassDecoratorContext<T>) => T {
return function decorator<T extends Constructable<IAttributePattern<K['pattern']>>>(target: T, context: ClassDecoratorContext<T>): T {
const registrable = AttributePattern.create(patternDefs, target);
// Decorators are by nature static, so we need to store the metadata on the class itself, assuming only one set of patterns per class.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.metadata[registrableMetadataKey] = registrable;
return target;
};
}
export const AttributePattern = /*@__PURE__*/ objectFreeze<AttributePatternKind>({
name: getResourceKeyFor('attribute-pattern'),
create(patternDefs, Type) {
return {
register(container: IContainer) {
container.get(IAttributeParser).registerPattern(patternDefs, Type);
singletonRegistration(IAttributePattern, Type).register(container);
}
};
},
});
export class DotSeparatedAttributePattern {
public static [Symbol.metadata] = {
[registrableMetadataKey]: /*@__PURE__*/AttributePattern.create(
[
{ pattern: 'PART.PART', symbols: '.' },
{ pattern: 'PART.PART.PART', symbols: '.' }
],
DotSeparatedAttributePattern
)
};
public 'PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], parts[1]);
}
public 'PART.PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, `${parts[0]}.${parts[1]}`, parts[2]);
}
}
export class RefAttributePattern {
public static [Symbol.metadata] = {
[registrableMetadataKey]: /*@__PURE__*/AttributePattern.create(
[
{ pattern: 'ref', symbols: '' },
{ pattern: 'PART.ref', symbols: '.' }
],
RefAttributePattern
)
};
public 'ref'(rawName: string, rawValue: string, _parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'element', 'ref');
}
public 'PART.ref'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
let target = parts[0];
if (target === 'view-model') {
target = 'component';
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(`[aurelia] Detected view-model.ref usage: "${rawName}=${rawValue}".`
+ ` This is deprecated and component.ref should be used instead`);
}
}
return new AttrSyntax(rawName, rawValue, target, 'ref');
}
}
export class EventAttributePattern {
public static [Symbol.metadata] = {
[registrableMetadataKey]: /*@__PURE__*/AttributePattern.create(
[
{ pattern: 'PART.trigger:PART', symbols: '.:' },
{ pattern: 'PART.capture:PART', symbols: '.:' },
],
EventAttributePattern
)
};
public 'PART.trigger:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger', parts);
}
public 'PART.capture:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'capture', parts);
}
}
export class ColonPrefixedBindAttributePattern {
public static [Symbol.metadata] = {
[registrableMetadataKey]: /*@__PURE__*/AttributePattern.create(
[{ pattern: ':PART', symbols: ':' }],
ColonPrefixedBindAttributePattern
)
};
public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
export class AtPrefixedTriggerAttributePattern {
public static [Symbol.metadata] = {
[registrableMetadataKey]: /*@__PURE__*/AttributePattern.create(
[
{ pattern: '@PART', symbols: '@' },
{ pattern: '@PART:PART', symbols: '@:' },
], AtPrefixedTriggerAttributePattern
)
};
public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
}
public '@PART:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger', [parts[0], 'trigger', ...parts.slice(1)]);
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/* istanbul ignore next */function testAttributePatternDeco() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
attributePattern({ pattern: 'abc', symbols: '.' })(class Def {});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
AttributePattern.create([{ pattern: 'abc', symbols: '.' }], class Def {});
}