feat: more flexible squared property read expression, fixes #643 (#646)

* fix: more flexible squared property read expression, fixes #643

* fix: unecessary error wrapping in browser bundles

* style: update code style and types

* perf: use token.value when evalToken
This commit is contained in:
Jun Yang
2023-08-23 00:45:49 +08:00
committed by GitHub
parent dc6a301387
commit 660d9be55f
22 changed files with 261 additions and 167 deletions
-1
View File
@@ -1,5 +1,4 @@
export * from './tokenizer'
export * from './parser'
export * from './parse-stream'
export * from './parse-string-literal'
export * from './token-kind'
-28
View File
@@ -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)
})
})
-14
View File
@@ -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
}
+1 -1
View File
@@ -48,7 +48,7 @@ export class Parser {
}
return new HTML(token)
} catch (e) {
if (e instanceof LiquidError) throw e
if (LiquidError.is(e)) throw e
throw new ParseError(e as Error, token)
}
}
+67 -26
View File
@@ -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.content).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.content).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.content).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\'')
+71 -32
View File
@@ -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, LiteralValue, 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<OperatorHandler>
private literalTrie: Trie<LiteralValue>
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<T> (trie: Trie<T>) {
let node: TrieNode<T> = 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)
const props: (QuotedToken | IdentifierToken)[] = []
this.skipBlank()
const begin = this.p
const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber()
const props: (ValueToken | 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 {
+27 -1
View File
@@ -1,7 +1,9 @@
import { Tokenizer } from '../parser'
import { Drop } from '../drop'
import { QuotedToken } from '../tokens'
import { Context } from '../context'
import { toPromise, toValueSync } from '../util'
import { evalQuotedToken } from './expression'
describe('Expression', function () {
const ctx = new Context({})
@@ -32,7 +34,9 @@ describe('Expression', function () {
expect(await toPromise(create('"foo"').evaluate(ctx, false))).toBe('foo')
expect(await toPromise(create('false').evaluate(ctx, false))).toBe(false)
})
it('should support evalQuotedToken()', async function () {
expect(evalQuotedToken(new QuotedToken('"foo"', 0, 5))).toBe('foo')
})
it('should eval property access', async function () {
const ctx = new Context({
foo: { bar: 'BAR' },
@@ -162,6 +166,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 +177,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 () {
+11 -18
View File
@@ -1,6 +1,5 @@
import { RangeToken, OperatorToken, Token, LiteralToken, NumberToken, PropertyAccessToken, QuotedToken, OperatorType, operatorTypes } from '../tokens'
import { isQuotedToken, isWordToken, isNumberToken, isLiteralToken, isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, literalValues, assert } from '../util'
import { parseStringLiteral } from '../parser'
import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes } from '../tokens'
import { isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import type { Context } from '../context'
import type { UnaryOperatorHandler } from '../render'
@@ -36,38 +35,32 @@ export class Expression {
}
export function * evalToken (token: Token | undefined, ctx: Context, lenient = false): IterableIterator<unknown> {
if (!token) return
if ('content' in token) return token.content
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 (isQuotedToken(token)) return evalQuotedToken(token)
}
function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
const props: string[] = []
const variable = yield evalToken(token.variable, ctx, lenient)
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())
}
function evalLiteralToken (token: LiteralToken) {
return literalValues[token.literal]
return token.content
}
function * evalRangeToken (token: RangeToken, ctx: Context) {
@@ -1,4 +1,4 @@
import { parseStringLiteral } from './parse-string-literal'
import { parseStringLiteral } from './string'
describe('parseStringLiteral()', function () {
it('should parse octal escape', () => {
+6 -6
View File
@@ -1,6 +1,6 @@
import { Context } from '../context'
import { toPromise } from '../util'
import { IdentifierToken, NumberToken, QuotedToken } from '../tokens'
import { NumberToken, QuotedToken } from '../tokens'
import { Filter } from './filter'
describe('filter', function () {
@@ -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')
})
})
+3
View File
@@ -1,7 +1,9 @@
import { Token } from './token'
import { TokenKind } from '../parser'
import { literalValues, LiteralValue } from '../util'
export class LiteralToken extends Token {
public content: LiteralValue
public literal: string
public constructor (
public input: string,
@@ -11,5 +13,6 @@ export class LiteralToken extends Token {
) {
super(TokenKind.Literal, input, begin, end, file)
this.literal = this.getText()
this.content = literalValues[this.literal]
}
}
+7 -4
View File
@@ -1,12 +1,15 @@
import { Token } from './token'
import { IdentifierToken } from './identifier-token'
import { TokenKind } from '../parser'
export class NumberToken extends Token {
public content: 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.content = Number(this.getText())
}
}
-14
View File
@@ -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')
})
})
})
+12 -9
View File
@@ -1,18 +1,21 @@
import { Token } from './token'
import { LiteralToken } from './literal-token'
import { ValueToken } from './value-token'
import { IdentifierToken } from './identifier-token'
import { NumberToken } from './number-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 | NumberToken | undefined,
public props: (ValueToken | IdentifierToken)[],
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)
}
}
+3
View File
@@ -1,7 +1,9 @@
import { Token } from './token'
import { TokenKind } from '../parser'
import { parseStringLiteral } from '../render/string'
export class QuotedToken extends Token {
public readonly content: string
constructor (
public input: string,
public begin: number,
@@ -9,5 +11,6 @@ export class QuotedToken extends Token {
public file?: string
) {
super(TokenKind.Quoted, input, begin, end, file)
this.content = parseStringLiteral(this.getText())
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { RangeToken } from './range-token'
import { LiteralToken } from './literal-token'
import { NumberToken } from './number-token'
import { QuotedToken } from './quoted-token'
import { PropertyAccessToken } from './property-access-token'
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken
+9
View File
@@ -2,6 +2,11 @@ import * as _ from './underscore'
import { Token } from '../tokens/token'
import { Template } from '../template/template'
/**
* targeting ES5, extends Error won't create a proper prototype chain, need a trait to kee track of classes
*/
const TRAIT = '__liquidClass__'
export abstract class LiquidError extends Error {
private token!: Token
public context = ''
@@ -14,6 +19,7 @@ export abstract class LiquidError extends Error {
super(typeof err === 'string' ? err : err.message)
if (typeof err !== 'string') Object.defineProperty(this, 'originalError', { value: err, enumerable: false })
Object.defineProperty(this, 'token', { value: token, enumerable: false })
Object.defineProperty(this, TRAIT, { value: 'LiquidError', enumerable: false })
}
protected update () {
Object.defineProperty(this, 'context', { value: mkContext(this.token), enumerable: false })
@@ -22,6 +28,9 @@ export abstract class LiquidError extends Error {
'\n' + this.stack
if (this.originalError) this.stack += '\nFrom ' + this.originalError.stack
}
static is (obj: unknown): obj is LiquidError {
return obj?.[TRAIT] === 'LiquidError'
}
}
export class TokenizationError extends LiquidError {
+3
View File
@@ -9,3 +9,6 @@ export const literalValues = {
'empty': new EmptyDrop(),
'blank': new BlankDrop()
}
export type LiteralKey = keyof typeof literalValues
export type LiteralValue = typeof literalValues[LiteralKey]
+14 -11
View File
@@ -1,22 +1,25 @@
import { Operators, OperatorHandler } from '../render/operator'
import { IDENTIFIER, TYPES } from '../util/character'
interface TrieLeafNode {
handler: OperatorHandler;
interface TrieInput<T> {
[key: string]: T
}
interface TrieLeafNode<T> {
data: T;
end: true;
needBoundary?: true;
}
export interface Trie {
[key: string]: Trie | TrieLeafNode;
export interface Trie<T> {
[key: string]: Trie<T> | TrieLeafNode<T>;
}
export type TrieNode = Trie | TrieLeafNode
export type TrieNode<T> = Trie<T> | TrieLeafNode<T>
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<T = any> (input: TrieInput<T>): Trie<T> {
const trie: Trie<T> = {}
for (const [name, data] of Object.entries(input)) {
let node: Trie<T> | TrieLeafNode<T> = 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
+14
View File
@@ -7,4 +7,18 @@ describe('browser', function () {
message: 'output "{{huh" not closed, line:1, col:1'
})
})
it('should throw tokenization error for invalid filter syntax', async () => {
const engine = new LiquidUMD()
const message = 'expected filter name, line:1, col:10'
const stack = [
'>> 1| {{ foo | ^ }}',
' ^',
`TokenizationError: ${message}`
].join('\n')
await expect(engine.parseAndRender('{{ foo | ^ }}')).rejects.toMatchObject({
message,
stack: expect.stringContaining(stack),
name: 'TokenizationError'
})
})
})
+10
View File
@@ -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')
})
})