Skip to content

Commit

Permalink
feat: nil/null/empty/blank literals, resolves #102
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Feb 24, 2019
1 parent 54b2adc commit 88c9e96
Show file tree
Hide file tree
Showing 19 changed files with 355 additions and 52 deletions.
12 changes: 12 additions & 0 deletions src/drop/blank-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { isNil, isString } from 'src/util/underscore'
import { isDrop } from 'src/drop/idrop'
import { EmptyDrop } from 'src/drop/empty-drop'

export class BlankDrop extends EmptyDrop {
equals (value: any) {
if (value === false) return true
if (isNil(isDrop(value) ? value.value() : value)) return true
if (isString(value)) return /^\s*$/.test(value)
return super.equals(value)
}
}
2 changes: 2 additions & 0 deletions src/drop/drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export abstract class Drop {
}
27 changes: 27 additions & 0 deletions src/drop/empty-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Drop } from './drop'
import { IComparable } from './icomparable'
import { isObject, isString, isArray } from 'src/util/underscore'
import { IDrop } from 'src/drop/idrop'

export class EmptyDrop extends Drop implements IDrop, IComparable {
equals (value: any) {
if (isString(value) || isArray(value)) return value.length === 0
if (isObject(value)) return Object.keys(value).length === 0
return false
}
gt () {
return false
}
geq () {
return false
}
lt () {
return false
}
leq () {
return false
}
value () {
return ''
}
}
13 changes: 13 additions & 0 deletions src/drop/icomparable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { isFunction } from 'src/util/underscore'

export interface IComparable {
equals: (rhs: any) => boolean
gt: (rhs: any) => boolean
geq: (rhs: any) => boolean
lt: (rhs: any) => boolean
leq: (rhs: any) => boolean
}

export function isComparable (arg: any): arg is IComparable {
return arg && isFunction(arg.equals)
}
10 changes: 10 additions & 0 deletions src/drop/idrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Drop } from './drop'
import { isFunction } from 'src/util/underscore'

export interface IDrop {
value(): any
}

export function isDrop (value: any): value is IDrop {
return value instanceof Drop && isFunction((value as any).value)
}
26 changes: 26 additions & 0 deletions src/drop/null-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Drop } from './drop'
import { IComparable } from './icomparable'
import { isNil } from 'src/util/underscore'
import { IDrop, isDrop } from 'src/drop/idrop'
import { BlankDrop } from 'src/drop/blank-drop'

export class NullDrop extends Drop implements IDrop, IComparable {
equals (value: any) {
return isNil(isDrop(value) ? value.value() : value) || value instanceof BlankDrop
}
gt () {
return false
}
geq () {
return false
}
lt () {
return false
}
leq () {
return false
}
value () {
return null
}
}
74 changes: 58 additions & 16 deletions src/render/syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,43 @@ import * as lexical from '../parser/lexical'
import assert from '../util/assert'
import Scope from 'src/scope/scope'
import { range, last } from 'src/util/underscore'
import { isComparable } from 'src/drop/icomparable'
import { NullDrop } from 'src/drop/null-drop'
import { EmptyDrop } from 'src/drop/empty-drop'
import { BlankDrop } from 'src/drop/blank-drop'
import { isDrop } from 'src/drop/idrop'

const operators = {
'==': (l: any, r: any) => l === r,
'!=': (l: any, r: any) => l !== r,
'>': (l: any, r: any) => l !== null && r !== null && l > r,
'<': (l: any, r: any) => l !== null && r !== null && l < r,
'>=': (l: any, r: any) => l !== null && r !== null && l >= r,
'<=': (l: any, r: any) => l !== null && r !== null && l <= r,
const binaryOperators: {[key: string]: (lhs: any, rhs: any) => boolean} = {
'==': (l: any, r: any) => {
if (isComparable(l)) return l.equals(r)
if (isComparable(r)) return r.equals(l)
return l === r
},
'!=': (l: any, r: any) => {
if (isComparable(l)) return !l.equals(r)
if (isComparable(r)) return !r.equals(l)
return l !== r
},
'>': (l: any, r: any) => {
if (isComparable(l)) return l.gt(r)
if (isComparable(r)) return r.lt(l)
return l > r
},
'<': (l: any, r: any) => {
if (isComparable(l)) return l.lt(r)
if (isComparable(r)) return r.gt(l)
return l < r
},
'>=': (l: any, r: any) => {
if (isComparable(l)) return l.geq(r)
if (isComparable(r)) return r.leq(l)
return l >= r
},
'<=': (l: any, r: any) => {
if (isComparable(l)) return l.leq(r)
if (isComparable(r)) return r.geq(l)
return l <= r
},
'contains': (l: any, r: any) => {
if (!l) return false
if (typeof l.indexOf !== 'function') return false
Expand All @@ -19,41 +48,54 @@ const operators = {
'or': (l: any, r: any) => isTruthy(l) || isTruthy(r)
}

export function evalExp (exp: string, scope: Scope): any {
assert(scope, 'unable to evalExp: scope undefined')
export function parseExp (exp: string, scope: Scope): any {
assert(scope, 'unable to parseExp: scope undefined')
const operatorREs = lexical.operators
let match
for (let i = 0; i < operatorREs.length; i++) {
const operatorRE = operatorREs[i]
const expRE = new RegExp(`^(${lexical.quoteBalanced.source})(${operatorRE.source})(${lexical.quoteBalanced.source})$`)
if ((match = exp.match(expRE))) {
const l = evalExp(match[1], scope)
const op = operators[match[2].trim()]
const r = evalExp(match[3], scope)
const l = parseExp(match[1], scope)
const op = binaryOperators[match[2].trim()]
const r = parseExp(match[3], scope)
return op(l, r)
}
}

if ((match = exp.match(lexical.rangeLine))) {
const low = evalValue(match[1], scope)
const high = evalValue(match[2], scope)
const low = parseValue(match[1], scope)
const high = parseValue(match[2], scope)
return range(low, high + 1)
}

return evalValue(exp, scope)
return parseValue(exp, scope)
}

export function evalExp (str: string, scope: Scope): any {
const value = parseExp(str, scope)
return isDrop(value) ? value.value() : value
}

export function evalValue (str: string, scope: Scope) {
function parseValue (str: string, scope: Scope): any {
if (!str) return null
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 str.slice(1, -1)
return scope.get(str)
}

export function evalValue (str: string, scope: Scope): any {
const value = parseValue(str, scope)
return isDrop(value) ? value.value() : value
}

export function isTruthy (val: any): boolean {
return !isFalsy(val)
}
Expand Down
4 changes: 0 additions & 4 deletions src/scope/icontext.ts

This file was deleted.

18 changes: 12 additions & 6 deletions src/scope/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import { __assign } from 'tslib'
import assert from '../util/assert'
import { NormalizedFullOptions, applyDefault } from '../liquid-options'
import BlockMode from './block-mode'
import IContext from './icontext'

export type Context = {
[key: string]: any
liquid_method_missing?: (key: string) => any // eslint-disable-line
to_liquid?: () => any // eslint-disable-line
toLiquid?: () => any // eslint-disable-line
}

export default class Scope {
opts: NormalizedFullOptions
contexts: Array<IContext>
contexts: Array<Context>
blocks: object = {}
groups: {[key: string]: number} = {}
blockMode: BlockMode = BlockMode.OUTPUT
Expand Down Expand Up @@ -67,10 +73,10 @@ export default class Scope {
}
return null
}
private readProperty (obj: IContext, key: string) {
private readProperty (obj: Context, key: string) {
let val
if (_.isNil(obj)) {
val = undefined
val = obj
} else {
obj = toLiquid(obj)
val = key === 'size' ? readSize(obj) : obj[key]
Expand Down Expand Up @@ -144,7 +150,7 @@ export default class Scope {
}
}

function toLiquid (obj: IContext) {
function toLiquid (obj: Context) {
if (_.isFunction(obj.to_liquid)) {
return obj.to_liquid()
}
Expand All @@ -154,7 +160,7 @@ function toLiquid (obj: IContext) {
return obj
}

function readSize (obj: IContext) {
function readSize (obj: Context) {
if (!_.isNil(obj.size)) return obj.size
if (_.isArray(obj) || _.isString(obj)) return obj.length
return obj.size
Expand Down
12 changes: 6 additions & 6 deletions src/util/underscore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ const arrToStr = Array.prototype.toString
* @param {any} value The value to check.
* @return {Boolean} Returns true if value is a string, else false.
*/
export function isString (value: any) {
export function isString (value: any): value is string {
return toStr.call(value) === '[object String]'
}

export function isFunction (value: any) {
export function isFunction (value: any): value is Function {
return typeof value === 'function'
}

Expand All @@ -37,7 +37,7 @@ export function stringify (value: any): string {
}

function defaultToString (value: any): string {
const cache: string[] = []
const cache: any[] = []
return JSON.stringify(value, (key, value) => {
if (isObject(value)) {
if (cache.indexOf(value) !== -1) {
Expand All @@ -57,12 +57,12 @@ export function isNil (value: any): boolean {
return value === null || value === undefined
}

export function isArray (value: any): boolean {
export function isArray (value: any): value is any[] {
// be compatible with IE 8
return toStr.call(value) === '[object Array]'
}

export function isError (value: any): boolean {
export function isError (value: any): value is Error {
const signature = toStr.call(value)
// [object XXXError]
return signature.substr(-6, 5) === 'Error' ||
Expand Down Expand Up @@ -102,7 +102,7 @@ export function last (arr: any[] | string): any | string {
* @param {any} value The value to check.
* @return {Boolean} Returns true if value is an object, else false.
*/
export function isObject (value: any): boolean {
export function isObject (value: any): value is object {
const type = typeof value
return value !== null && (type === 'object' || type === 'function')
}
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Liquid from '../..'
import { expect, use } from 'chai'
import * as chaiAsPromised from 'chai-as-promised'

use(chaiAsPromised)

describe('drop', function () {
var engine: Liquid
beforeEach(function () {
engine = new Liquid()
})
it('should test blank strings', async function () {
const src = `
{% unless settings.fp_heading == blank %}
<h1>{{ settings.fp_heading }}</h1>
{% endunless %}`
var ctx = { settings: { fp_heading: '' } }
const html = await engine.parseAndRender(src, ctx)
return expect(html).to.match(/^\s+$/)
})
})
5 changes: 5 additions & 0 deletions test/e2e/parse-and-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,9 @@ describe('.parseAndRender()', function () {
const html = await engine.parseAndRender(src)
return expect(html).to.equal('apples')
})
it('should support nil(null, undefined) literal', async function () {
const src = '{% if notexist == nil %}true{% endif %}'
const html = await engine.parseAndRender(src)
expect(html).to.equal('true')
})
})
22 changes: 10 additions & 12 deletions test/unit/builtin/tags/case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,20 @@ describe('tags/case', function () {
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('foo')
})
it('should resolve empty string if not hit', async function () {
const src = '{% case empty %}' +
'{% when "foo" %}foo{% when ""%}bar' +
'{%endcase%}'
const ctx = {
empty: ''
}
const html = await liquid.parseAndRender(src, ctx)
it('should resolve blank as empty string', async function () {
const src = '{% case blank %}{% when ""%}bar{%endcase%}'
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('bar')
})
it('should resolve empty as empty string', async function () {
const src = '{% case empty %}{% when ""%}bar{%endcase%}'
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('bar')
})
it('should accept empty string as branch name', async function () {
const src = '{% case false %}' +
'{% when "foo" %}foo{% when ""%}bar' +
'{%endcase%}'
const src = '{% case "" %}{% when ""%}bar{%endcase%}'
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('')
return expect(html).to.equal('bar')
})
it('should support boolean case', async function () {
const src = '{% case false %}' +
Expand Down
4 changes: 2 additions & 2 deletions test/unit/builtin/tags/for.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import Liquid from 'src/liquid'
import { expect, use } from 'chai'
import * as chaiAsPromised from 'chai-as-promised'
import IContext from 'src/scope/icontext'
import { Context } from 'src/scope/scope'

use(chaiAsPromised)

describe('tags/for', function () {
let liquid: Liquid, ctx: IContext
let liquid: Liquid, ctx: Context
before(function () {
liquid = new Liquid()
liquid.registerTag('throwingTag', {
Expand Down

0 comments on commit 88c9e96

Please sign in to comment.