From 9e67d02184b87402aae6cccf2c69f3f11fd6df44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Verg=C3=A9?= Date: Sun, 19 Mar 2023 12:21:16 +0100 Subject: [PATCH] anchors: Add new rule to detect undeclared or duplicated anchors According to the YAML specification [^1]: - > It is an error for an alias node to use an anchor that does not > previously occur in the document. The `forbid-undeclared-aliases` option checks that aliases do have a matching anchor declared previously in the document. Since this is required by the YAML spec, this option is enabled by default. - > The alias refers to the most recent preceding node having the same > anchor. This means that having a same anchor repeated in a document is allowed. However users could want to avoid this, so the new option `forbid-duplicated-anchors` allows that. It's disabled by default. - > It is not an error to specify an anchor that is not used by any > alias node. This means that it's OK to declare anchors but don't have any alias referencing them. However users could want to avoid this, so a new option (e.g. `forbid-unused-anchors`) could be implemented in the future. See https://github.com/adrienverge/yamllint/pull/537. Fixes #395 Closes #420 [^1]: https://yaml.org/spec/1.2.2/#71-alias-nodes --- docs/rules.rst | 5 + tests/rules/test_anchors.py | 209 ++++++++++++++++++++++++++++++++++++ yamllint/conf/default.yaml | 1 + yamllint/rules/__init__.py | 2 + yamllint/rules/anchors.py | 118 ++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 tests/rules/test_anchors.py create mode 100644 yamllint/rules/anchors.py diff --git a/docs/rules.rst b/docs/rules.rst index c030c3db..eb3bc821 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -14,6 +14,11 @@ This page describes the rules and their options. :local: :depth: 1 +anchors +------- + +.. automodule:: yamllint.rules.anchors + braces ------ diff --git a/tests/rules/test_anchors.py b/tests/rules/test_anchors.py new file mode 100644 index 00000000..da1c5233 --- /dev/null +++ b/tests/rules/test_anchors.py @@ -0,0 +1,209 @@ +# Copyright (C) 2023 Adrien Vergé +# +# 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 . + +from tests.common import RuleTestCase + + +class AnchorsTestCase(RuleTestCase): + rule_id = 'anchors' + + def test_disabled(self): + conf = 'anchors: disable' + self.check('---\n' + '- &b true\n' + '- &i 42\n' + '- &s hello\n' + '- &f_m {k: v}\n' + '- &f_s [1, 2]\n' + '- *b\n' + '- *i\n' + '- *s\n' + '- *f_m\n' + '- *f_s\n' + '---\n' # redeclare anchors in a new document + '- &b true\n' + '- &i 42\n' + '- &s hello\n' + '- *b\n' + '- *i\n' + '- *s\n' + '---\n' + 'block mapping: &b_m\n' + ' key: value\n' + 'extended:\n' + ' <<: *b_m\n' + ' foo: bar\n' + '---\n' + '{a: 1, &x b: 2, c: &y 3, *x: 4, e: *y}\n' + '...\n', conf) + self.check('---\n' + '- &i 42\n' + '---\n' + '- &b true\n' + '- &b true\n' + '- &b true\n' + '- &s hello\n' + '- *b\n' + '- *i\n' # declared in a previous document + '- *f_m\n' # never declared + '- *f_m\n' + '- *f_m\n' + '- *f_s\n' # declared after + '- &f_s [1, 2]\n' + '---\n' + 'block mapping: &b_m\n' + ' key: value\n' + '---\n' + 'block mapping 1: &b_m_bis\n' + ' key: value\n' + 'block mapping 2: &b_m_bis\n' + ' key: value\n' + 'extended:\n' + ' <<: *b_m\n' + ' foo: bar\n' + '---\n' + '{a: 1, &x b: 2, c: &x 3, *x: 4, e: *y}\n' + '...\n', conf) + + def test_forbid_undeclared_aliases(self): + conf = ('anchors:\n' + ' forbid-undeclared-aliases: true\n' + ' forbid-duplicated-anchors: false\n') + self.check('---\n' + '- &b true\n' + '- &i 42\n' + '- &s hello\n' + '- &f_m {k: v}\n' + '- &f_s [1, 2]\n' + '- *b\n' + '- *i\n' + '- *s\n' + '- *f_m\n' + '- *f_s\n' + '---\n' # redeclare anchors in a new document + '- &b true\n' + '- &i 42\n' + '- &s hello\n' + '- *b\n' + '- *i\n' + '- *s\n' + '---\n' + 'block mapping: &b_m\n' + ' key: value\n' + 'extended:\n' + ' <<: *b_m\n' + ' foo: bar\n' + '---\n' + '{a: 1, &x b: 2, c: &y 3, *x: 4, e: *y}\n' + '...\n', conf) + self.check('---\n' + '- &i 42\n' + '---\n' + '- &b true\n' + '- &b true\n' + '- &b true\n' + '- &s hello\n' + '- *b\n' + '- *i\n' # declared in a previous document + '- *f_m\n' # never declared + '- *f_m\n' + '- *f_m\n' + '- *f_s\n' # declared after + '- &f_s [1, 2]\n' + '---\n' + 'block mapping: &b_m\n' + ' key: value\n' + '---\n' + 'block mapping 1: &b_m_bis\n' + ' key: value\n' + 'block mapping 2: &b_m_bis\n' + ' key: value\n' + 'extended:\n' + ' <<: *b_m\n' + ' foo: bar\n' + '---\n' + '{a: 1, &x b: 2, c: &x 3, *x: 4, e: *y}\n' + '...\n', conf, + problem1=(9, 3), + problem2=(10, 3), + problem3=(11, 3), + problem4=(12, 3), + problem5=(13, 3), + problem6=(24, 7), + problem7=(27, 36)) + + def test_forbid_duplicated_anchors(self): + conf = ('anchors:\n' + ' forbid-undeclared-aliases: false\n' + ' forbid-duplicated-anchors: true\n') + self.check('---\n' + '- &b true\n' + '- &i 42\n' + '- &s hello\n' + '- &f_m {k: v}\n' + '- &f_s [1, 2]\n' + '- *b\n' + '- *i\n' + '- *s\n' + '- *f_m\n' + '- *f_s\n' + '---\n' # redeclare anchors in a new document + '- &b true\n' + '- &i 42\n' + '- &s hello\n' + '- *b\n' + '- *i\n' + '- *s\n' + '---\n' + 'block mapping: &b_m\n' + ' key: value\n' + 'extended:\n' + ' <<: *b_m\n' + ' foo: bar\n' + '---\n' + '{a: 1, &x b: 2, c: &y 3, *x: 4, e: *y}\n' + '...\n', conf) + self.check('---\n' + '- &i 42\n' + '---\n' + '- &b true\n' + '- &b true\n' + '- &b true\n' + '- &s hello\n' + '- *b\n' + '- *i\n' # declared in a previous document + '- *f_m\n' # never declared + '- *f_m\n' + '- *f_m\n' + '- *f_s\n' # declared after + '- &f_s [1, 2]\n' + '---\n' + 'block mapping: &b_m\n' + ' key: value\n' + '---\n' + 'block mapping 1: &b_m_bis\n' + ' key: value\n' + 'block mapping 2: &b_m_bis\n' + ' key: value\n' + 'extended:\n' + ' <<: *b_m\n' + ' foo: bar\n' + '---\n' + '{a: 1, &x b: 2, c: &x 3, *x: 4, e: *y}\n' + '...\n', conf, + problem1=(5, 3), + problem2=(6, 3), + problem3=(21, 18), + problem4=(27, 20)) diff --git a/yamllint/conf/default.yaml b/yamllint/conf/default.yaml index 0dea0aa4..b082e228 100644 --- a/yamllint/conf/default.yaml +++ b/yamllint/conf/default.yaml @@ -6,6 +6,7 @@ yaml-files: - '.yamllint' rules: + anchors: enable braces: enable brackets: enable colons: enable diff --git a/yamllint/rules/__init__.py b/yamllint/rules/__init__.py index 5a4fe811..6b5e4467 100644 --- a/yamllint/rules/__init__.py +++ b/yamllint/rules/__init__.py @@ -14,6 +14,7 @@ # along with this program. If not, see . from yamllint.rules import ( + anchors, braces, brackets, colons, @@ -39,6 +40,7 @@ ) _RULES = { + anchors.ID: anchors, braces.ID: braces, brackets.ID: brackets, colons.ID: colons, diff --git a/yamllint/rules/anchors.py b/yamllint/rules/anchors.py new file mode 100644 index 00000000..6d0b1e60 --- /dev/null +++ b/yamllint/rules/anchors.py @@ -0,0 +1,118 @@ +# Copyright (C) 2023 Adrien Vergé +# +# 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 report duplicated anchors and aliases referencing undeclared +anchors. + +.. rubric:: Options + +* Set ``forbid-undeclared-aliases`` to ``true`` to avoid aliases that reference + an anchor that hasn't been declared (either not declared at all, or declared + later in the document). +* Set ``forbid-duplicated-anchors`` to ``true`` to avoid duplications of a same + anchor. + +.. rubric:: Default values (when enabled) + +.. code-block:: yaml + + rules: + anchors: + forbid-undeclared-aliases: true + forbid-duplicated-anchors: false + +.. rubric:: Examples + +#. With ``anchors: {forbid-undeclared-aliases: true}`` + + the following code snippet would **PASS**: + :: + + --- + - &anchor + foo: bar + - *anchor + + the following code snippet would **FAIL**: + :: + + --- + - &anchor + foo: bar + - *unknown + + the following code snippet would **FAIL**: + :: + + --- + - &anchor + foo: bar + - <<: *unknown + extra: value + +#. With ``anchors: {forbid-duplicated-anchors: true}`` + + the following code snippet would **PASS**: + :: + + --- + - &anchor1 Foo Bar + - &anchor2 [item 1, item 2] + + the following code snippet would **FAIL**: + :: + + --- + - &anchor Foo Bar + - &anchor [item 1, item 2] +""" + + +import yaml + +from yamllint.linter import LintProblem + + +ID = 'anchors' +TYPE = 'token' +CONF = {'forbid-undeclared-aliases': bool, + 'forbid-duplicated-anchors': bool} +DEFAULT = {'forbid-undeclared-aliases': True, + 'forbid-duplicated-anchors': False} + + +def check(conf, token, prev, next, nextnext, context): + if conf['forbid-undeclared-aliases'] or conf['forbid-duplicated-anchors']: + if isinstance(token, (yaml.StreamStartToken, yaml.DocumentStartToken)): + context['anchors'] = set() + + if (conf['forbid-undeclared-aliases'] and + isinstance(token, yaml.AliasToken) and + token.value not in context['anchors']): + yield LintProblem( + token.start_mark.line + 1, token.start_mark.column + 1, + f'found undeclared alias "{token.value}"') + + if (conf['forbid-duplicated-anchors'] and + isinstance(token, yaml.AnchorToken) and + token.value in context['anchors']): + yield LintProblem( + token.start_mark.line + 1, token.start_mark.column + 1, + f'found duplicated anchor "{token.value}"') + + if conf['forbid-undeclared-aliases'] or conf['forbid-duplicated-anchors']: + if isinstance(token, yaml.AnchorToken): + context['anchors'].add(token.value)