Skip to content

Commit 6ab6856

Browse files
authored
Add parsing + prettier support for @param in {% doc %} tags (#646)
## What are you adding in this PR? Closes Shopify/developer-tools-team#441 https://share.descript.com/view/sZkEWbd5kAr Added support for parsing and formatting `@param` tags within Liquid doc blocks. - Added a configurable option to prettier `liquidDocParamDash` to format all descriptions with `-` (or not) ## What's next? Any followup issues? - I'm moving on to completions while Josh is going to add parsing + prettier support for the `@example` tag - I want to add support for optional params, default values, etc afterwards ## What did you learn? Creating ohm syntax rules is an art form ## Testing https://github.com/user-attachments/assets/3a902774-8088-4a00-9f8c-c915aec75fe3 ## Before you deploy - [x] I included a minor bump `changeset` - [x] My feature is backward compatible
2 parents 8091c60 + ac55577 commit 6ab6856

File tree

16 files changed

+379
-61
lines changed

16 files changed

+379
-61
lines changed

.changeset/slow-years-float.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'@shopify/prettier-plugin-liquid': minor
4+
'@shopify/liquid-html-parser': minor
5+
---
6+
7+
Add prettier support for LiquidDoc {% doc %} tag and @param annotation

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

+17-1
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,23 @@ LiquidStatement <: Liquid {
389389
}
390390

391391
LiquidDoc <: Helpers {
392-
Node := (TextNode)*
392+
Node := (LiquidDocNode | TextNode)*
393+
LiquidDocNode =
394+
| paramNode
395+
| fallbackNode
396+
397+
// By default, space matches new lines as well. We override it here to make writing rules easier.
398+
strictSpace = " " | "\t"
399+
// We use this as an escape hatch to stop matching TextNode and try again when one of these characters is encountered
400+
openControl:= "@" | end
401+
402+
fallbackNode = "@" anyExceptStar<endOfParam>
403+
paramNode = "@param" strictSpace* paramType? strictSpace* paramName (strictSpace* "-")? strictSpace* paramDescription
404+
paramType = "{" strictSpace* paramTypeContent strictSpace* "}"
405+
paramTypeContent = anyExceptStar<("}"| strictSpace)>
406+
paramName = identifierCharacter+
407+
paramDescription = anyExceptStar<endOfParam>
408+
endOfParam = strictSpace* (newline | end)
393409
}
394410

395411
LiquidHTML <: Liquid {

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

+116-35
Original file line numberDiff line numberDiff line change
@@ -981,53 +981,134 @@ describe('Unit: Stage 1 (CST)', () => {
981981
expectPath(cst, '0.blockEndLocEnd').to.equal(testStr.length);
982982
}
983983
});
984+
});
984985

985-
it('should parse doc tags', () => {
986-
for (const { toCST, expectPath } of testCases) {
987-
const testStr = `{% doc -%} Renders loading-spinner. {%- enddoc %}`;
988-
986+
describe('Case: LiquidDoc', () => {
987+
for (const { toCST, expectPath } of testCases) {
988+
it('should parse basic doc tag structure', () => {
989+
const testStr = `{% doc -%} content {%- enddoc %}`;
989990
cst = toCST(testStr);
991+
990992
expectPath(cst, '0.type').to.equal('LiquidRawTag');
991993
expectPath(cst, '0.name').to.equal('doc');
992-
expectPath(cst, '0.body').to.include('Renders loading-spinner');
993994
expectPath(cst, '0.whitespaceStart').to.equal('');
994995
expectPath(cst, '0.whitespaceEnd').to.equal('-');
995996
expectPath(cst, '0.delimiterWhitespaceStart').to.equal('-');
996997
expectPath(cst, '0.delimiterWhitespaceEnd').to.equal('');
997-
expectPath(cst, '0.blockStartLocStart').to.equal(0);
998-
expectPath(cst, '0.blockStartLocEnd').to.equal(0 + '{% doc -%}'.length);
998+
expectPath(cst, '0.blockStartLocStart').to.equal(testStr.indexOf('{% doc -%}'));
999+
expectPath(cst, '0.blockStartLocEnd').to.equal(
1000+
testStr.indexOf('{% doc -%}') + '{% doc -%}'.length,
1001+
);
9991002
expectPath(cst, '0.blockEndLocStart').to.equal(testStr.length - '{%- enddoc %}'.length);
10001003
expectPath(cst, '0.blockEndLocEnd').to.equal(testStr.length);
1001-
expectPath(cst, '0.children').to.deep.equal([
1002-
{
1003-
locEnd: 35,
1004-
locStart: 11,
1005-
source: '{% doc -%} Renders loading-spinner. {%- enddoc %}',
1006-
type: 'TextNode',
1007-
value: 'Renders loading-spinner.',
1008-
},
1009-
]);
1010-
}
1011-
});
1004+
});
10121005

1013-
it('should parse tag open / close', () => {
1014-
BLOCKS.forEach((block: string) => {
1015-
for (const { toCST, expectPath } of testCases) {
1016-
cst = toCST(`{% ${block} args -%}{%- end${block} %}`);
1017-
expectPath(cst, '0.type').to.equal('LiquidTagOpen', block);
1018-
expectPath(cst, '0.name').to.equal(block);
1019-
expectPath(cst, '0.whitespaceStart').to.equal(null);
1020-
expectPath(cst, '0.whitespaceEnd').to.equal('-');
1021-
if (!NamedTags.hasOwnProperty(block)) {
1022-
expectPath(cst, '0.markup').to.equal('args');
1023-
}
1024-
expectPath(cst, '1.type').to.equal('LiquidTagClose');
1025-
expectPath(cst, '1.name').to.equal(block);
1026-
expectPath(cst, '1.whitespaceStart').to.equal('-');
1027-
expectPath(cst, '1.whitespaceEnd').to.equal(null);
1028-
}
1006+
it('should not parse @param without a name', () => {
1007+
const testStr = `{% doc %} @param {% enddoc %}`;
1008+
cst = toCST(testStr);
1009+
1010+
expectPath(cst, '0.children.0.type').to.equal('TextNode');
1011+
expectPath(cst, '0.children.0.value').to.equal('@param');
10291012
});
1030-
});
1013+
1014+
it('should parse @param with name', () => {
1015+
const testStr = `{% doc %} @param paramWithNoDescription {% enddoc %}`;
1016+
cst = toCST(testStr);
1017+
1018+
expectPath(cst, '0.children.0.type').to.equal('LiquidDocParamNode');
1019+
expectPath(cst, '0.children.0.paramName.type').to.equal('TextNode');
1020+
expectPath(cst, '0.children.0.paramName.value').to.equal('paramWithNoDescription');
1021+
expectPath(cst, '0.children.0.paramName.locStart').to.equal(
1022+
testStr.indexOf('paramWithNoDescription'),
1023+
);
1024+
expectPath(cst, '0.children.0.paramName.locEnd').to.equal(
1025+
testStr.indexOf('paramWithNoDescription') + 'paramWithNoDescription'.length,
1026+
);
1027+
expectPath(cst, '0.children.0.paramDescription.type').to.equal('TextNode');
1028+
expectPath(cst, '0.children.0.paramDescription.value').to.equal('');
1029+
});
1030+
1031+
it('should parse @param with name and description', () => {
1032+
const testStr = `{% doc %} @param paramWithDescription param with description {% enddoc %}`;
1033+
cst = toCST(testStr);
1034+
1035+
expectPath(cst, '0.children.0.type').to.equal('LiquidDocParamNode');
1036+
expectPath(cst, '0.children.0.paramName.type').to.equal('TextNode');
1037+
expectPath(cst, '0.children.0.paramName.value').to.equal('paramWithDescription');
1038+
expectPath(cst, '0.children.0.paramDescription.type').to.equal('TextNode');
1039+
expectPath(cst, '0.children.0.paramDescription.value').to.equal('param with description');
1040+
});
1041+
1042+
it('should parse @param with type', () => {
1043+
const testStr = `{% doc %} @param {String} paramWithType {% enddoc %}`;
1044+
cst = toCST(testStr);
1045+
1046+
expectPath(cst, '0.children.0.type').to.equal('LiquidDocParamNode');
1047+
expectPath(cst, '0.children.0.paramName.value').to.equal('paramWithType');
1048+
1049+
expectPath(cst, '0.children.0.paramType.type').to.equal('TextNode');
1050+
expectPath(cst, '0.children.0.paramType.value').to.equal('String');
1051+
expectPath(cst, '0.children.0.paramType.locStart').to.equal(testStr.indexOf('String'));
1052+
expectPath(cst, '0.children.0.paramType.locEnd').to.equal(
1053+
testStr.indexOf('String') + 'String'.length,
1054+
);
1055+
});
1056+
1057+
it('should strip whitespace around param type for @param annotation', () => {
1058+
const testStr = `{% doc %} @param { String } paramWithType {% enddoc %}`;
1059+
cst = toCST(testStr);
1060+
1061+
expectPath(cst, '0.children.0.type').to.equal('LiquidDocParamNode');
1062+
expectPath(cst, '0.children.0.paramName.value').to.equal('paramWithType');
1063+
1064+
expectPath(cst, '0.children.0.paramType.type').to.equal('TextNode');
1065+
expectPath(cst, '0.children.0.paramType.value').to.equal('String');
1066+
expectPath(cst, '0.children.0.paramType.locStart').to.equal(testStr.indexOf('String'));
1067+
expectPath(cst, '0.children.0.paramType.locEnd').to.equal(
1068+
testStr.indexOf('String') + 'String'.length,
1069+
);
1070+
});
1071+
1072+
it('should accept punctation inside the param description body', () => {
1073+
const testStr = `{% doc %} @param paramName paramDescription - asdf . \`should\` work {% enddoc %}`;
1074+
cst = toCST(testStr);
1075+
1076+
expectPath(cst, '0.children.0.paramDescription.value').to.equal(
1077+
'paramDescription - asdf . `should` work',
1078+
);
1079+
});
1080+
1081+
it('should parse fallback nodes as text nodes', () => {
1082+
const testStr = `{% doc %} @unsupported this should get matched as a fallback node and translated into a text node {% enddoc %}`;
1083+
cst = toCST(testStr);
1084+
1085+
expectPath(cst, '0.children.0.type').to.equal('TextNode');
1086+
expectPath(cst, '0.children.0.value').to.equal(
1087+
'@unsupported this should get matched as a fallback node and translated into a text node',
1088+
);
1089+
});
1090+
1091+
it('should parse multiple doc tags in sequence', () => {
1092+
const testStr = `{% doc %}
1093+
@param param1 first parameter
1094+
@param param2 second parameter
1095+
@unsupported
1096+
{% enddoc %}`;
1097+
1098+
cst = toCST(testStr);
1099+
1100+
expectPath(cst, '0.children.0.type').to.equal('LiquidDocParamNode');
1101+
expectPath(cst, '0.children.0.paramName.value').to.equal('param1');
1102+
expectPath(cst, '0.children.0.paramDescription.value').to.equal('first parameter');
1103+
1104+
expectPath(cst, '0.children.1.type').to.equal('LiquidDocParamNode');
1105+
expectPath(cst, '0.children.1.paramName.value').to.equal('param2');
1106+
expectPath(cst, '0.children.1.paramDescription.value').to.equal('second parameter');
1107+
1108+
expectPath(cst, '0.children.2.type').to.equal('TextNode');
1109+
expectPath(cst, '0.children.2.value').to.equal('@unsupported');
1110+
});
1111+
}
10311112
});
10321113
});
10331114

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

+41-9
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export enum ConcreteNodeTypes {
8383
PaginateMarkup = 'PaginateMarkup',
8484
RenderVariableExpression = 'RenderVariableExpression',
8585
ContentForNamedArgument = 'ContentForNamedArgument',
86+
87+
LiquidDocParamNode = 'LiquidDocParamNode',
8688
}
8789

8890
export const LiquidLiteralValues = {
@@ -105,6 +107,14 @@ export interface ConcreteBasicNode<T> {
105107
locEnd: number;
106108
}
107109

110+
export interface ConcreteLiquidDocParamNode
111+
extends ConcreteBasicNode<ConcreteNodeTypes.LiquidDocParamNode> {
112+
name: 'param';
113+
paramName: ConcreteTextNode;
114+
paramDescription: ConcreteTextNode | null;
115+
paramType: ConcreteTextNode | null;
116+
}
117+
108118
export interface ConcreteHtmlNodeBase<T> extends ConcreteBasicNode<T> {
109119
attrList?: ConcreteAttributeNode[];
110120
}
@@ -431,19 +441,21 @@ export interface ConcreteYamlFrontmatterNode
431441

432442
export type LiquidHtmlConcreteNode =
433443
| ConcreteHtmlNode
434-
| ConcreteLiquidNode
435-
| ConcreteTextNode
436-
| ConcreteYamlFrontmatterNode;
444+
| ConcreteYamlFrontmatterNode
445+
| LiquidConcreteNode;
437446

438447
export type LiquidConcreteNode =
439448
| ConcreteLiquidNode
440449
| ConcreteTextNode
441-
| ConcreteYamlFrontmatterNode;
450+
| ConcreteYamlFrontmatterNode
451+
| LiquidDocConcreteNode;
442452

443453
export type LiquidHtmlCST = LiquidHtmlConcreteNode[];
444454

445455
export type LiquidCST = LiquidConcreteNode[];
446456

457+
export type LiquidDocConcreteNode = ConcreteLiquidDocParamNode;
458+
447459
interface Mapping {
448460
[k: string]: number | TemplateMapping | TopLevelFunctionMapping;
449461
}
@@ -1304,17 +1316,37 @@ function toLiquidDocAST(source: string, matchingSource: string, offset: number)
13041316
throw new LiquidHTMLCSTParsingError(res);
13051317
}
13061318

1319+
/**
1320+
* Reusable text node type
1321+
*/
1322+
const textNode = {
1323+
type: ConcreteNodeTypes.TextNode,
1324+
value: function () {
1325+
return (this as any).sourceString;
1326+
},
1327+
locStart,
1328+
locEnd,
1329+
source,
1330+
};
1331+
13071332
const LiquidDocMappings: Mapping = {
13081333
Node: 0,
1309-
TextNode: {
1310-
type: ConcreteNodeTypes.TextNode,
1311-
value: function () {
1312-
return (this as any).sourceString;
1313-
},
1334+
TextNode: textNode,
1335+
paramNode: {
1336+
type: ConcreteNodeTypes.LiquidDocParamNode,
1337+
name: 'param',
13141338
locStart,
13151339
locEnd,
13161340
source,
1341+
paramType: 2,
1342+
paramName: 4,
1343+
paramDescription: 8,
13171344
},
1345+
paramType: 2,
1346+
paramTypeContent: textNode,
1347+
paramName: textNode,
1348+
paramDescription: textNode,
1349+
fallbackNode: textNode,
13181350
};
13191351

13201352
return toAST(res, LiquidDocMappings);

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

+26-13
Original file line numberDiff line numberDiff line change
@@ -1229,22 +1229,35 @@ describe('Unit: Stage 2 (AST)', () => {
12291229
expectPath(ast, 'children.0.body.type').toEqual('RawMarkup');
12301230
expectPath(ast, 'children.0.body.nodes').toEqual([]);
12311231

1232-
ast = toLiquidAST(`{% doc -%} single line doc {%- enddoc %}`);
1233-
expectPath(ast, 'children.0.type').to.eql('LiquidRawTag');
1234-
expectPath(ast, 'children.0.name').to.eql('doc');
1235-
expectPath(ast, 'children.0.body.value').to.eql(' single line doc ');
1236-
expectPath(ast, 'children.0.body.nodes.0.type').toEqual('TextNode');
1237-
1238-
ast = toLiquidAST(`{% doc -%}
1239-
multi line doc
1240-
multi line doc
1241-
{%- enddoc %}`);
1232+
ast = toLiquidAST(`
1233+
{% doc -%}
1234+
@param asdf
1235+
@param {String} paramWithDescription - param with description and \`punctation\`. This is still a valid param description.
1236+
@unsupported this node falls back to a text node
1237+
{%- enddoc %}
1238+
`);
12421239
expectPath(ast, 'children.0.type').to.eql('LiquidRawTag');
12431240
expectPath(ast, 'children.0.name').to.eql('doc');
1244-
expectPath(ast, 'children.0.body.nodes.0.value').to.eql(
1245-
`multi line doc\n multi line doc`,
1241+
expectPath(ast, 'children.0.body.nodes.0.type').to.eql('LiquidDocParamNode');
1242+
expectPath(ast, 'children.0.body.nodes.0.name').to.eql('param');
1243+
expectPath(ast, 'children.0.body.nodes.0.paramName.type').to.eql('TextNode');
1244+
expectPath(ast, 'children.0.body.nodes.0.paramName.value').to.eql('asdf');
1245+
expectPath(ast, 'children.0.body.nodes.0.paramDescription.type').to.eql('TextNode');
1246+
expectPath(ast, 'children.0.body.nodes.0.paramDescription.value').to.eql('');
1247+
expectPath(ast, 'children.0.body.nodes.1.type').to.eql('LiquidDocParamNode');
1248+
expectPath(ast, 'children.0.body.nodes.1.name').to.eql('param');
1249+
expectPath(ast, 'children.0.body.nodes.1.paramName.type').to.eql('TextNode');
1250+
expectPath(ast, 'children.0.body.nodes.1.paramName.value').to.eql('paramWithDescription');
1251+
expectPath(ast, 'children.0.body.nodes.1.paramDescription.type').to.eql('TextNode');
1252+
expectPath(ast, 'children.0.body.nodes.1.paramDescription.value').to.eql(
1253+
'param with description and `punctation`. This is still a valid param description.',
1254+
);
1255+
expectPath(ast, 'children.0.body.nodes.1.paramType.type').to.eql('TextNode');
1256+
expectPath(ast, 'children.0.body.nodes.1.paramType.value').to.eql('String');
1257+
expectPath(ast, 'children.0.body.nodes.2.type').to.eql('TextNode');
1258+
expectPath(ast, 'children.0.body.nodes.2.value').to.eql(
1259+
'@unsupported this node falls back to a text node',
12461260
);
1247-
expectPath(ast, 'children.0.body.nodes.0.type').toEqual('TextNode');
12481261
});
12491262

12501263
it('should parse unclosed tables with assignments', () => {

0 commit comments

Comments
 (0)