├── .gitattributes ├── jinjalint ├── __main__.py ├── test.py ├── __init__.py ├── file.py ├── util_test.py ├── config.py ├── config_test.py ├── util.py ├── issue.py ├── cli.py ├── lint.py ├── ast.py ├── check.py ├── parse_test.py ├── _version.py └── parse.py ├── bin └── jinjalint ├── .editorconfig ├── MANIFEST.in ├── .gitignore ├── Pipfile ├── .pre-commit-hooks.yaml ├── .travis.yml ├── setup.cfg ├── example_config.py ├── requirements.txt ├── LICENSE ├── setup.py ├── Pipfile.lock ├── README.md └── versioneer.py /.gitattributes: -------------------------------------------------------------------------------- 1 | jinjalint/_version.py export-subst 2 | -------------------------------------------------------------------------------- /jinjalint/__main__.py: -------------------------------------------------------------------------------- 1 | from jinjalint.cli import main 2 | 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /bin/jinjalint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from jinjalint.cli import main 4 | 5 | 6 | main() 7 | -------------------------------------------------------------------------------- /jinjalint/test.py: -------------------------------------------------------------------------------- 1 | from . import parse_test, util_test, config_test 2 | 3 | 4 | parse_test.test() 5 | util_test.test() 6 | config_test.test() 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include versioneer.py 4 | include jinjalint/_version.py 5 | include requirements.txt 6 | 7 | recursive-exclude * __pycache__ 8 | recursive-exclude * *.py[co] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python modules. 2 | *.pyc 3 | 4 | # Setuptools distribution folder. 5 | /dist/ 6 | /build/ 7 | 8 | # Python egg metadata, regenerated from source files by setuptools. 9 | /*.egg-info 10 | -------------------------------------------------------------------------------- /jinjalint/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | from ._version import get_versions 3 | 4 | 5 | __version__ = get_versions()['version'] 6 | del get_versions 7 | 8 | 9 | if __name__ == '__main__': 10 | main() 11 | -------------------------------------------------------------------------------- /jinjalint/file.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | 4 | @attr.s(frozen=True) 5 | class File: 6 | lines = attr.ib() # [str] 7 | source = attr.ib() # str 8 | tree = attr.ib() # ast.Node 9 | path = attr.ib() # Path 10 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | 3 | name = "pypi" 4 | verify_ssl = true 5 | url = "https://pypi.python.org/simple" 6 | 7 | 8 | [dev-packages] 9 | 10 | pycodestyle = "*" 11 | 12 | [packages] 13 | 14 | parsy = "*" 15 | attrs = "*" 16 | docopt = "*" 17 | -------------------------------------------------------------------------------- /jinjalint/util_test.py: -------------------------------------------------------------------------------- 1 | from .util import flatten 2 | 3 | 4 | def test(): 5 | assert list(flatten(())) == [] 6 | assert list(flatten([])) == [] 7 | assert list(flatten((1,))) == [1] 8 | assert list(flatten([2, [], (), [3, [(4, 5), (6,)]]])) == [2, 3, 4, 5, 6] 9 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: jinjalint 2 | name: jinjalint 3 | description: A linter which checks the indentation and the correctness of Jinja-like/HTML templates. 4 | language: python 5 | language_version: python3 6 | entry: jinjalint 7 | types: [jinja] 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.5" 5 | - "3.6" 6 | - "nightly" 7 | 8 | install: 9 | - pip install pipenv pycodestyle 10 | - pipenv install --dev 11 | 12 | script: 13 | - pipenv run python -m jinjalint.test 14 | - pipenv run pycodestyle jinjalint/ 15 | -------------------------------------------------------------------------------- /jinjalint/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def parse_config(path): 5 | path = Path(path) 6 | with path.open('r') as f: 7 | source = f.read() 8 | config = {} 9 | exec(source, config) 10 | config = dict(config) 11 | del config['__builtins__'] 12 | return config 13 | -------------------------------------------------------------------------------- /jinjalint/config_test.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | from .config import parse_config 4 | 5 | 6 | def test(): 7 | with tempfile.NamedTemporaryFile() as f: 8 | f.write(b'hello = "worl" + "d"\n') 9 | f.seek(0) 10 | config = parse_config(f.name) 11 | assert config == {'hello': 'world'} 12 | -------------------------------------------------------------------------------- /jinjalint/util.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | 4 | def flatten(l): 5 | """ 6 | Deeply flattens an iterable. 7 | """ 8 | for el in l: 9 | if (isinstance(el, collections.Iterable) and 10 | not isinstance(el, (str, bytes))): 11 | yield from flatten(el) 12 | else: 13 | yield el 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | # See the docstring in versioneer.py for instructions. Note that you must 3 | # re-run 'versioneer.py setup' after changing this section, and commit the 4 | # resulting files. 5 | 6 | [versioneer] 7 | VCS = git 8 | style = pep440 9 | versionfile_source = jinjalint/_version.py 10 | versionfile_build = jinjalint/_version.py 11 | tag_prefix = 12 | parentdir_prefix = 13 | -------------------------------------------------------------------------------- /example_config.py: -------------------------------------------------------------------------------- 1 | 2 | # Specify additional Jinja elements which can wrap HTML here. You 3 | # don't neet to specify simple elements which can't wrap anything like 4 | # {% extends %} or {% include %}. 5 | jinja_custom_elements_names = [ 6 | ('cache', 'endcache'), 7 | ('captureas', 'endcaptureas'), 8 | # ('for', 'else', 'empty', 'endfor'), 9 | ] 10 | 11 | # How many spaces 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | parsy==1.1.0 --hash=sha256:25bd5cea2954950ebbfdf71f8bdaf7fd45a5df5325fd36a1064be2204d9d4c94 --hash=sha256:36173ba01a5372c7a1b32352cc73a279a49198f52252adf1c8c1ed41d1f94e8d 2 | attrs==17.2.0 --hash=sha256:a7e0d9183f6457de12df7ba6a81f6569c7d6b25f67ad509b5ad52e8545970a2f --hash=sha256:5d4d1b99f94d69338f485984127e4473b3ab9e20f43821b0e546cc3b2302fd11 3 | docopt==0.6.2 --hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Antoine Motet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /jinjalint/issue.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from . import ast 4 | from .file import File 5 | 6 | 7 | @attr.s(frozen=True) 8 | class IssueLocation: 9 | file_path = attr.ib() # Path 10 | column = attr.ib() 11 | line = attr.ib() 12 | 13 | def __str__(self): 14 | return '{}:{}:{}'.format( 15 | self.file_path, 16 | self.line + 1, 17 | self.column, 18 | ) 19 | 20 | @staticmethod 21 | def from_ast(file_path, ast_location): 22 | if isinstance(file_path, File): 23 | file_path = file_path.path 24 | 25 | return IssueLocation( 26 | file_path=file_path, 27 | column=ast_location.column, 28 | line=ast_location.line, 29 | ) 30 | 31 | 32 | @attr.s(frozen=True) 33 | class Issue: 34 | location = attr.ib() 35 | message = attr.ib() 36 | 37 | def __str__(self): 38 | return '{}: {}'.format(self.location, self.message) 39 | 40 | def __attrs_post_init__(self): 41 | assert isinstance(self.location, IssueLocation) 42 | 43 | @staticmethod 44 | def from_ast(file_path, ast_location, message): 45 | return Issue( 46 | IssueLocation.from_ast(file_path, ast_location), 47 | message, 48 | ) 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from pathlib import Path 3 | import versioneer 4 | 5 | 6 | here = Path(__file__).parent 7 | 8 | with (here / 'README.md').open('r') as f: 9 | long_description = '\n' + f.read() 10 | 11 | with open('requirements.txt') as f: 12 | lines = f.read().split('\n') 13 | install_requires = [line.split()[0] for line in lines if line] 14 | 15 | setup( 16 | name='jinjalint', 17 | author='Antoine Motet', 18 | author_email='antoine.motet@gmail.com', 19 | url='https://github.com/motet-a/jinjalint', 20 | version=versioneer.get_version(), 21 | cmdclass=versioneer.get_cmdclass(), 22 | description='A linter for Jinja-like templates', 23 | long_description=long_description, 24 | packages=['jinjalint'], 25 | include_package_data=True, 26 | license='MIT', 27 | install_requires=install_requires, 28 | classifiers=[ 29 | 'Development Status :: 3 - Alpha', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Intended Audience :: Developers', 32 | 'Programming Language :: Python :: 3.5', 33 | 'Programming Language :: Python :: 3.6', 34 | 'Programming Language :: Python :: 3.7', 35 | ], 36 | entry_points={ 37 | 'console_scripts': ['jinjalint=jinjalint.cli:main'], 38 | }, 39 | ) 40 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "020b03afd17955915f118e6e0df6a7d510f00e21dbd46c80b2aac258152ef236" 5 | }, 6 | "host-environment-markers": { 7 | "implementation_name": "cpython", 8 | "implementation_version": "3.6.3", 9 | "os_name": "posix", 10 | "platform_machine": "x86_64", 11 | "platform_python_implementation": "CPython", 12 | "platform_release": "4.17.0-1-amd64", 13 | "platform_system": "Linux", 14 | "platform_version": "#1 SMP Debian 4.17.8-1 (2018-07-20)", 15 | "python_full_version": "3.6.3", 16 | "python_version": "3.6", 17 | "sys_platform": "linux" 18 | }, 19 | "pipfile-spec": 6, 20 | "requires": {}, 21 | "sources": [ 22 | { 23 | "name": "pypi", 24 | "url": "https://pypi.python.org/simple", 25 | "verify_ssl": true 26 | } 27 | ] 28 | }, 29 | "default": { 30 | "attrs": { 31 | "hashes": [ 32 | "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", 33 | "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" 34 | ], 35 | "version": "==18.1.0" 36 | }, 37 | "docopt": { 38 | "hashes": [ 39 | "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" 40 | ], 41 | "version": "==0.6.2" 42 | }, 43 | "parsy": { 44 | "hashes": [ 45 | "sha256:05a70bc232e88bcb6ca100e09f3ef38296937698cc21d2b895bac7331d96906c", 46 | "sha256:ddd4306421a84fbdb8047f3806683d364be6a07fe5f452645d75f9d20f78b855" 47 | ], 48 | "version": "==1.2.0" 49 | } 50 | }, 51 | "develop": { 52 | "pycodestyle": { 53 | "hashes": [ 54 | "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", 55 | "sha256:74abc4e221d393ea5ce1f129ea6903209940c1ecd29e002e8c6933c2b21026e0", 56 | "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" 57 | ], 58 | "version": "==2.4.0" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /jinjalint/cli.py: -------------------------------------------------------------------------------- 1 | """jinjalint 2 | 3 | Usage: 4 | jinjalint [-v | --verbose] [--config CONFIG] [--parse-only] 5 | [--extension EXT | -e EXT]... [INPUT ...] 6 | jinjalint (-h | --help) 7 | jinjalint --version 8 | 9 | Options: 10 | -h --help Show this help message and exit. 11 | --version Show version information and exit. 12 | -v --verbose Verbose mode. 13 | -c --config CONFIG Specify the configuration file. 14 | --parse-only Don’t lint, check for syntax errors and exit. 15 | -e --extension EXT Extension of the files to analyze (used if INPUT 16 | contains directories to crawl). 17 | [default: html jinja twig] 18 | 19 | The configuration file must be a valid Python file. 20 | """ 21 | from docopt import docopt 22 | 23 | from .lint import lint, resolve_file_paths 24 | from .config import parse_config 25 | from ._version import get_versions 26 | 27 | 28 | def print_issues(issues, config): 29 | sorted_issues = sorted( 30 | issues, 31 | key=lambda i: ( 32 | i.location.file_path, 33 | i.location.line, 34 | i.location.column 35 | ), 36 | ) 37 | 38 | for issue in sorted_issues: 39 | print(str(issue)) 40 | 41 | 42 | def main(): 43 | arguments = docopt(__doc__) 44 | 45 | input_names = arguments['INPUT'] or ['.'] 46 | extensions = ['.' + e for e in arguments['--extension']] 47 | verbose = arguments['--verbose'] 48 | 49 | if arguments['--version']: 50 | print(get_versions()['version']) 51 | return 52 | 53 | if arguments['--config']: 54 | if verbose: 55 | print('Using configuration file {}'.format(arguments['--config'])) 56 | config = parse_config(arguments['--config']) 57 | else: 58 | config = {} 59 | 60 | config['verbose'] = verbose 61 | config['parse_only'] = arguments['--parse-only'] 62 | 63 | paths = list(resolve_file_paths(input_names, extensions=extensions)) 64 | 65 | if verbose: 66 | print('Files being analyzed:') 67 | print('\n'.join(str(p) for p in paths)) 68 | print() 69 | 70 | issues = lint(paths, config) 71 | print_issues(issues, config) 72 | 73 | if any(issues): 74 | exit(1) 75 | 76 | 77 | if __name__ == '__main__': 78 | main() 79 | -------------------------------------------------------------------------------- /jinjalint/lint.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import parsy 3 | 4 | from .parse import make_parser 5 | from .util import flatten 6 | from .check import check_files 7 | from .issue import Issue, IssueLocation 8 | from .file import File 9 | 10 | 11 | def get_parsy_error_location(error, file_path): 12 | line, column = parsy.line_info_at(error.stream, error.index) 13 | return IssueLocation( 14 | line=line, 15 | column=column, 16 | file_path=file_path, 17 | ) 18 | 19 | 20 | def resolve_file_paths_(input_name, extensions): 21 | path = Path(input_name) 22 | if not path.exists(): 23 | raise Exception('{} does not exist'.format(path)) 24 | 25 | if path.is_dir(): 26 | return flatten( 27 | resolve_file_paths_(child, extensions) for child in path.iterdir() 28 | ) 29 | 30 | if not path.is_file(): 31 | raise Exception('{} is not a regular file'.format(path)) 32 | 33 | return [path] if path.suffix in extensions else [] 34 | 35 | 36 | def resolve_file_paths(input_names, extensions): 37 | path_lists = (resolve_file_paths_(i, extensions) for i in input_names) 38 | return flatten(path_lists) 39 | 40 | 41 | def parse_file(path_and_config): 42 | """ 43 | Returns a tuple ([Issue], File | None). 44 | """ 45 | path, config = path_and_config 46 | 47 | with path.open('r') as f: 48 | source = f.read() 49 | 50 | parser = make_parser(config) 51 | 52 | try: 53 | file = File( 54 | path=path, 55 | source=source, 56 | lines=source.split('\n'), 57 | tree=parser['content'].parse(source), 58 | ) 59 | return [], file 60 | except parsy.ParseError as error: 61 | location = get_parsy_error_location(error, path) 62 | issue = Issue(location, 'Parse error: ' + str(error)) 63 | return [issue], None 64 | 65 | 66 | def lint(paths, config): 67 | issues = [] 68 | files = [] 69 | 70 | from multiprocessing import Pool 71 | pool = Pool() 72 | 73 | parse_file_args = ((p, config) for p in paths) 74 | results = pool.map(parse_file, parse_file_args) 75 | for result in results: 76 | parse_issues, file = result 77 | issues += parse_issues 78 | if file is not None: 79 | files.append(file) 80 | 81 | if not config.get('parse_only', False): 82 | issues += check_files(files, config) 83 | 84 | return issues 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jinjalint 2 | 3 | A prototype linter which checks the indentation and the correctness of 4 | [Jinja][jinja]-like/HTML templates. Can [fix issues][django-commit]. 5 | 6 | It works with [Django’s templates][djangotemplates] too, it should 7 | work with [Twig](https://twig.symfony.com/) and similar template languages. 8 | It should work fine with any kind of HTML 4 and 5, however XHTML is not 9 | supported. 10 | 11 | This linter parses both HTML and Jinja tags and will report mismatched 12 | tags and indentation errors: 13 | 14 | ```html+jinja 15 |
16 | {% if something %} 17 |
18 | {% endif %} 19 | ``` 20 | 21 | ```html+jinja 22 |
23 | 24 |
25 | 26 | ``` 27 | 28 | ```html+jinja 29 | {% if something %} 30 |
not indented properly 31 |
32 | {% endif %} 33 | ``` 34 | 35 | ```html+jinja 36 | {% if something %}{% endif %} 37 |

something

38 | {% if not something %}
{% endif %} 39 | ``` 40 | 41 | ## Usage 42 | 43 | You need Python 3. Jinjalint doesn’t work with Python 2. Install it with 44 | `pip install jinjalint` (or `pip3 install jinjalint` depending on how `pip` is 45 | called on your system), then run it with: 46 | 47 | ```sh 48 | $ jinjalint template-directory/ 49 | ``` 50 | 51 | …or: 52 | 53 | ```sh 54 | $ jinjalint some-file.html some-other-file.html 55 | ``` 56 | 57 | This is a work in progress. Feel free to contribute :upside_down_face: 58 | 59 | 60 | ## Usage with [pre-commit](https://pre-commit.com) git hooks framework 61 | 62 | Add to your `.pre-commit-config.yaml`: 63 | 64 | ```yaml 65 | - repo: https://github.com/motet-a/jinjalint 66 | rev: '' # select a tag / sha to point at 67 | hooks: 68 | - id: jinjalint 69 | ``` 70 | 71 | Make sure to fill in the `rev` with a valid revision. 72 | 73 | _Note_: by default this configuration will only match `.jinja` and `.jinja2` 74 | files. To match by regex pattern instead, override `types` and `files` as 75 | follows: 76 | 77 | ```yaml 78 | - id: jinjalint 79 | types: [file] # restore the default `types` matching 80 | files: \.(html|sls)$ 81 | ``` 82 | 83 | ## Hacking 84 | 85 | Jinjalint is powered by [Parsy][parsy]. Parsy is an extremely powerful 86 | library and Jinjalint’s parser relies heavily on it. You have to read 87 | Parsy’s documentation in order to understand what’s going on in 88 | `parse.py`. 89 | 90 | [jinja]: http://jinja.pocoo.org/docs/2.9/ 91 | [django-commit]: https://github.com/django/djangoproject.com/commit/14a964d626196c857809d9b3b492ff4cfa4b3f40 92 | [djangotemplates]: https://docs.djangoproject.com/en/1.11/ref/templates/language/ 93 | [parsy]: https://github.com/python-parsy/parsy 94 | -------------------------------------------------------------------------------- /jinjalint/ast.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | 4 | @attr.s(frozen=True) 5 | class Location: 6 | """A location in a source file.""" 7 | line = attr.ib() # 0-based 8 | column = attr.ib() 9 | index = attr.ib() 10 | 11 | def __str__(self): 12 | return '{}:{}'.format(self.line + 1, self.column) 13 | 14 | 15 | @attr.s(frozen=True) 16 | class Node: 17 | """Base abstract type for AST nodes.""" 18 | begin = attr.ib() # Location of the first character of the node 19 | end = attr.ib() # Location of the last character 20 | 21 | 22 | @attr.s(frozen=True) 23 | class Slash(Node): 24 | """The `/` of (self-)closing HTML tags""" 25 | def __str__(self): 26 | return '/' 27 | 28 | 29 | @attr.s(frozen=True) 30 | class OpeningTag(Node): 31 | name = attr.ib() 32 | attributes = attr.ib() # Interpolated 33 | slash = attr.ib(default=None) # Slash | None (`Slash` if self-closing tag) 34 | 35 | def __str__(self): 36 | name = str(self.name) 37 | inner = ' '.join([name] + [str(a) for a in self.attributes]) 38 | if self.slash: 39 | inner += ' /' 40 | return '<' + inner + '>' 41 | 42 | 43 | @attr.s(frozen=True) 44 | class ClosingTag(Node): 45 | name = attr.ib() 46 | 47 | def __str__(self): 48 | return ''.format(self.name) 49 | 50 | 51 | @attr.s(frozen=True) 52 | class Element(Node): 53 | opening_tag = attr.ib() # OpeningTag 54 | closing_tag = attr.ib() # ClosingTag | None 55 | content = attr.ib() # Interpolated | None 56 | 57 | def __attrs_post_init__(self): 58 | assert (self.closing_tag is None) == (self.content is None) 59 | 60 | if self.closing_tag is not None: 61 | if isinstance(self.opening_tag.name, str): 62 | assert str(self.opening_tag.name) == self.closing_tag.name 63 | 64 | @property 65 | def name(self): 66 | return self.opening_tag.name 67 | 68 | @property 69 | def attributes(self): 70 | return self.opening_tag.attributes 71 | 72 | def __str__(self): 73 | if self.content is None: 74 | content_str = '' 75 | else: 76 | content_str = ''.join(str(n) for n in self.content) 77 | return ''.join([ 78 | str(self.opening_tag), 79 | content_str, 80 | str(self.closing_tag or ''), 81 | ]) 82 | 83 | 84 | @attr.s(frozen=True) 85 | class String(Node): 86 | value = attr.ib() # str 87 | quote = attr.ib() # '"' | "'" | None 88 | 89 | def __str__(self): 90 | if self.quote is None: 91 | return str(self.value) 92 | return self.quote + str(self.value) + self.quote 93 | 94 | 95 | @attr.s(frozen=True) 96 | class Integer(Node): 97 | value = attr.ib() # int 98 | has_percent = attr.ib() # bool 99 | 100 | def __str__(self): 101 | return str(self.value) + ('%' if self.has_percent else '') 102 | 103 | 104 | @attr.s(frozen=True) 105 | class Attribute(Node): 106 | name = attr.ib() # str 107 | value = attr.ib() # String | Integer 108 | 109 | def __str__(self): 110 | return '{}={}'.format(self.name, self.value) 111 | 112 | 113 | @attr.s(frozen=True) 114 | class Comment(Node): 115 | text = attr.ib() # str 116 | 117 | def __str__(self): 118 | return ''.format(self.text) 119 | 120 | 121 | @attr.s(frozen=True) 122 | class Jinja(Node): 123 | pass 124 | 125 | 126 | @attr.s(frozen=True) 127 | class JinjaVariable(Jinja): 128 | content = attr.ib() # str 129 | left_plus = attr.ib(default=False) 130 | left_minus = attr.ib(default=False) 131 | right_minus = attr.ib(default=False) 132 | 133 | def __str__(self): 134 | return ''.join([ 135 | '{{', 136 | '+' if self.left_plus else '', 137 | '-' if self.left_minus else '', 138 | ' ', 139 | self.content, 140 | ' ', 141 | '-' if self.right_minus else '', 142 | '}}' 143 | ]) 144 | 145 | 146 | @attr.s(frozen=True) 147 | class JinjaComment(Jinja): 148 | text = attr.ib() # str 149 | 150 | def __str__(self): 151 | return '{##}' 152 | 153 | 154 | @attr.s(frozen=True) 155 | class JinjaTag(Jinja): 156 | name = attr.ib() 157 | content = attr.ib() # str | None 158 | left_plus = attr.ib(default=False) 159 | left_minus = attr.ib(default=False) 160 | right_minus = attr.ib(default=False) 161 | 162 | def __str__(self): 163 | return ''.join([ 164 | '{%', 165 | '+' if self.left_plus else '', 166 | '-' if self.left_minus else '', 167 | (' ' + self.name) if self.name else '', 168 | (' ' + self.content) if self.content else '', 169 | ' ', 170 | '-' if self.right_minus else '', 171 | '%}' 172 | ]) 173 | 174 | 175 | @attr.s(frozen=True) 176 | class JinjaElementPart(Jinja): 177 | tag = attr.ib() # JinjaTag 178 | content = attr.ib() # Interpolated | None 179 | 180 | def __str__(self): 181 | return str(self.tag) + str(self.content or '') 182 | 183 | 184 | @attr.s(frozen=True) 185 | class JinjaElement(Jinja): 186 | parts = attr.ib() # [JinjaElementPart] 187 | closing_tag = attr.ib() # JinjaTag 188 | 189 | def __str__(self): 190 | return ( 191 | ''.join(str(p) for p in self.parts) + 192 | str(self.closing_tag or '') 193 | ) 194 | 195 | 196 | @attr.s(frozen=True) 197 | class JinjaOptionalContainer(Jinja): 198 | first_opening_if = attr.ib() # JinjaTag 199 | opening_tag = attr.ib() # OpeningTag 200 | first_closing_if = attr.ib() # JinjaTag 201 | content = attr.ib() # Interpolated 202 | second_opening_if = attr.ib() # JinjaTag 203 | closing_tag = attr.ib() # ClosingTag 204 | second_closing_if = attr.ib() # JinjaTag 205 | 206 | def __str__(self): 207 | nodes = [ 208 | self.first_opening_if, 209 | self.opening_tag, 210 | self.first_closing_if, 211 | self.content, 212 | self.second_opening_if, 213 | self.closing_tag, 214 | self.second_closing_if, 215 | ] 216 | return ''.join(str(n) for n in nodes) 217 | 218 | 219 | @attr.s(frozen=True) 220 | class InterpolatedBase(Node): 221 | nodes = attr.ib() # [any | Jinja] 222 | 223 | @property 224 | def single_node(self): 225 | return self.nodes[0] if len(self.nodes) == 1 else None 226 | 227 | @property 228 | def single_str(self): 229 | node = self.single_node 230 | return node if isinstance(node, str) else None 231 | 232 | def __getitem__(self, index): 233 | return self.nodes.__getitem__(index) 234 | 235 | def __iter__(self): 236 | return self.nodes.__iter__() 237 | 238 | def __len__(self): 239 | return len(self.nodes) 240 | 241 | def __str__(self): 242 | return ''.join(str(n) for n in self.nodes) 243 | 244 | 245 | def _concat_strings(nodes): 246 | if len(nodes) <= 1: 247 | return nodes 248 | a, b, *rest = nodes 249 | if isinstance(a, str) and isinstance(b, str): 250 | return _concat_strings([a + b] + rest) 251 | return [a] + _concat_strings([b] + rest) 252 | 253 | 254 | def _normalize_nodes(thing): 255 | if not isinstance(thing, list): 256 | nodes = [thing] 257 | else: 258 | nodes = thing 259 | return _concat_strings(nodes) 260 | 261 | 262 | class Interpolated(InterpolatedBase): 263 | def __init__(self, *args, **kwargs): 264 | if len(args) == 1: 265 | assert 'nodes' not in kwargs 266 | nodes = _normalize_nodes(args[0]) 267 | super().__init__(nodes=nodes, **kwargs) 268 | return 269 | 270 | assert len(args) == 0 271 | kwargs = kwargs.copy() 272 | kwargs['nodes'] = _normalize_nodes(kwargs['nodes']) 273 | super().__init__(**kwargs) 274 | -------------------------------------------------------------------------------- /jinjalint/check.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from . import ast 4 | from .util import flatten 5 | from .issue import Issue, IssueLocation 6 | 7 | 8 | WHITESPACE_INDENT_RE = re.compile(r'^\s*') 9 | INDENT_RE = re.compile('^ *') 10 | 11 | 12 | def get_line_beginning(source, node): 13 | source = source[:node.begin.index] 14 | return source.split('\n')[-1] 15 | 16 | 17 | def get_indent_level(source, node): 18 | """ 19 | Returns the number of whitespace characters before the given node, 20 | in the first line of node. 21 | Returns `None` if some characters before the given node in this 22 | line aren’t whitespace. 23 | 24 | For example, if the source file contains `
` on a line, 25 | `get_indent_level` will return 3 if called with the `
` tag 26 | as `node`. 27 | """ 28 | beginning = get_line_beginning(source, node) 29 | if beginning and not beginning.isspace(): 30 | return None 31 | return len(beginning) 32 | 33 | 34 | def contains_exclusively(string, char): 35 | return string.replace(char, '') == '' 36 | 37 | 38 | def truncate(s, length=16): 39 | return s[:length] + (s[length:] and '…') 40 | 41 | 42 | def check_indentation(file, config): 43 | indent_size = config.get('indent_size', 4) 44 | 45 | issues = [] 46 | 47 | def add_issue(location, msg): 48 | issues.append(Issue.from_ast(file, location, msg)) 49 | 50 | def check_indent(expected_level, node, inline=False, 51 | allow_same_line=False): 52 | node_level = get_indent_level(file.source, node) 53 | if node_level is None: 54 | if not inline and not allow_same_line: 55 | node_s = repr(truncate(str(node))) 56 | add_issue(node.begin, node_s + ' should be on the next line') 57 | return 58 | 59 | if node_level != expected_level: 60 | msg = 'Bad indentation, expected {}, got {}'.format( 61 | expected_level, node_level, 62 | ) 63 | add_issue(node.begin, msg) 64 | 65 | def check_attribute(expected_level, attr, inline=False, **_): 66 | if not attr.value: 67 | return 68 | 69 | if attr.begin.line != attr.value.begin.line: 70 | add_issue( 71 | attr.begin, 72 | 'The value must begin on line {}'.format(attr.begin.line), 73 | ) 74 | check_content( 75 | expected_level, 76 | attr.value, 77 | inline=attr.value.begin.line == attr.value.end.line, 78 | allow_same_line=True 79 | ) 80 | 81 | def check_opening_tag(expected_level, tag, inline=False, **_): 82 | if len(tag.attributes) and tag.begin.line != tag.end.line: 83 | first = tag.attributes[0] 84 | check_node( 85 | expected_level + indent_size, 86 | first, 87 | inline=isinstance(first, ast.Attribute), 88 | ) 89 | attr_level = len(get_line_beginning(file.source, first)) 90 | for attr in tag.attributes[1:]: 91 | # attr may be a JinjaElement 92 | check_node( 93 | expected_level if inline else attr_level, 94 | attr, 95 | inline=isinstance(attr, ast.Attribute), 96 | ) 97 | 98 | def check_comment(expected_level, tag, **_): 99 | pass 100 | 101 | def check_jinja_comment(expected_level, tag, **_): 102 | pass 103 | 104 | def check_jinja_tag(expected_level, tag, **_): 105 | pass 106 | 107 | def check_string(expected_level, string, inline=False, 108 | allow_same_line=False): 109 | if string.value.begin.line != string.value.end.line: 110 | inline = False 111 | check_content(string.value.begin.column, string.value, inline=inline, 112 | allow_same_line=allow_same_line) 113 | 114 | def check_integer(expected_level, integer, **_): 115 | pass 116 | 117 | def get_first_child_node(parent): 118 | for c in parent: 119 | if isinstance(c, ast.Node): 120 | return c 121 | return None 122 | 123 | def has_jinja_element_child(parent, tag_name): 124 | child = get_first_child_node(parent) 125 | return ( 126 | isinstance(child, ast.JinjaElement) and 127 | child.parts[0].tag.name == tag_name 128 | ) 129 | 130 | def check_jinja_element_part(expected_level, part, inline=False, 131 | allow_same_line=False): 132 | check_node(expected_level, part.tag, inline=inline, 133 | allow_same_line=allow_same_line) 134 | element_names_to_not_indent = ( 135 | config.get('jinja_element_names_to_not_indent', []) 136 | ) 137 | do_not_indent = part.tag.name in element_names_to_not_indent and \ 138 | has_jinja_element_child(part.content, part.tag.name) 139 | if part.begin.line != part.end.line: 140 | inline = False 141 | shift = 0 if inline or do_not_indent else indent_size 142 | content_level = expected_level + shift 143 | if part.content is not None: 144 | check_content(content_level, part.content, inline=inline) 145 | 146 | def check_jinja_optional_container_if(expected_level, o_if, html_tag, c_if, 147 | inline=False): 148 | check_indent(expected_level, o_if, inline=inline) 149 | shift = 0 if inline else indent_size 150 | if isinstance(html_tag, ast.OpeningTag): 151 | check_opening_tag(expected_level + shift, html_tag, inline=inline) 152 | elif isinstance(html_tag, ast.ClosingTag): 153 | check_indent(expected_level + shift, html_tag, inline=inline) 154 | else: 155 | raise AssertionError('invalid tag') 156 | check_indent(expected_level, c_if, inline=inline) 157 | return inline 158 | 159 | def check_jinja_optional_container(expected_level, element, 160 | inline=False, **_): 161 | if element.first_opening_if.begin.line == \ 162 | element.second_opening_if.end.line: 163 | inline = True 164 | 165 | inline = check_jinja_optional_container_if( 166 | expected_level, 167 | element.first_opening_if, 168 | element.opening_tag, 169 | element.first_closing_if, 170 | inline=inline) 171 | 172 | check_content(expected_level, element.content, inline=inline) 173 | 174 | check_jinja_optional_container_if( 175 | expected_level, 176 | element.second_opening_if, 177 | element.closing_tag, 178 | element.second_closing_if, 179 | inline=inline) 180 | 181 | def check_jinja_element(expected_level, element, inline=False, 182 | allow_same_line=False): 183 | if element.begin.line == element.end.line: 184 | inline = True 185 | for part in element.parts: 186 | check_node( 187 | expected_level, 188 | part, 189 | inline=inline, 190 | allow_same_line=allow_same_line) 191 | if element.closing_tag is not None: 192 | check_indent(expected_level, element.closing_tag, inline=inline) 193 | 194 | def check_jinja_variable(expected_level, var, **_): 195 | pass 196 | 197 | def check_element(expected_level, element, inline=False, **_): 198 | opening_tag = element.opening_tag 199 | closing_tag = element.closing_tag 200 | check_opening_tag(expected_level, opening_tag, inline=inline) 201 | if not closing_tag: 202 | return 203 | if inline or opening_tag.end.line == closing_tag.begin.line: 204 | check_content(expected_level, element.content, inline=True) 205 | else: 206 | check_content( 207 | expected_level + indent_size, 208 | element.content, 209 | ) 210 | check_indent(expected_level, closing_tag) 211 | 212 | def check_node(expected_level, node, inline=False, 213 | allow_same_line=False, **_): 214 | check_indent( 215 | expected_level, 216 | node, 217 | inline=inline, 218 | allow_same_line=allow_same_line 219 | ) 220 | 221 | types_to_functions = { 222 | ast.Attribute: check_attribute, 223 | ast.Comment: check_comment, 224 | ast.Element: check_element, 225 | ast.Integer: check_integer, 226 | ast.JinjaComment: check_jinja_comment, 227 | ast.JinjaElement: check_jinja_element, 228 | ast.JinjaElementPart: check_jinja_element_part, 229 | ast.JinjaOptionalContainer: check_jinja_optional_container, 230 | ast.JinjaTag: check_jinja_tag, 231 | ast.JinjaVariable: check_jinja_variable, 232 | ast.String: check_string, 233 | } 234 | 235 | func = types_to_functions.get(type(node)) 236 | if func is None: 237 | raise Exception('Unexpected {!r} node at {}'.format( 238 | type(node), node.begin, 239 | )) 240 | 241 | func(expected_level, node, inline=inline, 242 | allow_same_line=allow_same_line) 243 | 244 | def check_content_str(expected_level, string, parent_node): 245 | lines = string.split('\n') 246 | expected_indent = expected_level * ' ' 247 | 248 | indent = INDENT_RE.match(lines[0]).group(0) 249 | 250 | if len(indent) > 1: 251 | msg = ( 252 | 'Expected at most one space at the beginning of the text ' 253 | 'node, got {} spaces' 254 | ).format(len(indent)) 255 | add_issue(parent_node.begin, msg) 256 | 257 | # skip the first line since there is certainly an HTML tag before 258 | for line in lines[1:]: 259 | if line.strip() == '': 260 | continue 261 | indent = INDENT_RE.match(line).group(0) 262 | if indent != expected_indent: 263 | msg = 'Bad text indentation, expected {}, got {}'.format( 264 | expected_level, len(indent), 265 | ) 266 | add_issue(parent_node.begin, msg) 267 | 268 | def check_content(expected_level, parent_node, inline=False, 269 | allow_same_line=False): 270 | inline_parent = inline 271 | for i, child in enumerate(parent_node): 272 | next_child = get_first_child_node(parent_node[i + 1:]) 273 | 274 | if isinstance(child, str): 275 | check_content_str(expected_level, child, parent_node) 276 | if not child.strip(' '): 277 | inline = True 278 | elif child.strip() and child.count('\n') <= 1: 279 | inline = True 280 | elif (next_child and 281 | child.strip() and 282 | not child.replace(' ', '').endswith('\n')): 283 | inline = True 284 | elif child.replace(' ', '').endswith('\n\n'): 285 | inline = False 286 | if inline_parent and not inline: 287 | msg = ( 288 | 'An inline parent element must only contain ' 289 | 'inline children' 290 | ) 291 | add_issue(parent_node.begin, msg) 292 | continue 293 | 294 | if isinstance(child, ast.Node): 295 | if next_child and child.begin.line == next_child.end.line: 296 | inline = True 297 | check_node( 298 | expected_level, 299 | child, 300 | inline=inline, 301 | allow_same_line=allow_same_line 302 | ) 303 | continue 304 | 305 | raise Exception() 306 | 307 | check_content(0, file.tree) 308 | 309 | return issues 310 | 311 | 312 | def check_space_only_indent(file, _config): 313 | issues = [] 314 | for i, line in enumerate(file.lines): 315 | indent = WHITESPACE_INDENT_RE.match(line).group(0) 316 | if not contains_exclusively(indent, ' '): 317 | loc = IssueLocation( 318 | file_path=file.path, 319 | line=i, 320 | column=0, 321 | ) 322 | issue = Issue(loc, 'Should be indented with spaces') 323 | issues.append(issue) 324 | return issues 325 | 326 | 327 | checks = [ 328 | check_space_only_indent, 329 | check_indentation, 330 | ] 331 | 332 | 333 | def check_file(file, config): 334 | return set(flatten(check(file, config) for check in checks)) 335 | 336 | 337 | def check_files(files, config): 338 | return flatten(check_file(file, config) for file in files) 339 | -------------------------------------------------------------------------------- /jinjalint/parse_test.py: -------------------------------------------------------------------------------- 1 | import parsy as P 2 | 3 | from .parse import ( 4 | tag_name, tag_name_char, comment, jinja_comment, 5 | make_attribute_value_parser, make_attribute_parser, 6 | make_attributes_parser, 7 | make_closing_tag_parser, make_opening_tag_parser, make_parser, 8 | ) 9 | 10 | from . import ast 11 | from .ast import Location 12 | 13 | 14 | parser = make_parser() 15 | element = parser['element'] 16 | jinja = parser['jinja'] 17 | content = parser['content'] 18 | attribute_value = make_attribute_value_parser(jinja=jinja) 19 | attribute = make_attribute_parser(jinja=jinja) 20 | opening_tag = make_opening_tag_parser({}, jinja=jinja) 21 | 22 | 23 | class DummyLocation(): 24 | """ 25 | Any instance of this class is equal to any Location. 26 | """ 27 | def __eq__(self, other): 28 | if not isinstance(other, Location): 29 | return False 30 | return True 31 | 32 | def __ne__(self, other): 33 | return not self.__eq__(other) 34 | 35 | def __repr__(self): 36 | return self.__class__.__name__ + '()' 37 | 38 | 39 | def with_dummy_locations(node_class): 40 | def create_node(*args, **kwargs): 41 | kwargs = kwargs.copy() 42 | kwargs['begin'] = DummyLocation() 43 | kwargs['end'] = DummyLocation() 44 | 45 | try: 46 | return node_class( 47 | *args, 48 | **kwargs, 49 | ) 50 | except Exception as error: 51 | print(node_class) 52 | raise error 53 | 54 | return create_node 55 | 56 | 57 | Attribute = with_dummy_locations(ast.Attribute) 58 | Element = with_dummy_locations(ast.Element) 59 | ClosingTag = with_dummy_locations(ast.ClosingTag) 60 | Comment = with_dummy_locations(ast.Comment) 61 | JinjaComment = with_dummy_locations(ast.JinjaComment) 62 | Integer = with_dummy_locations(ast.Integer) 63 | Interp = with_dummy_locations(ast.Interpolated) 64 | JinjaComment = with_dummy_locations(ast.JinjaComment) 65 | JinjaElement = with_dummy_locations(ast.JinjaElement) 66 | JinjaElementPart = with_dummy_locations(ast.JinjaElementPart) 67 | JinjaTag = with_dummy_locations(ast.JinjaTag) 68 | JinjaVariable = with_dummy_locations(ast.JinjaVariable) 69 | OpeningTag = with_dummy_locations(ast.OpeningTag) 70 | String = with_dummy_locations(ast.String) 71 | 72 | 73 | def test_dummy_location(): 74 | dummy = DummyLocation() 75 | real = Location(0, 0, 0) 76 | assert dummy == real 77 | assert real == dummy 78 | 79 | assert not dummy != real 80 | assert not real != dummy 81 | 82 | 83 | def test_tag_name(): 84 | assert tag_name_char.parse('a') == 'a' 85 | assert tag_name.parse('bcd-ef9') == 'bcd-ef9' 86 | 87 | 88 | def test_attribute_value(): 89 | assert attribute_value.parse('hello-world') == String( 90 | value=Interp('hello-world'), 91 | quote=None, 92 | ) 93 | 94 | assert attribute_value.parse('hello{{a}}world') == String( 95 | value=Interp([ 96 | 'hello', 97 | JinjaVariable(content='a'), 98 | 'world', 99 | ]), 100 | quote=None, 101 | ) 102 | 103 | assert attribute_value.parse('123') == Integer( 104 | value=123, 105 | has_percent=False, 106 | ) 107 | 108 | assert attribute_value.parse('"hello"') == String( 109 | value=Interp('hello'), 110 | quote='"', 111 | ) 112 | 113 | assert attribute_value.parse("'hello'") == String( 114 | value=Interp('hello'), 115 | quote="'", 116 | ) 117 | 118 | assert attribute_value.parse("''") == String( 119 | value=Interp([]), 120 | quote="'", 121 | ) 122 | 123 | assert attribute_value.parse("'hello{{b}}world'") == String( 124 | value=Interp([ 125 | 'hello', 126 | JinjaVariable(content='b'), 127 | 'world', 128 | ]), 129 | quote="'", 130 | ) 131 | 132 | 133 | def test_attribute(): 134 | assert attribute.parse('hello=world') == Attribute( 135 | name=Interp('hello'), 136 | value=Interp( 137 | String( 138 | value=Interp('world'), 139 | quote=None, 140 | ), 141 | ), 142 | ) 143 | 144 | assert attribute.parse('a= "b"') == Attribute( 145 | name=Interp('a'), 146 | value=Interp( 147 | String( 148 | value=Interp('b'), 149 | quote='"', 150 | ), 151 | ), 152 | ) 153 | 154 | assert attribute.parse('a =b_c23') == Attribute( 155 | name=Interp('a'), 156 | value=Interp( 157 | String( 158 | value=Interp('b_c23'), 159 | quote=None, 160 | ), 161 | ), 162 | ) 163 | 164 | assert attribute.parse('valueless-attribute') == Attribute( 165 | name=Interp('valueless-attribute'), 166 | value=None, 167 | ) 168 | 169 | 170 | def test_comment(): 171 | assert comment.parse('') == Comment( 172 | text='hello--world', 173 | ) 174 | 175 | 176 | def test_jinja_comment(): 177 | assert jinja_comment.parse('{# hello world #}') == JinjaComment( 178 | text='hello world', 179 | ) 180 | 181 | 182 | def test_opening_tag(): 183 | assert opening_tag.parse('
') == OpeningTag( 184 | name='div', 185 | attributes=Interp([]), 186 | ) 187 | 188 | assert opening_tag.parse('') == OpeningTag( 189 | name='div', 190 | attributes=Interp([]), 191 | ) 192 | 193 | assert opening_tag.parse('
') == OpeningTag( 194 | name='div', 195 | attributes=Interp([ 196 | Attribute( 197 | name=Interp('class'), 198 | value=Interp( 199 | String( 200 | value=Interp('red'), 201 | quote='"', 202 | ), 203 | ), 204 | ), 205 | 206 | Attribute( 207 | name=Interp('style'), 208 | value=Interp( 209 | String( 210 | value=Interp([]), 211 | quote='"', 212 | ), 213 | ), 214 | ), 215 | ]), 216 | ) 217 | 218 | 219 | def test_closing_tag(): 220 | closing_tag = make_closing_tag_parser(P.string('div')) 221 | assert closing_tag.parse('
') == ClosingTag( 222 | name='div', 223 | ) 224 | 225 | 226 | def test_raw_text_elements(): 227 | assert element.parse('') == Element( 228 | content=' ', 229 | 230 | opening_tag=OpeningTag( 231 | name='style', 232 | attributes=Interp([ 233 | 234 | Attribute( 235 | name=Interp('a'), 236 | value=Interp( 237 | String( 238 | value=Interp('b'), 239 | quote=None, 240 | ), 241 | ), 242 | ), 243 | 244 | ]), 245 | ), 246 | 247 | closing_tag=ClosingTag( 248 | name='style', 249 | ), 250 | ) 251 | 252 | 253 | def test_element(): 254 | assert element.parse('
hey
') == Element( 255 | opening_tag=OpeningTag( 256 | name='div', 257 | attributes=Interp([]), 258 | ), 259 | content=Interp([' hey ']), 260 | closing_tag=ClosingTag( 261 | name='div', 262 | ), 263 | ) 264 | 265 | attributes = [ 266 | Attribute( 267 | name=Interp('onclick'), 268 | value=Interp( 269 | String( 270 | value=Interp([]), 271 | quote='"', 272 | ), 273 | ), 274 | ), 275 | 276 | JinjaVariable(content='var'), 277 | 278 | Attribute( 279 | name=Interp('class'), 280 | value=Interp( 281 | String( 282 | value=Interp('red'), 283 | quote='"', 284 | ), 285 | ), 286 | ), 287 | ] 288 | 289 | assert element.parse('
') == Element( 290 | opening_tag=OpeningTag( 291 | name='br', 292 | attributes=Interp(attributes), 293 | ), 294 | closing_tag=None, 295 | content=None, 296 | ) 297 | 298 | src = '<{% if a %}bcd{% endif %}>' 299 | assert src == str(element.parse(src)) 300 | 301 | src = '
' 302 | assert '
' == str(element.parse(src)) 303 | 304 | src = '' 305 | assert '
' == \ 306 | str(element.parse(src)) 307 | 308 | src = '' 309 | assert '
' == \ 310 | str(element.parse(src)) 311 | 312 | src = '' 313 | assert src == str(element.parse(src)) 314 | 315 | 316 | def test_self_closing_elements(): 317 | assert element.parse('
') == Element( 318 | opening_tag=OpeningTag( 319 | name='br', 320 | attributes=Interp([]), 321 | ), 322 | content=None, 323 | closing_tag=None, 324 | ) 325 | 326 | src = '
' 327 | assert src == str(element.parse(src)) 328 | 329 | 330 | def test_jinja_blocks(): 331 | assert jinja.parse('{% name something == 123 %}') == JinjaElement( 332 | parts=[ 333 | JinjaElementPart( 334 | tag=JinjaTag( 335 | name='name', 336 | content='something == 123', 337 | ), 338 | content=None, 339 | ), 340 | ], 341 | closing_tag=None, 342 | ) 343 | 344 | assert jinja.parse('{% if a %}b{% else %}c{% endif %}') == JinjaElement( 345 | parts=[ 346 | JinjaElementPart( 347 | tag=JinjaTag( 348 | name='if', 349 | content='a', 350 | ), 351 | content=Interp(['b']), 352 | ), 353 | JinjaElementPart( 354 | tag=JinjaTag( 355 | name='else', 356 | content='', 357 | ), 358 | content=Interp(['c']), 359 | ), 360 | ], 361 | closing_tag=JinjaTag( 362 | name='endif', 363 | content='', 364 | ), 365 | ) 366 | 367 | src = '{% if a %}b{% elif %}c{% elif %}d{% else %}e{% endif %}' 368 | assert src == str(jinja.parse(src)) 369 | 370 | 371 | def test_jinja_whitespace_controls(): 372 | assert jinja.parse('{%- foo -%}') == JinjaElement( 373 | parts=[ 374 | JinjaElementPart( 375 | tag=JinjaTag( 376 | name='foo', 377 | content='', 378 | left_minus=True, 379 | right_minus=True, 380 | ), 381 | content=None, 382 | ), 383 | ], 384 | closing_tag=None, 385 | ) 386 | 387 | assert str(jinja.parse('{%- foo -%}')) == '{%- foo -%}' 388 | assert str(jinja.parse('{%- foo %}')) == '{%- foo %}' 389 | assert str(jinja.parse('{{- bar -}}')) == '{{- bar -}}' 390 | assert str(jinja.parse('{{ bar -}}')) == '{{ bar -}}' 391 | assert str(jinja.parse('{%+ foo %}')) == '{%+ foo %}' 392 | assert str(jinja.parse('{{+ bar }}')) == '{{+ bar }}' 393 | 394 | 395 | def test_doctype(): 396 | assert content.parse('') == Interp('') 397 | 398 | 399 | def test_attrs(): 400 | attrs = make_attributes_parser({}, jinja) 401 | parse = attrs.parse 402 | 403 | assert str(parse('{% if %}{% endif %}')) == '{% if %}{% endif %}' 404 | assert str(parse('{% if %} {% endif %}')) == '{% if %}{% endif %}' 405 | assert str(parse('{% if %}a=b{% endif %}')) == '{% if %}a=b{% endif %}' 406 | assert str(parse('{% if %} a=b {% endif %}')) == '{% if %}a=b{% endif %}' 407 | 408 | 409 | def test_optional_container(): 410 | src = '{% if a %}{% endif %}cd{% if a %}{% endif %}' 411 | assert src == str(content.parse(src)) 412 | 413 | src = ''' 414 | {% if a %} {% endif %} 415 | c d 416 | {% if a %} {% endif %} 417 | ''' 418 | content.parse(src) 419 | 420 | 421 | def test_whole_document(): 422 | src = 'Hello
' 423 | assert src == str(element.parse(src)) 424 | 425 | 426 | def test(): 427 | test_dummy_location() 428 | test_tag_name() 429 | test_attribute_value() 430 | test_attribute() 431 | test_comment() 432 | test_jinja_comment() 433 | test_opening_tag() 434 | test_closing_tag() 435 | test_raw_text_elements() 436 | test_element() 437 | test_self_closing_elements() 438 | test_jinja_blocks() 439 | test_jinja_whitespace_controls() 440 | test_doctype() 441 | test_attrs() 442 | test_optional_container() 443 | -------------------------------------------------------------------------------- /jinjalint/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (HEAD -> master)" 27 | git_full = "01e0a4d8f1ee775aee00eb9d715eff2b4cc49e4d" 28 | git_date = "2018-12-26 22:39:44 +0100" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "" 46 | cfg.versionfile_source = "jinjalint/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | -------------------------------------------------------------------------------- /jinjalint/parse.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import parsy as P 4 | 5 | from .ast import ( 6 | Attribute, ClosingTag, Comment, Element, Integer, Interpolated, 7 | Jinja, JinjaComment, JinjaElement, JinjaElementPart, JinjaTag, 8 | JinjaVariable, JinjaOptionalContainer, Location, OpeningTag, String, 9 | ) 10 | from .util import flatten 11 | 12 | 13 | # TODO: Move it elsewhere 14 | import sys 15 | sys.setrecursionlimit(10000) 16 | 17 | 18 | # Also called “void elements” 19 | SELF_CLOSING_ELEMENTS = """ 20 | area base br col command embed hr img input keygen link meta param source 21 | track wbr 22 | """.split() 23 | 24 | DEPRECATED_ELEMENTS = """ 25 | acronym applet basefont big center dir font frame frameset noframes isindex 26 | strike tt 27 | """.split() 28 | 29 | DEFAULT_JINJA_STRUCTURED_ELEMENTS_NAMES = [ 30 | ('autoescape', 'endautoescape'), 31 | ('block', 'endblock'), 32 | ('blocktrans', 'plural', 'endblocktrans'), 33 | ('comment', 'endcomment'), 34 | ('filter', 'endfilter'), 35 | ('for', 'else', 'empty', 'endfor'), 36 | ('if', 'elif', 'else', 'endif'), 37 | ('ifchanged', 'else', 'endifchanged'), 38 | ('ifequal', 'endifequal'), 39 | ('ifnotequal', 'endifnotequal'), 40 | ('spaceless', 'endspaceless'), 41 | ('verbatim', 'endverbatim'), 42 | ('with', 'endwith'), 43 | ] 44 | 45 | 46 | # `\s` in an `str` pattern does match Unicode fancy spaces (especially the 47 | # non-breaking ones). We’ll warn against some of these fancy spaces in the 48 | # check phase. 49 | # XXX: It could be better and simpler to only allow ASCII whitespaces here. 50 | whitespace = P.regex(r'\s*') 51 | mandatory_whitespace = P.regex(r'\s+') 52 | 53 | 54 | def until(parser): 55 | return parser.should_fail(repr(parser)).then(P.any_char).many() 56 | 57 | 58 | def combine_locations(begin_index, begin, result, end_index, end): 59 | begin_loc = Location(line=begin[0], column=begin[1], index=begin_index) 60 | end_loc = Location(line=end[0], column=end[1], index=end_index) 61 | locations = { 62 | 'begin': begin_loc, 63 | 'end': end_loc, 64 | } 65 | 66 | return ( 67 | locations, 68 | result, 69 | ) 70 | 71 | 72 | def locate(parser): 73 | 74 | return ( 75 | P.seq( 76 | P.index, 77 | P.line_info, 78 | parser, 79 | P.index, 80 | P.line_info, 81 | ) 82 | .combine(combine_locations) 83 | ) 84 | 85 | 86 | def _combine_jinja_tag(locations, props): 87 | return JinjaTag( 88 | name=props['name'], 89 | content=props['extra_content'], 90 | left_plus=props['left_plus'], 91 | left_minus=props['left_minus'], 92 | right_minus=props['right_minus'], 93 | **locations, 94 | ) 95 | 96 | 97 | def _combine_jinja_variable(locations, props): 98 | return JinjaVariable( 99 | content=props['extra_content'], 100 | left_plus=props['left_plus'], 101 | left_minus=props['left_minus'], 102 | right_minus=props['right_minus'], 103 | **locations, 104 | ) 105 | 106 | 107 | def _combine_jinja_comment(locations, text): 108 | return JinjaComment( 109 | text=text, 110 | **locations, 111 | ) 112 | 113 | 114 | def _combine_jinja_tag_like(locations, props): 115 | return ( 116 | locations, 117 | { 118 | 'left_plus': props[0] is not None, 119 | 'left_minus': props[1] is not None, 120 | 'name': props[2], 121 | 'extra_content': props[3], 122 | 'right_minus': props[4] is not None, 123 | } 124 | ) 125 | 126 | 127 | def make_jinja_tag_like_parser(name, ml='{', mr='}'): 128 | """ 129 | Create parsers for Jinja variables and regular Jinja tags. 130 | 131 | `name` should be a parser to parse the tag name. 132 | """ 133 | end = whitespace.then(P.string('-').optional()).skip(P.string(mr + '}')) 134 | return locate(P.seq( 135 | P.string('{' + ml).then(P.string('+').optional()), 136 | P.string('-').optional().skip(whitespace), 137 | name.skip(whitespace), 138 | until(end).concat(), 139 | end 140 | )).combine(_combine_jinja_tag_like) 141 | 142 | 143 | jinja_variable = make_jinja_tag_like_parser( 144 | P.success(None), '{', '}' 145 | ).combine(_combine_jinja_variable) 146 | 147 | 148 | jinja_comment = ( 149 | locate( 150 | P.string('{#') 151 | .skip(whitespace) 152 | .then(until(whitespace + P.string('#}')).concat()) 153 | .skip(whitespace + P.string('#}')) 154 | ) 155 | .combine(_combine_jinja_comment) 156 | ) 157 | 158 | 159 | def make_jinja_tag_parser(name_parser): 160 | return ( 161 | make_jinja_tag_like_parser(name_parser, '%', '%') 162 | .combine(_combine_jinja_tag) 163 | ) 164 | 165 | 166 | def _combine_jinja_element(locations, content): 167 | parts = list(flatten(content[0])) 168 | closing_tag = content[1] if len(content) == 2 else None 169 | 170 | e = JinjaElement( 171 | parts=parts, 172 | closing_tag=closing_tag, 173 | **locations, 174 | ) 175 | return e 176 | 177 | 178 | def _combine_jinja_element_part(locations, props): 179 | tag, content = props 180 | return JinjaElementPart( 181 | tag=tag, 182 | content=content, 183 | **locations, 184 | ) 185 | 186 | 187 | def make_jinja_element_part_parser(name_parser, content): 188 | return locate(P.seq( 189 | make_jinja_tag_parser(name_parser), 190 | content, 191 | )).combine(_combine_jinja_element_part) 192 | 193 | 194 | def make_jinja_element_parser(name_parsers, content): 195 | """ 196 | `name_parsers` must be a list of tag name parsers. For example, 197 | `name_parsers` can be defined as follow in order to parse `if` statements: 198 | 199 | name_parsers = [P.string(n) for n in ['if', 'elif', 'else', 'endif']] 200 | """ 201 | 202 | if len(name_parsers) == 1: 203 | tag = make_jinja_tag_parser(name_parsers[0]) 204 | part = locate(P.seq( 205 | tag, P.success(None), 206 | )).combine(_combine_jinja_element_part) 207 | parts = [part] 208 | end_tag_parser = None 209 | else: 210 | part_names = name_parsers[:-1] 211 | first_part = make_jinja_element_part_parser( 212 | part_names[0], content=content) 213 | next_parts = [ 214 | make_jinja_element_part_parser(name, content=content).many() 215 | for name in part_names[1:] 216 | ] 217 | parts = [first_part] + next_parts 218 | end_tag_parser = make_jinja_tag_parser(name_parsers[-1]) 219 | 220 | content = [P.seq(*parts)] 221 | if end_tag_parser: 222 | content.append(end_tag_parser) 223 | 224 | return ( 225 | locate(P.seq(*content)) 226 | .combine(_combine_jinja_element) 227 | ) 228 | 229 | 230 | jinja_name = P.letter + ( 231 | P.letter | P.decimal_digit | P.string('_') 232 | ).many().concat() 233 | 234 | 235 | def interpolated(parser): 236 | def combine_interpolated(locations, result): 237 | return Interpolated( 238 | nodes=result, 239 | **locations, 240 | ) 241 | 242 | return ( 243 | locate(parser) 244 | .combine(combine_interpolated) 245 | ) 246 | 247 | 248 | tag_name_start_char = P.regex(r'[:a-z]') 249 | tag_name_char = tag_name_start_char | P.regex(r'[0-9-_.]') 250 | tag_name = tag_name_start_char + tag_name_char.many().concat() 251 | 252 | dtd = P.regex(r']*>') 253 | 254 | string_attribute_char = P.char_from('-_./+,?=:;#') | P.regex(r'[0-9a-zA-Z]') 255 | 256 | 257 | def make_quoted_string_attribute_parser(quote, jinja): 258 | """ 259 | quote: A single or a double quote 260 | """ 261 | def combine(locations, value): 262 | return String( 263 | value=value, 264 | quote=quote, 265 | **locations, 266 | ) 267 | 268 | value_char = P.regex(r'[^<]', flags=re.DOTALL) 269 | value = interpolated( 270 | P.string(quote).should_fail('no ' + quote) 271 | .then(jinja | value_char) 272 | .many() 273 | ) 274 | 275 | return locate( 276 | P.string(quote) 277 | .then(value) 278 | .skip(P.string(quote)) 279 | ).combine(combine) 280 | 281 | 282 | def _combine_string_attribute_value(locations, value): 283 | return String( 284 | value=value, 285 | quote=None, 286 | **locations, 287 | ) 288 | 289 | 290 | def _combine_int_attribute_value(locations, value): 291 | return Integer( 292 | value=value, 293 | has_percent=False, 294 | **locations, 295 | ) 296 | 297 | 298 | int_attribute_value = locate( 299 | P.regex(r'[0-9]+') 300 | .map(int) 301 | ).combine(_combine_int_attribute_value) 302 | 303 | 304 | def _combine_attribute(locations, props): 305 | name, equal_and_value = props 306 | value = None if equal_and_value is None else equal_and_value['value'] 307 | return Attribute( 308 | name=name, 309 | value=value, 310 | **locations, 311 | ) 312 | 313 | 314 | def make_attribute_value_parser(jinja): 315 | string_attribute_value = locate( 316 | interpolated( 317 | (jinja | string_attribute_char) 318 | .at_least(1) 319 | ) 320 | ).combine(_combine_string_attribute_value) 321 | 322 | return ( 323 | make_quoted_string_attribute_parser('"', jinja) | 324 | make_quoted_string_attribute_parser("'", jinja) | 325 | int_attribute_value | 326 | string_attribute_value 327 | ).desc('attribute value') 328 | 329 | 330 | def make_attribute_parser(jinja): 331 | attribute_value = make_attribute_value_parser(jinja) 332 | return ( 333 | locate( 334 | P.seq( 335 | interpolated(tag_name), 336 | whitespace.then( 337 | P.seq( 338 | P.string('=').skip(whitespace).tag('equal'), 339 | interpolated(attribute_value).tag('value'), 340 | ).map(dict) 341 | ).optional(), 342 | ) 343 | ) 344 | .combine(_combine_attribute) 345 | .desc('attribute') 346 | ) 347 | 348 | 349 | def make_attributes_parser(config, jinja): 350 | attribute = make_attribute_parser(jinja) 351 | 352 | jinja_attr = make_jinja_parser( 353 | config, 354 | interpolated( 355 | whitespace 356 | .then( 357 | (attribute | jinja).sep_by(whitespace) 358 | ) 359 | .skip(whitespace) 360 | ) 361 | ) 362 | 363 | attrs = interpolated( 364 | ( 365 | whitespace.then(jinja_attr) | 366 | mandatory_whitespace.then(attribute) 367 | ).many() 368 | ) 369 | 370 | return attrs 371 | 372 | 373 | def _combine_comment(locations, text): 374 | return Comment( 375 | text=text, 376 | **locations, 377 | ) 378 | 379 | 380 | comment = locate( 381 | P.string(')', flags=re.DOTALL)) 383 | .skip(P.string('-->')) 384 | ).combine(_combine_comment) 385 | 386 | 387 | def _combine_opening_tag(locations, props): 388 | _lt, tag_name, attributes, slash, _gt = props 389 | return OpeningTag( 390 | name=tag_name, 391 | attributes=attributes, 392 | slash=slash, 393 | **locations, 394 | ) 395 | 396 | 397 | def _combine_closing_tag(locations, name): 398 | return ClosingTag( 399 | name=name, 400 | **locations 401 | ) 402 | 403 | 404 | def make_closing_tag_parser(tag_name_parser): 405 | return locate( 406 | P.string('')) 409 | ).combine(_combine_closing_tag) 410 | 411 | 412 | def _combine_slash(locations, _): 413 | return locations['begin'] 414 | 415 | 416 | def make_opening_tag_parser(config, 417 | jinja, 418 | tag_name_parser=None, 419 | allow_slash=False): 420 | attributes = make_attributes_parser(config, jinja) 421 | 422 | if not tag_name_parser: 423 | tag_name_parser = tag_name | jinja 424 | 425 | if allow_slash: 426 | slash = ( 427 | locate( 428 | P.string('/') 429 | .skip(whitespace) 430 | ) 431 | .combine(_combine_slash) 432 | .optional() 433 | ) 434 | else: 435 | slash = P.success(None) 436 | 437 | return ( 438 | locate(P.seq( 439 | P.string('<'), 440 | tag_name_parser, 441 | attributes.skip(whitespace), 442 | slash, 443 | P.string('>'), 444 | )) 445 | .combine(_combine_opening_tag) 446 | ) 447 | 448 | 449 | def _combine_element(locations, props): 450 | opening_tag, content, closing_tag = props 451 | return Element( 452 | opening_tag=opening_tag, 453 | closing_tag=closing_tag, 454 | content=content, 455 | **locations, 456 | ) 457 | 458 | 459 | def make_raw_text_element_parser(config, tag_name, jinja): 460 | """ 461 | Used for