From 59c43dcb5bcc1d2b1a33d8e434ca3efbc96a3d97 Mon Sep 17 00:00:00 2001 From: James Meng Date: Tue, 26 Nov 2024 14:22:13 -0800 Subject: [PATCH] Add liquidDoc tag to liquidHTMLParser Add stage-2 ast tests for doc tags --- .../grammar/liquid-html.ohm | 14 +++++ packages/liquid-html-parser/src/grammar.ts | 2 + .../src/stage-1-cst.spec.ts | 28 +++++++++ .../liquid-html-parser/src/stage-1-cst.ts | 57 +++++++++++++++++++ .../src/stage-2-ast.spec.ts | 27 +++++++++ .../liquid-html-syntax-error/index.spec.ts | 2 +- 6 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/liquid-html-parser/grammar/liquid-html.ohm b/packages/liquid-html-parser/grammar/liquid-html.ohm index d35441ee..5897b539 100644 --- a/packages/liquid-html-parser/grammar/liquid-html.ohm +++ b/packages/liquid-html-parser/grammar/liquid-html.ohm @@ -37,6 +37,7 @@ Liquid <: Helpers { endOfIdentifier = endOfTagName | endOfVarName liquidNode = + | liquidDoc | liquidBlockComment | liquidRawTag | liquidDrop @@ -211,6 +212,15 @@ Liquid <: Helpers { commentBlockStart = "{%" "-"? space* ("comment" endOfIdentifier) space* tagMarkup "-"? "%}" commentBlockEnd = "{%" "-"? space* ("endcomment" endOfIdentifier) space* tagMarkup "-"? "%}" + liquidDoc = + liquidDocStart + liquidDocBody + liquidDocEnd + + liquidDocStart = "{%" "-"? space* ("doc" endOfIdentifier) space* tagMarkup "-"? "%}" + liquidDocEnd = "{%" "-"? space* ("enddoc" endOfIdentifier) space* tagMarkup "-"? "%}" + liquidDocBody = anyExceptStar<(liquidDocStart | liquidDocEnd)> + // In order for the grammar to "fallback" to the base case, this // rule must pass if and only if we support what we parse. This // implies that—since we don't support filters yet—we have a @@ -373,6 +383,10 @@ LiquidStatement <: Liquid { delimTag := liquidStatementEnd } +LiquidDoc <: Helpers { + Node := (TextNode)* +} + LiquidHTML <: Liquid { Node := yamlFrontmatter? (HtmlNode | liquidNode | TextNode)* openControl += "<" diff --git a/packages/liquid-html-parser/src/grammar.ts b/packages/liquid-html-parser/src/grammar.ts index ab673506..18167b8e 100644 --- a/packages/liquid-html-parser/src/grammar.ts +++ b/packages/liquid-html-parser/src/grammar.ts @@ -3,6 +3,7 @@ import ohm from 'ohm-js'; export const liquidHtmlGrammars = ohm.grammars(require('../grammar/liquid-html.ohm.js')); export const TextNodeGrammar = liquidHtmlGrammars['Helpers']; +export const LiquidDocGrammar = liquidHtmlGrammars['LiquidDoc']; export interface LiquidGrammars { Liquid: ohm.Grammar; @@ -52,4 +53,5 @@ export const TAGS_WITHOUT_MARKUP = [ 'continue', 'comment', 'raw', + 'doc', ]; diff --git a/packages/liquid-html-parser/src/stage-1-cst.spec.ts b/packages/liquid-html-parser/src/stage-1-cst.spec.ts index fff2867c..55c9cdbf 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.spec.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.spec.ts @@ -978,6 +978,34 @@ describe('Unit: Stage 1 (CST)', () => { } }); + it('should parse doc tags', () => { + for (const { toCST, expectPath } of testCases) { + const testStr = `{% doc -%} Renders loading-spinner. {%- enddoc %}`; + + cst = toCST(testStr); + expectPath(cst, '0.type').to.equal('LiquidRawTag'); + expectPath(cst, '0.name').to.equal('doc'); + expectPath(cst, '0.body').to.include('Renders loading-spinner'); + expectPath(cst, '0.whitespaceStart').to.equal(''); + expectPath(cst, '0.whitespaceEnd').to.equal('-'); + expectPath(cst, '0.delimiterWhitespaceStart').to.equal('-'); + expectPath(cst, '0.delimiterWhitespaceEnd').to.equal(''); + expectPath(cst, '0.blockStartLocStart').to.equal(0); + expectPath(cst, '0.blockStartLocEnd').to.equal(0 + '{% doc -%}'.length); + expectPath(cst, '0.blockEndLocStart').to.equal(testStr.length - '{%- enddoc %}'.length); + expectPath(cst, '0.blockEndLocEnd').to.equal(testStr.length); + expectPath(cst, '0.children').to.deep.equal([ + { + locEnd: 35, + locStart: 11, + source: '{% doc -%} Renders loading-spinner. {%- enddoc %}', + type: 'TextNode', + value: 'Renders loading-spinner.', + }, + ]); + } + }); + it('should parse tag open / close', () => { BLOCKS.forEach((block: string) => { for (const { toCST, expectPath } of testCases) { diff --git a/packages/liquid-html-parser/src/stage-1-cst.ts b/packages/liquid-html-parser/src/stage-1-cst.ts index b3a1d391..9c26473d 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.ts @@ -34,6 +34,7 @@ import { Parser } from 'prettier'; import ohm, { Node } from 'ohm-js'; import { toAST } from 'ohm-js/extras'; import { + LiquidDocGrammar, LiquidGrammars, TextNodeGrammar, placeholderGrammars, @@ -625,6 +626,30 @@ function toCST( blockEndLocStart: (tokens: Node[]) => tokens[2].source.startIdx, blockEndLocEnd: (tokens: Node[]) => tokens[2].source.endIdx, }, + liquidDoc: { + type: ConcreteNodeTypes.LiquidRawTag, + name: 'doc', + body: (tokens: Node[]) => tokens[1].sourceString, + children: (tokens: Node[]) => { + const contentNode = tokens[1]; + return toLiquidDocAST( + source, + contentNode.sourceString, + offset + contentNode.source.startIdx, + ); + }, + whitespaceStart: (tokens: Node[]) => tokens[0].children[1].sourceString, + whitespaceEnd: (tokens: Node[]) => tokens[0].children[7].sourceString, + delimiterWhitespaceStart: (tokens: Node[]) => tokens[2].children[1].sourceString, + delimiterWhitespaceEnd: (tokens: Node[]) => tokens[2].children[7].sourceString, + locStart, + locEnd, + source, + blockStartLocStart: (tokens: Node[]) => tokens[0].source.startIdx, + blockStartLocEnd: (tokens: Node[]) => tokens[0].source.endIdx, + blockEndLocStart: (tokens: Node[]) => tokens[2].source.startIdx, + blockEndLocEnd: (tokens: Node[]) => tokens[2].source.endIdx, + }, liquidInlineComment: { type: ConcreteNodeTypes.LiquidTag, name: 3, @@ -1251,3 +1276,35 @@ function toCST( return toAST(res, selectedMappings) as T; } + +/** + * Builds an AST for LiquidDoc content. + * + * `toCST` includes mappings and logic that are not needed for LiquidDoc so we're separating this logic + */ +function toLiquidDocAST(source: string, matchingSource: string, offset: number) { + // When we switch parser, our locStart and locEnd functions must account + // for the offset of the {% doc %} markup + const locStart = (tokens: Node[]) => offset + tokens[0].source.startIdx; + const locEnd = (tokens: Node[]) => offset + tokens[tokens.length - 1].source.endIdx; + + const res = LiquidDocGrammar.match(matchingSource, 'Node'); + if (res.failed()) { + throw new LiquidHTMLCSTParsingError(res); + } + + const LiquidDocMappings: Mapping = { + Node: 0, + TextNode: { + type: ConcreteNodeTypes.TextNode, + value: function () { + return (this as any).sourceString; + }, + locStart, + locEnd, + source, + }, + }; + + return toAST(res, LiquidDocMappings); +} diff --git a/packages/liquid-html-parser/src/stage-2-ast.spec.ts b/packages/liquid-html-parser/src/stage-2-ast.spec.ts index de6de263..84848ccb 100644 --- a/packages/liquid-html-parser/src/stage-2-ast.spec.ts +++ b/packages/liquid-html-parser/src/stage-2-ast.spec.ts @@ -1220,6 +1220,33 @@ describe('Unit: Stage 2 (AST)', () => { expectPath(ast, 'children.0.markup.1.children.0.children.1.markup.name').to.eql('var3'); }); + it(`should parse doc tags`, () => { + ast = toLiquidAST(`{% doc %}{% enddoc %}`); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.markup').toEqual(''); + expectPath(ast, 'children.0.body.value').to.eql(''); + expectPath(ast, 'children.0.body.type').toEqual('RawMarkup'); + expectPath(ast, 'children.0.body.nodes').toEqual([]); + + ast = toLiquidAST(`{% doc -%} single line doc {%- enddoc %}`); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.body.value').to.eql(' single line doc '); + expectPath(ast, 'children.0.body.nodes.0.type').toEqual('TextNode'); + + ast = toLiquidAST(`{% doc -%} + multi line doc + multi line doc + {%- enddoc %}`); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.body.nodes.0.value').to.eql( + `multi line doc\n multi line doc`, + ); + expectPath(ast, 'children.0.body.nodes.0.type').toEqual('TextNode'); + }); + it('should parse unclosed tables with assignments', () => { ast = toLiquidAST(` {%- liquid diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts index d73d8ab0..bf1abbf7 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts @@ -86,7 +86,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", or "comment"`, + `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", "comment", or "doc"`, ); });