Skip to content

Commit 92ae407

Browse files
authored
Merge pull request #1023 from Shopify/update-ohm-to-support-bool-expressions
Update our parsing rules to support boolean expressions
2 parents d28024b + 1ebf924 commit 92ae407

File tree

12 files changed

+258
-13
lines changed

12 files changed

+258
-13
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'@shopify/prettier-plugin-liquid': minor
4+
'@shopify/liquid-html-parser': minor
5+
'@shopify/theme-check-common': minor
6+
---
7+
8+
Detect and fix use of boolean expressions

packages/liquid-html-parser/grammar/liquid-html.ohm

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ Liquid <: Helpers {
237237
// because we'd otherwise positively match the following string
238238
// instead of falling back to the other rule:
239239
// {{ 'string' | some_filter }}
240-
liquidVariable<delim> = liquidExpression<delim> liquidFilter<delim>* space* &delim
240+
liquidVariable<delim> = liquidComplexExpression<delim> liquidFilter<delim>* space* &delim
241241

242242
liquidExpression<delim> =
243243
| liquidString<delim>
@@ -246,6 +246,18 @@ Liquid <: Helpers {
246246
| liquidRange<delim>
247247
| liquidVariableLookup<delim>
248248

249+
liquidComplexExpression<delim> =
250+
| liquidBooleanExpression<delim>
251+
| liquidExpression<delim>
252+
253+
liquidBooleanExpression<delim> = booleanExpressionCondition<delim> listOf<booleanExpressionSubsequentCondition<delim>, conditionSeparator>
254+
255+
// This might over-capture things since the conditions below can contain any `liquidExpression`s.
256+
// We will need to clean this up in CST. If we restrict the conditions to only contain comparisons and
257+
// variable lookups, we can't support "truthy" expressions like `{{ some_var and 'this' }}`
258+
booleanExpressionSubsequentCondition<delim> = space* logicalOperator space* (comparison<delim> | liquidExpression<delim>) space*
259+
booleanExpressionCondition<delim> = comparison<delim> | liquidExpression<delim>
260+
249261
liquidString<delim> = liquidSingleQuotedString<delim> | liquidDoubleQuotedString<delim>
250262
liquidSingleQuotedString<delim> = "'" anyExceptStar<("'"| delim)> "'"
251263
liquidDoubleQuotedString<delim> = "\"" anyExceptStar<("\""| delim)> "\""

packages/liquid-html-parser/src/stage-1-cst.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,56 @@ describe('Unit: Stage 1 (CST)', () => {
3939
}
4040
});
4141

42+
it('should parse single conditions', () => {
43+
[
44+
{ expression: `"1" == "1"`, comparator: '==' },
45+
{ expression: `1 == 1`, comparator: '==' },
46+
{ expression: `1 != 1`, comparator: '!=' },
47+
{ expression: `1 > 1`, comparator: '>' },
48+
{ expression: `1 < 1`, comparator: '<' },
49+
{ expression: `1 >= 1`, comparator: '>=' },
50+
{ expression: `1 <= 1`, comparator: '<=' },
51+
].forEach(({ expression, comparator }) => {
52+
for (const { toCST, expectPath } of testCases) {
53+
cst = toCST(`{{ ${expression} }}`);
54+
expectPath(cst, '0.type').to.equal('LiquidVariableOutput');
55+
expectPath(cst, '0.markup.type').to.equal('LiquidVariable');
56+
expectPath(cst, '0.markup.expression.type').to.equal('BooleanExpression');
57+
expectPath(cst, '0.markup.expression.conditions.0.type').to.equal('Condition');
58+
expectPath(cst, '0.markup.expression.conditions.0.expression.type').to.equal(
59+
'Comparison',
60+
);
61+
expectPath(cst, '0.markup.expression.conditions.0.expression.comparator').to.equal(
62+
comparator,
63+
);
64+
expectPath(cst, '0.markup.expression.conditions.0.expression.left.value').to.equal('1');
65+
expectPath(cst, '0.markup.expression.conditions.0.expression.right.value').to.equal(
66+
'1',
67+
);
68+
}
69+
});
70+
});
71+
72+
it('should parse multiple conditions', () => {
73+
[
74+
{ expression: `1 == 1 and 2 == 2` },
75+
{ expression: `1 == 1 or 2 == 2` },
76+
{ expression: `1 == 1 and 2 == 2 or 3 == 3` },
77+
{ expression: `1 == 1 and some_variable or 3 == 3` },
78+
{ expression: `some_var and 'raw string'` },
79+
].forEach(({ expression }) => {
80+
for (const { toCST, expectPath } of testCases) {
81+
cst = toCST(`{{ ${expression} }}`);
82+
expectPath(cst, '0.type').to.equal('LiquidVariableOutput');
83+
expectPath(cst, '0.markup.type').to.equal('LiquidVariable');
84+
expectPath(cst, '0.markup.expression.type').to.equal('BooleanExpression');
85+
expectPath(cst, '0.markup.expression.conditions.length').to.equal(
86+
expression.split(/and|or/).length,
87+
);
88+
}
89+
});
90+
});
91+
4292
it('should parse strings', () => {
4393
[
4494
{ expression: `"string o' string"`, value: `string o' string`, single: false },

packages/liquid-html-parser/src/stage-1-cst.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export enum ConcreteNodeTypes {
6969
NamedArgument = 'NamedArgument',
7070
LiquidLiteral = 'LiquidLiteral',
7171
VariableLookup = 'VariableLookup',
72+
BooleanExpression = 'BooleanExpression',
7273
String = 'String',
7374
Number = 'Number',
7475
Range = 'Range',
@@ -434,6 +435,15 @@ export type ConcreteLiquidExpression =
434435
| ConcreteLiquidRange
435436
| ConcreteLiquidVariableLookup;
436437

438+
export type ConcreteComplexLiquidExpression =
439+
| ConcreteLiquidBooleanExpression
440+
| ConcreteLiquidExpression;
441+
442+
export interface ConcreteLiquidBooleanExpression
443+
extends ConcreteBasicNode<ConcreteNodeTypes.BooleanExpression> {
444+
conditions: ConcreteLiquidCondition[];
445+
}
446+
437447
export interface ConcreteStringLiteral extends ConcreteBasicNode<ConcreteNodeTypes.String> {
438448
value: string;
439449
single: boolean;
@@ -934,6 +944,7 @@ function toCST<T>(
934944

935945
liquidDropCases: 0,
936946
liquidExpression: 0,
947+
liquidComplexExpression: 0,
937948
liquidDropBaseCase: (sw: Node) => sw.sourceString.trimEnd(),
938949
liquidVariable: {
939950
type: ConcreteNodeTypes.LiquidVariable,
@@ -1004,6 +1015,50 @@ function toCST<T>(
10041015
source,
10051016
},
10061017

1018+
liquidBooleanExpression(initialCondition: Node, subsequentConditions: Node) {
1019+
const initialConditionAst = initialCondition.toAST(
1020+
(this as any).args.mapping,
1021+
) as ConcreteLiquidCondition;
1022+
const subsequentConditionAsts = subsequentConditions.toAST(
1023+
(this as any).args.mapping,
1024+
) as ConcreteLiquidCondition[];
1025+
1026+
// liquidBooleanExpression can capture too much. If there are no comparisons (e.g. `==`, `>`, etc.)
1027+
// and we only have a single condition (i.e. no `and` or `or` operators), we can return the expression directly.
1028+
if (
1029+
subsequentConditionAsts.length === 0 &&
1030+
initialConditionAst.expression.type !== ConcreteNodeTypes.Comparison
1031+
) {
1032+
return initialConditionAst.expression;
1033+
}
1034+
1035+
const asts = [initialConditionAst, ...subsequentConditionAsts];
1036+
1037+
return {
1038+
type: ConcreteNodeTypes.BooleanExpression,
1039+
conditions: asts,
1040+
locStart: asts.at(0)!.locStart,
1041+
locEnd: asts.at(-1)!.locEnd,
1042+
source,
1043+
};
1044+
},
1045+
booleanExpressionCondition: {
1046+
type: ConcreteNodeTypes.Condition,
1047+
relation: null,
1048+
expression: 0,
1049+
locStart,
1050+
locEnd,
1051+
source,
1052+
},
1053+
booleanExpressionSubsequentCondition: {
1054+
type: ConcreteNodeTypes.Condition,
1055+
relation: 1,
1056+
expression: 3,
1057+
locStart,
1058+
locEnd,
1059+
source,
1060+
},
1061+
10071062
liquidString: 0,
10081063
liquidDoubleQuotedString: {
10091064
type: ConcreteNodeTypes.String,

packages/liquid-html-parser/src/stage-2-ast.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,55 @@ describe('Unit: Stage 2 (AST)', () => {
3030
}
3131
});
3232

33+
it('should parse comparisons as LiquidVariable > BooleanExpression > Comparison', () => {
34+
[
35+
{ expression: `1 == 1` },
36+
{ expression: `1 != 1` },
37+
{ expression: `1 > 1` },
38+
{ expression: `1 < 1` },
39+
{ expression: `1 >= 1` },
40+
{ expression: `1 <= 1` },
41+
].forEach(({ expression }) => {
42+
for (const { toAST, expectPath, expectPosition } of testCases) {
43+
ast = toAST(`{{ ${expression} }}`);
44+
expectPath(ast, 'children.0').to.exist;
45+
expectPath(ast, 'children.0.type').to.eql('LiquidVariableOutput');
46+
expectPath(ast, 'children.0.markup.type').to.eql('LiquidVariable');
47+
expectPath(ast, 'children.0.markup.rawSource').to.eql(expression);
48+
expectPath(ast, 'children.0.markup.expression.type').to.eql('BooleanExpression');
49+
expectPath(ast, 'children.0.markup.expression.condition.type').to.eql('Comparison');
50+
expectPosition(ast, 'children.0');
51+
expectPosition(ast, 'children.0.markup');
52+
expectPosition(ast, 'children.0.markup.expression');
53+
}
54+
});
55+
});
56+
57+
it('should parse logical operations as LiquidVariable > BooleanExpression > LogicalExpression', () => {
58+
[
59+
{ expression: `1 == 1 and 2 == 2` },
60+
{ expression: `1 == 1 or 2 == 2` },
61+
{ expression: `1 == 1 and 2 == 2 or 3 == 3` },
62+
{ expression: `1 == 1 and some_variable or 3 == 3` },
63+
{ expression: `some_var and 'raw string'` },
64+
].forEach(({ expression }) => {
65+
for (const { toAST, expectPath, expectPosition } of testCases) {
66+
ast = toAST(`{{ ${expression} }}`);
67+
expectPath(ast, 'children.0').to.exist;
68+
expectPath(ast, 'children.0.type').to.eql('LiquidVariableOutput');
69+
expectPath(ast, 'children.0.markup.type').to.eql('LiquidVariable');
70+
expectPath(ast, 'children.0.markup.rawSource').to.eql(expression);
71+
expectPath(ast, 'children.0.markup.expression.type').to.eql('BooleanExpression');
72+
expectPath(ast, 'children.0.markup.expression.condition.type').to.eql(
73+
'LogicalExpression',
74+
);
75+
expectPosition(ast, 'children.0');
76+
expectPosition(ast, 'children.0.markup');
77+
expectPosition(ast, 'children.0.markup.expression');
78+
}
79+
});
80+
});
81+
3382
it('should parse strings as LiquidVariable > String', () => {
3483
[
3584
{ expression: `"string o' string"`, value: `string o' string`, single: false },

packages/liquid-html-parser/src/stage-2-ast.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
LiquidHtmlConcreteNode,
7575
ConcreteLiquidTagBaseCase,
7676
ConcreteLiquidTagContentForMarkup,
77+
ConcreteComplexLiquidExpression,
7778
} from './stage-1-cst';
7879
import { Comparators, NamedTags, NodeTypes, nonTraversableProperties, Position } from './types';
7980
import { assertNever, deepGet, dropLast } from './utils';
@@ -95,7 +96,7 @@ export type LiquidHtmlNode =
9596
| HtmlNode
9697
| AttributeNode
9798
| LiquidVariable
98-
| LiquidExpression
99+
| ComplexLiquidExpression
99100
| LiquidFilter
100101
| LiquidNamedArgument
101102
| AssignMarkup
@@ -508,7 +509,7 @@ export interface LiquidVariableOutput extends ASTNode<NodeTypes.LiquidVariableOu
508509
/** Represents an expression and filters, e.g. expression | filter1 | filter2 */
509510
export interface LiquidVariable extends ASTNode<NodeTypes.LiquidVariable> {
510511
/** expression | filter1 | filter2 */
511-
expression: LiquidExpression;
512+
expression: ComplexLiquidExpression;
512513

513514
/** expression | filter1 | filter2 */
514515
filters: LiquidFilter[];
@@ -525,6 +526,8 @@ export type LiquidExpression =
525526
| LiquidRange
526527
| LiquidVariableLookup;
527528

529+
export type ComplexLiquidExpression = LiquidBooleanExpression | LiquidExpression;
530+
528531
/** https://shopify.dev/docs/api/liquid/filters */
529532
export interface LiquidFilter extends ASTNode<NodeTypes.LiquidFilter> {
530533
/** name: arg1, arg2, namedArg3: value3 */
@@ -545,6 +548,10 @@ export interface LiquidNamedArgument extends ASTNode<NodeTypes.NamedArgument> {
545548
value: LiquidExpression;
546549
}
547550

551+
export interface LiquidBooleanExpression extends ASTNode<NodeTypes.BooleanExpression> {
552+
condition: LiquidConditionalExpression;
553+
}
554+
548555
/** https://shopify.dev/docs/api/liquid/basics#string */
549556
export interface LiquidString extends ASTNode<NodeTypes.String> {
550557
/** single or double quote? */
@@ -1941,14 +1948,30 @@ function toLiquidVariableOutput(node: ConcreteLiquidVariableOutput): LiquidVaria
19411948
function toLiquidVariable(node: ConcreteLiquidVariable): LiquidVariable {
19421949
return {
19431950
type: NodeTypes.LiquidVariable,
1944-
expression: toExpression(node.expression),
1951+
expression: toComplexExpression(node.expression),
19451952
filters: node.filters.map(toFilter),
19461953
position: position(node),
19471954
rawSource: node.rawSource,
19481955
source: node.source,
19491956
};
19501957
}
19511958

1959+
function toComplexExpression(node: ConcreteComplexLiquidExpression): ComplexLiquidExpression {
1960+
switch (node.type) {
1961+
case ConcreteNodeTypes.BooleanExpression: {
1962+
return {
1963+
type: NodeTypes.BooleanExpression,
1964+
position: position(node),
1965+
condition: toConditionalExpression(node.conditions),
1966+
source: node.source,
1967+
};
1968+
}
1969+
default: {
1970+
return toExpression(node);
1971+
}
1972+
}
1973+
}
1974+
19521975
function toExpression(node: ConcreteLiquidExpression): LiquidExpression {
19531976
switch (node.type) {
19541977
case ConcreteNodeTypes.String: {

packages/liquid-html-parser/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export enum NodeTypes {
2929
LiquidFilter = 'LiquidFilter',
3030
NamedArgument = 'NamedArgument',
3131
LiquidLiteral = 'LiquidLiteral',
32+
BooleanExpression = 'BooleanExpression',
3233
String = 'String',
3334
Number = 'Number',
3435
Range = 'Range',

packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ function getCssDisplay(node: AugmentedNode<WithSiblings>, options: LiquidParserO
115115
case NodeTypes.LiquidFilter:
116116
case NodeTypes.NamedArgument:
117117
case NodeTypes.LiquidLiteral:
118+
case NodeTypes.BooleanExpression:
118119
case NodeTypes.String:
119120
case NodeTypes.Number:
120121
case NodeTypes.Range:
@@ -225,6 +226,7 @@ function getNodeCssStyleWhiteSpace(
225226
case NodeTypes.LiquidFilter:
226227
case NodeTypes.NamedArgument:
227228
case NodeTypes.LiquidLiteral:
229+
case NodeTypes.BooleanExpression:
228230
case NodeTypes.String:
229231
case NodeTypes.Number:
230232
case NodeTypes.Range:

packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,10 @@ function printNode(
506506
return ['---', hardline, node.body, '---'];
507507
}
508508

509+
case NodeTypes.BooleanExpression: {
510+
return path.call((p: any) => print(p), 'condition');
511+
}
512+
509513
case NodeTypes.String: {
510514
const preferredQuote = options.liquidSingleQuote ? `'` : `"`;
511515
const valueHasQuotes = node.value.includes(preferredQuote);

0 commit comments

Comments
 (0)