From f014ea9942e52ae181bc6bc4edc3a3f634fe35d9 Mon Sep 17 00:00:00 2001 From: Kevin Wojniak Date: Tue, 28 Feb 2023 20:39:45 -0800 Subject: [PATCH] Add list-ordering rule This is based on the key-ordering rule and behaves the same with regards to locales. Closes #454 --- docs/configuration.rst | 6 +- docs/rules.rst | 5 ++ tests/rules/test_list_ordering.py | 133 ++++++++++++++++++++++++++++++ yamllint/conf/default.yaml | 1 + yamllint/rules/__init__.py | 2 + yamllint/rules/list_ordering.py | 119 ++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 tests/rules/test_list_ordering.py create mode 100644 yamllint/rules/list_ordering.py diff --git a/docs/configuration.rst b/docs/configuration.rst index 18258d0..a42ec35 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -243,9 +243,9 @@ It is possible to set the ``locale`` option globally. This is passed to Python's so an empty string ``""`` will use the system default locale, while e.g. ``"en_US.UTF-8"`` will use that. -Currently this only affects the ``key-ordering`` rule. The default will order -by Unicode code point number, while locales will sort case and accents -properly as well. +Currently this only affects the ``key-ordering`` and ``list-ordering`` rules. +The default will order by Unicode code point number, while locales will sort +case and accents properly as well. .. code-block:: yaml diff --git a/docs/rules.rst b/docs/rules.rst index c030c3d..2738b32 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -95,6 +95,11 @@ line-length .. automodule:: yamllint.rules.line_length +list-ordering +-------------- + +.. automodule:: yamllint.rules.list_ordering + new-line-at-end-of-file ----------------------- diff --git a/tests/rules/test_list_ordering.py b/tests/rules/test_list_ordering.py new file mode 100644 index 0000000..a955465 --- /dev/null +++ b/tests/rules/test_list_ordering.py @@ -0,0 +1,133 @@ +# Copyright (C) 2023 Johannes F. Knauf and Kevin Wojniak +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import locale + +from tests.common import RuleTestCase + + +class ListOrderingTestCase(RuleTestCase): + rule_id = 'list-ordering' + + def test_disabled(self): + conf = 'list-ordering: disable' + self.check('---\n' + '- seconditem\n' + '- firstitem\n', conf) + self.check('---\n' + '[seconditem, firstitem]\n', conf) + self.check('---\n' + '- second\n' + '- at\n', conf) + + def test_enabled(self): + conf = 'list-ordering: enable' + self.check('---\n' + '- seconditem\n' + '- firstitem\n', conf, + problem=(3, 3)) + self.check('---\n' + '[seconditem, firstitem]\n', conf, + problem=(2, 14)) + self.check('---\n' + '- second\n' + '- at\n', conf, + problem=(3, 3)) + self.check('---\n' + 'nested but OK:\n' + ' - third: [second]\n' + ' - first\n', conf) + self.check('---\n' + 'nested failure:\n' + ' - third\n' + ' - first:\n' + ' items:\n' + ' - z\n' + ' - a\n', conf, + problem=(7, 9)) + + def test_word_length(self): + conf = 'list-ordering: enable' + self.check('---\n' + '- a\n' + '- ab\n' + '- abc\n', conf) + self.check('---\n' + '- a\n' + '- abc\n' + '- ab\n', conf, + problem=(4, 3)) + + def test_case(self): + conf = 'list-ordering: enable' + self.check('---\n' + '- T-shirt\n' + '- T-shirts\n' + '- t-shirt\n' + '- t-shirts\n', conf) + self.check('---\n' + '- T-shirt\n' + '- t-shirt\n' + '- T-shirts\n' + '- t-shirts\n', conf, + problem=(4, 3)) + + def test_accents(self): + conf = 'list-ordering: enable' + self.check('---\n' + '- hair\n' + '- hais\n' + '- haïr\n' + '- haïssable\n', conf) + self.check('---\n' + '- haïr\n' + '- hais\n', conf, + problem=(3, 3)) + + def test_locale_case(self): + self.addCleanup(locale.setlocale, locale.LC_ALL, (None, None)) + try: + locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + except locale.Error: # pragma: no cover + self.skipTest('locale en_US.UTF-8 not available') + conf = ('list-ordering: enable') + self.check('---\n' + '- t-shirt\n' + '- T-shirt\n' + '- t-shirts\n' + '- T-shirts\n', conf) + self.check('---\n' + '- t-shirt\n' + '- t-shirts\n' + '- T-shirt\n' + '- T-shirts\n', conf, + problem=(4, 3)) + + def test_locale_accents(self): + self.addCleanup(locale.setlocale, locale.LC_ALL, (None, None)) + try: + locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + except locale.Error: # pragma: no cover + self.skipTest('locale en_US.UTF-8 not available') + conf = ('list-ordering: enable') + self.check('---\n' + '- hair\n' + '- haïr\n' + '- hais\n' + '- haïssable\n', conf) + self.check('---\n' + '- hais\n' + '- haïr\n', conf, + problem=(3, 3)) diff --git a/yamllint/conf/default.yaml b/yamllint/conf/default.yaml index 0dea0aa..b80887f 100644 --- a/yamllint/conf/default.yaml +++ b/yamllint/conf/default.yaml @@ -25,6 +25,7 @@ rules: key-duplicates: enable key-ordering: disable line-length: enable + list-ordering: disable new-line-at-end-of-file: enable new-lines: enable octal-values: disable diff --git a/yamllint/rules/__init__.py b/yamllint/rules/__init__.py index 5a4fe81..bd88d15 100644 --- a/yamllint/rules/__init__.py +++ b/yamllint/rules/__init__.py @@ -29,6 +29,7 @@ from yamllint.rules import ( key_duplicates, key_ordering, line_length, + list_ordering, new_line_at_end_of_file, new_lines, octal_values, @@ -55,6 +56,7 @@ _RULES = { key_duplicates.ID: key_duplicates, key_ordering.ID: key_ordering, line_length.ID: line_length, + list_ordering.ID: list_ordering, new_line_at_end_of_file.ID: new_line_at_end_of_file, new_lines.ID: new_lines, octal_values.ID: octal_values, diff --git a/yamllint/rules/list_ordering.py b/yamllint/rules/list_ordering.py new file mode 100644 index 0000000..5ff0c55 --- /dev/null +++ b/yamllint/rules/list_ordering.py @@ -0,0 +1,119 @@ +# Copyright (C) 2023 Johannes F. Knauf and Kevin Wojniak +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Use this rule to enforce alphabetical ordering of items in lists. The sorting +order uses the Unicode code point number as a default. As a result, the +ordering is case-sensitive and not accent-friendly (see examples below). +This can be changed by setting the global ``locale`` option. This allows one +to sort case and accents properly. + +.. rubric:: Examples + +#. With ``list-ordering: {}`` + + the following code snippets would **PASS**: + :: + + - key 1 + - key 2 + - key 3 + + - [a, b, c] + + - T-shirt + - T-shirts + - t-shirt + - t-shirts + + - hair + - hais + - haïr + - haïssable + + the following code snippets would **FAIL**: + :: + + - key 2 + - key 1 + + - [b, a] + + - T-shirt + - t-shirt + - T-shirts + - t-shirts + + - haïr + - hais + +#. With global option ``locale: "en_US.UTF-8"`` and rule ``list-ordering: {}`` + + as opposed to before, the following code snippets would now **PASS**: + :: + + - t-shirt + - T-shirt + - t-shirts + - T-shirts + + - hair + - haïr + - hais + - haïssable +""" + +from locale import strcoll + +import yaml + +from yamllint.linter import LintProblem + + +ID = 'list-ordering' +TYPE = 'token' + +MAP, SEQ = range(2) + + +class Parent: + def __init__(self, type): + self.type = type + self.items = [] + + +def check(conf, token, prev, next, nextnext, context): + if 'stack' not in context: + context['stack'] = [] + + if isinstance(token, (yaml.BlockMappingStartToken, + yaml.FlowMappingStartToken)): + context['stack'].append(Parent(MAP)) + elif isinstance(token, (yaml.BlockSequenceStartToken, + yaml.FlowSequenceStartToken)): + context['stack'].append(Parent(SEQ)) + elif isinstance(token, (yaml.BlockEndToken, + yaml.FlowMappingEndToken, + yaml.FlowSequenceEndToken)): + context['stack'].pop() + elif isinstance(token, yaml.ScalarToken): + if len(context['stack']) > 0 and context['stack'][-1].type == SEQ: + if any(strcoll(token.value, item) < 0 + for item in context['stack'][-1].items): + yield LintProblem( + token.start_mark.line + 1, token.start_mark.column + 1, + 'wrong list item order "%s"' % token.value) + else: + context['stack'][-1].items.append(token.value)