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 './tokenizer'
export * from './parser' export * from './parser'
export * from './parse-stream' export * from './parse-stream'
export * from './parse-string-literal'
export * from './token-kind' 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) return new HTML(token)
} catch (e) { } catch (e) {
if (e instanceof LiquidError) throw e if (LiquidError.is(e)) throw e
throw new ParseError(e as Error, token) 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 { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken } from '../tokens'
import { Tokenizer } from './tokenizer' import { Tokenizer } from './tokenizer'
import { defaultOperators } from '../render/operator'
import { createTrie } from '../util/operator-trie'
describe('Tokenizer', function () { describe('Tokenizer', function () {
it('should read quoted', () => { it('should read quoted', () => {
@@ -15,12 +17,31 @@ describe('Tokenizer', function () {
// eslint-disable-next-line deprecation/deprecation // eslint-disable-next-line deprecation/deprecation
expect(new Tokenizer('foo bar').readWord()).toHaveProperty('content', 'foo') expect(new Tokenizer('foo bar').readWord()).toHaveProperty('content', 'foo')
}) })
it('should read number value', () => { it('should read integer number', () => {
const token: NumberToken = new Tokenizer('2.33.2').readValueOrThrow() as any const token: NumberToken = new Tokenizer('123').readValueOrThrow() as any
expect(token).toBeInstanceOf(NumberToken) expect(token).toBeInstanceOf(NumberToken)
expect(token.whole.getText()).toBe('2') expect(token.getText()).toBe('123')
expect(token.decimal!.getText()).toBe('33') expect(token.content).toBe(123)
expect(token.getText()).toBe('2.33') })
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', () => { it('should read quoted value', () => {
const value = new Tokenizer('"foo"a').readValue() const value = new Tokenizer('"foo"a').readValue()
@@ -33,11 +54,7 @@ describe('Tokenizer', function () {
it('should read quoted property access value', () => { it('should read quoted property access value', () => {
const value = new Tokenizer('["a prop"]').readValue() const value = new Tokenizer('["a prop"]').readValue()
expect(value).toBeInstanceOf(PropertyAccessToken) expect(value).toBeInstanceOf(PropertyAccessToken)
expect((value as PropertyAccessToken).variable.getText()).toBe('"a prop"') expect((value as QuotedToken).getText()).toBe('["a prop"]')
})
it('should throw for broken quoted property access', () => {
const tokenizer = new Tokenizer('[5]')
expect(() => tokenizer.readValueOrThrow()).toThrow()
}) })
it('should throw for incomplete quoted property access', () => { it('should throw for incomplete quoted property access', () => {
const tokenizer = new Tokenizer('["a prop"') const tokenizer = new Tokenizer('["a prop"')
@@ -277,10 +294,10 @@ describe('Tokenizer', function () {
const pa: PropertyAccessToken = token!.args[0] as any const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken) expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken)
expect((pa.variable as any).content).toBe('arr') expect(pa.props).toHaveLength(2)
expect(pa.props).toHaveLength(1) expect((pa.props[0] as any).content).toBe('arr')
expect(pa.props[0]).toBeInstanceOf(NumberToken) expect(pa.props[1]).toBeInstanceOf(NumberToken)
expect(pa.props[0].getText()).toBe('0') expect(pa.props[1].getText()).toBe('0')
}) })
it('should read a filter with obj.foo argument', function () { it('should read a filter with obj.foo argument', function () {
const tokenizer = new Tokenizer('| plus: obj.foo') const tokenizer = new Tokenizer('| plus: obj.foo')
@@ -290,10 +307,10 @@ describe('Tokenizer', function () {
const pa: PropertyAccessToken = token!.args[0] as any const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken) expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken)
expect((pa.variable as any).content).toBe('obj') expect(pa.props).toHaveLength(2)
expect(pa.props).toHaveLength(1) expect((pa.props[0] as any).content).toBe('obj')
expect(pa.props[0]).toBeInstanceOf(IdentifierToken) expect(pa.props[1]).toBeInstanceOf(IdentifierToken)
expect(pa.props[0].getText()).toBe('foo') expect(pa.props[1].getText()).toBe('foo')
}) })
it('should read a filter with obj["foo"] argument', function () { it('should read a filter with obj["foo"] argument', function () {
const tokenizer = new Tokenizer('| plus: obj["good luck"]') const tokenizer = new Tokenizer('| plus: obj["good luck"]')
@@ -304,8 +321,8 @@ describe('Tokenizer', function () {
const pa: PropertyAccessToken = token!.args[0] as any const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken) expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken)
expect(pa.getText()).toBe('obj["good luck"]') expect(pa.getText()).toBe('obj["good luck"]')
expect((pa.variable as any).content).toBe('obj') expect((pa.props[0] as any).content).toBe('obj')
expect(pa.props[0].getText()).toBe('"good luck"') expect(pa.props[1].getText()).toBe('"good luck"')
}) })
}) })
describe('#readFilters()', () => { describe('#readFilters()', () => {
@@ -341,7 +358,7 @@ describe('Tokenizer', function () {
expect(tokens[2].args).toHaveLength(1) expect(tokens[2].args).toHaveLength(1)
expect(tokens[2].args[0]).toBeInstanceOf(PropertyAccessToken) 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).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()', () => { describe('#readExpression()', () => {
@@ -358,10 +375,10 @@ describe('Tokenizer', function () {
expect(exp).toHaveLength(1) expect(exp).toHaveLength(1)
const pa = exp[0] as PropertyAccessToken const pa = exp[0] as PropertyAccessToken
expect(pa).toBeInstanceOf(PropertyAccessToken) expect(pa).toBeInstanceOf(PropertyAccessToken)
expect((pa.variable as any).content).toEqual('a') expect(pa.props).toHaveLength(3)
expect(pa.props).toHaveLength(2) 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).toBeInstanceOf(IdentifierToken)
expect(p1.getText()).toBe('') expect(p1.getText()).toBe('')
expect(p2).toBeInstanceOf(PropertyAccessToken) expect(p2).toBeInstanceOf(PropertyAccessToken)
@@ -373,8 +390,8 @@ describe('Tokenizer', function () {
expect(exp).toHaveLength(1) expect(exp).toHaveLength(1)
const pa = exp[0] as PropertyAccessToken const pa = exp[0] as PropertyAccessToken
expect(pa).toBeInstanceOf(PropertyAccessToken) expect(pa).toBeInstanceOf(PropertyAccessToken)
expect((pa.variable as any).content).toEqual('a') expect(pa.props).toHaveLength(1)
expect(pa.props).toHaveLength(0) expect((pa.props[0] as any).content).toEqual('a')
}) })
it('should read expression `a ==`', () => { it('should read expression `a ==`', () => {
const exp = [...new Tokenizer('a ==').readExpressionTokens()] const exp = [...new Tokenizer('a ==').readExpressionTokens()]
@@ -481,6 +498,30 @@ describe('Tokenizer', function () {
expect(rhs.getText()).toEqual('"\\""') 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', () => { describe('#readLiquidTagTokens', () => {
it('should read newline terminated tokens', () => { it('should read newline terminated tokens', () => {
const tokenizer = new Tokenizer('echo \'hello\'') 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 { 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 { Operators, Expression } from '../render'
import { NormalizedFullOptions, defaultOptions } from '../liquid-options' import { NormalizedFullOptions, defaultOptions } from '../liquid-options'
import { FilterArg } from './filter-arg' import { FilterArg } from './filter-arg'
import { matchOperator } from './match-operator'
import { whiteSpaceCtrl } from './whitespace-ctrl' import { whiteSpaceCtrl } from './whitespace-ctrl'
export class Tokenizer { export class Tokenizer {
p: number p: number
N: number N: number
private rawBeginAt = -1 private rawBeginAt = -1
private opTrie: Trie private opTrie: Trie<OperatorHandler>
private literalTrie: Trie<LiteralValue>
constructor ( constructor (
public input: string, public input: string,
@@ -21,6 +22,7 @@ export class Tokenizer {
this.p = range ? range[0] : 0 this.p = range ? range[0] : 0
this.N = range ? range[1] : input.length this.N = range ? range[1] : input.length
this.opTrie = createTrie(operators) this.opTrie = createTrie(operators)
this.literalTrie = createTrie(literalValues)
} }
readExpression () { readExpression () {
@@ -44,10 +46,22 @@ export class Tokenizer {
} }
readOperator (): OperatorToken | undefined { readOperator (): OperatorToken | undefined {
this.skipBlank() this.skipBlank()
const end = matchOperator(this.input, this.p, this.opTrie) const end = this.matchTrie(this.opTrie)
if (end === -1) return if (end === -1) return
return new OperatorToken(this.input, this.p, (this.p = end), this.file) 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 { readFilteredValue (): FilteredValueToken {
const begin = this.p const begin = this.p
const initial = this.readExpression() const initial = this.readExpression()
@@ -272,8 +286,8 @@ export class Tokenizer {
return this.input.slice(this.p, this.N) return this.input.slice(this.p, this.N)
} }
advance (i = 1) { advance (step = 1) {
this.p += i this.p += step
} }
end () { end () {
@@ -289,43 +303,68 @@ export class Tokenizer {
} }
readValue (): ValueToken | undefined { readValue (): ValueToken | undefined {
const value = this.readQuoted() || this.readRange() this.skipBlank()
if (value) return value const begin = this.p
const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber()
if (this.peek() === '[') { const props: (ValueToken | IdentifierToken)[] = []
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)[] = []
while (true) { while (true) {
if (this.peek() === '[') { if (this.peek() === '[') {
isNumber = false
this.p++ this.p++
const prop = this.readValue() || new IdentifierToken(this.input, this.p, this.p, this.file) 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) 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++ this.p++
const prop = this.readIdentifier() const prop = this.readIdentifier()
if (!prop.size()) break if (!prop.size()) break
if (!prop.isNumber()) isNumber = false
props.push(prop) 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 } else break
} }
if (!props.length && literalValues.hasOwnProperty(variable.content)) { if (digitFound && !(this.peekType(n) & IDENTIFIER)) {
return new LiteralToken(this.input, variable.begin, variable.end, this.file) 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 { readRange (): RangeToken | undefined {
@@ -388,7 +427,7 @@ export class Tokenizer {
} }
peekType (n = 0) { 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 { peek (n = 0): string {
+27 -1
View File
@@ -1,7 +1,9 @@
import { Tokenizer } from '../parser' import { Tokenizer } from '../parser'
import { Drop } from '../drop' import { Drop } from '../drop'
import { QuotedToken } from '../tokens'
import { Context } from '../context' import { Context } from '../context'
import { toPromise, toValueSync } from '../util' import { toPromise, toValueSync } from '../util'
import { evalQuotedToken } from './expression'
describe('Expression', function () { describe('Expression', function () {
const ctx = new Context({}) 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('"foo"').evaluate(ctx, false))).toBe('foo')
expect(await toPromise(create('false').evaluate(ctx, false))).toBe(false) 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 () { it('should eval property access', async function () {
const ctx = new Context({ const ctx = new Context({
foo: { bar: 'BAR' }, foo: { bar: 'BAR' },
@@ -162,6 +166,10 @@ describe('Expression', function () {
const ctx = new Context({ obj: { foo: 'FOO' }, keys: { "what's this": 'foo' } }) 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') 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 () { it('should support not', async function () {
expect(await toPromise(create('not 1 < 2').evaluate(ctx))).toBe(false) 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 or not 1 > 2').evaluate(ctx))).toBe(true)
expect(await toPromise(create('not 1 < 2 and not 1 > 2').evaluate(ctx))).toBe(false) 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 () { describe('sync', function () {
+11 -18
View File
@@ -1,6 +1,5 @@
import { RangeToken, OperatorToken, Token, LiteralToken, NumberToken, PropertyAccessToken, QuotedToken, OperatorType, operatorTypes } from '../tokens' import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes } from '../tokens'
import { isQuotedToken, isWordToken, isNumberToken, isLiteralToken, isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, literalValues, assert } from '../util' import { isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import { parseStringLiteral } from '../parser'
import type { Context } from '../context' import type { Context } from '../context'
import type { UnaryOperatorHandler } from '../render' import type { UnaryOperatorHandler } from '../render'
@@ -36,38 +35,32 @@ export class Expression {
} }
export function * evalToken (token: Token | undefined, ctx: Context, lenient = false): IterableIterator<unknown> { 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 (isPropertyAccessToken(token)) return yield evalPropertyAccessToken(token, ctx, lenient)
if (isRangeToken(token)) return yield evalRangeToken(token, ctx) 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> { function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
const props: string[] = [] const props: string[] = []
const variable = yield evalToken(token.variable, ctx, lenient)
for (const prop of token.props) { for (const prop of token.props) {
props.push((yield evalToken(prop, ctx, false)) as unknown as string) props.push((yield evalToken(prop, ctx, false)) as unknown as string)
} }
try { 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) { } catch (e) {
if (lenient && (e as Error).name === 'InternalUndefinedVariableError') return null if (lenient && (e as Error).name === 'InternalUndefinedVariableError') return null
throw (new UndefinedVariableError(e as Error, token)) 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) { export function evalQuotedToken (token: QuotedToken) {
return parseStringLiteral(token.getText()) return token.content
}
function evalLiteralToken (token: LiteralToken) {
return literalValues[token.literal]
} }
function * evalRangeToken (token: RangeToken, ctx: Context) { function * evalRangeToken (token: RangeToken, ctx: Context) {
@@ -1,4 +1,4 @@
import { parseStringLiteral } from './parse-string-literal' import { parseStringLiteral } from './string'
describe('parseStringLiteral()', function () { describe('parseStringLiteral()', function () {
it('should parse octal escape', () => { it('should parse octal escape', () => {
+6 -6
View File
@@ -1,6 +1,6 @@
import { Context } from '../context' import { Context } from '../context'
import { toPromise } from '../util' import { toPromise } from '../util'
import { IdentifierToken, NumberToken, QuotedToken } from '../tokens' import { NumberToken, QuotedToken } from '../tokens'
import { Filter } from './filter' import { Filter } from './filter'
describe('filter', function () { describe('filter', function () {
@@ -13,7 +13,7 @@ describe('filter', function () {
it('should call filter impl with correct arguments', async function () { it('should call filter impl with correct arguments', async function () {
const spy = jest.fn() 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) const filter = new Filter('foo', spy, [thirty], liquid)
await toPromise(filter.render('foo', ctx)) await toPromise(filter.render('foo', ctx))
expect(spy).toHaveBeenCalledWith('foo', 30) expect(spy).toHaveBeenCalledWith('foo', 30)
@@ -23,7 +23,7 @@ describe('filter', function () {
const val = yield this.context._get([valStr]) const val = yield this.context._get([valStr])
return `${this.liquid.testVersion}: ${val + diff}` 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 filter = new Filter('add', spy, [ten], liquid)
const val = await toPromise(filter.render('thirty', ctx)) const val = await toPromise(filter.render('thirty', ctx))
expect(val).toEqual('1.0: 40') expect(val).toEqual('1.0: 40')
@@ -33,12 +33,12 @@ describe('filter', function () {
}) })
it('should render filters with argument', async 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) 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 () { 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) 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') 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 () { 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') 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 { Token } from './token'
import { TokenKind } from '../parser' import { TokenKind } from '../parser'
import { literalValues, LiteralValue } from '../util'
export class LiteralToken extends Token { export class LiteralToken extends Token {
public content: LiteralValue
public literal: string public literal: string
public constructor ( public constructor (
public input: string, public input: string,
@@ -11,5 +13,6 @@ export class LiteralToken extends Token {
) { ) {
super(TokenKind.Literal, input, begin, end, file) super(TokenKind.Literal, input, begin, end, file)
this.literal = this.getText() this.literal = this.getText()
this.content = literalValues[this.literal]
} }
} }
+7 -4
View File
@@ -1,12 +1,15 @@
import { Token } from './token' import { Token } from './token'
import { IdentifierToken } from './identifier-token'
import { TokenKind } from '../parser' import { TokenKind } from '../parser'
export class NumberToken extends Token { export class NumberToken extends Token {
public content: number
constructor ( constructor (
public whole: IdentifierToken, public input: string,
public decimal?: IdentifierToken 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 { Token } from './token'
import { LiteralToken } from './literal-token'
import { ValueToken } from './value-token'
import { IdentifierToken } from './identifier-token' import { IdentifierToken } from './identifier-token'
import { NumberToken } from './number-token'
import { RangeToken } from './range-token'
import { QuotedToken } from './quoted-token' import { QuotedToken } from './quoted-token'
import { TokenKind, parseStringLiteral } from '../parser' import { TokenKind } from '../parser'
export class PropertyAccessToken extends Token { export class PropertyAccessToken extends Token {
public propertyName: string
constructor ( constructor (
public variable: IdentifierToken | QuotedToken, public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | undefined,
public props: (IdentifierToken | QuotedToken | PropertyAccessToken)[], public props: (ValueToken | IdentifierToken)[],
end: number input: string,
begin: number,
end: number,
file?: string
) { ) {
super(TokenKind.PropertyAccess, variable.input, variable.begin, end, variable.file) super(TokenKind.PropertyAccess, input, begin, end, file)
this.propertyName = this.variable instanceof IdentifierToken
? this.variable.getText()
: parseStringLiteral(this.variable.getText())
} }
} }
+3
View File
@@ -1,7 +1,9 @@
import { Token } from './token' import { Token } from './token'
import { TokenKind } from '../parser' import { TokenKind } from '../parser'
import { parseStringLiteral } from '../render/string'
export class QuotedToken extends Token { export class QuotedToken extends Token {
public readonly content: string
constructor ( constructor (
public input: string, public input: string,
public begin: number, public begin: number,
@@ -9,5 +11,6 @@ export class QuotedToken extends Token {
public file?: string public file?: string
) { ) {
super(TokenKind.Quoted, input, begin, end, file) 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 { RangeToken } from './range-token'
import { LiteralToken } from './literal-token' import { LiteralToken } from './literal-token'
import { NumberToken } from './number-token'
import { QuotedToken } from './quoted-token' import { QuotedToken } from './quoted-token'
import { PropertyAccessToken } from './property-access-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 { Token } from '../tokens/token'
import { Template } from '../template/template' 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 { export abstract class LiquidError extends Error {
private token!: Token private token!: Token
public context = '' public context = ''
@@ -14,6 +19,7 @@ export abstract class LiquidError extends Error {
super(typeof err === 'string' ? err : err.message) super(typeof err === 'string' ? err : err.message)
if (typeof err !== 'string') Object.defineProperty(this, 'originalError', { value: err, enumerable: false }) if (typeof err !== 'string') Object.defineProperty(this, 'originalError', { value: err, enumerable: false })
Object.defineProperty(this, 'token', { value: token, enumerable: false }) Object.defineProperty(this, 'token', { value: token, enumerable: false })
Object.defineProperty(this, TRAIT, { value: 'LiquidError', enumerable: false })
} }
protected update () { protected update () {
Object.defineProperty(this, 'context', { value: mkContext(this.token), enumerable: false }) Object.defineProperty(this, 'context', { value: mkContext(this.token), enumerable: false })
@@ -22,6 +28,9 @@ export abstract class LiquidError extends Error {
'\n' + this.stack '\n' + this.stack
if (this.originalError) this.stack += '\nFrom ' + this.originalError.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 { export class TokenizationError extends LiquidError {
+3
View File
@@ -9,3 +9,6 @@ export const literalValues = {
'empty': new EmptyDrop(), 'empty': new EmptyDrop(),
'blank': new BlankDrop() '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' import { IDENTIFIER, TYPES } from '../util/character'
interface TrieLeafNode { interface TrieInput<T> {
handler: OperatorHandler; [key: string]: T
}
interface TrieLeafNode<T> {
data: T;
end: true; end: true;
needBoundary?: true; needBoundary?: true;
} }
export interface Trie { export interface Trie<T> {
[key: string]: Trie | TrieLeafNode; [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 { export function createTrie<T = any> (input: TrieInput<T>): Trie<T> {
const trie: Trie = {} const trie: Trie<T> = {}
for (const [name, handler] of Object.entries(operators)) { for (const [name, data] of Object.entries(input)) {
let node: Trie | TrieLeafNode = trie let node: Trie<T> | TrieLeafNode<T> = trie
for (let i = 0; i < name.length; i++) { for (let i = 0; i < name.length; i++) {
const c = name[i] const c = name[i]
@@ -29,7 +32,7 @@ export function createTrie (operators: Operators): Trie {
node = node[c] node = node[c]
} }
node.handler = handler node.data = data
node.end = true node.end = true
} }
return trie return trie
+14
View File
@@ -7,4 +7,18 @@ describe('browser', function () {
message: 'output "{{huh" not closed, line:1, col:1' 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 }) const result = engine.parseAndRenderSync(template, { product })
expect(result).toEqual('This is a love potion!') 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')
})
}) })