perf: introduce AST to avoid reparse

This commit is contained in:
harttle
2020-03-15 02:51:25 +08:00
committed by Jun Yang
parent 3b58f1c3f6
commit d2d6a38235
96 changed files with 1553 additions and 1168 deletions
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
function isQuote (c) {
return c === '"' || c === "'"
}
function isOperator (c) {
return '!=<>'.includes(c)
}
function isNumber (c) {
return c >= '0' && c <= '9'
}
function isCharacter (c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
function isVariable (c) {
return '_-?'.includes(c) || isCharacter(c) || isNumber(c)
}
function isBlank (c) {
return c === '\n' || c === '\t' || c === ' ' || c === '\r'
}
const types = []
for (let i = 0; i < 128; i++) {
const c = String.fromCharCode(i)
let n = 0
if (isVariable(c)) n |= 1
if (isOperator(c)) n |= 2
if (isBlank(c)) n |= 4
if (isQuote(c)) n |= 8
types.push(n)
}
console.log(`
const TYPES = '${types.join('')}'
const VARIABLE = 1
const OPERATOR = 2
const BLANK = 4
const QUOTE = 8
`.trim())
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
const isQuote = c => c === '"' || c === "'"
const isOperator = c => '!=<>'.includes(c)
const isNumber = c => c >= '0' && c <= '9'
const isCharacter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
const isVariable = c => '_-?'.includes(c) || isCharacter(c) || isNumber(c)
const isBlank = c => c === '\n' || c === '\t' || c === ' ' || c === '\r'
const isInlineBlank = c => c === '\t' || c === ' ' || c === '\r'
const isSign = c => c === '-' || c === '+'
const types = []
for (let i = 0; i < 128; i++) {
const c = String.fromCharCode(i)
let n = 0
if (isVariable(c)) n |= 1
if (isOperator(c)) n |= 2
if (isBlank(c)) n |= 4
if (isQuote(c)) n |= 8
if (isInlineBlank(c)) n |= 16
if (isNumber(c)) n |= 32
if (isSign(c)) n |= 64
types.push(n)
}
console.log(`
// bitmask character types to boost performance
// generated by bin/character-gen.js
export const TYPES = [${types.join(', ')}]
export const VARIABLE = 1
export const OPERATOR = 2
export const BLANK = 4
export const QUOTE = 8
export const INLINE_BLANK = 16
export const NUMBER = 32
export const SIGN = 64
`.trim())
+1 -1
View File
@@ -31,7 +31,7 @@ function slice<T> (v: T[], begin: number, length = 1): T[] {
function where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): T[] {
return arr.filter(obj => {
const value = this.context.getFromScope(obj, property)
const value = this.context.getFromScope(obj, property.split('.'))
return expected === undefined ? isTruthy(value) : value === expected
})
}
+7 -9
View File
@@ -1,15 +1,13 @@
import { assert } from '../../util/assert'
import { identifier } from '../../parser/lexical'
import { TagImplOptions, TagToken, Context } from '../../types'
const re = new RegExp(`(${identifier.source})\\s*=([^]*)`)
import { Tokenizer, assert, TagImplOptions, TagToken, Context } from '../../types'
export default {
parse: function (token: TagToken) {
const match = token.args.match(re) as RegExpMatchArray
assert(match, `illegal token ${token.raw}`)
this.key = match[1]
this.value = match[2]
const tokenizer = new Tokenizer(token.args)
this.key = tokenizer.readWord().content
tokenizer.skipBlank()
assert(tokenizer.peek() === '=', () => `illegal token ${token.getText()}`)
tokenizer.advance()
this.value = tokenizer.remaining()
},
render: function * (ctx: Context) {
ctx.bottom()[this.key] = yield this.liquid._evalValue(this.value, ctx)
+3 -3
View File
@@ -1,8 +1,8 @@
import BlockMode from '../../context/block-mode'
import { ParseStream, TagToken, Token, Template, Context, TagImplOptions, Emitter } from '../../types'
import { ParseStream, TagToken, TopLevelToken, Template, Context, TagImplOptions, Emitter } from '../../types'
export default {
parse: function (token: TagToken, remainTokens: Token[]) {
parse: function (token: TagToken, remainTokens: TopLevelToken[]) {
const match = /\w+/.exec(token.args)
this.block = match ? match[0] : ''
this.tpls = [] as Template[]
@@ -10,7 +10,7 @@ export default {
.on('tag:endblock', () => stream.stop())
.on('template', (tpl: Template) => this.tpls.push(tpl))
.on('end', () => {
throw new Error(`tag ${token.raw} not closed`)
throw new Error(`tag ${token.getText()} not closed`)
})
stream.start()
},
+6 -10
View File
@@ -1,22 +1,18 @@
import { assert } from '../../util/assert'
import { identifier } from '../../parser/lexical'
import { Template, Context, TagImplOptions, TagToken, Token } from '../../types'
const re = new RegExp(`(${identifier.source})`)
import { Tokenizer, assert, Template, Context, TagImplOptions, TagToken, TopLevelToken } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
const match = tagToken.args.match(re) as RegExpMatchArray
assert(match, `${tagToken.args} not valid identifier`)
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
const tokenizer = new Tokenizer(tagToken.args)
this.variable = tokenizer.readWord().content
assert(this.variable, () => `${tagToken.args} not valid identifier`)
this.variable = match[1]
this.templates = []
const stream = this.liquid.parser.parseStream(remainTokens)
stream.on('tag:endcapture', () => stream.stop())
.on('template', (tpl: Template) => this.templates.push(tpl))
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
},
+3 -3
View File
@@ -1,7 +1,7 @@
import { Expression, Emitter, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { Expression, Emitter, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
this.cond = tagToken.args
this.cases = []
this.elseTemplates = []
@@ -18,7 +18,7 @@ export default {
.on('tag:endcase', () => stream.stop())
.on('template', (tpl: Template) => p.push(tpl))
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
+4 -4
View File
@@ -1,16 +1,16 @@
import { TagToken } from '../../parser/tag-token'
import { Token } from '../../parser/token'
import { TagToken } from '../../tokens/tag-token'
import { TopLevelToken } from '../../tokens/toplevel-token'
import { TagImplOptions } from '../../template/tag/tag-impl-options'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
const stream = this.liquid.parser.parseStream(remainTokens)
stream
.on('token', (token: TagToken) => {
if (token.name === 'endcomment') stream.stop()
})
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
}
+20 -16
View File
@@ -1,28 +1,32 @@
import { assert } from '../../util/assert'
import { value as rValue } from '../../parser/lexical'
import { Emitter, Expression, TagToken, Context, TagImplOptions } from '../../types'
const groupRE = new RegExp(`^(?:(${rValue.source})\\s*:\\s*)?(.*)$`)
const candidatesRE = new RegExp(rValue.source, 'g')
import { evalToken, Emitter, TagToken, Context, TagImplOptions } from '../../types'
import { Tokenizer } from '../../parser/tokenizer'
export default {
parse: function (tagToken: TagToken) {
let match: RegExpExecArray | null = groupRE.exec(tagToken.args) as RegExpExecArray
assert(match, `illegal tag: ${tagToken.raw}`)
this.group = new Expression(match[1])
const candidates = match[2]
const tokenizer = new Tokenizer(tagToken.args)
const group = tokenizer.readValue()
tokenizer.skipBlank()
this.candidates = []
while ((match = candidatesRE.exec(candidates))) {
this.candidates.push(match[0])
if (group) {
if (tokenizer.peek() === ':') {
this.group = group
tokenizer.advance()
} else this.candidates.push(group)
}
assert(this.candidates.length, `empty candidates: ${tagToken.raw}`)
while (!tokenizer.end()) {
const value = tokenizer.readValue()
if (value) this.candidates.push(value)
tokenizer.readTo(',')
}
assert(this.candidates.length, () => `empty candidates: ${tagToken.getText()}`)
},
render: function * (ctx: Context, emitter: Emitter) {
const group = yield this.group.value(ctx)
render: function (ctx: Context, emitter: Emitter) {
const group = evalToken(this.group, ctx)
const fingerprint = `cycle:${group}:` + this.candidates.join(',')
const groups = ctx.getRegister('cycle')
let idx = groups[fingerprint]
@@ -34,7 +38,7 @@ export default {
const candidate = this.candidates[idx]
idx = (idx + 1) % this.candidates.length
groups[fingerprint] = idx
const html = yield new Expression(candidate).value(ctx)
const html = evalToken(candidate, ctx)
emitter.write(html)
}
} as TagImplOptions
+3 -6
View File
@@ -1,13 +1,10 @@
import { assert } from '../../util/assert'
import { identifier } from '../../parser/lexical'
import { Emitter, TagToken, Context, TagImplOptions } from '../../types'
import { Tokenizer, Emitter, TagToken, Context, TagImplOptions } from '../../types'
import { isNumber, stringify } from '../../util/underscore'
export default {
parse: function (token: TagToken) {
const match = token.args.match(identifier) as RegExpMatchArray
assert(match, `illegal identifier ${token.args}`)
this.variable = match[0]
const tokenizer = new Tokenizer(token.args)
this.variable = tokenizer.readWord().content
},
render: function (context: Context, emitter: Emitter) {
const scope = context.environments
+17 -15
View File
@@ -1,21 +1,24 @@
import { Emitter, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { assert, Tokenizer, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { toCollection } from '../../util/collection'
import { Expression } from '../../render/expression'
import { assert } from '../../util/assert'
import { identifier, value } from '../../parser/lexical'
import { ForloopDrop } from '../../drop/forloop-drop'
import { Hash } from '../../template/tag/hash'
const re = new RegExp(`^(${identifier.source})\\s+in\\s+(${value.source})`)
export default {
type: 'block',
parse: function (tagToken: TagToken, remainTokens: Token[]) {
const match = re.exec(tagToken.args) as RegExpExecArray
assert(match, `illegal tag: ${tagToken.raw}`)
this.variable = match[1]
this.collection = match[2]
this.hash = new Hash(tagToken.args.slice(match[0].length))
parse: function (token: TagToken, remainTokens: TopLevelToken[]) {
const toknenizer = new Tokenizer(token.args)
const variable = toknenizer.readWord()
const inStr = toknenizer.readWord()
const collection = toknenizer.readValue()
assert(
variable.size() && inStr.content === 'in' && collection,
() => `illegal tag: ${token.getText()}`
)
this.variable = variable.content
this.collection = collection
this.hash = new Hash(toknenizer.remaining())
this.templates = []
this.elseTemplates = []
@@ -26,15 +29,14 @@ export default {
.on('tag:endfor', () => stream.stop())
.on('template', (tpl: Template) => p.push(tpl))
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${token.getText()} not closed`)
})
stream.start()
},
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
let collection = yield new Expression(this.collection).value(ctx)
collection = toCollection(collection)
let collection = toCollection(evalToken(this.collection, ctx))
if (!collection.length) {
yield r.renderTemplates(this.elseTemplates, ctx, emitter)
+3 -3
View File
@@ -1,7 +1,7 @@
import { Emitter, isTruthy, Expression, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { Emitter, isTruthy, Expression, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
this.branches = []
this.elseTemplates = []
@@ -21,7 +21,7 @@ export default {
.on('tag:endif', () => stream.stop())
.on('template', (tpl: Template) => p.push(tpl))
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
+24 -18
View File
@@ -1,35 +1,41 @@
import { assert } from '../../util/assert'
import { Expression, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types'
import { quoted, value, quotedLine } from '../../parser/lexical'
import { assert, evalQuotedToken, TypeGuards, Tokenizer, evalToken, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types'
import BlockMode from '../../context/block-mode'
const rFile = new RegExp(`^(${quoted.source}|[^\\s,]+)(?:\\s+with\\s+(${value.source}))?`)
export default {
parse: function (token: TagToken) {
const match = rFile.exec(token.args)
if (!match) {
throw new Error(`illegal argument "${token.args}"`)
}
this.file = match[1]
this.hash = new Hash(token.args.slice(match[0].length))
this.withVar = match[2]
const args = token.args
const tokenizer = new Tokenizer(args)
this.file = this.liquid.options.dynamicPartials
? tokenizer.readValue()
: tokenizer.readFileName()
assert(this.file, () => `illegal argument "${token.args}"`)
const begin = tokenizer.p
const withStr = tokenizer.readWord()
if (withStr.content === 'with') {
tokenizer.skipBlank()
if (tokenizer.peek() !== ':') {
this.withVar = tokenizer.readValue()
} else tokenizer.p = begin
} else tokenizer.p = begin
this.hash = new Hash(tokenizer.remaining())
},
render: function * (ctx: Context, emitter: Emitter) {
const { liquid, hash, withVar, file } = this
const { renderer } = liquid
const filepath = ctx.opts.dynamicPartials
? (quotedLine.exec(file)
? yield renderer.renderTemplates(liquid.parse(file.slice(1, -1)), ctx)
: yield new Expression(file).value(ctx))
: file
assert(filepath, `illegal filename "${file}":"${filepath}"`)
? (TypeGuards.isQuotedToken(file)
? yield renderer.renderTemplates(liquid.parse(evalQuotedToken(file)), ctx)
: yield evalToken(file, ctx))
: file.getText()
assert(filepath, () => `illegal filename "${file}":"${filepath}"`)
const saved = ctx.saveRegister('blocks', 'blockMode')
ctx.setRegister('blocks', {})
ctx.setRegister('blockMode', BlockMode.OUTPUT)
const scope = yield hash.render(ctx)
if (withVar) scope[filepath] = yield new Expression(withVar).evaluate(ctx)
if (withVar) scope[filepath] = evalToken(withVar, ctx)
const templates = yield liquid._parseFile(filepath, ctx.opts, ctx.sync)
ctx.push(scope)
yield renderer.renderTemplates(templates, ctx, emitter)
+3 -6
View File
@@ -1,13 +1,10 @@
import { assert } from '../../util/assert'
import { identifier } from '../../parser/lexical'
import { isNumber, stringify } from '../../util/underscore'
import { Emitter, TagToken, Context, TagImplOptions } from '../../types'
import { Tokenizer, Emitter, TagToken, Context, TagImplOptions } from '../../types'
export default {
parse: function (token: TagToken) {
const match = token.args.match(identifier)
assert(match, `illegal identifier ${token.args}`)
this.variable = match![0]
const tokenizer = new Tokenizer(token.args)
this.variable = tokenizer.readWord().content
},
render: function (context: Context, emitter: Emitter) {
const scope = context.environments
+13 -17
View File
@@ -1,29 +1,25 @@
import { assert } from '../../util/assert'
import { quotedLine, quoted } from '../../parser/lexical'
import { Emitter, Hash, Expression, TagToken, Token, Context, TagImplOptions } from '../../types'
import { assert, evalQuotedToken, TypeGuards, evalToken, Tokenizer, Emitter, Hash, TagToken, TopLevelToken, Context, TagImplOptions } from '../../types'
import BlockMode from '../../context/block-mode'
const rFile = new RegExp(`^(${quoted.source}|[^\\s,]+)`)
export default {
parse: function (token: TagToken, remainTokens: Token[]) {
const match = rFile.exec(token.args)
if (!match) {
throw new Error(`illegal argument "${token.args}"`)
}
this.file = match[1]
this.hash = new Hash(token.args.slice(match[0].length))
parse: function (token: TagToken, remainTokens: TopLevelToken[]) {
const tokenizer = new Tokenizer(token.args)
const file = this.liquid.options.dynamicPartials ? tokenizer.readValue() : tokenizer.readFileName()
assert(file, () => `illegal argument "${token.args}"`)
this.file = file
this.hash = new Hash(tokenizer.remaining())
this.tpls = this.liquid.parser.parse(remainTokens)
},
render: function * (ctx: Context, emitter: Emitter) {
const { liquid, hash, file } = this
const { renderer } = liquid
const filepath = ctx.opts.dynamicPartials
? (quotedLine.exec(file)
? yield renderer.renderTemplates(liquid.parse(file.slice(1, -1)), ctx)
: yield new Expression(file).value(ctx))
: this.file
assert(filepath, `illegal filename "${file}":"${filepath}"`)
? (TypeGuards.isQuotedToken(file)
? yield renderer.renderTemplates(liquid.parse(evalQuotedToken(file)), ctx)
: evalToken(this.file, ctx))
: file.getText()
assert(filepath, () => `illegal filename "${file.getText()}":"${filepath}"`)
// render the remaining tokens immediately
ctx.setRegister('blockMode', BlockMode.STORE)
+4 -4
View File
@@ -1,7 +1,7 @@
import { TagToken, Token, TagImplOptions } from '../../types'
import { TagToken, TopLevelToken, TagImplOptions } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
this.tokens = []
const stream = this.liquid.parser.parseStream(remainTokens)
@@ -11,11 +11,11 @@ export default {
else this.tokens.push(token)
})
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
},
render: function () {
return this.tokens.map((token: Token) => token.raw).join('')
return this.tokens.map((token: TopLevelToken) => token.getText()).join('')
}
} as TagImplOptions
+45 -32
View File
@@ -1,56 +1,69 @@
import { assert } from '../../util/assert'
import { ForloopDrop } from '../../drop/forloop-drop'
import { toCollection } from '../../util/collection'
import { Expression, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types'
import { identifier, value, quoted, quotedLine } from '../../parser/lexical'
const rFile = new RegExp(`^(${quoted.source}|[^\\s,]+)`)
const rWith = new RegExp(`^\\s+with\\s+(${value.source})(?:\\s+as\\s+(${identifier.source}))?`)
const rFor = new RegExp(`^\\s+for\\s+(${value.source})\\s+as\\s+(${identifier.source})`)
import { evalQuotedToken, TypeGuards, Tokenizer, evalToken, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types'
export default {
parse: function (token: TagToken) {
let args = token.args
let match = rFile.exec(args)
const args = token.args
const tokenizer = new Tokenizer(args)
this.file = this.liquid.options.dynamicPartials
? tokenizer.readValue()
: tokenizer.readFileName()
assert(this.file, () => `illegal argument "${token.args}"`)
assert(match, `illegal argument "${token.args}"`)
this.file = match![1]
args = args.substr(match![0].length)
while (!tokenizer.end()) {
tokenizer.skipBlank()
const begin = tokenizer.p
const keyword = tokenizer.readWord()
if (keyword.content === 'with' || keyword.content === 'for') {
tokenizer.skipBlank()
if (tokenizer.peek() !== ':') {
const value = tokenizer.readValue()
if (value) {
const beforeAs = tokenizer.p
const asStr = tokenizer.readWord()
let alias
if (asStr.content === 'as') alias = tokenizer.readWord()
else tokenizer.p = beforeAs
while (true) {
if ((match = rWith.exec(args))) {
this.withVar = match[1]
this.withAs = match[2]
args = args.substr(match[0].length)
} else if ((match = rFor.exec(args))) {
this.forVar = match[1]
this.forAs = match[2]
args = args.substr(match[0].length)
} else break
this[keyword.content] = { value, alias: alias && alias.content }
tokenizer.skipBlank()
if (tokenizer.peek() === ',') tokenizer.advance()
continue
}
}
}
tokenizer.p = begin
break
}
this.hash = new Hash(args)
this.hash = new Hash(tokenizer.remaining())
},
render: function * (ctx: Context, emitter: Emitter) {
const { liquid, withVar, withAs, forVar, forAs, file, hash } = this
const { liquid, file, hash } = this
const { renderer } = liquid
const filepath = ctx.opts.dynamicPartials
? (quotedLine.exec(file)
? yield renderer.renderTemplates(liquid.parse(file.slice(1, -1)), ctx)
: yield new Expression(file).value(ctx))
: this.file
assert(filepath, `illegal filename "${file}":"${filepath}"`)
? (TypeGuards.isQuotedToken(file)
? yield renderer.renderTemplates(liquid.parse(evalQuotedToken(file)), ctx)
: evalToken(file, ctx))
: file.getText()
assert(filepath, () => `illegal filename "${file.getText()}":"${filepath}"`)
const childCtx = new Context({}, ctx.opts, ctx.sync)
const scope = yield hash.render(ctx)
if (withVar) scope[withAs || filepath] = yield new Expression(withVar).evaluate(ctx)
if (this['with']) {
const { value, alias } = this['with']
scope[alias || filepath] = evalToken(value, ctx)
}
childCtx.push(scope)
if (forVar) {
let collection = yield new Expression(forVar).value(ctx)
if (this['for']) {
const { value, alias } = this['for']
let collection = evalToken(value, ctx)
collection = toCollection(collection)
scope['forloop'] = new ForloopDrop(collection.length)
for (const item of collection) {
scope[forAs] = item
scope[alias] = item
const templates = yield liquid._parseFile(filepath, childCtx.opts, childCtx.sync)
yield renderer.renderTemplates(templates, childCtx, emitter)
scope.forloop.next()
+15 -15
View File
@@ -1,21 +1,21 @@
import { assert } from '../../util/assert'
import { toCollection } from '../../util/collection'
import { Expression, Emitter, Hash, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { identifier, value } from '../../parser/lexical'
import { assert, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { TablerowloopDrop } from '../../drop/tablerowloop-drop'
const re = new RegExp(`^(${identifier.source})\\s+in\\s+` +
`(${value.source})`)
import { Tokenizer } from '../../parser/tokenizer'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
const match = re.exec(tagToken.args) as RegExpExecArray
assert(match, `illegal tag: ${tagToken.raw}`)
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
const tokenizer = new Tokenizer(tagToken.args)
this.variable = match[1]
this.collection = match[2]
this.variable = tokenizer.readWord()
tokenizer.skipBlank()
const tmp = tokenizer.readWord()
assert(tmp && tmp.content === 'in', () => `illegal tag: ${tagToken.getText()}`)
this.collection = tokenizer.readValue()
this.hash = new Hash(tokenizer.remaining())
this.templates = []
this.hash = new Hash(tagToken.args.slice(match[0].length))
let p
const stream: ParseStream = this.liquid.parser.parseStream(remainTokens)
@@ -23,14 +23,14 @@ export default {
.on('tag:endtablerow', () => stream.stop())
.on('template', (tpl: Template) => p.push(tpl))
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
},
render: function * (ctx: Context, emitter: Emitter) {
let collection = toCollection(yield new Expression(this.collection).value(ctx))
let collection = toCollection(evalToken(this.collection, ctx))
const hash = yield this.hash.render(ctx)
const offset = hash.offset || 0
const limit = (hash.limit === undefined) ? collection.length : hash.limit
@@ -44,7 +44,7 @@ export default {
ctx.push(scope)
for (let idx = 0; idx < collection.length; idx++, tablerowloop.next()) {
scope[this.variable] = collection[idx]
scope[this.variable.content] = collection[idx]
if (tablerowloop.col0() === 0) {
if (tablerowloop.row() !== 1) emitter.write('</tr>')
emitter.write(`<tr class="row${tablerowloop.row()}">`)
+4 -4
View File
@@ -1,7 +1,7 @@
import { Emitter, Expression, isFalsy, ParseStream, Context, TagImplOptions, Token, TagToken } from '../../types'
import { TopLevelToken, Template, Emitter, Expression, isFalsy, ParseStream, Context, TagImplOptions, Token, TagToken } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
this.templates = []
this.elseTemplates = []
let p
@@ -12,9 +12,9 @@ export default {
})
.on('tag:else', () => (p = this.elseTemplates))
.on('tag:endunless', () => stream.stop())
.on('template', tpl => p.push(tpl))
.on('template', (tpl: Template) => p.push(tpl))
.on('end', () => {
throw new Error(`tag ${tagToken.raw} not closed`)
throw new Error(`tag ${tagToken.getText()} not closed`)
})
stream.start()
+3 -77
View File
@@ -1,6 +1,5 @@
import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { assert } from '../util/assert'
import { NormalizedFullOptions, defaultOptions } from '../liquid-options'
import { Scope } from './scope'
import { isArray, isNil, isString, isFunction, toLiquid } from '../util/underscore'
@@ -34,13 +33,12 @@ export class Context {
return [this.globals, this.environments, ...this.scopes]
.reduce((ctx, val) => __assign(ctx, val), {})
}
public get (path: string) {
const paths = this.parseProp(path)
public get (paths: string[]) {
const scope = this.findScope(paths[0])
return this.getFromScope(scope, paths)
}
public getFromScope (scope: object, paths: string[] | string) {
if (!isArray(paths)) paths = this.parseProp(paths)
if (typeof paths === 'string') paths = paths.split('.')
return paths.reduce((scope, path) => {
scope = readProperty(scope, path)
if (isNil(scope) && this.opts.strictVariables) {
@@ -61,67 +59,11 @@ export class Context {
private findScope (key: string) {
for (let i = this.scopes.length - 1; i >= 0; i--) {
const candidate = this.scopes[i]
if (key in candidate) {
return candidate
}
if (key in candidate) return candidate
}
if (key in this.environments) return this.environments
return this.globals
}
/*
* Parse property access sequence from access string
* @example
* accessSeq("foo.bar") // ['foo', 'bar']
* accessSeq("foo['bar']") // ['foo', 'bar']
* accessSeq("foo['b]r']") // ['foo', 'b]r']
* accessSeq("foo[bar.coo]") // ['foo', 'bar'], for bar.coo == 'bar'
*/
private parseProp (str: string) {
str = String(str)
const seq: string[] = []
const push = () => name.length && (seq.push(name), (name = ''))
let name = ''
let j
let i = 0
while (i < str.length) {
switch (str[i]) {
case '[':
push()
const delemiter = str[i + 1]
if (/['"]/.test(delemiter)) { // foo["bar"]
j = str.indexOf(delemiter, i + 2)
assert(j !== -1, `unbalanced ${delemiter}: ${str}`)
name = str.slice(i + 2, j)
push()
i = j + 2
} else { // foo[bar.coo]
j = matchRightBracket(str, i + 1)
assert(j !== -1, `unbalanced []: ${str}`)
name = str.slice(i + 1, j)
if (!/^[+-]?\d+$/.test(name)) { // foo[bar] vs. foo[1]
name = String(this.get(name))
}
push()
i = j + 1
}
break
case '.':// foo.bar, foo[0].bar
push()
i++
break
default:// foo.bar
name += str[i++]
}
}
push()
if (!seq.length) {
throw new TypeError(`invalid path:"${str}"`)
}
return seq
}
}
export function readProperty (obj: Scope, key: string) {
@@ -152,19 +94,3 @@ function readSize (obj: Scope) {
if (isArray(obj) || isString(obj)) return obj.length
return obj['size']
}
function matchRightBracket (str: string, begin: number) {
let stack = 1 // count of '[' - count of ']'
for (let i = begin; i < str.length; i++) {
if (str[i] === '[') {
stack++
}
if (str[i] === ']') {
stack--
if (stack === 0) {
return i
}
}
}
return -1
}
+2 -2
View File
@@ -38,8 +38,8 @@ export class Liquid {
_.forOwn(builtinFilters, (handler, name) => this.registerFilter(name, handler))
}
public parse (html: string, filepath?: string): Template[] {
const tokenizer = new Tokenizer(html, filepath, this.options)
const tokens = tokenizer.readTokens()
const tokenizer = new Tokenizer(html, filepath)
const tokens = tokenizer.readTopLevelTokens(this.options)
return this.parser.parse(tokens)
}
+4 -3
View File
@@ -1,9 +1,10 @@
import { isArray } from '../util/underscore'
import { ValueToken } from '../tokens/value-token'
type KeyValuePair = [string?, string?]
type KeyValuePair = [string?, ValueToken?]
export type FilterArg = string|KeyValuePair
export type FilterArg = ValueToken | KeyValuePair
export function isKeyValuePair (arr: FilterArg): arr is KeyValuePair {
export function isKeyValuePair (arr: FilterArg): arr is KeyValuePair { // TODO check
return isArray(arr)
}
-11
View File
@@ -1,11 +0,0 @@
import { Token } from './token'
export class HTMLToken extends Token {
public constructor (str: string, input: string, line: number, col: number, file?: string) {
super(str, input, line, col, file)
this.content = str
}
public static is (token: Token): token is HTMLToken {
return token instanceof HTMLToken
}
}
-29
View File
@@ -1,29 +0,0 @@
// quote related
const singleQuoted = /'[^']*'/
const doubleQuoted = /"[^"]*"/
export const quoted = new RegExp(`${singleQuoted.source}|${doubleQuoted.source}`)
export const quoteBalanced = new RegExp(`(?:${quoted.source}|[^'"])*`)
// basic types
export const number = /[+-]?(?:\d+\.?\d*|\.?\d+)/
export const bool = /true|false/
// property access
export const identifier = /[\w-]+[?]?/
export const subscript = new RegExp(`\\[(?:${quoted.source}|[\\w-\\.]+)\\]`)
export const literal = new RegExp(`(?:${quoted.source}|${bool.source}|${number.source})`)
export const variable = new RegExp(`${identifier.source}(?:\\.${identifier.source}|${subscript.source})*`)
// range related
export const rangeLimit = new RegExp(`(?:${variable.source}|${number.source})`)
export const range = new RegExp(`\\(${rangeLimit.source}\\.\\.${rangeLimit.source}\\)`)
export const rangeCapture = new RegExp(`\\((${rangeLimit.source})\\.\\.(${rangeLimit.source})\\)`)
export const value = new RegExp(`(?:${variable.source}|${literal.source}|${range.source})`)
// full match
export const tagLine = new RegExp(`^\\s*(${identifier.source})\\s*([\\s\\S]*?)\\s*$`)
export const numberLine = new RegExp(`^${number.source}$`)
export const boolLine = new RegExp(`^${bool.source}$`, 'i')
export const quotedLine = new RegExp(`^${quoted.source}$`)
export const rangeLine = new RegExp(`^${rangeCapture.source}$`)
+24
View File
@@ -0,0 +1,24 @@
import { VARIABLE } from '../util/character'
const trie = {
a: { n: { d: { end: true, needBoundary: true } } },
o: { r: { end: true, needBoundary: true } },
c: { o: { n: { t: { a: { i: { n: { s: { end: true, needBoundary: true } } } } } } } },
'=': { '=': { end: true } },
'!': { '=': { end: true } },
'>': { end: true, '=': { end: true } },
'<': { end: true, '=': { end: true } }
}
export function matchOperator (str: string, begin: number, end = str.length) {
let node = 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'] && str.charCodeAt(i) & VARIABLE) return -1
return i
}
-20
View File
@@ -1,20 +0,0 @@
import { DelimitedToken } from './delimited-token'
import { Token } from './token'
import { NormalizedFullOptions } from '../liquid-options'
export class OutputToken extends DelimitedToken {
public constructor (
raw: string,
value: string,
input: string,
line: number,
pos: number,
options: NormalizedFullOptions,
file?: string
) {
super(raw, value, input, line, pos, options.trimOutputLeft, options.trimOutputRight, file)
}
public static is (token: Token): token is OutputToken {
return token instanceof OutputToken
}
}
+11 -10
View File
@@ -1,20 +1,21 @@
import { Token } from '../parser/token'
import { Token } from '../tokens/token'
import { Template } from '../template/template'
import { TagToken } from './tag-token'
import { isTagToken } from '../util/type-guards'
import { TopLevelToken } from '../tokens/toplevel-token'
type ParseToken = ((token: Token, remainTokens: Token[]) => Template)
type ParseToken<T extends Token> = ((token: T, remainTokens: T[]) => Template)
export class ParseStream {
private tokens: Token[]
export class ParseStream<T extends Token = TopLevelToken> {
private tokens: T[]
private handlers: {[key: string]: (arg: any) => void} = {}
private stopRequested = false
private parseToken: ParseToken
private parseToken: ParseToken<T>
public constructor (tokens: Token[], parseToken: ParseToken) {
public constructor (tokens: T[], parseToken: ParseToken<T>) {
this.tokens = tokens
this.parseToken = parseToken
}
public on<T extends Template | Token | undefined> (name: string, cb: (arg: T) => void): ParseStream {
public on<T2 extends Template | T | undefined> (name: string, cb: (arg: T2) => void): ParseStream<T> {
this.handlers[name] = cb
return this
}
@@ -24,10 +25,10 @@ export class ParseStream {
}
public start () {
this.trigger('start')
let token: Token | undefined
let token: T | undefined
while (!this.stopRequested && (token = this.tokens.shift())) {
if (this.trigger('token', token)) continue
if (TagToken.is(token) && this.trigger(`tag:${token.name}`, token)) {
if (isTagToken(token) && this.trigger(`tag:${token.name}`, token)) {
continue
}
const template = this.parseToken(token, this.tokens)
@@ -1,10 +1,3 @@
import { last } from '../util/underscore'
import { NullDrop } from '../drop/null-drop'
import { EmptyDrop } from '../drop/empty-drop'
import { BlankDrop } from '../drop/blank-drop'
type literal = true | false | NullDrop | EmptyDrop | BlankDrop | number | string
const rHex = /[\da-fA-F]/
const rOct = /[0-7]/
const escapeChar = {
@@ -23,18 +16,6 @@ function hexVal (c: string) {
return code - 48
}
export function parseLiteral (str: string): literal | undefined {
str = str.trim()
if (str === 'true') return true
if (str === 'false') return false
if (str === 'nil' || str === 'null') return new NullDrop()
if (str === 'empty') return new EmptyDrop()
if (str === 'blank') return new BlankDrop()
if (!isNaN(Number(str))) return Number(str)
if ((str[0] === '"' || str[0] === "'") && str[0] === last(str)) return parseStringLiteral(str)
}
export function parseStringLiteral (str: string): string {
let ret = ''
for (let i = 1; i < str.length - 1; i++) {
@@ -66,3 +47,4 @@ export function parseStringLiteral (str: string): string {
}
return ret
}
+8 -8
View File
@@ -1,13 +1,13 @@
import { ParseError } from '../util/error'
import { Liquid } from '../liquid'
import { ParseStream } from './parse-stream'
import { Token } from './token'
import { TagToken } from './tag-token'
import { OutputToken } from './output-token'
import { isTagToken, isOutputToken } from '../util/type-guards'
import { OutputToken } from '../tokens/output-token'
import { Tag } from '../template/tag/tag'
import { Output } from '../template/output'
import { HTML } from '../template/html'
import { Template } from '../template/template'
import { TopLevelToken } from '../tokens/toplevel-token'
export default class Parser {
private liquid: Liquid
@@ -15,7 +15,7 @@ export default class Parser {
public constructor (liquid: Liquid) {
this.liquid = liquid
}
public parse (tokens: Token[]) {
public parse (tokens: TopLevelToken[]) {
let token
const templates: Template[] = []
while ((token = tokens.shift())) {
@@ -23,12 +23,12 @@ export default class Parser {
}
return templates
}
public parseToken (token: Token, remainTokens: Token[]) {
public parseToken (token: TopLevelToken, remainTokens: TopLevelToken[]) {
try {
if (TagToken.is(token)) {
if (isTagToken(token)) {
return new Tag(token, remainTokens, this.liquid)
}
if (OutputToken.is(token)) {
if (isOutputToken(token)) {
return new Output(token as OutputToken, this.liquid.filters)
}
return new HTML(token)
@@ -36,7 +36,7 @@ export default class Parser {
throw new ParseError(e, token)
}
}
public parseStream (tokens: Token[]) {
public parseStream (tokens: TopLevelToken[]) {
return new ParseStream(tokens, (token, tokens) => this.parseToken(token, tokens))
}
}
-19
View File
@@ -1,19 +0,0 @@
export class Substr {
constructor (
public str: string,
public begin: number,
public end: number = begin
) {}
size () {
return this.end - this.begin
}
toString () {
return this.str.slice(this.begin, this.end)
}
first () {
return this.str[this.begin]
}
last () {
return this.str[this.end - 1]
}
}
-30
View File
@@ -1,30 +0,0 @@
import { DelimitedToken } from './delimited-token'
import { Token } from './token'
import { TokenizationError } from '../util/error'
import * as lexical from './lexical'
import { NormalizedFullOptions } from '../liquid-options'
export class TagToken extends DelimitedToken {
public name: string
public args: string
public constructor (
raw: string,
value: string,
input: string,
line: number,
pos: number,
options: NormalizedFullOptions,
file?: string
) {
super(raw, value, input, line, pos, options.trimTagLeft, options.trimTagRight, file)
const match = this.content.match(lexical.tagLine)
if (!match) {
throw new TokenizationError(`illegal tag syntax`, this)
}
this.name = match[1]
this.args = match[2]
}
public static is (token: Token): token is TagToken {
return token instanceof TagToken
}
}
+14
View File
@@ -0,0 +1,14 @@
export enum TokenKind {
Number,
Literal,
Tag,
Output,
HTML,
Filter,
Hash,
PropertyAccess,
Word,
Range,
Quoted,
Operator
}
-14
View File
@@ -1,14 +0,0 @@
export class Token {
public trimLeft = false
public trimRight = false
public content: string
public constructor (
public raw: string,
public input: string,
public line: number,
public col: number,
public file?: string
) {
this.content = raw
}
}
+195 -193
View File
@@ -1,158 +1,158 @@
import { whiteSpaceCtrl } from './whitespace-ctrl'
import { Substr } from './substr'
import { NumberToken } from '../tokens/number-token'
import { WordToken } from '../tokens/word-token'
import { literalValues } from '../util/literal'
import { LiteralToken } from '../tokens/literal-token'
import { OperatorToken } from '../tokens/operator-token'
import { PropertyAccessToken } from '../tokens/property-access-token'
import { assert } from '../util/assert'
import { TopLevelToken } from '../tokens/toplevel-token'
import { FilterArg } from './filter-arg'
import { FilterToken } from './filter-token'
import { FilterToken } from '../tokens/filter-token'
import { HashToken } from '../tokens/hash-token'
import { QuotedToken } from '../tokens/quoted-token'
import { ellipsis } from '../util/underscore'
import { HTMLToken } from './html-token'
import { TagToken } from './tag-token'
import { Token } from './token'
import { OutputToken } from './output-token'
import { HTMLToken } from '../tokens/html-token'
import { TagToken } from '../tokens/tag-token'
import { Token } from '../tokens/token'
import { RangeToken } from '../tokens/range-token'
import { ValueToken } from '../tokens/value-token'
import { OutputToken } from '../tokens/output-token'
import { TokenizationError } from '../util/error'
import { NormalizedFullOptions, defaultOptions } from '../liquid-options'
// bitmask character types to boost performance
// generated by bin/char-types.js
const TYPES = '00000000044004000000000000000000428000080000010011111111110022210111111111111111111111111110000101111111111111111111111111100000'
const VARIABLE = 1
const OPERATOR = 2
const BLANK = 4
const QUOTE = 8
import { TYPES, QUOTE, BLANK, VARIABLE } from '../util/character'
import { matchOperator } from './match-operator'
export class Tokenizer {
private p = 0
private N: number
private line = 1
private col = 1
p = 0
N: number
constructor (
private input: string,
private file: string = '',
private options: NormalizedFullOptions = defaultOptions
private file: string = ''
) {
this.N = input.length
}
* readExpression (): IterableIterator<string> {
* readExpression (): IterableIterator<Token> {
const operand = this.readValue()
if (!operand) return
yield operand
while (this.p < this.N) {
const operator = this.readOperator()
if (!operator) return
const operand = this.readValue()
if (operand.size()) {
yield operand.toString()
continue
}
this.readBlank()
const operator = new Substr(this.input, this.p)
while (OPERATOR & this.peekType()) operator.end = this.read()
if (operator.size()) {
yield operator.toString()
continue
}
this.read()
if (!operand) return
yield operator
yield operand
}
}
readFilterTokens (): FilterToken[] {
readOperator (): OperatorToken | undefined {
this.skipBlank()
const end = matchOperator(this.input, this.p, this.p + 8)
if (end === -1) return
return new OperatorToken(this.input, this.p, (this.p = end), this.file)
}
readFilters (): FilterToken[] {
const filters = []
while (true) {
const filter = this.readFilterToken()
const filter = this.readFilter()
if (!filter) return filters
filters.push(filter)
}
}
// | foo
// | foo: a
// | foo: a, b
// | foo: a, b: 1
readFilterToken (): FilterToken | null {
readFilter (): FilterToken | null {
this.readTo('|')
const begin = this.p
const name = this.readVariable().toString()
if (!name) return null
const name = this.readWord()
if (!name.size()) return null
const args = []
this.readBlank()
this.skipBlank()
if (this.peek() === ':') {
do {
this.read()
++this.p
const arg = this.readFilterArg()
arg && args.push(arg)
while (this.p < this.N && this.peek() !== ',' && this.peek() !== '|') this.read()
while (this.p < this.N && this.peek() !== ',' && this.peek() !== '|') ++this.p
} while (this.peek() === ',')
}
const raw = this.input.slice(begin, this.p)
return new FilterToken(name, args, raw, this.input, this.line, this.col, this.file)
return new FilterToken(name.getText(), args, this.input, begin, this.p, this.file)
}
readFilterArg (): FilterArg | null {
readFilterArg (): FilterArg | undefined {
const key = this.readValue()
if (!key.size()) return null
this.readBlank()
if (this.peek() === ':') {
this.read()
return [key.toString(), this.readValue().toString()]
}
return key.toString()
if (!key) return
this.skipBlank()
if (this.peek() !== ':') return key
++this.p
const value = this.readValue()
return [key.getText(), value]
}
readTokens (): Token[] {
const tokens: Token[] = []
readTopLevelTokens (options: NormalizedFullOptions = defaultOptions): TopLevelToken[] {
const tokens: TopLevelToken[] = []
while (this.p < this.N) {
const token = this.readToken()
const token = this.readTopLevelToken(options)
tokens.push(token)
}
whiteSpaceCtrl(tokens, this.options)
whiteSpaceCtrl(tokens, options)
return tokens
}
readToken (): Token {
const { tagDelimiterLeft, outputDelimiterLeft } = this.options
if (this.matchWord(tagDelimiterLeft)) return this.readTagToken()
if (this.matchWord(outputDelimiterLeft)) return this.readOutputToken()
return this.readHTMLToken()
readTopLevelToken (options: NormalizedFullOptions): TopLevelToken {
const { tagDelimiterLeft, outputDelimiterLeft } = options
if (this.matchWord(tagDelimiterLeft)) return this.readTagToken(options)
if (this.matchWord(outputDelimiterLeft)) return this.readOutputToken(options)
return this.readHTMLToken(options)
}
readHTMLToken (): HTMLToken {
const html = new Substr(this.input, this.p)
readHTMLToken (options: NormalizedFullOptions): HTMLToken {
const begin = this.p
while (this.p < this.N) {
const { tagDelimiterLeft, outputDelimiterLeft } = this.options
const { tagDelimiterLeft, outputDelimiterLeft } = options
if (this.matchWord(tagDelimiterLeft)) break
if (this.matchWord(outputDelimiterLeft)) break
html.end = this.read()
++this.p
}
return new HTMLToken(html.toString(), this.input, this.line, this.col, this.file)
return new HTMLToken(this.input, begin, this.p, this.file)
}
readTagToken (): TagToken {
const { line, col, file, input, options } = this
const { tagDelimiterLeft, tagDelimiterRight } = options
const buffer = this.readTo(tagDelimiterRight).toString()
if (!this.reverseMatchWord(tagDelimiterRight, buffer)) {
throw new TokenizationError(
`tag "${ellipsis(buffer, 16)}" not closed`,
new Token(buffer, input, line, col, file)
)
readTagToken (options: NormalizedFullOptions): TagToken {
const { file, input } = this
const { tagDelimiterRight } = options
const begin = this.p
if (this.readTo(tagDelimiterRight) === -1) {
this.mkError(`tag "${this.ellipsis(begin)}" not closed`, begin)
}
const value = buffer.slice(tagDelimiterLeft.length, -tagDelimiterRight.length)
return new TagToken(buffer, value, input, line, col, options, file)
return new TagToken(input, begin, this.p, options, file)
}
readOutputToken (): OutputToken {
const { line, col, file, input, options } = this
const { outputDelimiterLeft, outputDelimiterRight } = options
const buffer = this.readTo(outputDelimiterRight).toString()
if (!this.reverseMatchWord(outputDelimiterRight, buffer)) {
throw new TokenizationError(
`output "${ellipsis(buffer, 16)}" not closed`,
new Token(buffer, input, line, col, file)
)
readOutputToken (options: NormalizedFullOptions): OutputToken {
const { file, input } = this
const { outputDelimiterRight } = options
const begin = this.p
if (this.readTo(outputDelimiterRight) === -1) {
this.mkError(`output "${this.ellipsis(begin)}" not closed`, begin)
}
const value = buffer.slice(outputDelimiterLeft.length, -outputDelimiterRight.length)
return new OutputToken(buffer, value, input, line, col, options, file)
return new OutputToken(input, begin, this.p, options, file)
}
readVariable (): Substr {
this.readBlank()
const ans = new Substr(this.input, this.p)
while (this.peekType() & VARIABLE) ans.end = this.read()
return ans
mkError (msg: string, begin: number) {
throw new TokenizationError(msg, new WordToken(this.input, begin, this.N, this.file))
}
ellipsis (begin: number = this.p) {
return ellipsis(this.input.slice(begin), 16)
}
readWord (): WordToken { // rename to identifier
this.skipBlank()
const begin = this.p
while (this.peekType() & VARIABLE) ++this.p
return new WordToken(this.input, begin, this.p, this.file)
}
readHashes () {
@@ -164,133 +164,135 @@ export class Tokenizer {
}
}
readHash () {
this.readBlank()
if (this.peek() === ',') this.read()
const name = this.readVariable().toString()
if (!name) return null
readHash (): HashToken | undefined {
this.skipBlank()
if (this.peek() === ',') ++this.p
const begin = this.p
const name = this.readWord()
if (!name.size()) return
let value
this.readBlank()
let value = ''
this.skipBlank()
if (this.peek() === ':') {
this.read()
value = this.readValue().toString()
++this.p
value = this.readValue()
}
return [name, value]
return new HashToken(this.input, begin, this.p, name, value, this.file)
}
readPropertyAccess (): Substr {
this.readBlank()
const ans = new Substr(this.input, this.p)
let nested = 0
remaining () {
return this.input.slice(this.p)
}
advance (i = 1) {
this.p += i
}
end () {
return this.p >= this.N
}
readTo (end: string): number {
while (this.p < this.N) {
const c = this.peek()
const code = this.peekType()
if (c === '[') {
this.read()
ans.end = this.readValue().end
nested++
} else if (c === ']') {
if (!nested) break
ans.end = this.read()
nested--
} else if (c === '.') {
if (this.peekType(1) & VARIABLE) {
this.read()
ans.end = this.readVariable().end
} else break
} else if (code & VARIABLE) {
ans.end = this.read()
} else {
if (nested) this.read()
else break
}
++this.p
if (this.reverseMatchWord(end)) return this.p
}
return ans
return -1
}
readTo (end: string): Substr {
const ans = new Substr(this.input, this.p)
while (this.p < this.N) {
ans.end = this.read()
if (this.reverseMatchWord(end)) break
readValue (): ValueToken | undefined {
const value = this.readQuoted() || this.readRange()
if (value) return value
const variable = this.readWord()
if (!variable.size()) return
let isNumber = variable.isNumber(true)
const props: (QuotedToken | WordToken)[] = []
while (true) {
if (this.peek() === '[') {
isNumber = false
this.p++
const prop = this.readValue() || new WordToken(this.input, this.p, this.p, this.file)
this.readTo(']')
props.push(prop)
} else if (this.peek() === '.' && this.peek(1) !== '.') { // skip range syntax
this.p++
const prop = this.readWord()
if (!prop.size()) break
if (!prop.isNumber()) isNumber = false
props.push(prop)
} else break
}
return ans
if (!props.length && literalValues.hasOwnProperty(variable.content)) {
return new LiteralToken(this.input, variable.begin, variable.end, this.file)
}
if (isNumber) return new NumberToken(variable, props[0] as WordToken)
return new PropertyAccessToken(variable, props, this.p)
}
readValue (): Substr {
let val = this.readQuoted()
if (val.size()) return val
val = this.readBoolean()
if (val.size()) return val
val = this.readPropertyAccess()
if (val.size()) return val
return this.readRange()
readRange (): RangeToken | undefined {
this.skipBlank()
const begin = this.p
if (this.peek() !== '(') return
++this.p
const lhs = this.readValueOrThrow()
this.p += 2
const rhs = this.readValueOrThrow()
++this.p
return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file)
}
readRange (): Substr {
this.readBlank()
const ans = new Substr(this.input, this.p)
if (this.peek() !== '(') return ans
this.read()
this.readValue()
this.read(2)
this.readValue()
ans.end = this.read()
return ans
readValueOrThrow (): ValueToken {
const value = this.readValue()
assert(value, () => `unexpected token "${this.ellipsis()}", value expected`)
return value!
}
readBoolean (): Substr {
this.readBlank()
const ans = new Substr(this.input, this.p)
if (this.matchWord('true') && !(this.peekType(4) & VARIABLE)) ans.end = this.read(4)
else if (this.matchWord('false') && !(this.peekType(5) & VARIABLE)) ans.end = this.read(5)
return ans
}
readQuoted (): Substr {
this.readBlank()
const ans = new Substr(this.input, this.p)
if (!(this.peekType() & QUOTE)) return ans
ans.end = this.read()
readQuoted (): QuotedToken | undefined {
this.skipBlank()
const begin = this.p
if (!(this.peekType() & QUOTE)) return
++this.p
let escaped = false
while (this.p < this.N) {
ans.end = this.read()
if (ans.last() === ans.first() && !escaped) break
++this.p
if (this.input[this.p - 1] === this.input[begin] && !escaped) break
if (escaped) escaped = false
else if (ans.last() === '\\') escaped = true
else if (this.input[this.p - 1] === '\\') escaped = true
}
return ans
return new QuotedToken(this.input, begin, this.p, this.file)
}
read (n = 1): number {
if (n > 1) this.read(n - 1)
const c = this.input[this.p++]
if (c === '\n') {
this.line++
this.col = 1
} else {
this.col++
}
return this.p
readFileName (): WordToken {
const begin = this.p
while (!(this.peekType() & BLANK) && this.peek() !== ',' && this.p < this.N) this.p++
return new WordToken(this.input, begin, this.p, this.file)
}
matchWord (word: string) {
for (let i = 0; i < word.length; i++) {
if (word[i] !== this.input[this.p + i]) return false
}
return true
}
reverseMatchWord (word: string, buffer?: string) {
const str = buffer || this.input
const end = buffer === undefined ? this.p : buffer.length
reverseMatchWord (word: string) {
for (let i = 0; i < word.length; i++) {
if (word[word.length - 1 - i] !== str[end - 1 - i]) return false
if (word[word.length - 1 - i] !== this.input[this.p - 1 - i]) return false
}
return true
}
peekType (n = 0) {
return +TYPES[this.input.charCodeAt(this.p + n)]
return TYPES[this.input.charCodeAt(this.p + n)]
}
peek (n = 0) {
return this.input[this.p + n]
}
readBlank () {
let ans = ''
while (this.peekType() & BLANK) ans += this.read()
return ans
skipBlank () {
while (this.peekType() & BLANK) ++this.p
}
}
+13 -10
View File
@@ -1,7 +1,8 @@
import { Token } from '../parser/token'
import { TagToken } from '../parser/tag-token'
import { HTMLToken } from '../parser/html-token'
import { Token } from '../tokens/token'
import { DelimitedToken } from '../tokens/delimited-token'
import { isTagToken, isHTMLToken } from '../util/type-guards'
import { NormalizedFullOptions } from '../liquid-options'
import { TYPES, INLINE_BLANK, BLANK } from '../util/character'
export function whiteSpaceCtrl (tokens: Token[], options: NormalizedFullOptions) {
options = { greedy: true, ...options }
@@ -9,11 +10,12 @@ export function whiteSpaceCtrl (tokens: Token[], options: NormalizedFullOptions)
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (!(token instanceof DelimitedToken)) continue
if (!inRaw && token.trimLeft) {
trimLeft(tokens[i - 1], options.greedy)
}
if (TagToken.is(token)) {
if (isTagToken(token)) {
if (token.name === 'raw') inRaw = true
else if (token.name === 'endraw') inRaw = false
}
@@ -25,15 +27,16 @@ export function whiteSpaceCtrl (tokens: Token[], options: NormalizedFullOptions)
}
function trimLeft (token: Token, greedy: boolean) {
if (!token || !HTMLToken.is(token)) return
if (!token || !isHTMLToken(token)) return
const rLeft = greedy ? /\s+$/g : /[\t\r ]*$/g
token.content = token.content.replace(rLeft, '')
const mask = greedy ? BLANK : INLINE_BLANK
while (TYPES[token.input.charCodeAt(token.end - 1 - token.trimRight)] & mask) token.trimRight++
}
function trimRight (token: Token, greedy: boolean) {
if (!token || !HTMLToken.is(token)) return
if (!token || !isHTMLToken(token)) return
const rRight = greedy ? /^\s+/g : /^[\t\r ]*\n?/g
token.content = token.content.replace(rRight, '')
const mask = greedy ? BLANK : INLINE_BLANK
while (TYPES[token.input.charCodeAt(token.begin + token.trimLeft)] & mask) token.trimLeft++
if (token.input.charAt(token.begin + token.trimLeft) === '\n') token.trimLeft++
}
+60 -33
View File
@@ -1,47 +1,87 @@
import { QuotedToken } from '../tokens/quoted-token'
import { NumberToken } from '../tokens/number-token'
import { assert } from '../util/assert'
import { rangeLine } from '../parser/lexical'
import { parseLiteral } from '../parser/literal'
import { literalValues } from '../util/literal'
import { LiteralToken } from '../tokens/literal-token'
import * as TypeGuards from '../util/type-guards'
import { Token } from '../tokens/token'
import { OperatorToken } from '../tokens/operator-token'
import { RangeToken } from '../tokens/range-token'
import { parseStringLiteral } from '../parser/parse-string-literal'
import { Context } from '../context/context'
import { range, toValue } from '../util/underscore'
import { isOperator, precedence, operatorImpls } from './operator'
import { Tokenizer } from '../parser/tokenizer'
import { operatorImpls } from '../render/operator'
export class Expression {
private operands: any[] = []
private postfix: string[]
private postfix: IterableIterator<Token>
public constructor (str = '') {
public constructor (str: string) {
const tokenizer = new Tokenizer(str)
this.postfix = [...toPostfix(tokenizer.readExpression())]
this.postfix = toPostfix(tokenizer.readExpression())
}
public * evaluate (ctx: Context): any {
assert(ctx, 'unable to evaluate: context not defined')
public evaluate (ctx: Context): any {
for (const token of this.postfix) {
if (isOperator(token)) {
if (TypeGuards.isOperatorToken(token)) {
const r = this.operands.pop()
const l = this.operands.pop()
const result = operatorImpls[token](l, r)
const result = evalOperatorToken(token, l, r)
this.operands.push(result)
} else if (isRange(token)) {
this.operands.push(yield rangeValue(token, ctx))
} else {
const literal = parseLiteral(token)
this.operands.push(literal !== undefined ? literal : yield ctx.get(token))
this.operands.push(evalToken(token, ctx))
}
}
return this.operands[0]
}
public * value (ctx: Context) {
return toValue(yield this.evaluate(ctx))
return toValue(this.evaluate(ctx))
}
}
function * toPostfix (tokens: IterableIterator<string>): IterableIterator<string> {
const ops = []
export function evalToken (token: Token | undefined, ctx: Context): any {
assert(ctx, () => 'unable to evaluate: context not defined')
if (TypeGuards.isPropertyAccessToken(token)) {
const variable = token.variable.getText()
const props: string[] = token.props.map(prop => evalToken(prop, ctx))
return ctx.get([variable, ...props])
}
if (TypeGuards.isRangeToken(token)) return evalRangeToken(token, ctx)
if (TypeGuards.isLiteralToken(token)) return evalLiteralToken(token)
if (TypeGuards.isNumberToken(token)) return evalNumberToken(token)
if (TypeGuards.isWordToken(token)) return token.getText()
if (TypeGuards.isQuotedToken(token)) return evalQuotedToken(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 evalOperatorToken (token: OperatorToken, lhs: any, rhs: any) {
const impl = operatorImpls[token.operator]
return impl(lhs, rhs)
}
function evalLiteralToken (token: LiteralToken) {
return literalValues[token.literal]
}
function evalRangeToken (token: RangeToken, ctx: Context) {
const low: number = evalToken(token.lhs, ctx)
const high: number = evalToken(token.rhs, ctx)
return range(+low, +high + 1)
}
function * toPostfix (tokens: IterableIterator<Token>): IterableIterator<Token> {
const ops: OperatorToken[] = []
for (const token of tokens) {
if (isOperator(token)) {
while (ops.length && precedence[ops[ops.length - 1]] > precedence[token]) {
if (TypeGuards.isOperatorToken(token)) {
while (ops.length && ops[ops.length - 1].getPrecedence() > token.getPrecedence()) {
yield ops.pop()!
}
ops.push(token)
@@ -51,16 +91,3 @@ function * toPostfix (tokens: IterableIterator<string>): IterableIterator<string
yield ops.pop()!
}
}
function * rangeValue (token: string, ctx: Context) {
let match
if ((match = token.match(rangeLine))) {
const low = yield new Expression(match[1]).value(ctx)
const high = yield new Expression(match[2]).value(ctx)
return range(+low, +high + 1)
}
}
function isRange (str: string) {
return rangeLine.test(str)
}
-16
View File
@@ -2,18 +2,6 @@ import { isComparable } from '../drop/icomparable'
import { isFunction } from '../util/underscore'
import { isTruthy } from '../render/boolean'
export const precedence = {
'==': 1,
'!=': 1,
'>': 1,
'<': 1,
'>=': 1,
'<=': 1,
'contains': 1,
'and': 0,
'or': 0
}
export const operatorImpls: {[key: string]: (lhs: any, rhs: any) => boolean} = {
'==': (l: any, r: any) => {
if (isComparable(l)) return l.equals(r)
@@ -51,7 +39,3 @@ export const operatorImpls: {[key: string]: (lhs: any, rhs: any) => boolean} = {
'and': (l: any, r: any) => isTruthy(l) && isTruthy(r),
'or': (l: any, r: any) => isTruthy(l) || isTruthy(r)
}
export function isOperator (token: string) {
return precedence.hasOwnProperty(token)
}
+1 -1
View File
@@ -10,7 +10,7 @@ export class FilterMap {
get (name: string) {
const impl = this.impls[name]
assert(impl || !this.strictFilters, `undefined filter: ${name}`)
assert(impl || !this.strictFilters, () => `undefined filter: ${name}`)
return impl
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { Expression } from '../../render/expression'
import { evalToken } from '../../render/expression'
import { Context } from '../../context/context'
import { identify } from '../../util/underscore'
import { FilterImplOptions } from './filter-impl-options'
@@ -16,9 +16,9 @@ export class Filter {
}
public * render (value: any, context: Context) {
const argv: any[] = []
for (const arg of this.args) {
if (isKeyValuePair(arg)) argv.push([arg[0], yield new Expression(arg[1]).evaluate(context)])
else argv.push(yield new Expression(arg).evaluate(context))
for (const arg of this.args as FilterArg[]) {
if (isKeyValuePair(arg)) argv.push([arg[0], yield evalToken(arg[1], context)])
else argv.push(yield evalToken(arg, context))
}
return this.impl.apply({ context }, [value, ...argv])
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { TemplateImpl } from '../template/template-impl'
import { Template } from '../template/template'
import { HTMLToken } from '../parser/html-token'
import { HTMLToken } from '../tokens/html-token'
import { Context } from '../context/context'
import { Emitter } from '../render/emitter'
@@ -8,7 +8,7 @@ export class HTML extends TemplateImpl<HTMLToken> implements Template {
private str: string
public constructor (token: HTMLToken) {
super(token)
this.str = token.content
this.str = token.getContent()
}
public * render (ctx: Context, emitter: Emitter): IterableIterator<void> {
emitter.write(this.str)
+1 -1
View File
@@ -5,7 +5,7 @@ import { TemplateImpl } from '../template/template-impl'
import { Template } from '../template/template'
import { Context } from '../context/context'
import { Emitter } from '../render/emitter'
import { OutputToken } from '../parser/output-token'
import { OutputToken } from '../tokens/output-token'
export class Output extends TemplateImpl<OutputToken> implements Template {
private value: Value
+6 -6
View File
@@ -1,4 +1,4 @@
import { Expression } from '../../render/expression'
import { evalToken } from '../../render/expression'
import { Context } from '../../context/context'
import { Tokenizer } from '../../parser/tokenizer'
@@ -11,17 +11,17 @@ import { Tokenizer } from '../../parser/tokenizer'
* hash['reversed'] === undefined
*/
export class Hash {
[key: string]: any
hash: { [key: string]: any } = {}
constructor (markup: string) {
const tokenizer = new Tokenizer(markup)
for (const [name, value] of tokenizer.readHashes()) {
this[name] = value
for (const hash of tokenizer.readHashes()) {
this.hash[hash.name.content] = hash.value
}
}
* render (ctx: Context) {
const hash = {}
for (const key of Object.keys(this)) {
hash[key] = yield new Expression(this[key]).evaluate(ctx)
for (const key of Object.keys(this.hash)) {
hash[key] = evalToken(this.hash[key], ctx)
}
return hash
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { Context } from '../../context/context'
import { TagToken } from '../../parser/tag-token'
import { Token } from '../../parser/token'
import { TagToken } from '../../tokens/tag-token'
import { TopLevelToken } from '../../tokens/toplevel-token'
import { TagImpl } from './tag-impl'
import { Hash } from '../../template/tag/hash'
import { Emitter } from '../../render/emitter'
export interface TagImplOptions {
parse?: (this: TagImpl, token: TagToken, remainingTokens: Token[]) => void;
parse?: (this: TagImpl, token: TagToken, remainingTokens: TopLevelToken[]) => void;
render: (this: TagImpl, ctx: Context, emitter: Emitter, hash: Hash) => any;
}
+1 -1
View File
@@ -6,7 +6,7 @@ export class TagMap {
get (name: string) {
const impl = this.impls[name]
assert(impl, `tag "${name}" not found`)
assert(impl, () => `tag "${name}" not found`)
return impl
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { isFunction } from '../../util/underscore'
import { Liquid } from '../../liquid'
import { TemplateImpl } from '../../template/template-impl'
import { Emitter, Hash, Context, TagImplOptions, TagToken, Template, Token } from '../../types'
import { Emitter, Hash, Context, TagImplOptions, TagToken, Template, TopLevelToken } from '../../types'
import { TagImpl } from './tag-impl'
export class Tag extends TemplateImpl<TagToken> implements Template {
@@ -9,7 +9,7 @@ export class Tag extends TemplateImpl<TagToken> implements Template {
private impl: TagImpl
private static impls: { [key: string]: TagImplOptions } = {}
public constructor (token: TagToken, tokens: Token[], liquid: Liquid) {
public constructor (token: TagToken, tokens: TopLevelToken[], liquid: Liquid) {
super(token)
this.name = token.name
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context } from '../context/context'
import { Token } from '../parser/token'
import { Token } from '../tokens/token'
import { Emitter } from '../render/emitter'
export interface Template {
+6 -5
View File
@@ -1,23 +1,24 @@
import { Expression } from '../render/expression'
import { evalToken } from '../render/expression'
import { Tokenizer } from '../parser/tokenizer'
import { FilterMap } from '../template/filter/filter-map'
import { Filter } from './filter/filter'
import { Context } from '../context/context'
import { ValueToken } from '../tokens/value-token'
export class Value {
public readonly filters: Filter[] = []
public readonly initial: string
public readonly initial?: ValueToken
/**
* @param str the value to be valuated, eg.: "foobar" | truncate: 3
*/
public constructor (str: string, private readonly filterMap: FilterMap) {
const tokenizer = new Tokenizer(str)
this.initial = tokenizer.readValue().toString()
this.filters = tokenizer.readFilterTokens().map(({ name, args }) => new Filter(name, this.filterMap.get(name), args))
this.initial = tokenizer.readValue()
this.filters = tokenizer.readFilters().map(({ name, args }) => new Filter(name, this.filterMap.get(name), args))
}
public * value (ctx: Context) {
let val = yield new Expression(this.initial).evaluate(ctx)
let val = yield evalToken(this.initial, ctx)
for (const filter of this.filters) {
val = yield filter.render(val, ctx)
}
@@ -1,18 +1,23 @@
import { Token } from './token'
import { TokenKind } from '../parser/token-kind'
import { last } from '../util/underscore'
export class DelimitedToken extends Token {
export abstract class DelimitedToken extends Token {
public trimLeft = false
public trimRight = false
public content: string
public constructor (
raw: string,
kind: TokenKind,
content: string,
input: string,
line: number,
pos: number,
begin: number,
end: number,
trimLeft: boolean,
trimRight: boolean,
file?: string
) {
super(raw, input, line, pos, file)
super(kind, input, begin, end, file)
this.content = this.getText()
const tl = content[0] === '-'
const tr = last(content) === '-'
this.content = content
@@ -1,16 +1,16 @@
import { Token } from './token'
import { FilterArg } from './filter-arg'
import { FilterArg } from '../parser/filter-arg'
import { TokenKind } from '../parser/token-kind'
export class FilterToken extends Token {
public constructor (
public name: string,
public args: FilterArg[],
raw: string,
input: string,
line: number,
col: number,
begin: number,
end: number,
file?: string
) {
super(raw, input, line, col, file)
super(TokenKind.Filter, input, begin, end, file)
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Token } from './token'
import { ValueToken } from './value-token'
import { WordToken } from './word-token'
import { TokenKind } from '../parser/token-kind'
export class HashToken extends Token {
constructor (
public input: string,
public begin: number,
public end: number,
public name: WordToken,
public value?: ValueToken,
public file?: string
) {
super(TokenKind.Hash, input, begin, end, file)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Token } from './token'
import { TokenKind } from '../parser/token-kind'
export class HTMLToken extends Token {
trimLeft = 0
trimRight = 0
constructor (
public input: string,
public begin: number,
public end: number,
public file?: string
) {
super(TokenKind.HTML, input, begin, end, file)
}
public getContent () {
return this.input.slice(this.begin + this.trimLeft, this.end - this.trimRight)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Token } from './token'
import { TokenKind } from '../parser/token-kind'
export class LiteralToken extends Token {
public literal: string
public constructor (
public input: string,
public begin: number,
public end: number,
public file?: string
) {
super(TokenKind.Literal, input, begin, end, file)
this.literal = this.getText()
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Token } from './token'
import { WordToken } from './word-token'
import { TokenKind } from '../parser/token-kind'
export class NumberToken extends Token {
constructor (
public whole: WordToken,
public decimal?: WordToken
) {
super(TokenKind.Number, whole.input, whole.begin, decimal ? decimal.end : whole.end, whole.file)
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Token } from './token'
import { TokenKind } from '../parser/token-kind'
export const precedence = {
'==': 1,
'!=': 1,
'>': 1,
'<': 1,
'>=': 1,
'<=': 1,
'contains': 1,
'and': 0,
'or': 0
}
export class OperatorToken extends Token {
public operator: string
public constructor (
public input: string,
public begin: number,
public end: number,
public file?: string
) {
super(TokenKind.Operator, input, begin, end, file)
this.operator = this.getText()
}
getPrecedence () {
return precedence[this.getText()]
}
}
+17
View File
@@ -0,0 +1,17 @@
import { DelimitedToken } from './delimited-token'
import { NormalizedFullOptions } from '../liquid-options'
import { TokenKind } from '../parser/token-kind'
export class OutputToken extends DelimitedToken {
public constructor (
input: string,
begin: number,
end: number,
options: NormalizedFullOptions,
file?: string
) {
const { trimOutputLeft, trimOutputRight, outputDelimiterLeft, outputDelimiterRight } = options
const value = input.slice(begin + outputDelimiterLeft.length, end - outputDelimiterRight.length)
super(TokenKind.Output, value, input, begin, end, trimOutputLeft, trimOutputRight, file)
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Token } from './token'
import { WordToken } from './word-token'
import { QuotedToken } from './quoted-token'
import { TokenKind } from '../parser/token-kind'
export class PropertyAccessToken extends Token {
constructor (
public variable: WordToken,
public props: (WordToken | QuotedToken | PropertyAccessToken)[],
end: number
) {
super(TokenKind.PropertyAccess, variable.input, variable.begin, end, variable.file)
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Token } from './token'
import { TokenKind } from '../parser/token-kind'
export class QuotedToken extends Token {
constructor (
public input: string,
public begin: number,
public end: number,
public file?: string
) {
super(TokenKind.Quoted, input, begin, end, file)
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Token } from './token'
import { ValueToken } from './value-token'
import { TokenKind } from '../parser/token-kind'
export class RangeToken extends Token {
constructor (
public input: string,
public begin: number,
public end: number,
public lhs: ValueToken,
public rhs: ValueToken,
public file?: string
) {
super(TokenKind.Range, input, begin, end, file)
}
}
+30
View File
@@ -0,0 +1,30 @@
import { DelimitedToken } from './delimited-token'
import { BLANK, TYPES, VARIABLE } from '../util/character'
import { TokenizationError } from '../util/error'
import { NormalizedFullOptions } from '../liquid-options'
import { TokenKind } from '../parser/token-kind'
export class TagToken extends DelimitedToken {
public name: string
public args: string
public constructor (
input: string,
begin: number,
end: number,
options: NormalizedFullOptions,
file?: string
) {
const { trimTagLeft, trimTagRight, tagDelimiterLeft, tagDelimiterRight } = options
const value = input.slice(begin + tagDelimiterLeft.length, end - tagDelimiterRight.length)
super(TokenKind.Tag, value, input, begin, end, trimTagLeft, trimTagRight, file)
let nameEnd = 0
while (TYPES[this.content.charCodeAt(nameEnd)] & VARIABLE) nameEnd++
this.name = this.content.slice(0, nameEnd)
if (!this.name) throw new TokenizationError(`illegal tag syntax`, this)
let argsBegin = nameEnd
while (TYPES[this.content.charCodeAt(argsBegin)] & BLANK) argsBegin++
this.args = this.content.slice(argsBegin)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { TokenKind } from '../parser/token-kind'
export abstract class Token {
public constructor (
public kind: TokenKind,
public input: string,
public begin: number,
public end: number,
public file?: string
) {}
public getText () {
return this.input.slice(this.begin, this.end)
}
public getPosition () {
let [row, col] = [1, 1]
for (let i = 0; i < this.begin; i++) {
if (this.input[i] === '\n') {
row++
col = 1
} else col++
}
return [row, col]
}
public size () {
return this.end - this.begin
}
}
+5
View File
@@ -0,0 +1,5 @@
import { TagToken } from './tag-token'
import { HTMLToken } from './html-token'
import { OutputToken } from './output-token'
export type TopLevelToken = TagToken | OutputToken | HTMLToken
+6
View File
@@ -0,0 +1,6 @@
import { RangeToken } from './range-token'
import { LiteralToken } from './literal-token'
import { QuotedToken } from './quoted-token'
import { PropertyAccessToken } from './property-access-token'
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken
+26
View File
@@ -0,0 +1,26 @@
import { Token } from './token'
import { NUMBER, TYPES, SIGN } from '../util/character'
import { TokenKind } from '../parser/token-kind'
// a word can be an identifier, a number, a keyword or a single-word-literal
export class WordToken extends Token {
public content: string
constructor (
public input: string,
public begin: number,
public end: number,
public file?: string
) {
super(TokenKind.Word, input, begin, end, file)
this.content = this.getText()
}
isNumber (allowSign = false) {
const begin = allowSign && TYPES[this.input.charCodeAt(this.begin)] & SIGN
? this.begin + 1
: this.begin
for (let i = begin; i < this.end; i++) {
if (!(TYPES[this.input.charCodeAt(i)] & NUMBER)) return false
}
return true
}
}
+7 -2
View File
@@ -1,13 +1,18 @@
import * as TypeGuards from './util/type-guards'
export { TypeGuards }
export { ParseError, TokenizationError, AssertionError } from './util/error'
export { assert } from './util/assert'
export { Drop } from './drop/drop'
export { Emitter } from './render/emitter'
export { Expression } from './render/expression'
export { isFalsy, isTruthy } from './render/boolean'
export { TagToken } from './parser/tag-token'
export { TagToken } from './tokens/tag-token'
export { Context } from './context/context'
export { Template } from './template/template'
export { TagImplOptions } from './template/tag/tag-impl-options'
export { ParseStream } from './parser/parse-stream'
export { Token } from './parser/token'
export { Token } from './tokens/token'
export { TopLevelToken } from './tokens/toplevel-token'
export { Tokenizer } from './parser/tokenizer'
export { Hash } from './template/tag/hash'
export { evalToken, evalQuotedToken } from './render/expression'
+3 -3
View File
@@ -1,8 +1,8 @@
import { AssertionError } from './error'
export function assert <T> (predicate: T | null | undefined, message?: string) {
export function assert <T> (predicate: T | null | undefined, message?: () => string) {
if (!predicate) {
message = message || `expect ${predicate} to be true`
throw new AssertionError(message)
const msg = message ? message() : `expect ${predicate} to be true`
throw new AssertionError(msg)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { isFunction } from './underscore'
type resolver = (x?: any) => Thenable
type resolver = (x?: any) => any
interface Thenable {
then (resolve: resolver, reject?: resolver): Thenable;
+10
View File
@@ -0,0 +1,10 @@
// bitmask character types to boost performance
// generated by bin/char-types.js
export const TYPES = [0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 4, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 2, 8, 0, 0, 0, 0, 8, 0, 0, 0, 64, 0, 65, 0, 0, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 0, 0, 2, 2, 2, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
export const VARIABLE = 1
export const OPERATOR = 2
export const BLANK = 4
export const QUOTE = 8
export const INLINE_BLANK = 16
export const NUMBER = 32
export const SIGN = 64
+7 -5
View File
@@ -1,5 +1,5 @@
import * as _ from './underscore'
import { Token } from '../parser/token'
import { Token } from '../tokens/token'
import { Template } from '../template/template'
abstract class LiquidError extends Error {
@@ -57,14 +57,15 @@ export class AssertionError extends Error {
}
function mkContext (token: Token) {
const [line] = token.getPosition()
const lines = token.input.split('\n')
const begin = Math.max(token.line - 2, 1)
const end = Math.min(token.line + 3, lines.length)
const begin = Math.max(line - 2, 1)
const end = Math.min(line + 3, lines.length)
const context = _
.range(begin, end + 1)
.map(lineNumber => {
const indicator = (lineNumber === token.line) ? '>> ' : ' '
const indicator = (lineNumber === line) ? '>> ' : ' '
const num = _.padStart(String(lineNumber), String(end).length)
const text = lines[lineNumber - 1]
return `${indicator}${num}| ${text}`
@@ -76,6 +77,7 @@ function mkContext (token: Token) {
function mkMessage (msg: string, token: Token) {
if (token.file) msg += `, file:${token.file}`
msg += `, line:${token.line}, col:${token.col}`
const [line, col] = token.getPosition()
msg += `, line:${line}, col:${col}`
return msg
}
+12
View File
@@ -0,0 +1,12 @@
import { NullDrop } from '../drop/null-drop'
import { EmptyDrop } from '../drop/empty-drop'
import { BlankDrop } from '../drop/blank-drop'
export const literalValues = {
'true': true,
'false': false,
'nil': new NullDrop(),
'null': new NullDrop(),
'empty': new EmptyDrop(),
'blank': new BlankDrop()
}
+55
View File
@@ -0,0 +1,55 @@
import { OperatorToken } from '../tokens/operator-token'
import { WordToken } from '../tokens/word-token'
import { TagToken } from '../tokens/tag-token'
import { HTMLToken } from '../tokens/html-token'
import { OutputToken } from '../tokens/output-token'
import { PropertyAccessToken } from '../tokens/property-access-token'
import { LiteralToken } from '../tokens/literal-token'
import { QuotedToken } from '../tokens/quoted-token'
import { NumberToken } from '../tokens/number-token'
import { RangeToken } from '../tokens/range-token'
import { TokenKind } from '../parser/token-kind'
export function isOperatorToken (val: any): val is OperatorToken {
return getKind(val) === TokenKind.Operator
}
export function isHTMLToken (val: any): val is HTMLToken {
return getKind(val) === TokenKind.HTML
}
export function isOutputToken (val: any): val is OutputToken {
return getKind(val) === TokenKind.Output
}
export function isTagToken (val: any): val is TagToken {
return getKind(val) === TokenKind.Tag
}
export function isQuotedToken (val: any): val is QuotedToken {
return getKind(val) === TokenKind.Quoted
}
export function isLiteralToken (val: any): val is LiteralToken {
return getKind(val) === TokenKind.Literal
}
export function isNumberToken (val: any): val is NumberToken {
return getKind(val) === TokenKind.Number
}
export function isPropertyAccessToken (val: any): val is PropertyAccessToken {
return getKind(val) === TokenKind.PropertyAccess
}
export function isWordToken (val: any): val is WordToken {
return getKind(val) === TokenKind.Word
}
export function isRangeToken (val: any): val is RangeToken {
return getKind(val) === TokenKind.Range
}
function getKind (val: any) {
return val ? val.kind : -1
}
+1 -1
View File
@@ -6,6 +6,6 @@ describe('#evalValueSync()', function () {
beforeEach(() => { engine = new Liquid() })
it('should throw when scope undefined', async function () {
return expect(() => engine.evalValueSync('{{"foo"}}', null as any)).to.throw(/context not defined/)
return expect(() => engine.evalValueSync('foo', null as any)).to.throw(/context not defined/)
})
})
+2 -2
View File
@@ -1,11 +1,11 @@
import { expect } from 'chai'
import { Liquid } from '../..'
describe('.evalValue()', function () {
describe('#evalValue()', function () {
var engine: Liquid
beforeEach(() => { engine = new Liquid() })
it('should throw when scope undefined', async function () {
return expect(engine.evalValue('{{"foo"}}', null as any)).to.be.rejectedWith(/context not defined/)
return expect(engine.evalValue('"foo"', null as any)).to.be.rejectedWith(/context not defined/)
})
})
@@ -6,11 +6,6 @@ use(chaiAsPromised)
describe('tags/decrement', function () {
const liquid = new Liquid()
it('should throw when variable expression illegal', function () {
const src = '{% decrement / %}{{var}}'
const ctx = {}
return expect(liquid.parseAndRender(src, ctx)).to.be.rejectedWith(/illegal/)
})
it('should decrement undefined variable', async function () {
const src = '{% decrement var %}{% decrement var %}{% decrement var %}'
+6
View File
@@ -46,6 +46,12 @@ describe('tags/for', function () {
.to.be.rejectedWith(/tag .* not closed/)
})
it('should reject when for in not found', function () {
const src = '{%for c alpha%}{{c}}'
return expect(liquid.parseAndRender(src, scope))
.to.be.rejectedWith('illegal tag: {%for c alpha%}, line:1, col:1')
})
it('should reject when inner templates rejected', function () {
const src = '{%for c in alpha%}{%throwingTag%}{%endfor%}'
return expect(liquid.parseAndRender(src, scope))
+16
View File
@@ -83,6 +83,22 @@ describe('tags/include', function () {
const html = await liquid.renderFile('with.html')
return expect(html).to.equal('color:red, shape:rect')
})
it('should ignore if with value not specified', async function () {
mock({
'/with.html': '{% include "color" with, shape: "rect" %}',
'/color.html': 'color:{{color}}, shape:{{shape}}'
})
const html = await liquid.renderFile('with.html')
return expect(html).to.equal('color:, shape:rect')
})
it('should treat with as a valid key', async function () {
mock({
'/with.html': '{% include "color" with: "foo" %}',
'/color.html': 'with:{{with}}'
})
const html = await liquid.renderFile('with.html')
return expect(html).to.equal('with:foo')
})
it('should support include: with as Drop', async function () {
class ColorDrop extends Drop {
public valueOf (): string {
+13 -1
View File
@@ -1,6 +1,9 @@
import { Liquid } from '../../../../src/liquid'
import { expect } from 'chai'
import { expect, use } from 'chai'
import { mock, restore } from '../../../stub/mockfs'
import * as chaiAsPromised from 'chai-as-promised'
use(chaiAsPromised)
describe('tags/layout', function () {
let liquid: Liquid
@@ -29,6 +32,15 @@ describe('tags/layout', function () {
expect(e.message).to.match(/illegal argument ""/)
})
})
it('should throw when filename resolved to falsy', function () {
mock({
'/parent.html': '{%layout foo%}'
})
return liquid.renderFile('/parent.html').catch(function (e) {
expect(e.name).to.equal('RenderError')
expect(e.message).to.match(/illegal filename "foo":"undefined"/)
})
})
describe('anonymous block', function () {
it('should handle anonymous block', async function () {
mock({
+25 -1
View File
@@ -121,9 +121,33 @@ describe('tags/render', function () {
const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] })
expect(html).to.equal('1: red\n2: green\n')
})
it('should support for <non-array> as', async function () {
mock({
'/index.html': '{% render "item" for "green" as color %}',
'/item.html': '{{forloop.index}}: {{color}}\n'
})
const html = await liquid.renderFile('index.html')
expect(html).to.equal('1: green\n')
})
it('should support for without as', async function () {
mock({
'/index.html': '{% render "item" for colors %}',
'/item.html': '{{forloop.index}}: {{color}}\n'
})
const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] })
expect(html).to.equal('1: \n2: \n')
})
it('should support for...as with other parameters', async function () {
mock({
'/index.html': '{% render "item" for colors as color with ".\n" as tail, sep: ". "%}',
'/index.html': '{% render "item" for colors as color with ".\n" as tail sep: ". "%}',
'/item.html': '{{forloop.index}}{{sep}}{{color}}{{tail}}'
})
const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] })
expect(html).to.equal('1. red.\n2. green.\n')
})
it('should support for...as with other parameters (comma separated)', async function () {
mock({
'/index.html': '{% render "item" for colors as color, with ".\n" as tail, sep: ". "%}',
'/item.html': '{{forloop.index}}{{sep}}{{color}}{{tail}}'
})
const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] })
+10 -1
View File
@@ -1,5 +1,8 @@
import { Liquid } from '../../../../src/liquid'
import { expect } from 'chai'
import { expect, use } from 'chai'
import * as chaiAsPromised from 'chai-as-promised'
use(chaiAsPromised)
describe('tags/tablerow', function () {
const liquid = new Liquid()
@@ -50,6 +53,12 @@ describe('tags/tablerow', function () {
.to.be.rejectedWith(/tag .* not closed/)
})
it('should throw when x in y not found', function () {
const src = '{% tablerow i (1..3) %}{{ i }}'
return expect(liquid.parseAndRender(src))
.to.be.rejectedWith('illegal tag: {% tablerow i (1..3) %}, line:1, col:1')
})
it('should support tablerow with range', async function () {
const src = '{% tablerow i in (1..5) cols:2 %}{{ i }}{% endtablerow %}'
const dst =
+1 -1
View File
@@ -93,7 +93,7 @@ describe('Liquid', function () {
})
const tpls = await engine.getTemplate('mocha')
expect(tpls.length).to.gte(1)
expect(tpls[0].token.raw).to.contain('module.exports')
expect(tpls[0].token.getText()).to.contain('module.exports')
})
})
describe('#evalValue', function () {
+1 -1
View File
@@ -21,7 +21,7 @@ describe('liquid#registerTag()', function () {
it('should have access to ctx in render()', async () => {
const liquid = new Liquid()
liquid.registerTag('dynamic-string', {
render: async (ctx) => ctx.get('c')
render: async (ctx) => ctx.get(['c'])
})
const html = await liquid.parseAndRender(`A{% dynamic-string %}C`, {
c: 'B'
@@ -1,7 +1,7 @@
import { normalize } from '../../../src/liquid-options'
import { expect } from 'chai'
describe('LiquidOptions', function () {
describe('LiquidOptions#root', function () {
describe('#normalize ()', function () {
it('should normalize string typed root array', function () {
const options = normalize({ root: 'foo' })
+1 -1
View File
@@ -45,7 +45,7 @@ describe('LiquidOptions#trimming', function () {
const html = await engine.parseAndRender(src, ctx)
return expect(html).to.equal('aharttle')
})
it('should respect to greedy:false by default', async function () {
it('should allow greedy:false', async function () {
const engine = new Liquid({ greedy: false } as any)
const html = await engine.parseAndRender(src, ctx)
return expect(html).to.equal('\n a \nharttle ')
@@ -1,5 +1,5 @@
import { expect } from 'chai'
import { Liquid } from '../..'
import { Liquid } from '../../../src/liquid'
const liquid = new Liquid()
+3 -20
View File
@@ -41,11 +41,6 @@ describe('error', function () {
const err = await expect(engine.parseAndRender(html)).be.rejected
expect(err.token.input).to.equal(html)
})
it('should contain line number in err.token.line', async function () {
const err = await expect(engine.parseAndRender('1\n2\n{% . a %}\n4')).be.rejected
expect(err.name).to.equal('TokenizationError')
expect(err.token.line).to.equal(3)
})
it('should contain stack in err.stack', async function () {
const err = await expect(engine.parseAndRender('{% . a %}')).be.rejected
expect(err.message).to.contain('illegal tag syntax')
@@ -58,11 +53,11 @@ describe('error', function () {
expect(err.stack).to.not.contain('at Object.parse')
})
})
it('should throw error with line and pos if tag unmatched', async function () {
it('should throw error with [line, col] if tag unmatched', async function () {
const err = await expect(engine.parseAndRender('1\n2\nfoo{% assign a = 4 }\n4')).be.rejected
console.log(err.stack)
expect(err.name).to.equal('TokenizationError')
expect(err.token.line).to.equal(3)
expect(err.token.col).to.equal(4)
expect(err.message).to.equal('tag "{% assign a =..." not closed, line:3, col:4')
})
})
@@ -175,12 +170,6 @@ describe('error', function () {
expect(err.stack).to.contain(message.join('\n'))
expect(err.name).to.equal('RenderError')
})
it('should contain line number in err.token.line', async function () {
const src = '1\n2\n{{1|throwingFilter}}\n4'
const err = await expect(engine.parseAndRender(src)).be.rejected
expect(err.token.line).to.equal(3)
expect(err.name).to.equal('RenderError')
})
it('should contain stack in err.stack', async function () {
const err = await expect(engine.parseAndRender('{%rejectingTag%}')).be.rejected
expect(err.message).to.contain('intended render reject')
@@ -256,12 +245,6 @@ describe('error', function () {
expect(err.stack).to.contain(message.join('\n'))
})
it('should contain line number in err.token.line', async function () {
const html = '<html>\n<head>\n\n{% raw %}\n\n'
const err = await expect(engine.parseAndRender(html)).be.rejected
expect(err.token.line).to.equal(4)
})
it('should contain stack in err.stack', async function () {
const err = await expect(engine.parseAndRender('{% -a %}')).be.rejected
expect(err.stack).to.contain('ParseError: tag "-a" not found')
+30 -104
View File
@@ -24,123 +24,49 @@ describe('Context', function () {
ctx = new Context(scope)
})
describe('#propertyAccessSeq()', function () {
it('should handle dot syntax', async function () {
expect(ctx.parseProp('foo.bar'))
.to.deep.equal(['foo', 'bar'])
})
it('should handle [<String>] syntax', async function () {
expect(ctx.parseProp('foo["bar"]'))
.to.deep.equal(['foo', 'bar'])
})
it('should handle [<Identifier>] syntax', async function () {
expect(ctx.parseProp('foo[foo]'))
.to.deep.equal(['foo', 'zoo'])
})
it('should handle nested access 1', async function () {
expect(ctx.parseProp('foo[bar.zoo]'))
.to.deep.equal(['foo', 'coo'])
})
it('should handle nested access 2', async function () {
expect(ctx.parseProp('foo[bar["zoo"]]'))
.to.deep.equal(['foo', 'coo'])
})
it('should handle nested access 3', async function () {
expect(ctx.parseProp('bar["foo"].zoo'))
.to.deep.equal(['bar', 'foo', 'zoo'])
})
it('should handle nested access 4', async function () {
expect(ctx.parseProp('foo[0].bar'))
.to.deep.equal(['foo', '0', 'bar'])
})
it('should handle nested access 5', async function () {
expect(ctx.parseProp('foo[one].bar'))
.to.deep.equal(['foo', '1', 'bar'])
})
it('should handle nested access 6', async function () {
expect(ctx.parseProp('foo[two].bar'))
.to.deep.equal(['foo', 'undefined', 'bar'])
})
})
describe('#get()', function () {
it('should get direct property', async function () {
expect(ctx.get('foo')).equal('zoo')
expect(ctx.get(['foo'])).equal('zoo')
})
it('should read nested property', async function () {
expect(ctx.get(['obj', 'first'])).to.equal('f')
expect(ctx.get(['obj', 'last'])).to.equal('l')
expect(ctx.get(['obj', 'size'])).to.equal(undefined)
})
it('undefined property should yield undefined', async function () {
expect(ctx.get('notdefined')).to.equal(undefined)
expect(ctx.get(false as any)).to.equal(undefined)
})
it('should throw for invalid path', async function () {
expect(() => ctx.get('')).to.throw('invalid path:""')
})
it('should throw when [] unbalanced', async function () {
expect(() => ctx.get('foo[bar')).to.throw(/unbalanced \[\]/)
})
it('should throw when "" unbalanced', async function () {
expect(() => ctx.get('foo["bar]')).to.throw(/unbalanced "/)
})
it("should throw when '' unbalanced", async function () {
expect(() => ctx.get("foo['bar]")).to.throw(/unbalanced '/)
expect(ctx.get(['notdefined'])).to.equal(undefined)
expect(ctx.get([false as any])).to.equal(undefined)
})
it('should respect to toLiquid', async function () {
const scope = new Context({ foo: {
toLiquid: () => ({ bar: 'BAR' }),
bar: 'bar'
} })
expect(scope.get('foo.bar')).to.equal('BAR')
expect(scope.get(['foo', 'bar'])).to.equal('BAR')
})
it('should access child property via dot syntax', async function () {
expect(ctx.get('bar.zoo')).to.equal('coo')
expect(ctx.get('bar.arr')).to.deep.equal(['a', 'b'])
})
it('should access child property via [<String>] syntax', async function () {
expect(ctx.get('bar["zoo"]')).to.equal('coo')
})
it('should access child property via [<Number>] syntax', async function () {
expect(ctx.get('bar.arr[0]')).to.equal('a')
})
it('should access child property via [<Identifier>] syntax', async function () {
expect(ctx.get('bar[foo]')).to.equal('coo')
})
it('should return undefined when not exist', async function () {
expect(ctx.get('foo.foo.foo')).to.be.undefined
expect(ctx.get(['foo', 'foo', 'foo'])).to.be.undefined
})
it('should return string length as size', async function () {
expect(ctx.get('foo.size')).to.equal(3)
expect(ctx.get(['foo', 'size'])).to.equal(3)
})
it('should return array length as size', async function () {
expect(ctx.get('bar.arr.size')).to.equal(2)
})
it('should return size property if exists', async function () {
expect(ctx.get('zoo.size')).to.equal(4)
})
it('should return undefined if do not have size and length', async function () {
expect(ctx.get('one.size')).to.equal(undefined)
expect(ctx.get(['bar', 'arr', 'size'])).to.equal(2)
})
it('should read .first of array', async function () {
expect(ctx.get('bar.arr.first')).to.equal('a')
})
it('should read .first of object', async function () {
expect(ctx.get('obj.first')).to.equal('f')
expect(ctx.get(['bar', 'arr', 'first'])).to.equal('a')
})
it('should read .last of array', async function () {
expect(ctx.get('bar.arr.last')).to.equal('b')
})
it('should read .last of object', async function () {
expect(ctx.get('obj.last')).to.equal('l')
expect(ctx.get(['bar', 'arr', 'last'])).to.equal('b')
})
})
describe('#getFromScope()', function () {
it('should support string', () => {
expect(ctx.getFromScope({ obj: { foo: 'FOO' } }, 'obj.foo')).to.equal('FOO')
})
})
describe('strictVariables', async function () {
let ctx: Context
beforeEach(function () {
@@ -149,22 +75,22 @@ describe('Context', function () {
} as any)
})
it('should throw when variable not defined', function () {
return expect(() => ctx.get('notdefined')).to.throw(/undefined variable: notdefined/)
return expect(() => ctx.get(['notdefined'])).to.throw(/undefined variable: notdefined/)
})
it('should throw when deep variable not exist', async function () {
ctx.push({ foo: 'FOO' })
return expect(() => ctx.get('foo.bar.not.defined')).to.throw(/undefined variable: bar/)
return expect(() => ctx.get(['foo', 'bar', 'not', 'defined'])).to.throw(/undefined variable: bar/)
})
it('should throw when itself not defined', async function () {
ctx.push({ foo: 'FOO' })
return expect(() => ctx.get('foo.BAR')).to.throw(/undefined variable: BAR/)
return expect(() => ctx.get(['foo', 'BAR'])).to.throw(/undefined variable: BAR/)
})
it('should find variable in parent scope', async function () {
ctx.push({ 'foo': 'foo' })
ctx.push({
'bar': 'bar'
})
expect(ctx.get('foo')).to.equal('foo')
expect(ctx.get(['foo'])).to.equal('foo')
})
})
@@ -180,14 +106,14 @@ describe('Context', function () {
ctx.push({
foo: 'foo'
})
expect(ctx.get('foo')).to.equal('foo')
expect(ctx.get('bar')).to.equal('bar')
expect(ctx.get(['foo'])).to.equal('foo')
expect(ctx.get(['bar'])).to.equal('bar')
})
it('should hide deep properties by push', async function () {
ctx.push({ bar: { bar: 'bar' } })
ctx.push({ bar: { foo: 'foo' } })
expect(ctx.get('bar.foo')).to.equal('foo')
expect(ctx.get('bar.bar')).to.equal(undefined)
expect(ctx.get(['bar', 'foo'])).to.equal('foo')
expect(ctx.get(['bar', 'bar'])).to.equal(undefined)
})
})
describe('.pop()', function () {
@@ -196,7 +122,7 @@ describe('Context', function () {
foo: 'foo'
})
ctx.pop()
expect(ctx.get('foo')).to.equal('zoo')
expect(ctx.get(['foo'])).to.equal('zoo')
})
})
})
+26
View File
@@ -0,0 +1,26 @@
import { expect } from 'chai'
import { matchOperator } from '../../../src/parser/match-operator'
describe('parser/matchOperator()', function () {
it('should match contains', () => {
expect(matchOperator('contains', 0)).to.equal(8)
})
it('should match comparision', () => {
expect(matchOperator('>', 0)).to.equal(1)
expect(matchOperator('>=', 0)).to.equal(2)
expect(matchOperator('<', 0)).to.equal(1)
expect(matchOperator('<=', 0)).to.equal(2)
})
it('should match binary logic', () => {
expect(matchOperator('and', 0)).to.equal(3)
expect(matchOperator('or', 0)).to.equal(2)
})
it('should not match if word not terminate', () => {
expect(matchOperator('true1', 0)).to.equal(-1)
expect(matchOperator('containsa', 0)).to.equal(-1)
})
it('should match if word boundary found', () => {
expect(matchOperator('>=1', 0)).to.equal(2)
expect(matchOperator('contains b', 0)).to.equal(8)
})
})
@@ -1,30 +1,5 @@
import { expect } from 'chai'
import { parseLiteral, parseStringLiteral } from '../../../src/parser/literal'
import { NullDrop } from '../../../src/drop/null-drop'
describe('parseLiteral()', function () {
it('should eval boolean literal', async function () {
expect(parseLiteral('true')).to.equal(true)
expect(parseLiteral('TrUE')).to.equal(undefined)
expect(parseLiteral('false')).to.equal(false)
})
it('should eval number literal', async function () {
expect(parseLiteral('2.3')).to.equal(2.3)
expect(parseLiteral('.32')).to.equal(0.32)
expect(parseLiteral('-23.')).to.equal(-23)
expect(parseLiteral('23')).to.equal(23)
})
it('should eval string literal', async function () {
expect(parseLiteral('"ab\'c"')).to.equal("ab'c")
expect(parseLiteral("'ab\"c'")).to.equal('ab"c')
})
it('should eval nil literal', async function () {
expect(parseLiteral('nil')).to.be.instanceOf(NullDrop)
})
it('should eval null literal', async function () {
expect(parseLiteral('null')).to.be.instanceOf(NullDrop)
})
})
import { parseStringLiteral } from '../../../src/parser/parse-string-literal'
describe('parseStringLiteral()', function () {
it('should parse octal escape', () => {
@@ -36,7 +11,7 @@ describe('parseStringLiteral()', function () {
it('should skip invalid octal escape', () => {
expect(parseStringLiteral(String.raw`"\9"`)).to.equal('9')
})
it('should parse \n, \t, \r', () => {
it('should parse \\n, \\t, \\r', () => {
expect(parseStringLiteral(String.raw`"fo\no"`)).to.equal('fo\no')
expect(parseStringLiteral(String.raw`'fo\to'`)).to.equal('fo\to')
expect(parseStringLiteral(String.raw`'fo\ro'`)).to.equal('fo\ro')
+356 -135
View File
@@ -1,210 +1,431 @@
import { expect } from 'chai'
import { WordToken } from '../../../src/tokens/word-token'
import { NumberToken } from '../../../src/tokens/number-token'
import { PropertyAccessToken } from '../../../src/tokens/property-access-token'
import { RangeToken } from '../../../src/tokens/range-token'
import { OperatorToken } from '../../../src/tokens/operator-token'
import { Tokenizer } from '../../../src/parser/tokenizer'
import { TagToken } from '../../../src/parser/tag-token'
import { OutputToken } from '../../../src/parser/output-token'
import { HTMLToken } from '../../../src/parser/html-token'
import { TagToken } from '../../../src/tokens/tag-token'
import { QuotedToken } from '../../../src/tokens/quoted-token'
import { OutputToken } from '../../../src/tokens/output-token'
import { HTMLToken } from '../../../src/tokens/html-token'
describe('Tokenize', function () {
it('should read quoted', () => {
expect(new Tokenizer('"foo" ff').readQuoted().toString()).to.equal('"foo"')
expect(new Tokenizer(' "foo"ff').readQuoted().toString()).to.equal('"foo"')
})
it('should read property access', () => {
expect(new Tokenizer('a[ b][ "c d" ]').readPropertyAccess().toString()).to.equal('a[ b][ "c d" ]')
expect(new Tokenizer('a.b[c[d.e]]').readPropertyAccess().toString()).to.equal('a.b[c[d.e]]')
expect(new Tokenizer('"foo" ff').readQuoted()!.getText()).to.equal('"foo"')
expect(new Tokenizer(' "foo"ff').readQuoted()!.getText()).to.equal('"foo"')
})
it('should read value', () => {
expect(new Tokenizer('2.33.2').readValue().toString()).to.equal('2.33.2')
expect(new Tokenizer('"foo"a').readValue().toString()).to.equal('"foo"')
expect(new Tokenizer('a[b]["c d"]').readValue().toString()).to.equal('a[b]["c d"]')
expect(new Tokenizer('a[ b][ "c d" ]').readValueOrThrow().getText()).to.equal('a[ b][ "c d" ]')
expect(new Tokenizer('a.b[c[d.e]]').readValueOrThrow().getText()).to.equal('a.b[c[d.e]]')
})
it('should read number value', () => {
const token: NumberToken = new Tokenizer('2.33.2').readValueOrThrow() as any
expect(token).to.be.instanceOf(NumberToken)
expect(token.whole.getText()).to.equal('2')
expect(token.decimal!.getText()).to.equal('33')
expect(token.getText()).to.equal('2.33')
})
it('should read quoted value', () => {
const value = new Tokenizer('"foo"a').readValue()
expect(value).to.be.instanceOf(QuotedToken)
expect(value!.getText()).to.equal('"foo"')
})
it('should read property access value', () => {
expect(new Tokenizer('a[b]["c d"]').readValueOrThrow().getText()).to.equal('a[b]["c d"]')
})
it('should read hash', () => {
expect(new Tokenizer('foo: 3').readHash()).to.deep.equal(['foo', '3'])
expect(new Tokenizer(', foo: a[ "bar"]').readHash()).to.deep.equal(['foo', 'a[ "bar"]'])
const hash1 = new Tokenizer('foo: 3').readHash()
expect(hash1!.name.content).to.equal('foo')
expect(hash1!.value!.getText()).to.equal('3')
const hash2 = new Tokenizer(', foo: a[ "bar"]').readHash()
expect(hash2!.name.content).to.equal('foo')
expect(hash2!.value!.getText()).to.equal('a[ "bar"]')
})
it('should read hashs', () => {
expect(new Tokenizer(', limit: 3 reverse offset:off').readHashes())
.to.deep.equal([['limit', '3'], ['reverse', ''], ['offset', 'off']])
expect(new Tokenizer('cols: 2, rows: data["rows"]').readHashes())
.to.deep.equal([['cols', '2'], ['rows', 'data["rows"]']])
it('should read multiple hashs', () => {
const hashes = new Tokenizer(', limit: 3 reverse offset:off').readHashes()
expect(hashes).to.have.lengthOf(3)
const [limit, reverse, offset] = hashes
expect(limit.name.content).to.equal('limit')
expect(limit.value!.getText()).to.equal('3')
expect(reverse.name.content).to.equal('reverse')
expect(reverse.value).to.be.undefined
expect(offset.name.content).to.equal('offset')
expect(offset.value!.getText()).to.equal('off')
})
it('should read hash value with property access', () => {
const hashes = new Tokenizer('cols: 2, rows: data["rows"]').readHashes()
expect(hashes).to.have.lengthOf(2)
const [cols, rols] = hashes
expect(cols.name.content).to.equal('cols')
expect(cols.value!.getText()).to.equal('2')
expect(rols.name.content).to.equal('rows')
expect(rols.value!.getText()).to.equal('data["rows"]')
})
it('should read HTML token', function () {
const html = '<html><body><p>Lorem Ipsum</p></body></html>'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(1)
expect(tokens[0].content).to.equal(html)
expect(tokens[0]).instanceOf(HTMLToken)
expect((tokens[0] as HTMLToken).getContent()).to.equal(html)
})
it('should read tag token', function () {
const html = '<p>{% for p in a[1]%}</p>'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(3)
expect(tokens[1]).instanceOf(TagToken)
expect(tokens[1].content).to.equal('for p in a[1]')
const tag = tokens[1] as TagToken
expect(tag).instanceOf(TagToken)
expect(tag.name).to.equal('for')
expect(tag.args).to.equal('p in a[1]')
})
it('should read value token', function () {
it('should read output token', function () {
const html = '<p>{{foo | date: "%Y-%m-%d"}}</p>'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(3)
expect(tokens[1]).instanceOf(OutputToken)
expect(tokens[1].content).to.equal('foo | date: "%Y-%m-%d"')
const output = tokens[1] as OutputToken
expect(output).instanceOf(OutputToken)
expect(output.content).to.equal('foo | date: "%Y-%m-%d"')
})
it('should handle consecutive value and tags', function () {
const html = '{{foo}}{{bar}}{%foo%}{%bar%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(4)
expect(tokens[0]).instanceOf(OutputToken)
expect(tokens[2]).instanceOf(TagToken)
const o1 = tokens[0] as OutputToken
const o2 = tokens[1] as OutputToken
const t1 = tokens[2] as TagToken
const t2 = tokens[3] as TagToken
expect(o1).instanceOf(OutputToken)
expect(o2).instanceOf(OutputToken)
expect(t1).instanceOf(TagToken)
expect(t2).instanceOf(TagToken)
expect(tokens[1].content).to.equal('bar')
expect(tokens[2].content).to.equal('foo')
expect(o1.content).to.equal('foo')
expect(o2.content).to.equal('bar')
expect(t1.name).to.equal('foo')
expect(t1.args).to.equal('')
expect(t2.name).to.equal('bar')
expect(t2.args).to.equal('')
})
it('should keep white spaces and newlines', function () {
const html = '{%foo%}\n{%bar %} \n {%alice%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(5)
expect(tokens[1]).instanceOf(HTMLToken)
expect(tokens[1].raw).to.equal('\n')
expect(tokens[1].getText()).to.equal('\n')
expect(tokens[3]).instanceOf(HTMLToken)
expect(tokens[3].raw).to.equal(' \n ')
expect(tokens[3].getText()).to.equal(' \n ')
})
it('should handle multiple lines tag', function () {
const html = '{%foo\na:a\nb:1.23\n%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(1)
expect(tokens[0]).instanceOf(TagToken)
expect((tokens[0] as TagToken).args).to.equal('a:a\nb:1.23')
expect(tokens[0].raw).to.equal('{%foo\na:a\nb:1.23\n%}')
expect(tokens[0].getText()).to.equal('{%foo\na:a\nb:1.23\n%}')
})
it('should handle multiple lines value', function () {
const html = '{{foo\n|date:\n"%Y-%m-%d"\n}}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(1)
expect(tokens[0]).instanceOf(OutputToken)
expect(tokens[0].raw).to.equal('{{foo\n|date:\n"%Y-%m-%d"\n}}')
expect(tokens[0].getText()).to.equal('{{foo\n|date:\n"%Y-%m-%d"\n}}')
})
it('should handle complex object property access', function () {
const html = '{{ obj["my:property with anything"] }}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTokens()
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).to.equal(1)
expect(tokens[0]).instanceOf(OutputToken)
expect(tokens[0].content).to.equal('obj["my:property with anything"]')
const output = tokens[0] as OutputToken
expect(output).instanceOf(OutputToken)
expect(output.content).to.equal('obj["my:property with anything"]')
})
it('should throw if tag not closed', function () {
const html = '{% assign foo = bar {{foo}}'
const tokenizer = new Tokenizer(html)
expect(() => tokenizer.readTokens()).to.throw(/tag "{% assign foo..." not closed/)
expect(() => tokenizer.readTopLevelTokens()).to.throw(/tag "{% assign foo..." not closed/)
})
it('should throw if output not closed', function () {
const tokenizer = new Tokenizer('{{name}')
expect(() => tokenizer.readTokens()).to.throw(/output "{{name}" not closed/)
expect(() => tokenizer.readTopLevelTokens()).to.throw(/output "{{name}" not closed/)
})
it('should read a simple filter', function () {
const tokenizer = new Tokenizer('| plus')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal([])
describe('#readRange()', () => {
it('should read `(1..3)`', () => {
const range = new Tokenizer('(1..3)').readRange()
expect(range).to.be.instanceOf(RangeToken)
expect(range!.getText()).to.deep.equal('(1..3)')
const { lhs, rhs } = range!
expect(lhs).to.be.instanceOf(NumberToken)
expect(lhs.getText()).to.equal('1')
expect(rhs).to.be.instanceOf(NumberToken)
expect(rhs.getText()).to.equal('3')
})
it('should throw for `(..3)`', () => {
expect(() => new Tokenizer('(..3)').readRange()).to.throw('unexpected token "..3)", value expected')
})
it('should read `(a.b..c["..d"])`', () => {
const range = new Tokenizer('(a.b..c["..d"])').readRange()
expect(range).to.be.instanceOf(RangeToken)
expect(range!.getText()).to.deep.equal('(a.b..c["..d"])')
})
})
it('should read a filter with argument', function () {
const tokenizer = new Tokenizer(' | plus: 1')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal(['1'])
})
it('should read a filter with colon but no argument', function () {
const tokenizer = new Tokenizer('| plus:')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal([])
})
it('should read a filter with k/v argument', function () {
const tokenizer = new Tokenizer(' | plus: a:1')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal([['a', '1']])
})
it('should read a filter with "arr[0]" argument', function () {
const tokenizer = new Tokenizer('| plus: arr[0]')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal(['arr[0]'])
})
it('should read a filter with obj.foo argument', function () {
const tokenizer = new Tokenizer('| plus: obj.foo')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal(['obj.foo'])
})
it('should read a filter with obj["foo"] argument', function () {
const tokenizer = new Tokenizer('| plus: obj["good luck"]')
const token = tokenizer.readFilterToken()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal(['obj["good luck"]'])
})
it('should read simple filters', function () {
const tokenizer = new Tokenizer('| plus: 3 | capitalize')
const tokens = tokenizer.readFilterTokens()
describe('#readFilter()', () => {
it('should read a simple filter', function () {
const tokenizer = new Tokenizer('| plus')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal([])
})
it('should read a filter with argument', function () {
const tokenizer = new Tokenizer(' | plus: 1')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token!.args).to.have.lengthOf(1)
expect(tokens).to.have.lengthOf(2)
expect(tokens[0]).to.have.property('name', 'plus')
expect(tokens[0]).to.have.property('args').to.deep.equal(['3'])
expect(tokens[1]).to.have.property('name', 'capitalize')
expect(tokens[1]).to.have.property('args').to.deep.equal([])
})
it('should read filters', function () {
const tokenizer = new Tokenizer('| plus: 3 | capitalize | append: foo[a.b["c d"]]')
const tokens = tokenizer.readFilterTokens()
const one: NumberToken = token!.args[0] as any
expect(one).to.be.instanceOf(NumberToken)
expect(one.getText()).to.equal('1')
})
it('should read a filter with colon but no argument', function () {
const tokenizer = new Tokenizer('| plus:')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token).to.have.property('args').to.deep.equal([])
})
it('should read a filter with k/v argument', function () {
const tokenizer = new Tokenizer(' | plus: a:1')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token!.args).to.have.lengthOf(1)
expect(tokens).to.have.lengthOf(3)
expect(tokens[0]).to.have.property('name', 'plus')
expect(tokens[0]).to.have.property('args').to.deep.equal(['3'])
expect(tokens[1]).to.have.property('name', 'capitalize')
expect(tokens[1]).to.have.property('args').to.deep.equal([])
expect(tokens[2]).to.have.property('name', 'append')
expect(tokens[2]).to.have.property('args').to.deep.equal(['foo[a.b["c d"]]'])
const [k, v]: [string, NumberToken] = token!.args[0] as any
expect(k).to.equal('a')
expect(v).to.be.instanceOf(NumberToken)
expect(v.getText()).to.equal('1')
})
it('should read a filter with "arr[0]" argument', function () {
const tokenizer = new Tokenizer('| plus: arr[0]')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token!.args).to.have.lengthOf(1)
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.equal('arr')
expect(pa.props).to.have.lengthOf(1)
expect(pa.props[0]).to.be.instanceOf(NumberToken)
expect(pa.props[0].getText()).to.equal('0')
})
it('should read a filter with obj.foo argument', function () {
const tokenizer = new Tokenizer('| plus: obj.foo')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token!.args).to.have.lengthOf(1)
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.equal('obj')
expect(pa.props).to.have.lengthOf(1)
expect(pa.props[0]).to.be.instanceOf(WordToken)
expect(pa.props[0].getText()).to.equal('foo')
})
it('should read a filter with obj["foo"] argument', function () {
const tokenizer = new Tokenizer('| plus: obj["good luck"]')
const token = tokenizer.readFilter()
expect(token).to.have.property('name', 'plus')
expect(token!.args).to.have.lengthOf(1)
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).to.be.instanceOf(PropertyAccessToken)
expect(pa.getText()).to.equal('obj["good luck"]')
expect(pa.variable.content).to.equal('obj')
expect(pa.props[0].getText()).to.equal('"good luck"')
})
})
it('should read expression `a==b`', () => {
const exp = new Tokenizer('a==b').readExpression()
expect([...exp]).to.deep.equal(['a', '==', 'b'])
describe('#readFilters()', () => {
it('should read simple filters', function () {
const tokenizer = new Tokenizer('| plus: 3 | capitalize')
const tokens = tokenizer.readFilters()
expect(tokens).to.have.lengthOf(2)
expect(tokens[0]).to.have.property('name', 'plus')
expect(tokens[0].args).to.have.lengthOf(1)
expect(tokens[0].args[0]).to.be.instanceOf(NumberToken)
expect((tokens[0].args[0] as any).getText()).to.equal('3')
expect(tokens[1]).to.have.property('name', 'capitalize')
expect(tokens[1].args).to.have.lengthOf(0)
})
it('should read filters', function () {
const tokenizer = new Tokenizer('| plus: a:3 | capitalize | append: foo[a.b["c d"]]')
const tokens = tokenizer.readFilters()
expect(tokens).to.have.lengthOf(3)
expect(tokens[0]).to.have.property('name', 'plus')
expect(tokens[0].args).to.have.lengthOf(1)
const [k, v]: [string, NumberToken] = tokens[0].args[0] as any
expect(k).to.equal('a')
expect(v).to.be.instanceOf(NumberToken)
expect(v.getText()).to.equal('3')
expect(tokens[1]).to.have.property('name', 'capitalize')
expect(tokens[1].args).to.have.lengthOf(0)
expect(tokens[2]).to.have.property('name', 'append')
expect(tokens[2].args).to.have.lengthOf(1)
expect(tokens[2].args[0]).to.be.instanceOf(PropertyAccessToken)
expect((tokens[2].args[0] as any).getText()).to.equal('foo[a.b["c d"]]')
expect((tokens[2].args[0] as any).props[0].getText()).to.equal('a.b["c d"]')
})
})
it('should read expression `^`', () => {
const exp = new Tokenizer('^').readExpression()
expect([...exp]).to.deep.equal([])
})
it('should read expression `a == b`', () => {
const exp = new Tokenizer('a == b').readExpression()
expect([...exp]).to.deep.equal(['a', '==', 'b'])
})
it('should read expression `(1..3) contains 3`', () => {
const exp = new Tokenizer('(1..3) contains 3').readExpression()
expect([...exp]).to.deep.equal(['(1..3)', 'contains', '3'])
})
it('should read expression `a[b] = c`', () => {
const exp = new Tokenizer('a[b] = c').readExpression()
expect([...exp]).to.deep.equal(['a[b]', '=', 'c'])
})
it('should read expression `c[a["b"]] >= c`', () => {
const exp = new Tokenizer('c[a["b"]] >= c').readExpression()
expect([...exp]).to.deep.equal(['c[a["b"]]', '>=', 'c'])
})
it('should read expression `"][" == var`', () => {
const exp = new Tokenizer('"][" == var').readExpression()
expect([...exp]).to.deep.equal(['"]["', '==', 'var'])
})
it('should read expression `"\\\'" == "\\""`', () => {
const exp = new Tokenizer('"\\\'" == "\\""').readExpression()
expect([...exp]).to.deep.equal(['"\\\'"', '==', '"\\""'])
describe('#readExpression()', () => {
it('should read expression `a `', () => {
const exp = [...new Tokenizer('a ').readExpression()]
expect(exp).to.have.lengthOf(1)
expect(exp[0]).to.be.instanceOf(PropertyAccessToken)
expect(exp[0].getText()).to.deep.equal('a')
})
it('should read expression `a[][b]`', () => {
const exp = [...new Tokenizer('a[][b]').readExpression()]
expect(exp).to.have.lengthOf(1)
const pa = exp[0] as PropertyAccessToken
expect(pa).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.deep.equal('a')
expect(pa.props).to.have.lengthOf(2)
const [p1, p2] = pa.props
expect(p1).to.be.instanceOf(WordToken)
expect(p1.getText()).to.equal('')
expect(p2).to.be.instanceOf(PropertyAccessToken)
expect(p2.getText()).to.equal('b')
})
it('should read expression `a.`', () => {
const exp = [...new Tokenizer('a.').readExpression()]
expect(exp).to.have.lengthOf(1)
const pa = exp[0] as PropertyAccessToken
expect(pa).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.deep.equal('a')
expect(pa.props).to.have.lengthOf(0)
})
it('should read expression `a ==`', () => {
const exp = [...new Tokenizer('a ==').readExpression()]
expect(exp).to.have.lengthOf(1)
expect(exp[0]).to.be.instanceOf(PropertyAccessToken)
expect(exp[0].getText()).to.deep.equal('a')
})
it('should read expression `a==b`', () => {
const exp = new Tokenizer('a==b').readExpression()
const [a, equals, b] = exp
expect(a).to.be.instanceOf(PropertyAccessToken)
expect(a.getText()).to.deep.equal('a')
expect(equals).to.be.instanceOf(OperatorToken)
expect(equals.getText()).to.equal('==')
expect(b).to.be.instanceOf(PropertyAccessToken)
expect(b.getText()).to.deep.equal('b')
})
it('should read expression `^`', () => {
const exp = new Tokenizer('^').readExpression()
expect([...exp]).to.deep.equal([])
})
it('should read expression `a == b`', () => {
const exp = new Tokenizer('a == b').readExpression()
const [a, equals, b] = exp
expect(a).to.be.instanceOf(PropertyAccessToken)
expect(a.getText()).to.deep.equal('a')
expect(equals).to.be.instanceOf(OperatorToken)
expect(equals.getText()).to.equal('==')
expect(b).to.be.instanceOf(PropertyAccessToken)
expect(b.getText()).to.deep.equal('b')
})
it('should read expression `(1..3) contains 3`', () => {
const exp = new Tokenizer('(1..3) contains 3').readExpression()
const [range, contains, rhs] = exp
expect(range).to.be.instanceOf(RangeToken)
expect(range.getText()).to.deep.equal('(1..3)')
expect(contains).to.be.instanceOf(OperatorToken)
expect(contains.getText()).to.equal('contains')
expect(rhs).to.be.instanceOf(NumberToken)
expect(rhs.getText()).to.deep.equal('3')
})
it('should read expression `a[b] == c`', () => {
const exp = new Tokenizer('a[b] == c').readExpression()
const [lhs, contains, rhs] = exp
expect(lhs).to.be.instanceOf(PropertyAccessToken)
expect(lhs.getText()).to.deep.equal('a[b]')
expect(contains).to.be.instanceOf(OperatorToken)
expect(contains.getText()).to.equal('==')
expect(rhs).to.be.instanceOf(PropertyAccessToken)
expect(rhs.getText()).to.deep.equal('c')
})
it('should read expression `c[a["b"]] >= c`', () => {
const exp = new Tokenizer('c[a["b"]] >= c').readExpression()
const [lhs, op, rhs] = exp
expect(lhs).to.be.instanceOf(PropertyAccessToken)
expect(lhs.getText()).to.deep.equal('c[a["b"]]')
expect(op).to.be.instanceOf(OperatorToken)
expect(op.getText()).to.equal('>=')
expect(rhs).to.be.instanceOf(PropertyAccessToken)
expect(rhs.getText()).to.deep.equal('c')
})
it('should read expression `"][" == var`', () => {
const exp = new Tokenizer('"][" == var').readExpression()
const [lhs, equals, rhs] = exp
expect(lhs).to.be.instanceOf(QuotedToken)
expect(lhs.getText()).to.deep.equal('"]["')
expect(equals).to.be.instanceOf(OperatorToken)
expect(equals.getText()).to.equal('==')
expect(rhs).to.be.instanceOf(PropertyAccessToken)
expect(rhs.getText()).to.deep.equal('var')
})
it('should read expression `"\\\'" == "\\""`', () => {
const exp = new Tokenizer('"\\\'" == "\\""').readExpression()
const [lhs, equals, rhs] = exp
expect(lhs).to.be.instanceOf(QuotedToken)
expect(lhs.getText()).to.deep.equal('"\\\'"')
expect(equals).to.be.instanceOf(OperatorToken)
expect(equals.getText()).to.equal('==')
expect(rhs).to.be.instanceOf(QuotedToken)
expect(rhs.getText()).to.deep.equal('"\\""')
})
})
})
+98 -42
View File
@@ -4,59 +4,115 @@ import { Context } from '../../../src/context/context'
import { toThenable } from '../../../src/util/async'
describe('Expression', function () {
let ctx: Context
beforeEach(function () {
ctx = new Context({
one: 1,
two: 2,
empty: '',
quote: '"',
space: ' ',
x: 'XXX',
y: undefined,
z: null,
obj: {
']': 'right bracket'
}
})
})
const ctx = new Context({})
it('should throw when context not defined', done => {
toThenable(new Expression().value(undefined!)).catch(err => {
expect(err.message).to.match(/context not defined/)
done()
return 0 as any
toThenable(new Expression('foo').value(undefined!))
.then(() => done(new Error('should not resolved')))
.catch(err => {
expect(err.message).to.match(/context not defined/)
done()
})
})
describe('single value', function () {
it('should eval literal', async function () {
expect(await toThenable(new Expression('2.4').value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"').value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false').value(ctx))).to.equal(false)
})
it('should eval range expression', async function () {
const ctx = new Context({ two: 2 })
expect(await toThenable(new Expression('(2..4)').value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(new Expression('(two..4)').value(ctx))).to.deep.equal([2, 3, 4])
})
it('should eval literal', async function () {
expect(await toThenable(new Expression('2.4').value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"').value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false').value(ctx))).to.equal(false)
})
it('should eval property access', async function () {
const ctx = new Context({
foo: { bar: 'BAR' },
coo: 'bar',
doo: { foo: 'bar', bar: { foo: 'bar' } }
})
expect(await toThenable(new Expression('foo.bar').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo["bar"]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[coo]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo.foo]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo["foo"]]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('doo[coo].foo').value(ctx))).to.equal('bar')
})
})
it('should eval simple expression', async function () {
expect(await toThenable(new Expression('1==2').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('1<2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1 < 2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1 < 2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('2 <= 2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('one <= two').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('x contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('x contains "X"').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1 contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('y contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('z contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('(1..5) contains 3').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('(1..5) contains 6').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('"<=" == "<="').value(ctx))).to.equal(true)
describe('simple expression', function () {
it('should return false for "1==2"', async () => {
expect(await toThenable(new Expression('1==2').value(ctx))).to.equal(false)
})
it('should return true for "1<2"', async () => {
expect(await toThenable(new Expression('1<2').value(ctx))).to.equal(true)
})
it('should return true for "1 < 2"', async () => {
expect(await toThenable(new Expression('1 < 2').value(ctx))).to.equal(true)
})
it('should return true for "1 < 2"', async () => {
expect(await toThenable(new Expression('1 < 2').value(ctx))).to.equal(true)
})
it('should return true for "2 <= 2"', async () => {
expect(await toThenable(new Expression('2 <= 2').value(ctx))).to.equal(true)
})
it('should return true for "one <= two"', async () => {
const ctx = new Context({ one: 1, two: 2 })
expect(await toThenable(new Expression('one <= two').value(ctx))).to.equal(true)
})
it('should return false for "x contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('x contains "x"').value(ctx))).to.equal(false)
})
it('should return true for "x contains "X""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('x contains "X"').value(ctx))).to.equal(true)
})
it('should return false for "1 contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('1 contains "x"').value(ctx))).to.equal(false)
})
it('should return false for "y contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('y contains "x"').value(ctx))).to.equal(false)
})
it('should return false for "z contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('z contains "x"').value(ctx))).to.equal(false)
})
it('should return true for "(1..5) contains 3"', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('(1..5) contains 3').value(ctx))).to.equal(true)
})
it('should return false for "(1..5) contains 6"', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('(1..5) contains 6').value(ctx))).to.equal(false)
})
it('should return true for ""<=" == "<=""', async () => {
expect(await toThenable(new Expression('"<=" == "<="').value(ctx))).to.equal(true)
})
})
it('should allow space in quoted value', async function () {
const ctx = new Context({ space: ' ' })
expect(await toThenable(new Expression('" " == space').value(ctx))).to.equal(true)
})
describe('escape', () => {
it('should escape quote', async function () {
const ctx = new Context({ quote: '"' })
expect(await toThenable(new Expression('"\\"" == quote').value(ctx))).to.equal(true)
})
it('should escape square bracket', async function () {
expect(await toThenable(new Expression('obj["]"] == "right bracket"').value(ctx))).to.equal(true)
const ctx = new Context({ obj: { ']': 'bracket' } })
expect(await toThenable(new Expression('obj["]"] == "bracket"').value(ctx))).to.equal(true)
})
})
@@ -71,6 +127,7 @@ describe('Expression', function () {
expect(await toThenable(new Expression('1 < 2 or x contains "x"').value(ctx))).to.equal(true)
})
it('should support value and !=', async function () {
const ctx = new Context({ empty: '' })
expect(await toThenable(new Expression('empty and empty != ""').value(ctx))).to.equal(false)
})
it('should recognize quoted value', async function () {
@@ -84,10 +141,9 @@ describe('Expression', function () {
const ctx = new Context({ obj: { foo: true } })
expect(await toThenable(new Expression('obj["foo"] and true').value(ctx))).to.equal(true)
})
})
it('should eval range expression', async function () {
expect(await toThenable(new Expression('(2..4)').value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(new Expression('(two..4)').value(ctx))).to.deep.equal([2, 3, 4])
it('should allow nested property access', async function () {
const ctx = new Context({ obj: { foo: 'FOO' }, keys: { "what's this": 'foo' } })
expect(await toThenable(new Expression('obj[keys["what\'s this"]]').value(ctx))).to.equal('FOO')
})
})
})
+2 -2
View File
@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { Context } from '../../../src/context/context'
import { Token } from '../../../src/parser/token'
import { HTMLToken } from '../../../src/tokens/html-token'
import { Render } from '../../../src/render/render'
import { HTML } from '../../../src/template/html'
import { toThenable } from '../../../src/util/async'
@@ -14,7 +14,7 @@ describe('render', function () {
describe('.renderTemplates()', function () {
it('should render html', async function () {
const scope = new Context()
const token = { content: '<p>' } as Token
const token = { getContent: () => '<p>' } as HTMLToken
const html = await toThenable(render.renderTemplates([new HTML(token)], scope))
return expect(html).to.equal('<p>')
})
+15 -6
View File
@@ -3,6 +3,9 @@ import * as sinon from 'sinon'
import * as sinonChai from 'sinon-chai'
import { Context } from '../../../../src/context/context'
import { toThenable } from '../../../../src/util/async'
import { NumberToken } from '../../../../src/tokens/number-token'
import { QuotedToken } from '../../../../src/tokens/quoted-token'
import { WordToken } from '../../../../src/tokens/word-token'
import { FilterMap } from '../../../../src/template/filter/filter-map'
chai.use(sinonChai)
@@ -27,13 +30,15 @@ describe('filter', function () {
it('should call filter impl with correct arguments', async function () {
const spy = sinon.spy()
filters.set('foo', spy)
await toThenable(filters.create('foo', ['33']).render('foo', ctx))
expect(spy).to.have.been.calledWith('foo', 33)
const thirty = new NumberToken(new WordToken('30', 0, 2), undefined)
await toThenable(filters.create('foo', [thirty]).render('foo', ctx))
expect(spy).to.have.been.calledWith('foo', 30)
})
it('should call filter impl with correct this arg', async function () {
const spy = sinon.spy()
filters.set('foo', spy)
await toThenable(filters.create('foo', ['33']).render('foo', ctx))
const thirty = new NumberToken(new WordToken('33', 0, 2), undefined)
await toThenable(filters.create('foo', [thirty]).render('foo', ctx))
expect(spy).to.have.been.calledOn(sinon.match.has('context', ctx))
})
it('should render a simple filter', async function () {
@@ -43,12 +48,15 @@ describe('filter', function () {
it('should render filters with argument', async function () {
filters.set('add', (a, b) => a + b)
expect(await toThenable(filters.create('add', ['2']).render(3, ctx))).to.equal(5)
const two = new NumberToken(new WordToken('2', 0, 1), undefined)
expect(await toThenable(filters.create('add', [two]).render(3, ctx))).to.equal(5)
})
it('should render filters with multiple arguments', async function () {
filters.set('add', (a, b, c) => a + b + c)
expect(await toThenable(filters.create('add', ['2', '"c"']).render(3, ctx))).to.equal('5c')
const two = new NumberToken(new WordToken('2', 0, 1), undefined)
const c = new QuotedToken('"c"', 0, 3)
expect(await toThenable(filters.create('add', [two, c]).render(3, ctx))).to.equal('5c')
})
it('should pass Objects/Drops as it is', async function () {
@@ -65,6 +73,7 @@ describe('filter', function () {
it('should support key value pairs', async function () {
filters.set('add', (a, b) => b[0] + ':' + (a + b[1]))
expect(await toThenable((filters.create('add', [['num', '2']]).render(3, ctx)))).to.equal('num:5')
const two = new NumberToken(new WordToken('2', 0, 1), undefined)
expect(await toThenable((filters.create('add', [['num', two]]).render(3, ctx)))).to.equal('num:5')
})
})
+2 -1
View File
@@ -28,7 +28,8 @@ describe('Hash', function () {
expect(hash.num).to.equal(2.3)
})
it('should parse "num:bar.coo"', async function () {
const hash = await toThenable(new Hash('num:bar.coo').render(new Context({ bar: { coo: 3 } })))
const pending = new Hash('num:bar.coo').render(new Context({ bar: { coo: 3 } }))
const hash = await toThenable(pending)
expect(hash.num).to.equal(3)
})
it('should parse "num1:2.3 reverse,num2:bar.coo\n num3: arr[0]"', async function () {
+1 -1
View File
@@ -2,7 +2,7 @@ import * as chai from 'chai'
import { toThenable } from '../../../src/util/async'
import { Context } from '../../../src/context/context'
import { Output } from '../../../src/template/output'
import { OutputToken } from '../../../src/parser/output-token'
import { OutputToken } from '../../../src/tokens/output-token'
import { FilterMap } from '../../../src/template/filter/filter-map'
const expect = chai.expect
+1 -1
View File
@@ -3,7 +3,7 @@ import { Tag } from '../../../src/template/tag/tag'
import { Context } from '../../../src/context/context'
import * as sinon from 'sinon'
import * as sinonChai from 'sinon-chai'
import { TagToken } from '../../../src/parser/tag-token'
import { TagToken } from '../../../src/tokens/tag-token'
import { toThenable } from '../../../src/util/async'
chai.use(sinonChai)
+8 -61
View File
@@ -1,4 +1,5 @@
import * as chai from 'chai'
import { QuotedToken } from '../../../src/tokens/quoted-token'
import { toThenable } from '../../../src/util/async'
import { FilterMap } from '../../../src/template/filter/filter-map'
import * as sinonChai from 'sinon-chai'
@@ -15,71 +16,17 @@ describe('Value', function () {
const filterMap = new FilterMap(false)
it('should parse "foo', function () {
const tpl = new Value('foo', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.initial!.getText()).to.equal('foo')
expect(tpl.filters).to.deep.equal([])
})
it('should parse "foo | add"', function () {
const tpl = new Value('foo | add', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.filters.length).to.equal(1)
expect(tpl.filters[0].args).to.eql([])
})
it('should parse "foo,foo | add"', function () {
const tpl = new Value('foo,foo | add', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.filters.length).to.equal(1)
expect(tpl.filters[0].args).to.eql([])
})
it('should parse "foo | add: 3, false"', function () {
const tpl = new Value('foo | add: 3, "foo"', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.filters.length).to.equal(1)
expect(tpl.filters[0].args).to.eql(['3', '"foo"'])
})
it('should parse "foo | add: "foo" bar, 3"', function () {
const tpl = new Value('foo | add: "foo" bar, 3', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.filters.length).to.equal(1)
expect(tpl.filters[0].name).to.eql('add')
expect(tpl.filters[0].args).to.eql(['"foo"', '3'])
})
it('should parse "foo | add: "|", 3', function () {
const tpl = new Value('foo | add: "|", 3', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.filters.length).to.equal(1)
expect(tpl.filters[0].args).to.eql(['"|"', '3'])
})
it('should parse "foo | add: "|", 3', function () {
const tpl = new Value('foo | add: "|", 3', filterMap)
expect(tpl.initial).to.equal('foo')
expect(tpl.filters.length).to.equal(1)
expect(tpl.filters[0].args).to.eql(['"|"', '3'])
})
it('should support arguments as named key/values', function () {
const f = new Value('o | foo: key1: "literal1", key2: value2', filterMap)
expect(f.filters[0].name).to.equal('foo')
expect(f.filters[0].args).to.eql([['key1', '"literal1"'], ['key2', 'value2']])
})
it('should support arguments as named key/values with inline literals', function () {
const f = new Value('o | foo: "test0", key1: "literal1", key2: value2', filterMap)
expect(f.filters[0].name).to.equal('foo')
expect(f.filters[0].args).to.deep.equal(['"test0"', ['key1', '"literal1"'], ['key2', 'value2']])
})
it('should support arguments as named key/values with inline values', function () {
const f = new Value('o | foo: test0, key1: "literal1", key2: value2', filterMap)
expect(f.filters[0].name).to.equal('foo')
expect(f.filters[0].args).to.deep.equal(['test0', ['key1', '"literal1"'], ['key2', 'value2']])
})
it('should support argument values named same as keys', function () {
const f = new Value('o | foo: a: a', filterMap)
expect(f.filters[0].name).to.equal('foo')
expect(f.filters[0].args).to.deep.equal([['a', 'a']])
})
it('should support argument literals named same as keys', function () {
it('should parse filters in value content', function () {
const f = new Value('o | foo: a: "a"', filterMap)
expect(f.filters[0].name).to.equal('foo')
expect(f.filters[0].args).to.deep.equal([['a', '"a"']])
expect(f.filters[0].args).to.have.lengthOf(1)
const [k, v] = f.filters[0].args[0] as any
expect(k).to.equal('a')
expect(v).to.be.instanceOf(QuotedToken)
expect((v as QuotedToken).getText()).to.equal('"a"')
})
})
+2 -2
View File
@@ -5,11 +5,11 @@ const expect = chai.expect
describe('assert', function () {
it('should not throw if predicate is truthy', function () {
const fn = () => assert('foo', 'bar')
const fn = () => assert('foo', () => 'bar')
expect(fn).to.not.throw()
})
it('should not throw if predicate is truthy', function () {
const fn = () => assert('', 'bar')
const fn = () => assert('', () => 'bar')
expect(fn).to.throw(/bar/)
})
it('should populate default message', function () {