From ac2d421ed856f4b032143544324abaf4b5d019d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 13:24:56 +0000 Subject: [PATCH] feat: add splitAssignmentsByVariable for per-variable Liquid slices - Implement splitAssignmentsByVariable() using existing parser AST - Support assign, assignVar, parseAssign tags - Handle if/elsif/else, unless, for, case, capture, raw blocks - Prune empty branches when pruneEmptyBlocks is true - Preserve control-flow structure per variable - Add unit tests and sample fixtures - Export from index.js Co-authored-by: Sanket Mishra --- index.js | 1 + src/split-assignments.js | 347 ++++++++++++++++++++++ test/fixtures/sample-liquid-input.liquid | 10 + test/fixtures/sample-liquid-output.liquid | 9 + test/split-assignments.js | 143 +++++++++ 5 files changed, 510 insertions(+) create mode 100644 src/split-assignments.js create mode 100644 test/fixtures/sample-liquid-input.liquid create mode 100644 test/fixtures/sample-liquid-output.liquid create mode 100644 test/split-assignments.js diff --git a/index.js b/index.js index 9a63420fcc..3a3d453d55 100644 --- a/index.js +++ b/index.js @@ -184,6 +184,7 @@ factory.isTruthy = Syntax.isTruthy factory.isFalsy = Syntax.isFalsy factory.evalExp = Syntax.evalExp factory.evalValue = Syntax.evalValue +factory.splitAssignmentsByVariable = require('./src/split-assignments.js').splitAssignmentsByVariable factory.Types = { ParseError: Errors.ParseError, TokenizationEroor: Errors.TokenizationError, diff --git a/src/split-assignments.js b/src/split-assignments.js new file mode 100644 index 0000000000..5000588da5 --- /dev/null +++ b/src/split-assignments.js @@ -0,0 +1,347 @@ +/** + * Split Liquid template into per-variable assignment slices. + * Uses the existing Liquid parser AST to isolate assignments per variable. + * + * @param {string} liquidText - The Liquid template source + * @param {Object} options - Configuration options + * @param {string[]} options.assignTags - Tag names that perform assignment (default: ['assign','assignVar','parseAssign']) + * @param {boolean} options.includeOtherAssigns - Include assigns to other variables (default: false) + * @param {boolean} options.pruneEmptyBlocks - Remove empty branches (default: true) + * @param {boolean} options.includeComments - Include comments (default: false, not reconstructible from AST) + * @param {boolean} options.keepNonAssign - Keep html, value, and non-control tags (default: false) + * @param {string} options.outputOrder - 'first-appearance' (default) + * @returns {string[]} Array of Liquid strings, one per variable, ordered by first appearance + */ +function splitAssignmentsByVariable (liquidText, options) { + const Liquid = require('../index.js') + const engine = Liquid() + const opts = Object.assign({ + assignTags: ['assign', 'assignVar', 'parseAssign'], + includeOtherAssigns: false, + pruneEmptyBlocks: true, + includeComments: false, + keepNonAssign: false, + outputOrder: 'first-appearance' + }, options || {}) + + const templates = engine.parse(liquidText) + const varFirstAppearance = identifyAssignedVariables(templates, opts.assignTags) + const orderedVars = Object.keys(varFirstAppearance).sort( + (a, b) => varFirstAppearance[a] - varFirstAppearance[b] + ) + + return orderedVars.map(targetVar => { + const filtered = filterTemplates(templates, targetVar, opts) + return renderTemplatesToLiquid(filtered) + }) +} + +/** + * Traverse AST and collect assigned variables with first appearance index. + */ +function identifyAssignedVariables (templates, assignTags, indexRef) { + indexRef = indexRef || { value: 0 } + const result = {} + + function traverse (tpls) { + if (!Array.isArray(tpls)) return + for (const t of tpls) { + if (t.type === 'tag' && assignTags.indexOf(t.name) >= 0 && t.tagImpl && t.tagImpl.key) { + const key = t.tagImpl.key + if (!(key in result)) { + result[key] = indexRef.value + } + } + indexRef.value++ + + const childTpls = getChildTemplates(t) + if (childTpls) traverse(childTpls) + } + } + + traverse(templates) + return result +} + +function getChildTemplates (template) { + if (template.type !== 'tag') return null + const impl = template.tagImpl + const name = template.name + if (name === 'if' && impl && impl.branches) { + const all = [] + for (const b of impl.branches) all.push(...(b.templates || [])) + if (impl.elseTemplates) all.push(...impl.elseTemplates) + return all + } + if (name === 'unless' && impl && (impl.templates || impl.elseTemplates)) { + return [...(impl.templates || []), ...(impl.elseTemplates || [])] + } + if ((name === 'for' || name === 'tablerow') && impl && (impl.templates || impl.elseTemplates)) { + return [...(impl.templates || []), ...(impl.elseTemplates || [])] + } + if (name === 'case' && impl && (impl.cases || impl.elseTemplates)) { + const all = [] + for (const c of (impl.cases || [])) all.push(...(c.templates || [])) + if (impl.elseTemplates) all.push(...impl.elseTemplates) + return all + } + if (name === 'capture' && impl && impl.templates) return impl.templates + return null +} + +/** + * Filter templates to keep only control-flow and assigns to targetVar. + */ +function filterTemplates (templates, targetVar, options) { + if (!Array.isArray(templates)) return [] + const result = [] + const assignTags = options.assignTags || [] + const includeOtherAssigns = options.includeOtherAssigns + const pruneEmptyBlocks = options.pruneEmptyBlocks + const keepNonAssign = options.keepNonAssign + + for (const t of templates) { + if (t.type === 'html') { + if (keepNonAssign) result.push(t) + continue + } + if (t.type === 'value') { + if (keepNonAssign) result.push(t) + continue + } + if (t.type === 'tag') { + if (assignTags.indexOf(t.name) >= 0) { + const key = t.tagImpl && t.tagImpl.key + if (key === targetVar) { + result.push(t) + } else if (includeOtherAssigns) { + result.push(t) + } + continue + } + if (t.name === 'comment' && !options.includeComments) continue + if (t.name === 'raw') { + if (keepNonAssign) result.push(t) + continue + } + + const filtered = filterControlFlow(t, targetVar, options) + if (filtered) { + if (pruneEmptyBlocks && isEmptyBlock(filtered)) continue + result.push(filtered) + } + } + } + return result +} + +function filterControlFlow (template, targetVar, options) { + const impl = template.tagImpl + const name = template.name + if (name === 'if' && impl && impl.branches) { + const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options) + const branchFiltered = impl.branches.map(b => filterTemplates(b.templates || [], targetVar, options)) + const hasContentLater = (idx) => + branchFiltered.some((tpls, i) => i > idx && tpls.length > 0) || elseFiltered.length > 0 + + const newBranches = [] + for (let i = 0; i < impl.branches.length; i++) { + const b = impl.branches[i] + const filtered = branchFiltered[i] + const keep = filtered.length > 0 || !options.pruneEmptyBlocks || + (i === 0 && hasContentLater(0)) || (i > 0 && hasContentLater(i)) + if (keep) { + newBranches.push({ cond: b.cond, templates: filtered }) + } + } + if (newBranches.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) { + return null + } + if (newBranches.length === 0 && elseFiltered.length > 0 && impl.branches.length > 0) { + newBranches.push({ cond: impl.branches[0].cond, templates: [] }) + } + const out = Object.create(template) + out.tagImpl = Object.create(impl) + out.tagImpl.branches = newBranches + out.tagImpl.elseTemplates = elseFiltered + return out + } + if (name === 'unless' && impl) { + const templatesFiltered = filterTemplates(impl.templates || [], targetVar, options) + const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options) + if (templatesFiltered.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) { + return null + } + const out = Object.create(template) + out.tagImpl = Object.create(impl) + out.tagImpl.templates = templatesFiltered + out.tagImpl.elseTemplates = elseFiltered + return out + } + if ((name === 'for' || name === 'tablerow') && impl) { + const templatesFiltered = filterTemplates(impl.templates || [], targetVar, options) + const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options) + if (templatesFiltered.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) { + return null + } + const out = Object.create(template) + out.tagImpl = Object.create(impl) + out.tagImpl.templates = templatesFiltered + out.tagImpl.elseTemplates = elseFiltered + return out + } + if (name === 'case' && impl) { + const newCases = [] + for (const c of (impl.cases || [])) { + const filtered = filterTemplates(c.templates || [], targetVar, options) + if (filtered.length > 0 || !options.pruneEmptyBlocks) { + newCases.push({ val: c.val, templates: filtered }) + } + } + const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options) + if (newCases.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) { + return null + } + const out = Object.create(template) + out.tagImpl = Object.create(impl) + out.tagImpl.cases = newCases + out.tagImpl.elseTemplates = elseFiltered + return out + } + if (name === 'capture' && impl) { + const filtered = filterTemplates(impl.templates || [], targetVar, options) + if (filtered.length === 0 && options.pruneEmptyBlocks) return null + const out = Object.create(template) + out.tagImpl = Object.create(impl) + out.tagImpl.templates = filtered + return out + } + return null +} + +function isEmptyBlock (template) { + if (template.type !== 'tag') return false + const impl = template.tagImpl + if (template.name === 'if' && impl && impl.branches) { + return impl.branches.every(b => (b.templates || []).length === 0) && + (impl.elseTemplates || []).length === 0 + } + if (template.name === 'unless' && impl) { + return (impl.templates || []).length === 0 && (impl.elseTemplates || []).length === 0 + } + if ((template.name === 'for' || template.name === 'tablerow') && impl) { + return (impl.templates || []).length === 0 && (impl.elseTemplates || []).length === 0 + } + if (template.name === 'case' && impl) { + return (impl.cases || []).every(c => (c.templates || []).length === 0) && + (impl.elseTemplates || []).length === 0 + } + if (template.name === 'capture' && impl) { + return (impl.templates || []).length === 0 + } + return false +} + +/** + * Render pruned AST back to Liquid string. + */ +function renderTemplatesToLiquid (templates) { + if (!Array.isArray(templates)) return '' + return templates.map(t => renderTemplateToLiquid(t)).join('') +} + +function renderTemplateToLiquid (template) { + if (!template) return '' + if (template.type === 'html') return template.raw || template.value || '' + if (template.type === 'value') return (template.token && template.token.raw) || '' + if (template.type !== 'tag') return '' + + const name = template.name + const token = template.token + + if (['assign', 'assignVar', 'parseAssign'].indexOf(name) >= 0) { + return (token && token.raw) || '' + } + + const impl = template.tagImpl + if (name === 'if' && impl && impl.branches && impl.branches.length > 0) { + let out = (token && token.raw) || '' + out += renderTemplatesToLiquid(impl.branches[0].templates || []) + for (let i = 1; i < impl.branches.length; i++) { + const b = impl.branches[i] + out += `{% elsif ${b.cond} %}` + out += renderTemplatesToLiquid(b.templates || []) + } + if ((impl.elseTemplates || []).length > 0) { + out += '{% else %}' + out += renderTemplatesToLiquid(impl.elseTemplates) + } + out += '{% endif %}' + return out + } + + if (name === 'unless' && impl) { + let out = (token && token.raw) || '' + out += renderTemplatesToLiquid(impl.templates || []) + if ((impl.elseTemplates || []).length > 0) { + out += '{% else %}' + out += renderTemplatesToLiquid(impl.elseTemplates) + } + out += '{% endunless %}' + return out + } + + if (name === 'for' && impl) { + let out = (token && token.raw) || '' + out += renderTemplatesToLiquid(impl.templates || []) + if ((impl.elseTemplates || []).length > 0) { + out += '{% else %}' + out += renderTemplatesToLiquid(impl.elseTemplates) + } + out += '{% endfor %}' + return out + } + + if (name === 'tablerow' && impl) { + let out = (token && token.raw) || '' + out += renderTemplatesToLiquid(impl.templates || []) + if ((impl.elseTemplates || []).length > 0) { + out += '{% else %}' + out += renderTemplatesToLiquid(impl.elseTemplates) + } + out += '{% endtablerow %}' + return out + } + + if (name === 'case' && impl) { + let out = (token && token.raw) || '' + for (const c of (impl.cases || [])) { + out += `{% when ${c.val} %}` + out += renderTemplatesToLiquid(c.templates || []) + } + if ((impl.elseTemplates || []).length > 0) { + out += '{% else %}' + out += renderTemplatesToLiquid(impl.elseTemplates) + } + out += '{% endcase %}' + return out + } + + if (name === 'capture' && impl) { + let out = (token && token.raw) || '' + out += renderTemplatesToLiquid(impl.templates || []) + out += '{% endcapture %}' + return out + } + + if (name === 'raw' && impl && impl.tokens) { + let out = (token && token.raw) || '' + out += impl.tokens.map(t => t.raw).join('') + out += '{% endraw %}' + return out + } + + return '' +} + +module.exports = { splitAssignmentsByVariable } diff --git a/test/fixtures/sample-liquid-input.liquid b/test/fixtures/sample-liquid-input.liquid new file mode 100644 index 0000000000..93972f65c1 --- /dev/null +++ b/test/fixtures/sample-liquid-input.liquid @@ -0,0 +1,10 @@ +{% assign a = 1 %} +{% if x %} + {% assign b = 2 %} +{% else %} + {% assign c = 3 %} +{% endif %} +{% for item in items %} + {% assign d = item %} +{% endfor %} +{% assign e = 5 %} diff --git a/test/fixtures/sample-liquid-output.liquid b/test/fixtures/sample-liquid-output.liquid new file mode 100644 index 0000000000..4dc3936910 --- /dev/null +++ b/test/fixtures/sample-liquid-output.liquid @@ -0,0 +1,9 @@ +{% assign a = 1 %} +--- +{% if x %}{% assign b = 2 %}{% endif %} +--- +{% if x %}{% else %}{% assign c = 3 %}{% endif %} +--- +{% for item in items %}{% assign d = item %}{% endfor %} +--- +{% assign e = 5 %} diff --git a/test/split-assignments.js b/test/split-assignments.js new file mode 100644 index 0000000000..e8ee72d908 --- /dev/null +++ b/test/split-assignments.js @@ -0,0 +1,143 @@ +const chai = require('chai') +const expect = chai.expect +const path = require('path') +const fs = require('fs') +const { splitAssignmentsByVariable } = require('../src/split-assignments.js') + +describe('split-assignments', function () { + describe('splitAssignmentsByVariable', function () { + it('should split sample input by variable', function () { + const inputPath = path.join(__dirname, 'fixtures', 'sample-liquid-input.liquid') + const outputPath = path.join(__dirname, 'fixtures', 'sample-liquid-output.liquid') + const input = fs.readFileSync(inputPath, 'utf8') + const expectedOutput = fs.readFileSync(outputPath, 'utf8') + const expected = expectedOutput.split('---').map(s => s.trim()).filter(Boolean) + + const result = splitAssignmentsByVariable(input) + expect(result).to.deep.equal(expected) + }) + + it('should prune empty branches when only one branch has target assign', function () { + const input = `{% if x %} + {% assign foo = 1 %} +{% else %} + no assign here +{% endif %}` + const result = splitAssignmentsByVariable(input) + expect(result).to.have.lengthOf(1) + expect(result[0]).to.include('{% assign foo = 1 %}') + expect(result[0]).to.include('{% if x %}') + expect(result[0]).to.include('{% endif %}') + expect(result[0]).not.to.include('{% else %}') + }) + + it('should preserve nested blocks structure', function () { + const input = `{% unless outer %} +{% if inner %} +{% for item in items %} + {% assign x = item %} +{% endfor %} +{% endif %} +{% endunless %}` + const result = splitAssignmentsByVariable(input) + expect(result).to.have.lengthOf(1) + expect(result[0]).to.include('{% unless outer %}') + expect(result[0]).to.include('{% if inner %}') + expect(result[0]).to.include('{% for item in items %}') + expect(result[0]).to.include('{% assign x = item %}') + expect(result[0]).to.include('{% endfor %}') + expect(result[0]).to.include('{% endif %}') + expect(result[0]).to.include('{% endunless %}') + }) + + it('should preserve raw block when keepNonAssign is true', function () { + const input = `{% raw %}{% assign x = 1 %}{% endraw %}` + const resultWithKeep = splitAssignmentsByVariable(input, { keepNonAssign: true }) + const resultWithoutKeep = splitAssignmentsByVariable(input, { keepNonAssign: false }) + + expect(resultWithKeep).to.have.lengthOf(0) + expect(resultWithoutKeep).to.have.lengthOf(0) + + const input2 = `{% assign a = 1 %} +{% raw %}some raw {{ content }}{% endraw %} +{% assign b = 2 %}` + const result2 = splitAssignmentsByVariable(input2, { keepNonAssign: true }) + expect(result2).to.have.lengthOf(2) + expect(result2[0]).to.include('{% assign a = 1 %}') + expect(result2[0]).to.include('{% raw %}') + expect(result2[0]).to.include('some raw {{ content }}') + expect(result2[0]).to.include('{% endraw %}') + expect(result2[1]).to.include('{% assign b = 2 %}') + }) + + it('should treat assignVar and parseAssign as assigns', function () { + const input = `{% assign src = "hello" %} +{% assignVar dest = src %} +{% parseAssign arr = "[1,2,3]" %}` + const result = splitAssignmentsByVariable(input) + expect(result).to.have.lengthOf(3) + expect(result[0]).to.include('{% assign src = "hello" %}') + expect(result[1]).to.include('{% assignVar dest = src %}') + expect(result[2]).to.include('{% parseAssign arr = "[1,2,3]" %}') + }) + + it('should order output by first appearance', function () { + const input = `{% assign z = 3 %} +{% assign a = 1 %} +{% assign m = 2 %}` + const result = splitAssignmentsByVariable(input) + expect(result).to.have.lengthOf(3) + expect(result[0]).to.include('z = 3') + expect(result[1]).to.include('a = 1') + expect(result[2]).to.include('m = 2') + }) + + it('should handle case/when blocks', function () { + const input = `{% case x %} +{% when 1 %} + {% assign a = "one" %} +{% when 2 %} + {% assign b = "two" %} +{% else %} + {% assign c = "other" %} +{% endcase %}` + const result = splitAssignmentsByVariable(input) + expect(result).to.have.lengthOf(3) + expect(result[0]).to.include('{% case x %}') + expect(result[0]).to.include('{% when 1 %}') + expect(result[0]).to.include('{% assign a = "one" %}') + expect(result[0]).to.include('{% endcase %}') + expect(result[0]).not.to.include('{% when 2 %}') + expect(result[1]).to.include('{% when 2 %}') + expect(result[1]).to.include('{% assign b = "two" %}') + expect(result[2]).to.include('{% else %}') + expect(result[2]).to.include('{% assign c = "other" %}') + }) + + it('should handle elsif branches', function () { + const input = `{% if a %} + {% assign x = 1 %} +{% elsif b %} + {% assign y = 2 %} +{% else %} + {% assign z = 3 %} +{% endif %}` + const result = splitAssignmentsByVariable(input) + expect(result).to.have.lengthOf(3) + expect(result[0]).to.include('{% if a %}') + expect(result[0]).to.include('{% assign x = 1 %}') + expect(result[0]).to.include('{% endif %}') + expect(result[0]).not.to.include('elsif') + expect(result[1]).to.include('{% elsif b %}') + expect(result[1]).to.include('{% assign y = 2 %}') + expect(result[2]).to.include('{% else %}') + expect(result[2]).to.include('{% assign z = 3 %}') + }) + + it('should return empty array when no assigns', function () { + const input = `Hello {{ name }}` + const result = splitAssignmentsByVariable(input) + expect(result).to.deep.equal([]) + }) + }) +})