diff --git a/src/parser/match-operator.spec.ts b/src/parser/match-operator.spec.ts deleted file mode 100644 index a271bb0f32..0000000000 --- a/src/parser/match-operator.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { matchOperator } from './match-operator' -import { defaultOperators } from '..' -import { createTrie } from '../util/operator-trie' - -describe('parser/matchOperator()', function () { - const trie = createTrie(defaultOperators) - it('should match contains', () => { - expect(matchOperator('contains', 0, trie)).toBe(8) - }) - it('should match comparision', () => { - expect(matchOperator('>', 0, trie)).toBe(1) - expect(matchOperator('>=', 0, trie)).toBe(2) - expect(matchOperator('<', 0, trie)).toBe(1) - expect(matchOperator('<=', 0, trie)).toBe(2) - }) - it('should match binary logic', () => { - expect(matchOperator('and', 0, trie)).toBe(3) - expect(matchOperator('or', 0, trie)).toBe(2) - }) - it('should not match if word not terminate', () => { - expect(matchOperator('true1', 0, trie)).toBe(-1) - expect(matchOperator('containsa', 0, trie)).toBe(-1) - }) - it('should match if word boundary found', () => { - expect(matchOperator('>=1', 0, trie)).toBe(2) - expect(matchOperator('contains b', 0, trie)).toBe(8) - }) -}) diff --git a/src/parser/match-operator.ts b/src/parser/match-operator.ts deleted file mode 100644 index 9ea80216e7..0000000000 --- a/src/parser/match-operator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Trie, TrieNode, IDENTIFIER, TYPES } from '../util' - -export function matchOperator (str: string, begin: number, trie: Trie, end = str.length) { - let node: TrieNode = trie - let i = begin - let info - while (node[str[i]] && i < end) { - node = node[str[i++]] - if (node['end']) info = node - } - if (!info) return -1 - if (info['needBoundary'] && (TYPES[str.charCodeAt(i)] & IDENTIFIER)) return -1 - return i -} diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index 4719ad5d77..c560afa4da 100644 --- a/src/parser/tokenizer.spec.ts +++ b/src/parser/tokenizer.spec.ts @@ -1,5 +1,7 @@ import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken } from '../tokens' import { Tokenizer } from './tokenizer' +import { defaultOperators } from '../render/operator' +import { createTrie } from '../util/operator-trie' describe('Tokenizer', function () { it('should read quoted', () => { @@ -15,12 +17,31 @@ describe('Tokenizer', function () { // eslint-disable-next-line deprecation/deprecation expect(new Tokenizer('foo bar').readWord()).toHaveProperty('content', 'foo') }) - it('should read number value', () => { - const token: NumberToken = new Tokenizer('2.33.2').readValueOrThrow() as any + it('should read integer number', () => { + const token: NumberToken = new Tokenizer('123').readValueOrThrow() as any expect(token).toBeInstanceOf(NumberToken) - expect(token.whole.getText()).toBe('2') - expect(token.decimal!.getText()).toBe('33') - expect(token.getText()).toBe('2.33') + expect(token.getText()).toBe('123') + expect(token.number).toBe(123) + }) + it('should read negative number', () => { + const token: NumberToken = new Tokenizer('-123').readValueOrThrow() as any + expect(token).toBeInstanceOf(NumberToken) + expect(token.getText()).toBe('-123') + expect(token.number).toBe(-123) + }) + it('should read float number', () => { + const token: NumberToken = new Tokenizer('1.23').readValueOrThrow() as any + expect(token).toBeInstanceOf(NumberToken) + expect(token.getText()).toBe('1.23') + expect(token.number).toBe(1.23) + }) + it('should treat 1.2.3 as property read', () => { + const token: PropertyAccessToken = new Tokenizer('1.2.3').readValueOrThrow() as any + expect(token).toBeInstanceOf(PropertyAccessToken) + expect(token.props).toHaveLength(3) + expect(token.props[0].getText()).toBe('1') + expect(token.props[1].getText()).toBe('2') + expect(token.props[2].getText()).toBe('3') }) it('should read quoted value', () => { const value = new Tokenizer('"foo"a').readValue() @@ -33,11 +54,7 @@ describe('Tokenizer', function () { it('should read quoted property access value', () => { const value = new Tokenizer('["a prop"]').readValue() expect(value).toBeInstanceOf(PropertyAccessToken) - expect((value as PropertyAccessToken).variable.getText()).toBe('"a prop"') - }) - it('should throw for broken quoted property access', () => { - const tokenizer = new Tokenizer('[5]') - expect(() => tokenizer.readValueOrThrow()).toThrow() + expect((value as QuotedToken).getText()).toBe('["a prop"]') }) it('should throw for incomplete quoted property access', () => { const tokenizer = new Tokenizer('["a prop"') @@ -277,10 +294,10 @@ describe('Tokenizer', function () { const pa: PropertyAccessToken = token!.args[0] as any expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken) - expect((pa.variable as any).content).toBe('arr') - expect(pa.props).toHaveLength(1) - expect(pa.props[0]).toBeInstanceOf(NumberToken) - expect(pa.props[0].getText()).toBe('0') + expect(pa.props).toHaveLength(2) + expect((pa.props[0] as any).content).toBe('arr') + expect(pa.props[1]).toBeInstanceOf(NumberToken) + expect(pa.props[1].getText()).toBe('0') }) it('should read a filter with obj.foo argument', function () { const tokenizer = new Tokenizer('| plus: obj.foo') @@ -290,10 +307,10 @@ describe('Tokenizer', function () { const pa: PropertyAccessToken = token!.args[0] as any expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken) - expect((pa.variable as any).content).toBe('obj') - expect(pa.props).toHaveLength(1) - expect(pa.props[0]).toBeInstanceOf(IdentifierToken) - expect(pa.props[0].getText()).toBe('foo') + expect(pa.props).toHaveLength(2) + expect((pa.props[0] as any).content).toBe('obj') + expect(pa.props[1]).toBeInstanceOf(IdentifierToken) + expect(pa.props[1].getText()).toBe('foo') }) it('should read a filter with obj["foo"] argument', function () { const tokenizer = new Tokenizer('| plus: obj["good luck"]') @@ -304,8 +321,8 @@ describe('Tokenizer', function () { const pa: PropertyAccessToken = token!.args[0] as any expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken) expect(pa.getText()).toBe('obj["good luck"]') - expect((pa.variable as any).content).toBe('obj') - expect(pa.props[0].getText()).toBe('"good luck"') + expect((pa.props[0] as any).content).toBe('obj') + expect(pa.props[1].getText()).toBe('"good luck"') }) }) describe('#readFilters()', () => { @@ -341,7 +358,7 @@ describe('Tokenizer', function () { expect(tokens[2].args).toHaveLength(1) expect(tokens[2].args[0]).toBeInstanceOf(PropertyAccessToken) expect((tokens[2].args[0] as any).getText()).toBe('foo[a.b["c d"]]') - expect((tokens[2].args[0] as any).props[0].getText()).toBe('a.b["c d"]') + expect((tokens[2].args[0] as any).props[1].getText()).toBe('a.b["c d"]') }) }) describe('#readExpression()', () => { @@ -358,10 +375,10 @@ describe('Tokenizer', function () { expect(exp).toHaveLength(1) const pa = exp[0] as PropertyAccessToken expect(pa).toBeInstanceOf(PropertyAccessToken) - expect((pa.variable as any).content).toEqual('a') - expect(pa.props).toHaveLength(2) + expect(pa.props).toHaveLength(3) + expect((pa.props[0] as any).content).toEqual('a') - const [p1, p2] = pa.props + const [, p1, p2] = pa.props expect(p1).toBeInstanceOf(IdentifierToken) expect(p1.getText()).toBe('') expect(p2).toBeInstanceOf(PropertyAccessToken) @@ -373,8 +390,8 @@ describe('Tokenizer', function () { expect(exp).toHaveLength(1) const pa = exp[0] as PropertyAccessToken expect(pa).toBeInstanceOf(PropertyAccessToken) - expect((pa.variable as any).content).toEqual('a') - expect(pa.props).toHaveLength(0) + expect(pa.props).toHaveLength(1) + expect((pa.props[0] as any).content).toEqual('a') }) it('should read expression `a ==`', () => { const exp = [...new Tokenizer('a ==').readExpressionTokens()] @@ -481,6 +498,30 @@ describe('Tokenizer', function () { expect(rhs.getText()).toEqual('"\\""') }) }) + describe('#matchTrie()', function () { + const opTrie = createTrie(defaultOperators) + it('should match contains', () => { + expect(new Tokenizer('contains').matchTrie(opTrie)).toBe(8) + }) + it('should match comparision', () => { + expect(new Tokenizer('>').matchTrie(opTrie)).toBe(1) + expect(new Tokenizer('>=').matchTrie(opTrie)).toBe(2) + expect(new Tokenizer('<').matchTrie(opTrie)).toBe(1) + expect(new Tokenizer('<=').matchTrie(opTrie)).toBe(2) + }) + it('should match binary logic', () => { + expect(new Tokenizer('and').matchTrie(opTrie)).toBe(3) + expect(new Tokenizer('or').matchTrie(opTrie)).toBe(2) + }) + it('should not match if word not terminate', () => { + expect(new Tokenizer('true1').matchTrie(opTrie)).toBe(-1) + expect(new Tokenizer('containsa').matchTrie(opTrie)).toBe(-1) + }) + it('should match if word boundary found', () => { + expect(new Tokenizer('>=1').matchTrie(opTrie)).toBe(2) + expect(new Tokenizer('contains b').matchTrie(opTrie)).toBe(8) + }) + }) describe('#readLiquidTagTokens', () => { it('should read newline terminated tokens', () => { const tokenizer = new Tokenizer('echo \'hello\'') diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 87d3ca0a50..4102bda294 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -1,16 +1,17 @@ import { FilteredValueToken, TagToken, HTMLToken, HashToken, QuotedToken, LiquidTagToken, OutputToken, ValueToken, Token, RangeToken, FilterToken, TopLevelToken, PropertyAccessToken, OperatorToken, LiteralToken, IdentifierToken, NumberToken } from '../tokens' -import { Trie, createTrie, ellipsis, literalValues, TokenizationError, TYPES, QUOTE, BLANK, IDENTIFIER } from '../util' +import { OperatorHandler } from '../render/operator' +import { TrieNode, isQuotedToken, isWordToken, Trie, createTrie, ellipsis, literalValues, TokenizationError, TYPES, QUOTE, BLANK, IDENTIFIER, NUMBER, SIGN } from '../util' import { Operators, Expression } from '../render' import { NormalizedFullOptions, defaultOptions } from '../liquid-options' import { FilterArg } from './filter-arg' -import { matchOperator } from './match-operator' import { whiteSpaceCtrl } from './whitespace-ctrl' export class Tokenizer { p: number N: number private rawBeginAt = -1 - private opTrie: Trie + private opTrie: Trie + private literalTrie: Trie constructor ( public input: string, @@ -21,6 +22,7 @@ export class Tokenizer { this.p = range ? range[0] : 0 this.N = range ? range[1] : input.length this.opTrie = createTrie(operators) + this.literalTrie = createTrie(literalValues) } readExpression () { @@ -44,10 +46,22 @@ export class Tokenizer { } readOperator (): OperatorToken | undefined { this.skipBlank() - const end = matchOperator(this.input, this.p, this.opTrie) + const end = this.matchTrie(this.opTrie) if (end === -1) return return new OperatorToken(this.input, this.p, (this.p = end), this.file) } + matchTrie (trie: Trie) { + let node: TrieNode = trie + let i = this.p + let info + while (node[this.input[i]] && i < this.N) { + node = node[this.input[i++]] + if (node['end']) info = node + } + if (!info) return -1 + if (info['needBoundary'] && (this.peekType(i - this.p) & IDENTIFIER)) return -1 + return i + } readFilteredValue (): FilteredValueToken { const begin = this.p const initial = this.readExpression() @@ -272,8 +286,8 @@ export class Tokenizer { return this.input.slice(this.p, this.N) } - advance (i = 1) { - this.p += i + advance (step = 1) { + this.p += step } end () { @@ -289,43 +303,68 @@ export class Tokenizer { } readValue (): ValueToken | undefined { - const value = this.readQuoted() || this.readRange() - if (value) return value - - if (this.peek() === '[') { - this.p++ - const prop = this.readQuoted() - if (!prop) return - if (this.peek() !== ']') return - this.p++ - return new PropertyAccessToken(prop, [], this.p) - } - - const variable = this.readIdentifier() - if (!variable.size()) return - - let isNumber = variable.isNumber(true) + this.skipBlank() + const begin = this.p + const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber() const props: (QuotedToken | IdentifierToken)[] = [] while (true) { if (this.peek() === '[') { - isNumber = false this.p++ const prop = this.readValue() || new IdentifierToken(this.input, this.p, this.p, this.file) - this.readTo(']') + this.assert(this.readTo(']') !== -1, '[ not closed') props.push(prop) - } else if (this.peek() === '.' && this.peek(1) !== '.') { // skip range syntax + continue + } + if (!variable && !props.length) { + const prop = this.readIdentifier() + if (prop.size()) { + props.push(prop) + continue + } + } + if (this.peek() === '.' && this.peek(1) !== '.') { // skip range syntax this.p++ const prop = this.readIdentifier() if (!prop.size()) break - if (!prop.isNumber()) isNumber = false props.push(prop) + continue + } + break + } + if (!props.length) return variable + return new PropertyAccessToken(variable, props, this.input, begin, this.p) + } + + readNumber (): NumberToken | undefined { + this.skipBlank() + let decimalFound = false + let digitFound = false + let n = 0 + if (this.peekType() & SIGN) n++ + while (this.p + n <= this.N) { + if (this.peekType(n) & NUMBER) { + digitFound = true + n++ + } else if (this.peek(n) === '.' && this.peek(n + 1) !== '.') { + if (decimalFound || !digitFound) return + decimalFound = true + n++ } else break } - if (!props.length && literalValues.hasOwnProperty(variable.content)) { - return new LiteralToken(this.input, variable.begin, variable.end, this.file) + if (digitFound && !(this.peekType(n) & IDENTIFIER)) { + const num = new NumberToken(this.input, this.p, this.p + n, this.file) + this.advance(n) + return num } - if (isNumber) return new NumberToken(variable, props[0] as IdentifierToken) - return new PropertyAccessToken(variable, props, this.p) + } + + readLiteral (): LiteralToken | undefined { + this.skipBlank() + const end = this.matchTrie(this.literalTrie) + if (end === -1) return + const literal = new LiteralToken(this.input, this.p, end, this.file) + this.p = end + return literal } readRange (): RangeToken | undefined { @@ -388,7 +427,7 @@ export class Tokenizer { } peekType (n = 0) { - return TYPES[this.input.charCodeAt(this.p + n)] + return this.p + n >= this.N ? 0 : TYPES[this.input.charCodeAt(this.p + n)] } peek (n = 0): string { diff --git a/src/render/expression.spec.ts b/src/render/expression.spec.ts index bbf783d030..816cc0883d 100644 --- a/src/render/expression.spec.ts +++ b/src/render/expression.spec.ts @@ -162,6 +162,10 @@ describe('Expression', function () { const ctx = new Context({ obj: { foo: 'FOO' }, keys: { "what's this": 'foo' } }) expect(await toPromise(create('obj[keys["what\'s this"]]').evaluate(ctx, false))).toBe('FOO') }) + it('should allow bracket quoted property access', async function () { + const ctx = new Context({ 'foo bar': { coo: 'FOO BAR' } }) + expect(await toPromise(create('["foo bar"].coo').evaluate(ctx, false))).toBe('FOO BAR') + }) it('should support not', async function () { expect(await toPromise(create('not 1 < 2').evaluate(ctx))).toBe(false) }) @@ -169,6 +173,24 @@ describe('Expression', function () { expect(await toPromise(create('not 1 < 2 or not 1 > 2').evaluate(ctx))).toBe(true) expect(await toPromise(create('not 1 < 2 and not 1 > 2').evaluate(ctx))).toBe(false) }) + it('should allow variable as squared sub property key', async function () { + const ctx = new Context({ 'foo': { bar: 'BAR' }, 'key': 'bar' }) + expect(await toPromise(create('foo[key]').evaluate(ctx))).toBe('BAR') + }) + it('should allow propertyAccessToken as squared sub property key', async function () { + const ctx = new Context({ 'foo': { bar: 'BAR', key: 'bar' } }) + expect(await toPromise(create('foo[foo.key]').evaluate(ctx))).toBe('BAR') + }) + it('should allow nested squared property read', async function () { + const ctx = new Context({ 'foo': { bar: 'BAR', key: 'bar' } }) + expect(await toPromise(create('foo[foo["key"]]').evaluate(ctx))).toBe('BAR') + }) + it('should allow string as property read variable', async function () { + expect(await toPromise(create('"foo"[2]').evaluate(ctx))).toBe('o') + }) + it('should allow range as property read variable', async function () { + expect(await toPromise(create('(3..5).size').evaluate(ctx))).toBe(3) + }) }) describe('sync', function () { diff --git a/src/render/expression.ts b/src/render/expression.ts index 663be1f86d..9e612f11a7 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -39,29 +39,29 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f if (isPropertyAccessToken(token)) return yield evalPropertyAccessToken(token, ctx, lenient) if (isRangeToken(token)) return yield evalRangeToken(token, ctx) if (isLiteralToken(token)) return evalLiteralToken(token) - if (isNumberToken(token)) return evalNumberToken(token) - if (isWordToken(token)) return token.getText() + if (isNumberToken(token)) return token.number + if (isWordToken(token)) return token.content if (isQuotedToken(token)) return evalQuotedToken(token) } function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator { const props: string[] = [] + const variable = token.variable ? yield evalToken(token.variable, ctx, lenient) : undefined for (const prop of token.props) { props.push((yield evalToken(prop, ctx, false)) as unknown as string) } try { - return yield ctx._get([token.propertyName, ...props]) + if (token.variable) { + return yield ctx._getFromScope(variable, props) + } else { + return yield ctx._get(props) + } } catch (e) { if (lenient && (e as Error).name === 'InternalUndefinedVariableError') return null throw (new UndefinedVariableError(e as Error, token)) } } -function evalNumberToken (token: NumberToken) { - const str = token.whole.content + '.' + (token.decimal ? token.decimal.content : '') - return Number(str) -} - export function evalQuotedToken (token: QuotedToken) { return parseStringLiteral(token.getText()) } diff --git a/src/template/filter.spec.ts b/src/template/filter.spec.ts index c5127a40cf..8e578082c8 100644 --- a/src/template/filter.spec.ts +++ b/src/template/filter.spec.ts @@ -13,7 +13,7 @@ describe('filter', function () { it('should call filter impl with correct arguments', async function () { const spy = jest.fn() - const thirty = new NumberToken(new IdentifierToken('30', 0, 2), undefined) + const thirty = new NumberToken('30', 0, 2, undefined) const filter = new Filter('foo', spy, [thirty], liquid) await toPromise(filter.render('foo', ctx)) expect(spy).toHaveBeenCalledWith('foo', 30) @@ -23,7 +23,7 @@ describe('filter', function () { const val = yield this.context._get([valStr]) return `${this.liquid.testVersion}: ${val + diff}` }) - const ten = new NumberToken(new IdentifierToken('10', 0, 2), undefined) + const ten = new NumberToken('10', 0, 2, undefined) const filter = new Filter('add', spy, [ten], liquid) const val = await toPromise(filter.render('thirty', ctx)) expect(val).toEqual('1.0: 40') @@ -33,12 +33,12 @@ describe('filter', function () { }) it('should render filters with argument', async function () { - const two = new NumberToken(new IdentifierToken('2', 0, 1), undefined) + const two = new NumberToken('2', 0, 1, undefined) expect(await toPromise(new Filter('add', (a: number, b: number) => a + b, [two], liquid).render(3, ctx))).toBe(5) }) it('should render filters with multiple arguments', async function () { - const two = new NumberToken(new IdentifierToken('2', 0, 1), undefined) + const two = new NumberToken('2', 0, 1, undefined) const c = new QuotedToken('"c"', 0, 3) expect(await toPromise(new Filter('add', (a: number, b: number, c: number) => a + b + c, [two, c], liquid).render(3, ctx))).toBe('5c') }) @@ -49,7 +49,7 @@ describe('filter', function () { }) it('should support key value pairs', async function () { - const two = new NumberToken(new IdentifierToken('2', 0, 1), undefined) + const two = new NumberToken('2', 0, 1, undefined) expect(await toPromise(new Filter('add', (a: number, b: number[]) => b[0] + ':' + (a + b[1]), [['num', two]], liquid).render(3, ctx))).toBe('num:5') }) }) diff --git a/src/tokens/number-token.ts b/src/tokens/number-token.ts index aa2b854db2..c90729a931 100644 --- a/src/tokens/number-token.ts +++ b/src/tokens/number-token.ts @@ -1,12 +1,15 @@ import { Token } from './token' -import { IdentifierToken } from './identifier-token' import { TokenKind } from '../parser' export class NumberToken extends Token { + public number: number constructor ( - public whole: IdentifierToken, - public decimal?: IdentifierToken + public input: string, + public begin: number, + public end: number, + public file?: string ) { - super(TokenKind.Number, whole.input, whole.begin, decimal ? decimal.end : whole.end, whole.file) + super(TokenKind.Number, input, begin, end, file) + this.number = Number(this.getText()) } } diff --git a/src/tokens/property-access-token.spec.ts b/src/tokens/property-access-token.spec.ts deleted file mode 100644 index 23da4ae569..0000000000 --- a/src/tokens/property-access-token.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { QuotedToken, PropertyAccessToken, IdentifierToken } from '.' - -describe('PropertyAccessToken', function () { - describe('#propertyName', function () { - it('should return correct value for IdentifierToken', function () { - const token = new PropertyAccessToken(new IdentifierToken('foo', 0, 3), [], 3) - expect(token.propertyName).toBe('foo') - }) - it('should return correct value for QuotedToken', function () { - const token = new PropertyAccessToken(new QuotedToken('"foo bar"', 0, 9), [], 9) - expect(token.propertyName).toBe('foo bar') - }) - }) -}) diff --git a/src/tokens/property-access-token.ts b/src/tokens/property-access-token.ts index c2ee6f003f..94af822719 100644 --- a/src/tokens/property-access-token.ts +++ b/src/tokens/property-access-token.ts @@ -1,18 +1,19 @@ import { Token } from './token' -import { IdentifierToken } from './identifier-token' +import { LiteralToken } from './literal-token' +import { ValueToken } from './value-token' +import { RangeToken } from './range-token' import { QuotedToken } from './quoted-token' -import { TokenKind, parseStringLiteral } from '../parser' +import { TokenKind } from '../parser' export class PropertyAccessToken extends Token { - public propertyName: string constructor ( - public variable: IdentifierToken | QuotedToken, - public props: (IdentifierToken | QuotedToken | PropertyAccessToken)[], - end: number + public variable: QuotedToken | RangeToken | LiteralToken | undefined, + public props: ValueToken[], + input: string, + begin: number, + end: number, + file?: string ) { - super(TokenKind.PropertyAccess, variable.input, variable.begin, end, variable.file) - this.propertyName = this.variable instanceof IdentifierToken - ? this.variable.getText() - : parseStringLiteral(this.variable.getText()) + super(TokenKind.PropertyAccess, input, begin, end, file) } } diff --git a/src/util/operator-trie.ts b/src/util/operator-trie.ts index 94bd7b80b3..7ca32bdd9e 100644 --- a/src/util/operator-trie.ts +++ b/src/util/operator-trie.ts @@ -1,22 +1,25 @@ -import { Operators, OperatorHandler } from '../render/operator' import { IDENTIFIER, TYPES } from '../util/character' -interface TrieLeafNode { - handler: OperatorHandler; +interface TrieInput { + [key: string]: T +} + +interface TrieLeafNode { + data: T; end: true; needBoundary?: true; } -export interface Trie { - [key: string]: Trie | TrieLeafNode; +export interface Trie { + [key: string]: Trie | TrieLeafNode; } -export type TrieNode = Trie | TrieLeafNode +export type TrieNode = Trie | TrieLeafNode -export function createTrie (operators: Operators): Trie { - const trie: Trie = {} - for (const [name, handler] of Object.entries(operators)) { - let node: Trie | TrieLeafNode = trie +export function createTrie (input: TrieInput): Trie { + const trie: Trie = {} + for (const [name, data] of Object.entries(input)) { + let node: Trie | TrieLeafNode = trie for (let i = 0; i < name.length; i++) { const c = name[i] @@ -29,7 +32,7 @@ export function createTrie (operators: Operators): Trie { node = node[c] } - node.handler = handler + node.data = data node.end = true } return trie diff --git a/test/e2e/issues.spec.ts b/test/e2e/issues.spec.ts index 24895a5f61..eee46ea3fb 100644 --- a/test/e2e/issues.spec.ts +++ b/test/e2e/issues.spec.ts @@ -454,4 +454,14 @@ describe('Issues', function () { const result = engine.parseAndRenderSync(template, { product }) expect(result).toEqual('This is a love potion!') }) + it('#643 Error When Accessing Subproperty of Bracketed Reference', () => { + const engine = new Liquid() + const tpl = '{{ ["Key String with Spaces"].subpropertyKey }}' + const ctx = { + 'Key String with Spaces': { + subpropertyKey: 'FOO' + } + } + expect(engine.parseAndRenderSync(tpl, ctx)).toEqual('FOO') + }) })