diff --git a/curlylint/cli.py b/curlylint/cli.py index 1080076..9b12f9b 100644 --- a/curlylint/cli.py +++ b/curlylint/cli.py @@ -1,11 +1,22 @@ import re from functools import partial from pathlib import Path -from typing import Any, Dict, Mapping, Optional, Pattern, Set, Tuple, Union +from typing import ( + Any, + Dict, + List, + Mapping, + Optional, + Pattern, + Set, + Tuple, + Union, +) import click # lgtm [py/import-and-import-from] from curlylint.rule_param import RULE +from curlylint.template_tags_param import TEMPLATE_TAGS from . import __version__ from .config import ( @@ -122,6 +133,15 @@ def path_empty( ), multiple=True, ) +@click.option( + "--template-tags", + type=TEMPLATE_TAGS, + default="[]", + help=( + 'Specify additional sets of template tags, with the syntax --template-tags \'[["cache", "endcache"]]\'. ' + ), + show_default=True, +) @click.argument( "src", nargs=-1, @@ -161,6 +181,7 @@ def main( include: str, exclude: str, rule: Union[Mapping[str, Any], Tuple[Mapping[str, Any], ...]], + template_tags: List[List[str]], src: Tuple[str, ...], ) -> None: """Prototype linter for Jinja and Django templates, forked from jinjalint""" @@ -236,6 +257,7 @@ def main( configuration["rules"] = rules configuration["verbose"] = verbose configuration["parse_only"] = parse_only + configuration["template_tags"] = template_tags if stdin_filepath: configuration["stdin_filepath"] = Path(stdin_filepath) diff --git a/curlylint/cli_test.py b/curlylint/cli_test.py index 234e5bb..3d1b0a0 100644 --- a/curlylint/cli_test.py +++ b/curlylint/cli_test.py @@ -1,37 +1,53 @@ import unittest from io import BytesIO +from typing import List from curlylint.tests.utils import BlackRunner from curlylint.cli import main -class TestParser(unittest.TestCase): - def test_no_flag(self): +class TestCLI(unittest.TestCase): + """ + Heavily inspired by Black’s CLI tests. + See https://github.com/psf/black/blob/master/tests/test_black.py. + """ + + def invoke_curlylint( + self, exit_code: int, args: List[str], input: str = None + ): runner = BlackRunner() - result = runner.invoke(main, []) + result = runner.invoke( + main, args, input=BytesIO(input.encode("utf8")) if input else None + ) + self.assertEqual( + result.exit_code, + exit_code, + msg=( + f"Failed with args: {args}\n" + f"stdout: {runner.stdout_bytes.decode()!r}\n" + f"stderr: {runner.stderr_bytes.decode()!r}\n" + f"exception: {result.exception}" + ), + ) + return runner + + def test_no_flag(self): + runner = self.invoke_curlylint(0, []) self.assertEqual(runner.stdout_bytes.decode(), "") self.assertEqual( runner.stderr_bytes.decode(), "No Path provided. Nothing to do 😴\n" ) - self.assertEqual(result.exit_code, 0) def test_stdin(self): - runner = BlackRunner() - result = runner.invoke( - main, ["-"], input=BytesIO("

Hello, World!

".encode("utf8")), - ) + runner = self.invoke_curlylint(0, ["-"], input="

Hello, World!

") self.assertEqual(runner.stdout_bytes.decode(), "") self.assertEqual(runner.stderr_bytes.decode(), "All done! ✨ 🍰 ✨\n\n") - self.assertEqual(result.exit_code, 0) def test_stdin_verbose(self): - runner = BlackRunner() - result = runner.invoke( - main, - ["--verbose", "-"], - input=BytesIO("

Hello, World!

".encode("utf8")), + runner = self.invoke_curlylint( + 0, ["--verbose", "-"], input="

Hello, World!

" ) self.assertEqual(runner.stdout_bytes.decode(), "") self.assertIn( @@ -45,14 +61,40 @@ def test_stdin_verbose(self): """, runner.stderr_bytes.decode(), ) - self.assertEqual(result.exit_code, 0) def test_flag_help(self): - runner = BlackRunner() - result = runner.invoke(main, ["--help"]) + runner = self.invoke_curlylint(0, ["--help"]) self.assertIn( "Prototype linter for Jinja and Django templates", runner.stdout_bytes.decode(), ) self.assertEqual(runner.stderr_bytes.decode(), "") - self.assertEqual(result.exit_code, 0) + + def test_template_tags_validation_fail_no_nesting(self): + runner = self.invoke_curlylint( + 2, + ["--template-tags", '["cache", "endcache"]', "-"], + input="

Hello, World!

", + ) + self.assertIn( + "Error: Invalid value for '--template-tags': expected a list of lists of tags as JSON, got '[\"cache\", \"endcache\"]'", + runner.stderr_bytes.decode(), + ) + + def test_template_tags_cli_configured(self): + self.invoke_curlylint( + 0, + ["--template-tags", '[["of", "elseof", "endof"]]', "-"], + input="

{% of a %}c{% elseof %}test{% endof %}

", + ) + + def test_template_tags_cli_unconfigured_fails(self): + runner = self.invoke_curlylint( + 1, + ["--template-tags", "[]", "-"], + input="

{% of a %}c{% elseof %}test{% endof %}

", + ) + self.assertIn( + "Parse error: expected one of 'autoescape', 'block', 'blocktrans', 'comment', 'filter', 'for', 'if', 'ifchanged', 'ifequal', 'ifnotequal', 'not an intermediate Jinja tag name', 'spaceless', 'verbatim', 'with' at 0:17\tparse_error", + runner.stdout_bytes.decode(), + ) diff --git a/curlylint/parse.py b/curlylint/parse.py index 612983b..f4eb598 100644 --- a/curlylint/parse.py +++ b/curlylint/parse.py @@ -592,6 +592,8 @@ def make_jinja_parser(config, content): (names[0], names) for names in ( DEFAULT_JINJA_STRUCTURED_ELEMENTS_NAMES + + config.get("template_tags", []) + # Deprecated, will be removed in a future release. + config.get("jinja_custom_elements_names", []) ) ).values() diff --git a/curlylint/parse_test.py b/curlylint/parse_test.py index cab2e0b..8d6ed3f 100644 --- a/curlylint/parse_test.py +++ b/curlylint/parse_test.py @@ -372,25 +372,25 @@ def test_jinja_blocks(self): src = "{% if a %}b{% elif %}c{% elif %}d{% else %}e{% endif %}" self.assertEqual(src, str(jinja.parse(src))) - def test_jinja_custom_block_self_closing(self): + def test_jinja_custom_tag_self_closing(self): self.assertEqual( - jinja.parse("{% exampletest %}"), + jinja.parse("{% potato %}"), JinjaElement( parts=[ JinjaElementPart( - tag=JinjaTag(name="exampletest", content=""), - content=None, + tag=JinjaTag(name="potato", content=""), content=None, ) ], closing_tag=None, ), ) - def test_jinja_custom_block_open_close_unconfigured(self): + def test_jinja_custom_tag_open_close_unconfigured(self): with pytest.raises(P.ParseError): jinja.parse("{% of a %}c{% endof %}") - def test_jinja_custom_block_open_close_configured(self): + def test_jinja_custom_tag_open_close_configured_deprecated(self): + # Deprecated, will be removed in a future release. parser = make_parser({"jinja_custom_elements_names": [["of", "endof"]]}) jinja = parser["jinja"] self.assertEqual( @@ -406,11 +406,27 @@ def test_jinja_custom_block_open_close_configured(self): ), ) - def test_jinja_custom_block_open_middle_close_unconfigured(self): + def test_jinja_custom_tag_open_close_configured(self): + parser = make_parser({"template_tags": [["of", "endof"]]}) + jinja = parser["jinja"] + self.assertEqual( + jinja.parse("{% of a %}c{% endof %}"), + JinjaElement( + parts=[ + JinjaElementPart( + tag=JinjaTag(name="of", content="a"), + content=Interp(["c"]), + ), + ], + closing_tag=JinjaTag(name="endof", content=""), + ), + ) + + def test_jinja_custom_tag_open_middle_close_unconfigured(self): with pytest.raises(P.ParseError): jinja.parse("{% of a %}b{% elseof %}c{% endof %}") - def test_jinja_custom_block_open_middle_close(self): + def test_jinja_custom_tag_open_middle_close(self): parser = make_parser( {"jinja_custom_elements_names": [["of", "elseof", "endof"]]} ) diff --git a/curlylint/template_tags_param.py b/curlylint/template_tags_param.py new file mode 100644 index 0000000..3569762 --- /dev/null +++ b/curlylint/template_tags_param.py @@ -0,0 +1,35 @@ +import json + +import click + + +class TemplateTagsParamType(click.ParamType): + """ + Validates and converts CLI-provided template tags configuration. + Expects: --template-tags '[["cache", "endcache"]]' + """ + + name = "template tags" + + def convert(self, value, param, ctx): + try: + if isinstance(value, str): + template_tags = json.loads(value) + else: + template_tags = value + # Validate the expected list of lists. + if not isinstance(template_tags, (list, tuple)): + raise ValueError + for tags in template_tags: + if not isinstance(tags, (list, tuple)): + raise ValueError + return template_tags + except ValueError: + self.fail( + f"expected a list of lists of tags as JSON, got {value!r}", + param, + ctx, + ) + + +TEMPLATE_TAGS = TemplateTagsParamType() diff --git a/example_pyproject.toml b/example_pyproject.toml index dbe59a1..3414d54 100644 --- a/example_pyproject.toml +++ b/example_pyproject.toml @@ -1,8 +1,9 @@ [tool.curlylint] # Specify additional Jinja elements which can wrap HTML here. You -# don't neet to specify simple elements which can't wrap anything like +# don’t neet to specify simple elements which can't wrap anything like # {% extends %} or {% include %}. -jinja-custom-elements-names = [ +template_tags = [ + ["of", "elseof", "endof"], ["cache", "endcache"], ["captureas", "endcaptureas"] ] diff --git a/website/docs/command-line-usage.md b/website/docs/command-line-usage.md index 375015f..5d05668 100644 --- a/website/docs/command-line-usage.md +++ b/website/docs/command-line-usage.md @@ -81,6 +81,16 @@ curlylint --rule 'html_has_lang: "en"' template-directory/ curlylint --rule 'html_has_lang: ["en", "en-US"]' template-directory/ ``` +### `--template-tags` + +Specify additional sets of template tags, with the syntax `--template-tags '[["start_tag", "end_tag"]]'`. This is only needed for tags that wrap other markup (like `{% block %}

Hello!

{% endblock %}`), not for single / “void” tags. + +🚧 Note the list of lists is formatted as JSON, with each sub-list containing the tags expected to work together as opening/intermediary/closing tags. + +```bash +curlylint --template-tags '[["cache", "endcache"]]' template-directory/ +``` + ### `--config` Read configuration from the provided file.