Skip to content

Commit fb9c582

Browse files
committed
Add static comment helpers
1 parent d2177cb commit fb9c582

8 files changed

Lines changed: 153 additions & 11 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Version 2.2.0 (unreleased)
44

55
- Added the `squish` filter. `{{ x | squish }}` is equivalent to `{{ x | strip | split | join }}`. See [#195](https://github.com/jg-rp/liquid/pull/195).
6+
- Added `BoundTemplate.comments()` and `BoundTemplate.docs()` for statically retrieving `{% comment %}`, `{% # inline comment %}` and `{% doc %}` nodes.
67
- Added an **experimental** `{% snippet %}` tag. Shopify/liquid released then quickly removed `{% snippet %}`. We're calling it "experimental" and keeping it disabled by default, pending more activity from Shopify/liquid. See [#191](https://github.com/jg-rp/liquid/pull/191) and [#193](https://github.com/jg-rp/liquid/pull/193).
78
- Improved static analysis of partial templates. Previously we would visit a partial template only once, regardless of how many times it is rendered with `{% render %}`. Now we visit partial templates once for each distinct set of arguments passed to `{% render %}`, potentially reporting "global" variables that we'd previously missed.
89
- Changed the `string_filter` decorator to coerce `None` to an empty string instead of `"None"`. This is what Shopify/liquid does with `nil.to_s`.

docs/known_issues.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The built-in [`date`](filter_reference.md#date) filter uses [dateutil](https://d
3232

3333
Python Liquid might not handle syntax or type errors in the same way as the reference implementation. We might fail earlier or later, and will almost certainly produce a different error message.
3434

35-
Python Liquid does not have a "lax" parser, like Ruby Liquid. If the lexer encounters unknown symbols, a `LiquidSyntaxError` is raised. Upon finding an error in [lax mode](/introduction/strictness), the parser simply discards the current block and continues to to the next block, if one is available. Also, Python Liquid will never inject error messages into an output document.
35+
Python Liquid does not have a "lax" parser, like Ruby Liquid. If the lexer encounters unknown symbols, a `LiquidSyntaxError` is raised. Upon finding an error in [lax mode](environment.md#tolerance), the parser simply discards the current block and continues to to the next block, if one is available. Also, Python Liquid will never inject error messages into an output document.
3636

3737
## Orphaned `{% break %}` and `{% continue %}`
3838

docs/static_analysis.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,38 @@ print(template.tag_names())
140140
## Variable, tag and filter locations
141141

142142
[`analyze()`](api/template.md#liquid.BoundTemplate.analyze) and [`analyze_async()`](api/template.md#liquid.BoundTemplate.analyze_async) return an instance of [`TemplateAnalysis`](api/template.md#liquid.static_analysis.TemplateAnalysis). It contains all of the information provided by the methods described above, but includes the location of each variable, tag and filter, each of which can appear many times across many templates.
143+
144+
## Comment and doc nodes
145+
146+
**_New in version 2.2.0_**
147+
148+
[`comments()`](api/template.md#liquid.BoundTemplate.comments) and [`docs()`](api/template.md#liquid.BoundTemplate.docs) return a list of `CommentNode` and `DocNode` instances, respectively. Both have `token` and `text` properties.
149+
150+
```python
151+
from liquid import parse
152+
153+
source = """\
154+
{% doc %}
155+
some doc comment
156+
{% enddoc %}
157+
158+
Hello!
159+
160+
{% comment %}
161+
some comment
162+
{% endcomment %}
163+
164+
{% if false %}
165+
{% # an inline comment %}
166+
{% endif %}
167+
"""
168+
169+
template = parse(source)
170+
print([node.text for node in template.comments()])
171+
print([node.text for node in template.docs()])
172+
```
173+
174+
```plain title="output"
175+
['\n some comment\n', 'an inline comment']
176+
['\n some doc comment\n']
177+
```

docs/tag_reference.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ Inside [liquid tags](#liquid), any line starting with a hash will be considered
4646
%}
4747
```
4848

49+
### Doc comments
50+
51+
**_New in version 2.0.0_**
52+
53+
```liquid2
54+
{% doc %} ... {% enddoc %}
55+
```
56+
57+
Like [block comments](#block-comments), doc comments are not rendered. Doc comments are designed to be read by tooling to improve Liquid template development.
58+
4959
## Output
5060

5161
```

liquid/builtin/tags/comment_tag.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(self, token: Token, text: Optional[str] = None):
3737
def __str__(self) -> str:
3838
return f"{{% comment %}}{self.text}{{% endcomment %}}"
3939

40-
def render_to_output(self, _: RenderContext, __: TextIO) -> int:
40+
def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: # noqa: ARG002
4141
"""Render the node to the output buffer."""
4242
return 0
4343

@@ -61,7 +61,7 @@ def parse(self, stream: TokenStream) -> CommentNode:
6161

6262
# A block `{% comment %}`
6363
token = stream.eat(TOKEN_TAG)
64-
text = []
64+
text: list[str] = []
6565

6666
while True:
6767
if stream.current.kind == TOKEN_EOF:

liquid/static_analysis.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ class TemplateAnalysis:
127127
tags: dict[str, list[Span]]
128128

129129

130-
def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnalysis:
130+
def analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnalysis:
131+
"""Report variable, filters and tags used in the given template.
132+
133+
This is used by `BoundTemplate.analyze`.
134+
"""
131135
variables = _VariableMap()
132136
globals = _VariableMap() # noqa: A001
133137
locals = _VariableMap() # noqa: A001
@@ -246,9 +250,13 @@ def _visit(
246250
)
247251

248252

249-
async def _analyze_async(
253+
async def analyze_async(
250254
template: BoundTemplate, *, include_partials: bool
251255
) -> TemplateAnalysis:
256+
"""Report variable, filters and tags used in the given template.
257+
258+
This is used by `BoundTemplate.analyze_async`.
259+
"""
252260
variables = _VariableMap()
253261
globals = _VariableMap() # noqa: A001
254262
locals = _VariableMap() # noqa: A001

liquid/template.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,20 @@
1313
from typing import Optional
1414
from typing import TextIO
1515
from typing import Type
16+
from typing import TypedDict
1617
from typing import Union
1718

19+
from .builtin.tags.comment_tag import CommentNode
20+
from .builtin.tags.doc_tag import DocNode
1821
from .context import FutureContext
1922
from .context import RenderContext
2023
from .exceptions import LiquidError
2124
from .exceptions import LiquidInterrupt
2225
from .exceptions import LiquidSyntaxError
2326
from .exceptions import StopRender
2427
from .output import LimitedStringIO
25-
from .static_analysis import _analyze
26-
from .static_analysis import _analyze_async
28+
from .static_analysis import analyze
29+
from .static_analysis import analyze_async
2730
from .utils import ReadOnlyChainMap
2831

2932
if TYPE_CHECKING:
@@ -262,11 +265,11 @@ def analyze(self, *, include_partials: bool = True) -> TemplateAnalysis:
262265
include_partials: If `True`, we will try to load partial templates and
263266
analyze those templates too.
264267
"""
265-
return _analyze(self, include_partials=include_partials)
268+
return analyze(self, include_partials=include_partials)
266269

267270
async def analyze_async(self, *, include_partials: bool = True) -> TemplateAnalysis:
268271
"""An async version of `analyze`."""
269-
return await _analyze_async(self, include_partials=include_partials)
272+
return await analyze_async(self, include_partials=include_partials)
270273

271274
def variables(self, *, include_partials: bool = True) -> list[str]:
272275
"""Return a list of variables used in this template without path segments.
@@ -520,6 +523,52 @@ async def tag_names_async(self, *, include_partials: bool = True) -> list[str]:
520523
"""Return a list of tag names used in this template."""
521524
return list((await self.analyze_async(include_partials=include_partials)).tags)
522525

526+
def comments(self) -> list[CommentNode]:
527+
"""Return a list of comment tag nodes found in this template.
528+
529+
Instances of `CommentNode` and `InlineCommentNode` have `token` and
530+
`text` properties. Use `[node.text for node in template.comments()]` to
531+
get a list of comment strings.
532+
533+
Note that this method does not try to load included or rendered
534+
templates.
535+
"""
536+
context = RenderContext(self)
537+
nodes: list[CommentNode] = []
538+
539+
def visit(node: Node) -> None:
540+
if isinstance(node, CommentNode):
541+
nodes.append(node)
542+
543+
for child in node.children(context, include_partials=False):
544+
visit(child)
545+
546+
for child in self.nodes:
547+
visit(child)
548+
549+
return nodes
550+
551+
def docs(self) -> list[DocNode]:
552+
"""Return a list of doc tag nodes found in this template.
553+
554+
Instances of `DocNode` have `token` and `text` properties. Use
555+
`[node.text for node in template.docs()]` to get a list of doc strings.
556+
"""
557+
context = RenderContext(self)
558+
nodes: list[DocNode] = []
559+
560+
def visit(node: Node) -> None:
561+
if isinstance(node, DocNode):
562+
nodes.append(node)
563+
564+
for child in node.children(context, include_partials=False):
565+
visit(child)
566+
567+
for child in self.nodes:
568+
visit(child)
569+
570+
return nodes
571+
523572

524573
class AwareBoundTemplate(BoundTemplate):
525574
"""A `BoundTemplate` subclass with a `TemplateDrop` in the global namespace."""
@@ -559,6 +608,12 @@ class FutureAwareBoundTemplate(AwareBoundTemplate):
559608
context_class = FutureContext
560609

561610

611+
class _TemplateDropItems(TypedDict):
612+
directory: str
613+
name: str
614+
suffix: str | None
615+
616+
562617
class TemplateDrop(Mapping[str, Optional[str]]):
563618
"""Template meta data mapping."""
564619

@@ -575,7 +630,7 @@ def __init__(self, name: str, path: Optional[Union[str, Path]]):
575630
if "." in self.stem:
576631
self.suffix = self.stem.split(".")[-1]
577632

578-
self._items = {
633+
self._items: _TemplateDropItems = {
579634
"directory": self.path.parent.name,
580635
"name": self.path.name.split(".")[0],
581636
"suffix": self.suffix,
@@ -594,7 +649,7 @@ def __contains__(self, item: object) -> bool:
594649
return item in self._items
595650

596651
def __getitem__(self, key: object) -> Optional[str]:
597-
return self._items[str(key)]
652+
return self._items[str(key)] # type: ignore
598653

599654
def __len__(self) -> int:
600655
return len(self._items)

tests/test_static_analysis_helpers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,36 @@ async def coro() -> list[str]:
148148
return await TEMPLATE.tag_names_async()
149149

150150
assert sorted(asyncio.run(coro())) == sorted(["assign", "for"])
151+
152+
153+
def test_get_comment_nodes() -> None:
154+
source = """\
155+
A template with comments.
156+
{% comment %}a{% endcomment %}
157+
{% comment %}b{% endcomment %}
158+
{% # c %}
159+
{% if false %}
160+
{% comment %}d{% endcomment %}
161+
{% # e %}
162+
{% endif %}
163+
"""
164+
165+
template = parse(source)
166+
nodes = template.comments()
167+
comment_strings = [node.text for node in nodes]
168+
assert comment_strings == ["a", "b", "c", "d", "e"]
169+
170+
171+
def test_get_doc_nodes() -> None:
172+
source = """\
173+
{% doc %}
174+
Some docs
175+
{% enddoc %}
176+
177+
{% doc %}More docs{% enddoc %}
178+
"""
179+
180+
template = parse(source)
181+
nodes = template.docs()
182+
doc_strings = [node.text for node in nodes]
183+
assert doc_strings == ["\n Some docs\n", "More docs"]

0 commit comments

Comments
 (0)