Skip to content

Commit 3b5627b

Browse files
authored
feat: support catching all errors, #220 (#710)
1 parent 612da52 commit 3b5627b

File tree

6 files changed

+115
-37
lines changed

6 files changed

+115
-37
lines changed

src/liquid-options.ts

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface LiquidOptions {
3232
strictFilters?: boolean;
3333
/** Whether or not to assert variable existence. If set to `false`, undefined variables will be rendered as empty string. Otherwise, undefined variables will cause an exception. Defaults to `false`. */
3434
strictVariables?: boolean;
35+
/** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */
36+
catchAllErrors?: boolean;
3537
/** Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. */
3638
ownPropertyOnly?: boolean;
3739
/** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/

src/parser/parser.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { TopLevelToken, OutputToken } from '../tokens'
55
import { Template, Output, HTML } from '../template'
66
import { LiquidCache } from '../cache'
77
import { FS, Loader, LookupType } from '../fs'
8-
import { LiquidError } from '../util/error'
8+
import { LiquidError, LiquidErrors } from '../util/error'
99
import type { Liquid } from '../liquid'
1010

1111
export class Parser {
@@ -31,9 +31,16 @@ export class Parser {
3131
public parseTokens (tokens: TopLevelToken[]) {
3232
let token
3333
const templates: Template[] = []
34+
const errors: LiquidError[] = []
3435
while ((token = tokens.shift())) {
35-
templates.push(this.parseToken(token, tokens))
36+
try {
37+
templates.push(this.parseToken(token, tokens))
38+
} catch (err) {
39+
if (this.liquid.options.catchAllErrors) errors.push(err as LiquidError)
40+
else throw err
41+
}
3642
}
43+
if (errors.length) throw new LiquidErrors(errors)
3744
return templates
3845
}
3946
public parseToken (token: TopLevelToken, remainTokens: TopLevelToken[]) {

src/render/render.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { toPromise, RenderError } from '../util'
1+
import { toPromise, RenderError, LiquidErrors, LiquidError } from '../util'
22
import { Context } from '../context'
33
import { Template } from '../template'
44
import { Emitter, KeepingTypeEmitter, StreamedEmitter, SimpleEmitter } from '../emitters'
@@ -14,6 +14,7 @@ export class Render {
1414
if (!emitter) {
1515
emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
1616
}
17+
const errors = []
1718
for (const tpl of templates) {
1819
try {
1920
// if tpl.render supports emitter, it'll return empty `html`
@@ -22,10 +23,14 @@ export class Render {
2223
html && emitter.write(html)
2324
if (emitter['break'] || emitter['continue']) break
2425
} catch (e) {
25-
const err = RenderError.is(e) ? e : new RenderError(e as Error, tpl)
26-
throw err
26+
const err = LiquidError.is(e) ? e : new RenderError(e as Error, tpl)
27+
if (ctx.opts.catchAllErrors) errors.push(err)
28+
else throw err
2729
}
2830
}
31+
if (errors.length) {
32+
throw new LiquidErrors(errors)
33+
}
2934
return emitter.buffer
3035
}
3136
}

src/util/error.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Template } from '../template/template'
88
const TRAIT = '__liquidClass__'
99

1010
export abstract class LiquidError extends Error {
11-
private token!: Token
11+
public token!: Token
1212
public context = ''
1313
private originalError?: Error
1414
public constructor (err: Error | string, token: Token) {
@@ -62,6 +62,19 @@ export class RenderError extends LiquidError {
6262
}
6363
}
6464

65+
export class LiquidErrors extends LiquidError {
66+
public constructor (public errors: RenderError[]) {
67+
super(errors[0], errors[0].token)
68+
this.name = 'LiquidErrors'
69+
const s = errors.length > 1 ? 's' : ''
70+
this.message = `${errors.length} error${s} found`
71+
super.update()
72+
}
73+
public static is (obj: any): obj is LiquidErrors {
74+
return obj.name === 'LiquidErrors'
75+
}
76+
}
77+
6578
export class UndefinedVariableError extends LiquidError {
6679
public constructor (err: Error, token: Token) {
6780
super(err, token)

test/integration/util/error.spec.ts

+76-31
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { RenderError } from '../../../src/util/error'
22
import { Liquid } from '../../../src/liquid'
3-
import * as path from 'path'
3+
import { resolve } from 'path'
44
import { mock, restore } from '../../stub/mockfs'
5+
import { throwIntendedError, rejectIntendedError } from '../../stub/util'
56

6-
let engine = new Liquid()
77
const strictEngine = new Liquid({
88
strictVariables: true,
99
strictFilters: true
1010
})
11+
const strictCatchingEngine = new Liquid({
12+
catchAllErrors: true,
13+
strictVariables: true,
14+
strictFilters: true
15+
})
16+
strictEngine.registerTag('throwingTag', { render: throwIntendedError })
17+
strictEngine.registerFilter('throwingFilter', throwIntendedError)
18+
strictCatchingEngine.registerTag('throwingTag', { render: throwIntendedError })
19+
strictCatchingEngine.registerFilter('throwingFilter', throwIntendedError)
1120

1221
describe('error', function () {
1322
afterEach(restore)
1423

1524
describe('TokenizationError', function () {
25+
const engine = new Liquid()
1626
it('should throw TokenizationError when tag illegal', async function () {
1727
await expect(engine.parseAndRender('{% . a %}', {})).rejects.toMatchObject({
1828
name: 'TokenizationError',
@@ -68,43 +78,34 @@ describe('error', function () {
6878
})
6979

7080
describe('RenderError', function () {
81+
let engine: Liquid
7182
beforeEach(function () {
7283
engine = new Liquid({
7384
root: '/'
7485
})
75-
engine.registerTag('throwingTag', {
76-
render: function () {
77-
throw new Error('intended render error')
78-
}
79-
})
80-
engine.registerTag('rejectingTag', {
81-
render: async function () {
82-
throw new Error('intended render reject')
83-
}
84-
})
85-
engine.registerFilter('throwingFilter', () => {
86-
throw new Error('thrown by filter')
87-
})
86+
engine.registerTag('throwingTag', { render: throwIntendedError })
87+
engine.registerTag('rejectingTag', { render: rejectIntendedError })
88+
engine.registerFilter('throwingFilter', throwIntendedError)
8889
})
8990
it('should throw RenderError when tag throws', async function () {
9091
const src = '{%throwingTag%}'
9192
await expect(engine.parseAndRender(src)).rejects.toMatchObject({
9293
name: 'RenderError',
93-
message: expect.stringContaining('intended render error')
94+
message: expect.stringContaining('intended error')
9495
})
9596
})
9697
it('should throw RenderError when tag rejects', async function () {
9798
const src = '{%rejectingTag%}'
9899
await expect(engine.parseAndRender(src)).rejects.toMatchObject({
99100
name: 'RenderError',
100-
message: expect.stringContaining('intended render reject')
101+
message: expect.stringContaining('intended reject')
101102
})
102103
})
103104
it('should throw RenderError when filter throws', async function () {
104105
const src = '{{1|throwingFilter}}'
105106
await expect(engine.parseAndRender(src)).rejects.toMatchObject({
106107
name: 'RenderError',
107-
message: expect.stringContaining('thrown by filter')
108+
message: expect.stringContaining('intended error')
108109
})
109110
})
110111
it('should not throw when variable undefined by default', async function () {
@@ -113,8 +114,8 @@ describe('error', function () {
113114
})
114115
it('should throw RenderError when variable not defined', async function () {
115116
await expect(strictEngine.parseAndRender('{{a}}')).rejects.toMatchObject({
116-
name: 'RenderError',
117-
message: expect.stringContaining('undefined variable: a')
117+
name: 'UndefinedVariableError',
118+
message: 'undefined variable: a, line:1, col:3'
118119
})
119120
})
120121
it('should contain template context in err.stack', async function () {
@@ -131,7 +132,7 @@ describe('error', function () {
131132
]
132133
await expect(engine.parseAndRender(html.join('\n'))).rejects.toMatchObject({
133134
name: 'RenderError',
134-
message: 'intended render error, line:4, col:2',
135+
message: 'intended error, line:4, col:2',
135136
stack: expect.stringContaining(message.join('\n'))
136137
})
137138
})
@@ -160,7 +161,7 @@ describe('error', function () {
160161
]
161162
await expect(engine.parseAndRender(html)).rejects.toMatchObject({
162163
name: 'RenderError',
163-
message: `intended render error, file:${path.resolve('/throwing-tag.html')}, line:4, col:2`,
164+
message: `intended error, file:${resolve('/throwing-tag.html')}, line:4, col:2`,
164165
stack: expect.stringContaining(message.join('\n'))
165166
})
166167
})
@@ -182,25 +183,69 @@ describe('error', function () {
182183
]
183184
await expect(engine.parseAndRender(html)).rejects.toMatchObject({
184185
name: 'RenderError',
185-
message: `intended render error, file:${path.resolve('/throwing-tag.html')}, line:4, col:2`,
186+
message: `intended error, file:${resolve('/throwing-tag.html')}, line:4, col:2`,
186187
stack: expect.stringContaining(message.join('\n'))
187188
})
188189
})
189190
it('should contain stack in err.stack', async function () {
190191
await expect(engine.parseAndRender('{%rejectingTag%}')).rejects.toMatchObject({
191-
message: expect.stringContaining('intended render reject'),
192+
message: expect.stringContaining('intended reject'),
192193
stack: expect.stringMatching(/at .*:\d+:\d+/)
193194
})
194195
})
195196
})
196197

198+
describe('catchAllErrors', function () {
199+
it('should catch render errors', async function () {
200+
const template = '{{foo}}\n{{"hello" | throwingFilter}}\n{% throwingTag %}'
201+
return expect(strictCatchingEngine.parseAndRender(template)).rejects.toMatchObject({
202+
name: 'LiquidErrors',
203+
message: '3 errors found, line:1, col:3',
204+
errors: [{
205+
name: 'UndefinedVariableError',
206+
message: 'undefined variable: foo, line:1, col:3'
207+
}, {
208+
name: 'RenderError',
209+
message: 'intended error, line:2, col:1'
210+
}, {
211+
name: 'RenderError',
212+
message: 'intended error, line:3, col:1'
213+
}]
214+
})
215+
})
216+
it('should catch some parse errors', async function () {
217+
const template = '{{"foo" | filter foo }}'
218+
return expect(strictCatchingEngine.parseAndRender(template)).rejects.toMatchObject({
219+
name: 'LiquidErrors',
220+
message: '1 error found, line:1, col:18',
221+
errors: [{
222+
name: 'TokenizationError',
223+
message: 'expected ":" after filter name, line:1, col:18'
224+
}]
225+
})
226+
})
227+
it('should catch parse errors from filter/tag', async function () {
228+
const template = '{{"foo" | nonExistFilter }} {% nonExistTag %}'
229+
return expect(strictCatchingEngine.parseAndRender(template)).rejects.toMatchObject({
230+
name: 'LiquidErrors',
231+
message: '2 errors found, line:1, col:1',
232+
errors: [{
233+
name: 'ParseError',
234+
message: 'undefined filter: nonExistFilter, line:1, col:1'
235+
}, {
236+
name: 'ParseError',
237+
message: 'tag "nonExistTag" not found, line:1, col:29'
238+
}]
239+
})
240+
})
241+
})
242+
197243
describe('ParseError', function () {
244+
let engine: Liquid
198245
beforeEach(function () {
199246
engine = new Liquid()
200247
engine.registerTag('throwsOnParse', {
201-
parse: function () {
202-
throw new Error('intended parse error')
203-
},
248+
parse: throwIntendedError,
204249
render: () => ''
205250
})
206251
})
@@ -225,7 +270,7 @@ describe('error', function () {
225270
it('should throw ParseError when tag parse throws', async function () {
226271
await expect(engine.parseAndRender('{%throwsOnParse%}')).rejects.toMatchObject({
227272
name: 'ParseError',
228-
message: expect.stringContaining('intended parse error')
273+
message: expect.stringContaining('intended error')
229274
})
230275
})
231276
it('should throw ParseError when tag not found', async function () {
@@ -294,14 +339,14 @@ describe('error', function () {
294339
})
295340
engine.registerTag('throwingTag', {
296341
render: function () {
297-
throw new Error('intended render error')
342+
throw new Error('intended error')
298343
}
299344
})
300345
})
301346
it('should throw RenderError when tag throws', function () {
302347
const src = '{%throwingTag%}'
303348
expect(() => engine.parseAndRenderSync(src)).toThrow(RenderError)
304-
expect(() => engine.parseAndRenderSync(src)).toThrow(/intended render error/)
349+
expect(() => engine.parseAndRenderSync(src)).toThrow(/intended error/)
305350
})
306351
it('should contain original error info for {% include %}', function () {
307352
mock({
@@ -323,7 +368,7 @@ describe('error', function () {
323368
throw new Error('expected throw')
324369
} catch (err) {
325370
expect(err).toHaveProperty('name', 'RenderError')
326-
expect(err).toHaveProperty('message', `intended render error, file:${path.resolve('/throwing-tag.html')}, line:4, col:2`)
371+
expect(err).toHaveProperty('message', `intended error, file:${resolve('/throwing-tag.html')}, line:4, col:2`)
327372
expect(err).toHaveProperty('stack', expect.stringContaining(message.join('\n')))
328373
}
329374
})

test/stub/util.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function throwIntendedError () {
2+
throw new Error('intended error')
3+
}
4+
export async function rejectIntendedError () {
5+
throw new Error('intended reject')
6+
}

0 commit comments

Comments
 (0)