From fc3c825b0757911e5c4824c9bf740374024cb20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20Aslan?= Date: Sun, 14 Jan 2024 04:48:35 +0100 Subject: [PATCH] improved parser error handling & added a new check for closing tags with attrs --- src/component.js | 6 +- src/lib/symbols.js | 1 + src/lib/templateparser/parser.js | 155 +++++++++++++++++++------- src/lib/templateparser/parser.test.js | 133 ++++++++++++++-------- vite/preCompiler.js | 10 +- 5 files changed, 208 insertions(+), 97 deletions(-) diff --git a/src/component.js b/src/component.js index 291684b6..26971b58 100644 --- a/src/component.js +++ b/src/component.js @@ -47,11 +47,11 @@ const required = (name) => { } const Component = (name = required('name'), config = required('config')) => { - const setupComponent = (lifecycle) => { + const setupComponent = (lifecycle, parentComponent) => { // code generation if (!config.code) { Log.debug(`Generating code for ${name} component`) - config.code = codegenerator.call(config, parser(config.template)) + config.code = codegenerator.call(config, parser(config.template, name, parentComponent)) } setupBase(component) @@ -115,7 +115,7 @@ const Component = (name = required('name'), config = required('config')) => { this.lifecycle = createLifecycle(this) if (!component.setup) { - setupComponent(this.lifecycle) + setupComponent(this.lifecycle, parentComponent) } this.parent = parentComponent diff --git a/src/lib/symbols.js b/src/lib/symbols.js index 6799c113..2940092c 100644 --- a/src/lib/symbols.js +++ b/src/lib/symbols.js @@ -1,5 +1,6 @@ export default { currentView: Symbol('currentView'), + cursorTagStart: Symbol('cursorTagStart'), computedKeys: Symbol('computedKeys'), destroy: Symbol('destroy'), index: Symbol('index'), diff --git a/src/lib/templateparser/parser.js b/src/lib/templateparser/parser.js index 2150697b..46b471a2 100644 --- a/src/lib/templateparser/parser.js +++ b/src/lib/templateparser/parser.js @@ -17,16 +17,9 @@ import symbols from '../symbols.js' -class TemplateParseError extends Error { - constructor(message, name, context) { - super(`TemplateParseError: ${message}`) - this.name = name - this.context = context - } -} - -export default (template = '') => { +export default (template = '', componentName, parentComponent, filePath = null) => { let cursor = 0 + let prevCursor = 0 let tags = [] let currentTag = null let currentLevel = 0 @@ -45,13 +38,10 @@ export default (template = '') => { parseLoop(parseEmptyTagStart) return format(tags) } catch (error) { - if (error instanceof TemplateParseError) { - console.error(`${error.message} | ${error.name}`) - } else { - console.error(error) + if (error.name == 'TemplateParseError' || error.name == 'TemplateStructureError') { + error.message = `${error.message}\n${error.context}` } - // return errors gracefully - return null + throw error } } @@ -63,9 +53,9 @@ export default (template = '') => { } // utils - const clean = (template) => { + const clean = (templateText) => { // remove all unnecessary new lines and comments - return template + return templateText .replace(//gms, '') // remove comments .replace(/\r?\n\s*\r\n/gm, ' ') // remove empty lines .replace(/\r?\n\s*(\S)/gm, ' $1') // remove line endings & spacing @@ -75,7 +65,10 @@ export default (template = '') => { const moveCursorOnMatch = (regex) => { const match = template.slice(cursor).match(regex) - if (match) cursor += match[0].length + if (match) { + prevCursor = cursor + cursor += match[0].length + } return match } @@ -83,7 +76,12 @@ export default (template = '') => { const parseEmptyTagStart = () => { const match = moveCursorOnMatch(emptyTagStartRegex) if (match) { - tags.push({ type: null, [symbols.type]: 'opening', [symbols.level]: currentLevel }) + tags.push({ + type: null, + [symbols.type]: 'opening', + [symbols.level]: currentLevel, + [symbols.cursorTagStart]: prevCursor, + }) currentLevel++ parseLoop(parseEmptyTagStart) } else { @@ -95,7 +93,11 @@ export default (template = '') => { const match = moveCursorOnMatch(emptyTagEndRegex) if (match) { currentLevel-- - tags.push({ type: null, [symbols.type]: 'closing', [symbols.level]: currentLevel }) + tags.push({ + type: null, + [symbols.type]: 'closing', + [symbols.level]: currentLevel, + }) parseLoop(parseEmptyTagStart) } else { parseLoop(parseTag) @@ -105,16 +107,22 @@ export default (template = '') => { const parseTag = () => { const match = moveCursorOnMatch(tagStartRegex) if (match) { + currentTag = { + type: match[1], + [symbols.level]: currentLevel, + [symbols.cursorTagStart]: prevCursor, + } if (match[0].startsWith(' { if (match) { if (match[1] === '/>') { if (currentTag[symbols.type] === 'closing') { - // 10 is arbitrary, just to show some context by moving the cursor back a bit - throw new TemplateParseError('InvalidClosingTag', template.slice(cursor - 10)) + throw TemplateParseError('InvalidClosingTag') } currentTag[symbols.type] = 'self-closing' currentLevel-- // because it was parsed as opening tag before @@ -147,9 +154,14 @@ export default (template = '') => { } } + //fixme: closing tags cannot have attributes const parseAttributes = () => { const attrNameMatch = moveCursorOnMatch(attrNameRegex) if (attrNameMatch) { + if (currentTag[symbols.type] === 'closing') { + throw TemplateParseError('AttributesInClosingTag') + } + const delimiter = attrNameMatch[2] const attrValueRegex = new RegExp(`^(.*?)${delimiter}\\s*`) const attrValueMatch = moveCursorOnMatch(attrValueRegex) @@ -160,10 +172,10 @@ export default (template = '') => { currentTag[attr.name] = attr.value parseLoop(parseTagEnd) } else { - throw new TemplateParseError('MissingOrInvalidAttributeValue', template.slice(cursor)) + throw TemplateParseError('MissingOrInvalidAttributeValue') } } else { - throw new TemplateParseError('InvalidAttribute', template.slice(cursor)) + throw TemplateParseError('InvalidAttribute') } } @@ -177,10 +189,8 @@ export default (template = '') => { return { name, value } } - // formating and validation - /* - validation rules: + Formatting & validation rules: #1: Every opening tag must have a corresponding closing tag at the same level. If a closing tag is encountered without a preceding opening tag at the same level, or if an opening tag is not followed by a corresponding closing tag at the same level, an error should be thrown. @@ -200,7 +210,7 @@ export default (template = '') => { // Rule #1 if (element[symbols.level] === 0 && element[symbols.type] !== 'closing') { if (rootElementDefined) { - throw new TemplateParseError('MultipleTopLevelTags', formatErrorContext(element)) + throw TemplateStructureError('MultipleTopLevelTags', element) } rootElementDefined = true } @@ -210,6 +220,7 @@ export default (template = '') => { stack.push({ [symbols.level]: element[symbols.level], [symbols.type]: element[symbols.type], + [symbols.cursorTagStart]: element[symbols.cursorTagStart], type: element.type, parent: currentParent, // helps getting the previous parent when closing tag is encountered }) @@ -223,7 +234,7 @@ export default (template = '') => { } if (isStackEmpty || isLevelMismatch || isTagMismatch) { - throw new TemplateParseError('MismatchedClosingTag', formatErrorContext(element)) + throw TemplateStructureError('MismatchedClosingTag', element) } // when we remove the closing element from the stack, we should set @@ -235,6 +246,7 @@ export default (template = '') => { const newItem = { ...element } delete newItem[symbols.type] delete newItem[symbols.level] + delete newItem[symbols.cursorTagStart] // if it is an opening tag, add children[] to it and update current parent if (element[symbols.type] === 'opening') { @@ -251,21 +263,78 @@ export default (template = '') => { // Check if all tags are closed (so stack should be empty)[Rule #1] if (stack.length > 0) { - const unclosedTags = stack - .map((item) => { - return formatErrorContext(item) - }) - .join(', ') - throw new TemplateParseError('UnclosedTags', unclosedTags) + throw TemplateStructureError('UnclosedTags', stack) + } + + return output + } + + // error reporting + const contextPaddingBefore = 10 // number of characters to show before the error location + const contextPaddingAfter = 50 // number of characters to show after the error location + + const TemplateParseError = (message, context) => { + const location = getErrorLocation() + message = `${message} in ${location}` + + const error = new Error(message) + error.name = 'TemplateParseError' + + // generate context if the error is related to parsing + if (!context) { + const start = Math.max(0, prevCursor - contextPaddingBefore) + const end = Math.min(template.length, cursor + contextPaddingAfter) + const contextText = template.slice(start, end) + + // add ^ caret to show where the error is + const caretPosition = cursor - start + error.context = insertContextCaret(caretPosition, contextText) + } else { + error.context = context } + return error + } - function formatErrorContext(element) { - return `${element.type || 'empty-tag'}[${element[symbols.type]}] at level ${ - element[symbols.level] - }` + const TemplateStructureError = (message, context) => { + const location = getErrorLocation() + message = `${message} in ${location}` + + const error = new Error(message) + error.name = 'TemplateStructureError' + + // check if context is an array + if (Array.isArray(context)) { + error.context = context.map((tag) => generateContext(tag)).join('\n') + } else { + error.context = generateContext(context) } - return output + function generateContext(element) { + const start = Math.max(0, element[symbols.cursorTagStart] - contextPaddingBefore) + const contextText = template.slice(start, start + contextPaddingAfter) + // add ^ caret to show where the error is + return insertContextCaret(contextPaddingBefore, contextText) + } + return error + } + + const insertContextCaret = (position, contextText) => { + const caret = ' '.repeat(position) + '^' + return `\n${contextText}\n${caret}\n` + } + + const getErrorLocation = () => { + if (parentComponent) { + let hierarchy = componentName || '' + let currentParent = parentComponent + + while (currentParent) { + hierarchy = `${currentParent.type}/${hierarchy}` + currentParent = currentParent.parent + } + return hierarchy + } + return filePath ? filePath : 'Blits.Application' } return parse() diff --git a/src/lib/templateparser/parser.test.js b/src/lib/templateparser/parser.test.js index d5b4c1cd..65f0be4a 100644 --- a/src/lib/templateparser/parser.test.js +++ b/src/lib/templateparser/parser.test.js @@ -594,43 +594,6 @@ test('Parse template with a nameless tag', (assert) => { assert.end() }) -// test('Parse template with a nameless tag but with arguments', (assert) => { -// const template = ` -// -// -// -// -// ` - -// const expected = { -// children: [ -// { -// type: null, -// x: '100', -// y: '200', -// children: [ -// { -// type: null, -// x: '50', -// y: '20', -// children: [ -// { -// type: 'Component', -// w: '100', -// h: '20', -// }, -// ], -// }, -// ], -// }, -// ], -// } -// const actual = parser(template) - -// assert.deepEqual(actual, expected, 'Parser should return object representation of template') -// assert.end() -// }) - test('Parse template with a transition argument (single value)', (assert) => { const template = ` @@ -974,8 +937,16 @@ test('Parse template with multiple top level elements and parsing should fail', /> ` - const actual = parser(template) - assert.equal(actual, null, 'Parser should throw an error') + try { + parser(template) + assert.fail('Parser should throw TemplateStructureError:MultipleTopLevelTags') + } catch (error) { + assert.equal(error.name, 'TemplateStructureError', 'Parser should throw TemplateStructureError') + assert.ok( + error.message.startsWith('MultipleTopLevelTags'), + 'Parser should throw TemplateStructureError:MultipleTopLevelTags' + ) + } assert.end() }) @@ -987,8 +958,16 @@ test('Parse template with unclosed tag and parsing should fail', (assert) => { ` - const actual = parser(template) - assert.equal(actual, null, 'Parser should throw an error') + try { + parser(template) + assert.fail('Parser should throw TemplateStructureError:MismatchedClosingTag') + } catch (error) { + assert.equal(error.name, 'TemplateStructureError', 'Parser should throw TemplateStructureError') + assert.ok( + error.message.startsWith('MismatchedClosingTag'), + 'Parser should throw TemplateStructureError:MismatchedClosingTag' + ) + } assert.end() }) @@ -1004,8 +983,16 @@ test('Parse template with multiple unclosed tags and parsing should fail', (asse ` - const actual = parser(template) - assert.equal(actual, null, 'Parser should throw an error') + try { + parser(template) + assert.fail('Parser should throw TemplateStructureError:MismatchedClosingTag') + } catch (error) { + assert.equal(error.name, 'TemplateStructureError', 'Parser should throw TemplateStructureError') + assert.ok( + error.message.startsWith('MismatchedClosingTag'), + 'Parser should throw TemplateStructureError:MismatchedClosingTag' + ) + } assert.end() }) @@ -1017,8 +1004,16 @@ test('Parse template with an invalid closing tag and parsing should fail', (asse ` - const actual = parser(template) - assert.equal(actual, null, 'Parser should throw an error') + try { + parser(template) + assert.fail('Parser should throw TemplateParseError:InvalidClosingTag') + } catch (error) { + assert.equal(error.name, 'TemplateParseError', 'Parser should throw TemplateParseError') + assert.ok( + error.message.startsWith('InvalidClosingTag'), + 'Parser should throw TemplateParseError:InvalidClosingTag' + ) + } assert.end() }) @@ -1029,8 +1024,17 @@ test('Parse template with multiple self-closing tags at the top level and parsin ` - const actual = parser(template) - assert.equal(actual, null, 'Parser should throw an error') + try { + parser(template) + assert.fail('Parser should throw TemplateStructureError:MultipleTopLevelTags') + } catch (error) { + assert.equal(error.name, 'TemplateStructureError', 'Parser should throw TemplateStructureError') + assert.ok( + error.message.startsWith('MultipleTopLevelTags'), + 'Parser should throw TemplateStructureError:MultipleTopLevelTags' + ) + } + assert.end() }) @@ -1042,7 +1046,38 @@ test('Parse template with a closing tag at the beginning and parsing should fail ` - const actual = parser(template) - assert.equal(actual, null, 'Parser should throw an error') + try { + parser(template) + assert.fail('Parser should throw TemplateStructureError:MismatchedClosingTag') + } catch (error) { + assert.equal(error.name, 'TemplateStructureError', 'Parser should throw TemplateStructureError') + assert.ok( + error.message.startsWith('MismatchedClosingTag'), + 'Parser should throw TemplateStructureError:MismatchedClosingTag' + ) + } + + assert.end() +}) + +test('Parse template with a closing tag that has attributes and parsing should fail', (assert) => { + const template = ` + + Lorem ipsum dolor sit amet + + + ` + + try { + parser(template) + assert.fail('Parser should throw TemplateParseError:AttributesInClosingTag') + } catch (error) { + assert.equal(error.name, 'TemplateParseError', 'Parser should throw TemplateParseError') + assert.ok( + error.message.startsWith('AttributesInClosingTag'), + 'Parser should throw TemplateParseError:AttributesInClosingTag' + ) + } + assert.end() }) diff --git a/vite/preCompiler.js b/vite/preCompiler.js index ce344b6e..9955b2a4 100644 --- a/vite/preCompiler.js +++ b/vite/preCompiler.js @@ -1,5 +1,6 @@ import parser from '../src/lib/templateparser/parser.js' import generator from '../src/lib/codegenerator/generator.js' +import path from 'path' export default function () { let config @@ -8,7 +9,7 @@ export default function () { configResolved(resolvedConfig) { config = resolvedConfig }, - transform(source) { + transform(source, filePath) { if (config.blits && config.blits.precompile === false) return source if (source.indexOf('Blits.Component(') > -1 || source.indexOf('Blits.Application(') > -1) { // get the start of the template key in de component configuration object @@ -27,7 +28,12 @@ export default function () { templateStartIndex + templateContentResult.index + templateContentResult[0].length // Parse the template - const parsed = parser(templateContentResult[1]) + let resourceName = 'Blits.Application' + if (source.indexOf('Blits.Component(') > -1) { + resourceName = source.match(/Blits\.Component\(['"](.*)['"]\s*,/)[1] + } + const componentPath = path.relative(process.cwd(), filePath) + const parsed = parser(templateContentResult[1], resourceName, null, componentPath) // Generate the code const code = generator.call({ components: {} }, parsed)