Skip to content

Commit 88c9e96

Browse files
committed
feat: nil/null/empty/blank literals, resolves #102
1 parent 54b2adc commit 88c9e96

19 files changed

+355
-52
lines changed

src/drop/blank-drop.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { isNil, isString } from 'src/util/underscore'
2+
import { isDrop } from 'src/drop/idrop'
3+
import { EmptyDrop } from 'src/drop/empty-drop'
4+
5+
export class BlankDrop extends EmptyDrop {
6+
equals (value: any) {
7+
if (value === false) return true
8+
if (isNil(isDrop(value) ? value.value() : value)) return true
9+
if (isString(value)) return /^\s*$/.test(value)
10+
return super.equals(value)
11+
}
12+
}

src/drop/drop.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export abstract class Drop {
2+
}

src/drop/empty-drop.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Drop } from './drop'
2+
import { IComparable } from './icomparable'
3+
import { isObject, isString, isArray } from 'src/util/underscore'
4+
import { IDrop } from 'src/drop/idrop'
5+
6+
export class EmptyDrop extends Drop implements IDrop, IComparable {
7+
equals (value: any) {
8+
if (isString(value) || isArray(value)) return value.length === 0
9+
if (isObject(value)) return Object.keys(value).length === 0
10+
return false
11+
}
12+
gt () {
13+
return false
14+
}
15+
geq () {
16+
return false
17+
}
18+
lt () {
19+
return false
20+
}
21+
leq () {
22+
return false
23+
}
24+
value () {
25+
return ''
26+
}
27+
}

src/drop/icomparable.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { isFunction } from 'src/util/underscore'
2+
3+
export interface IComparable {
4+
equals: (rhs: any) => boolean
5+
gt: (rhs: any) => boolean
6+
geq: (rhs: any) => boolean
7+
lt: (rhs: any) => boolean
8+
leq: (rhs: any) => boolean
9+
}
10+
11+
export function isComparable (arg: any): arg is IComparable {
12+
return arg && isFunction(arg.equals)
13+
}

src/drop/idrop.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Drop } from './drop'
2+
import { isFunction } from 'src/util/underscore'
3+
4+
export interface IDrop {
5+
value(): any
6+
}
7+
8+
export function isDrop (value: any): value is IDrop {
9+
return value instanceof Drop && isFunction((value as any).value)
10+
}

src/drop/null-drop.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Drop } from './drop'
2+
import { IComparable } from './icomparable'
3+
import { isNil } from 'src/util/underscore'
4+
import { IDrop, isDrop } from 'src/drop/idrop'
5+
import { BlankDrop } from 'src/drop/blank-drop'
6+
7+
export class NullDrop extends Drop implements IDrop, IComparable {
8+
equals (value: any) {
9+
return isNil(isDrop(value) ? value.value() : value) || value instanceof BlankDrop
10+
}
11+
gt () {
12+
return false
13+
}
14+
geq () {
15+
return false
16+
}
17+
lt () {
18+
return false
19+
}
20+
leq () {
21+
return false
22+
}
23+
value () {
24+
return null
25+
}
26+
}

src/render/syntax.ts

+58-16
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,43 @@ import * as lexical from '../parser/lexical'
22
import assert from '../util/assert'
33
import Scope from 'src/scope/scope'
44
import { range, last } from 'src/util/underscore'
5+
import { isComparable } from 'src/drop/icomparable'
6+
import { NullDrop } from 'src/drop/null-drop'
7+
import { EmptyDrop } from 'src/drop/empty-drop'
8+
import { BlankDrop } from 'src/drop/blank-drop'
9+
import { isDrop } from 'src/drop/idrop'
510

6-
const operators = {
7-
'==': (l: any, r: any) => l === r,
8-
'!=': (l: any, r: any) => l !== r,
9-
'>': (l: any, r: any) => l !== null && r !== null && l > r,
10-
'<': (l: any, r: any) => l !== null && r !== null && l < r,
11-
'>=': (l: any, r: any) => l !== null && r !== null && l >= r,
12-
'<=': (l: any, r: any) => l !== null && r !== null && l <= r,
11+
const binaryOperators: {[key: string]: (lhs: any, rhs: any) => boolean} = {
12+
'==': (l: any, r: any) => {
13+
if (isComparable(l)) return l.equals(r)
14+
if (isComparable(r)) return r.equals(l)
15+
return l === r
16+
},
17+
'!=': (l: any, r: any) => {
18+
if (isComparable(l)) return !l.equals(r)
19+
if (isComparable(r)) return !r.equals(l)
20+
return l !== r
21+
},
22+
'>': (l: any, r: any) => {
23+
if (isComparable(l)) return l.gt(r)
24+
if (isComparable(r)) return r.lt(l)
25+
return l > r
26+
},
27+
'<': (l: any, r: any) => {
28+
if (isComparable(l)) return l.lt(r)
29+
if (isComparable(r)) return r.gt(l)
30+
return l < r
31+
},
32+
'>=': (l: any, r: any) => {
33+
if (isComparable(l)) return l.geq(r)
34+
if (isComparable(r)) return r.leq(l)
35+
return l >= r
36+
},
37+
'<=': (l: any, r: any) => {
38+
if (isComparable(l)) return l.leq(r)
39+
if (isComparable(r)) return r.geq(l)
40+
return l <= r
41+
},
1342
'contains': (l: any, r: any) => {
1443
if (!l) return false
1544
if (typeof l.indexOf !== 'function') return false
@@ -19,41 +48,54 @@ const operators = {
1948
'or': (l: any, r: any) => isTruthy(l) || isTruthy(r)
2049
}
2150

22-
export function evalExp (exp: string, scope: Scope): any {
23-
assert(scope, 'unable to evalExp: scope undefined')
51+
export function parseExp (exp: string, scope: Scope): any {
52+
assert(scope, 'unable to parseExp: scope undefined')
2453
const operatorREs = lexical.operators
2554
let match
2655
for (let i = 0; i < operatorREs.length; i++) {
2756
const operatorRE = operatorREs[i]
2857
const expRE = new RegExp(`^(${lexical.quoteBalanced.source})(${operatorRE.source})(${lexical.quoteBalanced.source})$`)
2958
if ((match = exp.match(expRE))) {
30-
const l = evalExp(match[1], scope)
31-
const op = operators[match[2].trim()]
32-
const r = evalExp(match[3], scope)
59+
const l = parseExp(match[1], scope)
60+
const op = binaryOperators[match[2].trim()]
61+
const r = parseExp(match[3], scope)
3362
return op(l, r)
3463
}
3564
}
3665

3766
if ((match = exp.match(lexical.rangeLine))) {
38-
const low = evalValue(match[1], scope)
39-
const high = evalValue(match[2], scope)
67+
const low = parseValue(match[1], scope)
68+
const high = parseValue(match[2], scope)
4069
return range(low, high + 1)
4170
}
4271

43-
return evalValue(exp, scope)
72+
return parseValue(exp, scope)
73+
}
74+
75+
export function evalExp (str: string, scope: Scope): any {
76+
const value = parseExp(str, scope)
77+
return isDrop(value) ? value.value() : value
4478
}
4579

46-
export function evalValue (str: string, scope: Scope) {
80+
function parseValue (str: string, scope: Scope): any {
4781
if (!str) return null
4882
str = str.trim()
4983

5084
if (str === 'true') return true
5185
if (str === 'false') return false
86+
if (str === 'nil' || str === 'null') return new NullDrop()
87+
if (str === 'empty') return new EmptyDrop()
88+
if (str === 'blank') return new BlankDrop()
5289
if (!isNaN(Number(str))) return Number(str)
5390
if ((str[0] === '"' || str[0] === "'") && str[0] === last(str)) return str.slice(1, -1)
5491
return scope.get(str)
5592
}
5693

94+
export function evalValue (str: string, scope: Scope): any {
95+
const value = parseValue(str, scope)
96+
return isDrop(value) ? value.value() : value
97+
}
98+
5799
export function isTruthy (val: any): boolean {
58100
return !isFalsy(val)
59101
}

src/scope/icontext.ts

-4
This file was deleted.

src/scope/scope.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ import { __assign } from 'tslib'
33
import assert from '../util/assert'
44
import { NormalizedFullOptions, applyDefault } from '../liquid-options'
55
import BlockMode from './block-mode'
6-
import IContext from './icontext'
6+
7+
export type Context = {
8+
[key: string]: any
9+
liquid_method_missing?: (key: string) => any // eslint-disable-line
10+
to_liquid?: () => any // eslint-disable-line
11+
toLiquid?: () => any // eslint-disable-line
12+
}
713

814
export default class Scope {
915
opts: NormalizedFullOptions
10-
contexts: Array<IContext>
16+
contexts: Array<Context>
1117
blocks: object = {}
1218
groups: {[key: string]: number} = {}
1319
blockMode: BlockMode = BlockMode.OUTPUT
@@ -67,10 +73,10 @@ export default class Scope {
6773
}
6874
return null
6975
}
70-
private readProperty (obj: IContext, key: string) {
76+
private readProperty (obj: Context, key: string) {
7177
let val
7278
if (_.isNil(obj)) {
73-
val = undefined
79+
val = obj
7480
} else {
7581
obj = toLiquid(obj)
7682
val = key === 'size' ? readSize(obj) : obj[key]
@@ -144,7 +150,7 @@ export default class Scope {
144150
}
145151
}
146152

147-
function toLiquid (obj: IContext) {
153+
function toLiquid (obj: Context) {
148154
if (_.isFunction(obj.to_liquid)) {
149155
return obj.to_liquid()
150156
}
@@ -154,7 +160,7 @@ function toLiquid (obj: IContext) {
154160
return obj
155161
}
156162

157-
function readSize (obj: IContext) {
163+
function readSize (obj: Context) {
158164
if (!_.isNil(obj.size)) return obj.size
159165
if (_.isArray(obj) || _.isString(obj)) return obj.length
160166
return obj.size

src/util/underscore.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ const arrToStr = Array.prototype.toString
66
* @param {any} value The value to check.
77
* @return {Boolean} Returns true if value is a string, else false.
88
*/
9-
export function isString (value: any) {
9+
export function isString (value: any): value is string {
1010
return toStr.call(value) === '[object String]'
1111
}
1212

13-
export function isFunction (value: any) {
13+
export function isFunction (value: any): value is Function {
1414
return typeof value === 'function'
1515
}
1616

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

3939
function defaultToString (value: any): string {
40-
const cache: string[] = []
40+
const cache: any[] = []
4141
return JSON.stringify(value, (key, value) => {
4242
if (isObject(value)) {
4343
if (cache.indexOf(value) !== -1) {
@@ -57,12 +57,12 @@ export function isNil (value: any): boolean {
5757
return value === null || value === undefined
5858
}
5959

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

65-
export function isError (value: any): boolean {
65+
export function isError (value: any): value is Error {
6666
const signature = toStr.call(value)
6767
// [object XXXError]
6868
return signature.substr(-6, 5) === 'Error' ||
@@ -102,7 +102,7 @@ export function last (arr: any[] | string): any | string {
102102
* @param {any} value The value to check.
103103
* @return {Boolean} Returns true if value is an object, else false.
104104
*/
105-
export function isObject (value: any): boolean {
105+
export function isObject (value: any): value is object {
106106
const type = typeof value
107107
return value !== null && (type === 'object' || type === 'function')
108108
}

test/e2e/drop.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Liquid from '../..'
2+
import { expect, use } from 'chai'
3+
import * as chaiAsPromised from 'chai-as-promised'
4+
5+
use(chaiAsPromised)
6+
7+
describe('drop', function () {
8+
var engine: Liquid
9+
beforeEach(function () {
10+
engine = new Liquid()
11+
})
12+
it('should test blank strings', async function () {
13+
const src = `
14+
{% unless settings.fp_heading == blank %}
15+
<h1>{{ settings.fp_heading }}</h1>
16+
{% endunless %}`
17+
var ctx = { settings: { fp_heading: '' } }
18+
const html = await engine.parseAndRender(src, ctx)
19+
return expect(html).to.match(/^\s+$/)
20+
})
21+
})

test/e2e/parse-and-render.ts

+5
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,9 @@ describe('.parseAndRender()', function () {
6262
const html = await engine.parseAndRender(src)
6363
return expect(html).to.equal('apples')
6464
})
65+
it('should support nil(null, undefined) literal', async function () {
66+
const src = '{% if notexist == nil %}true{% endif %}'
67+
const html = await engine.parseAndRender(src)
68+
expect(html).to.equal('true')
69+
})
6570
})

test/unit/builtin/tags/case.ts

+10-12
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,20 @@ describe('tags/case', function () {
1919
const html = await liquid.parseAndRender(src)
2020
return expect(html).to.equal('foo')
2121
})
22-
it('should resolve empty string if not hit', async function () {
23-
const src = '{% case empty %}' +
24-
'{% when "foo" %}foo{% when ""%}bar' +
25-
'{%endcase%}'
26-
const ctx = {
27-
empty: ''
28-
}
29-
const html = await liquid.parseAndRender(src, ctx)
22+
it('should resolve blank as empty string', async function () {
23+
const src = '{% case blank %}{% when ""%}bar{%endcase%}'
24+
const html = await liquid.parseAndRender(src)
25+
return expect(html).to.equal('bar')
26+
})
27+
it('should resolve empty as empty string', async function () {
28+
const src = '{% case empty %}{% when ""%}bar{%endcase%}'
29+
const html = await liquid.parseAndRender(src)
3030
return expect(html).to.equal('bar')
3131
})
3232
it('should accept empty string as branch name', async function () {
33-
const src = '{% case false %}' +
34-
'{% when "foo" %}foo{% when ""%}bar' +
35-
'{%endcase%}'
33+
const src = '{% case "" %}{% when ""%}bar{%endcase%}'
3634
const html = await liquid.parseAndRender(src)
37-
return expect(html).to.equal('')
35+
return expect(html).to.equal('bar')
3836
})
3937
it('should support boolean case', async function () {
4038
const src = '{% case false %}' +

test/unit/builtin/tags/for.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import Liquid from 'src/liquid'
22
import { expect, use } from 'chai'
33
import * as chaiAsPromised from 'chai-as-promised'
4-
import IContext from 'src/scope/icontext'
4+
import { Context } from 'src/scope/scope'
55

66
use(chaiAsPromised)
77

88
describe('tags/for', function () {
9-
let liquid: Liquid, ctx: IContext
9+
let liquid: Liquid, ctx: Context
1010
before(function () {
1111
liquid = new Liquid()
1212
liquid.registerTag('throwingTag', {

0 commit comments

Comments
 (0)