├── .github ├── renovate.json └── workflows │ ├── release-github.yaml │ ├── release-mvn.yml │ ├── release-npm.yml │ ├── release-perl.yml │ ├── release-php.yaml │ ├── release-pypi.yaml │ ├── release-rubygem.yml │ ├── stryker-javascript.yml │ ├── test-go.yml │ ├── test-java.yml │ ├── test-javascript.yml │ ├── test-perl.yml │ ├── test-php.yml │ ├── test-python.yml │ └── test-ruby.yml ├── .gitignore ├── ARCHITECTURE.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASING.md ├── go ├── .gitignore ├── Makefile ├── README.md ├── errors_test.go ├── evaluations_test.go ├── go.mod ├── go.sum ├── parser.go ├── parsing_test.go └── stack.go ├── java ├── .gitignore ├── README.md ├── pom.xml └── src │ ├── main │ └── java │ │ └── io │ │ └── cucumber │ │ └── tagexpressions │ │ ├── Expression.java │ │ ├── TagExpressionException.java │ │ └── TagExpressionParser.java │ └── test │ └── java │ └── io │ └── cucumber │ └── tagexpressions │ ├── ErrorsTest.java │ ├── EvaluationsTest.java │ └── ParsingTest.java ├── javascript ├── .gitignore ├── .mocharc.json ├── .prettierrc.json ├── CONTRIBUTING.md ├── README.md ├── eslint.config.mjs ├── package-lock.json ├── package.cjs.json ├── package.json ├── src │ └── index.ts ├── stryker.conf.json ├── test │ ├── errors.test.ts │ ├── evaluations.test.ts │ ├── parsing.test.ts │ └── testDataDir.ts ├── tsconfig.build-cjs.json ├── tsconfig.build-esm.json ├── tsconfig.build.json └── tsconfig.json ├── perl ├── .gitignore ├── Makefile ├── VERSION ├── cpanfile ├── default.mk ├── dist.ini ├── lib │ └── Cucumber │ │ ├── TagExpressions.pm │ │ └── TagExpressions │ │ └── Node.pm └── t │ ├── 02-evaluate.t │ └── 03-shared-tests.t ├── php ├── LICENSE ├── README.md ├── composer.json └── src │ ├── Associativity.php │ ├── Expression.php │ ├── Expression │ ├── AndExpression.php │ ├── LiteralExpression.php │ ├── NotExpression.php │ ├── OrExpression.php │ └── TrueExpression.php │ ├── TagExpressionException.php │ ├── TagExpressionParser.php │ └── TokenType.php ├── python ├── .coveragerc ├── .editorconfig ├── .envrc ├── .envrc.use_venv ├── .gitignore ├── .pylintrc ├── DEVELOPMENT.rst ├── MANIFEST.in ├── Makefile ├── README.rst ├── cucumber_tag_expressions │ ├── __init__.py │ ├── model.py │ └── parser.py ├── default.mk ├── invoke.yaml ├── py.requirements │ ├── README.txt │ ├── all.txt │ ├── basic.txt │ ├── ci.github.testing.txt │ ├── develop.txt │ ├── packaging.txt │ └── testing.txt ├── pyproject.toml ├── pytest.ini ├── scripts │ ├── ensurepip_python27.sh │ ├── pytest_cmd.py │ └── toxcmd.py ├── tasks │ ├── __init__.py │ ├── __main__.py │ ├── _compat_shutil.py │ ├── _dry_run.py │ ├── _path.py │ ├── _setup.py │ ├── invoke_cleanup.py │ ├── py.requirements.txt │ └── test.py ├── tests │ ├── data │ │ ├── README.md │ │ ├── __init__.py │ │ ├── test_errors.py │ │ ├── test_evaluations.py │ │ └── test_parsing.py │ ├── functional │ │ ├── __init__.py │ │ └── test_tag_expression.py │ └── unit │ │ ├── __init__.py │ │ ├── test_model.py │ │ └── test_parser.py └── tox.ini ├── ruby ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── Gemfile ├── README.md ├── Rakefile ├── VERSION ├── cucumber-tag-expressions.gemspec ├── lib │ └── cucumber │ │ ├── tag_expressions.rb │ │ └── tag_expressions │ │ ├── expressions.rb │ │ └── parser.rb └── spec │ ├── errors_spec.rb │ ├── evaluations_spec.rb │ ├── parsing_spec.rb │ └── spec_helper.rb └── testdata ├── errors.yml ├── evaluations.yml └── parsing.yml /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>cucumber/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/release-github.yaml: -------------------------------------------------------------------------------- 1 | name: Release GitHub 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | create-github-release: 9 | name: Create GitHub Release and Git tag 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: cucumber/action-create-github-release@v1.1.1 17 | with: 18 | github-token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/release-mvn.yml: -------------------------------------------------------------------------------- 1 | name: Release Maven 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | publish-mvn: 9 | name: Publish Maven Package 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-java@v4 15 | with: 16 | distribution: 'temurin' 17 | java-version: '17' 18 | cache: 'maven' 19 | - uses: cucumber/action-publish-mvn@v3.0.0 20 | with: 21 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 22 | gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} 23 | nexus-username: ${{ secrets.SONATYPE_USERNAME }} 24 | nexus-password: ${{ secrets.SONATYPE_PASSWORD }} 25 | working-directory: java 26 | -------------------------------------------------------------------------------- /.github/workflows/release-npm.yml: -------------------------------------------------------------------------------- 1 | name: Release NPM 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | publish-npm: 9 | name: Publish NPM module 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '22.x' 17 | cache: 'npm' 18 | cache-dependency-path: javascript/package-lock.json 19 | - run: npm install-test 20 | working-directory: javascript 21 | - uses: cucumber/action-publish-npm@v1.1.1 22 | with: 23 | npm-token: ${{ secrets.NPM_TOKEN }} 24 | working-directory: javascript 25 | -------------------------------------------------------------------------------- /.github/workflows/release-perl.yml: -------------------------------------------------------------------------------- 1 | name: Publish to CPAN 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | publish-ui: 9 | name: Publish to CPAN 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: cucumber/action-publish-cpan@v1.0.1 15 | with: 16 | cpan-user: ${{ secrets.CPAN_USER }} 17 | cpan-password: ${{ secrets.CPAN_PASSWORD }} 18 | working-directory: "perl" 19 | -------------------------------------------------------------------------------- /.github/workflows/release-php.yaml: -------------------------------------------------------------------------------- 1 | name: Release PHP 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | publish-php-subrepo: 9 | name: Publish to cucumber/tag-expressions-php subrepo 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: '0' 18 | - uses: cucumber/action-publish-subrepo@v1.1.1 19 | with: 20 | working-directory: php 21 | github-token: ${{ secrets.CUKEBOT_GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/release-pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Release Python 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | permissions: 13 | id-token: write 14 | defaults: 15 | run: 16 | working-directory: python 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.13" 24 | - name: Show Python version 25 | run: python --version 26 | 27 | - name: Build package 28 | run: | 29 | python -m pip install build twine 30 | python -m build 31 | twine check --strict dist/* 32 | 33 | - name: Publish package distributions to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | packages-dir: python/dist 37 | -------------------------------------------------------------------------------- /.github/workflows/release-rubygem.yml: -------------------------------------------------------------------------------- 1 | name: Release RubyGems 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | publish-rubygem: 9 | name: Publish Ruby Gem 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: '3.4' 17 | bundler-cache: true 18 | - uses: cucumber/action-publish-rubygem@v1.0.0 19 | with: 20 | rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }} 21 | working_directory: ruby 22 | -------------------------------------------------------------------------------- /.github/workflows/stryker-javascript.yml: -------------------------------------------------------------------------------- 1 | name: stryker-javascript 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - renovate/** 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | stryker-javascript: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '22.x' 19 | cache: 'npm' 20 | cache-dependency-path: javascript/package-lock.json 21 | - run: npm install 22 | working-directory: javascript 23 | - run: npm run stryker 24 | working-directory: javascript 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: stryker-report 28 | path: | 29 | javascript/reports/mutation/html/index.html 30 | javascript/reports/mutation/mutation.json 31 | -------------------------------------------------------------------------------- /.github/workflows/test-go.yml: -------------------------------------------------------------------------------- 1 | name: test-go 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - go/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | - renovate/** 16 | paths: 17 | - go/** 18 | - testdata/** 19 | - .github/** 20 | workflow_call: 21 | 22 | jobs: 23 | test-go: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: 29 | - ubuntu-latest 30 | go-version: ['1.20.x', '1.21.x'] 31 | include: 32 | - os: windows-latest 33 | go-version: '1.21.x' 34 | - os: macos-latest 35 | go-version: '1.21.x' 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Set up Go 39 | uses: actions/setup-go@v5 40 | with: 41 | go-version: ${{ matrix.go-version }} 42 | - name: lint 43 | working-directory: go 44 | run: gofmt -w . 45 | - name: test 46 | working-directory: go 47 | run: go test ./... 48 | -------------------------------------------------------------------------------- /.github/workflows/test-java.yml: -------------------------------------------------------------------------------- 1 | name: test-java 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - java/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | - renovate/** 16 | paths: 17 | - java/** 18 | - testdata/** 19 | - .github/** 20 | workflow_call: 21 | 22 | jobs: 23 | test-java: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: 29 | - ubuntu-latest 30 | java: ['11', '17'] 31 | include: 32 | - os: windows-latest 33 | java: '17' 34 | - os: macos-latest 35 | java: '17' 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-java@v4 39 | with: 40 | distribution: 'temurin' 41 | java-version: ${{ matrix.java }} 42 | cache: 'maven' 43 | - run: mvn install 44 | working-directory: java 45 | - run: mvn test 46 | working-directory: java 47 | -------------------------------------------------------------------------------- /.github/workflows/test-javascript.yml: -------------------------------------------------------------------------------- 1 | name: test-javascript 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - javascript/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | - renovate/** 16 | paths: 17 | - javascript/** 18 | - testdata/** 19 | - .github/** 20 | workflow_call: 21 | 22 | jobs: 23 | test-javascript: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: 29 | - ubuntu-latest 30 | node-version: ['18.x', '20.x', '22.x', '23.x'] 31 | include: 32 | - os: windows-latest 33 | node-version: '22.x' 34 | - os: macos-latest 35 | node-version: '22.x' 36 | steps: 37 | - name: set git core.autocrlf to 'input' 38 | run: git config --global core.autocrlf input 39 | - uses: actions/checkout@v4 40 | - name: with Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | cache: 'npm' 45 | cache-dependency-path: javascript/package-lock.json 46 | - run: npm install-test 47 | working-directory: javascript 48 | - run: npm run lint 49 | working-directory: javascript 50 | -------------------------------------------------------------------------------- /.github/workflows/test-perl.yml: -------------------------------------------------------------------------------- 1 | name: test-perl 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - perl/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | - renovate/** 16 | paths: 17 | - perl/** 18 | - testdata/** 19 | - .github/** 20 | workflow_call: 21 | 22 | jobs: 23 | test-perl: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | matrix: 27 | perl: [ "5.14", "5.20", "5.26", "5.32" ] 28 | steps: 29 | - name: Check out repository 30 | uses: actions/checkout@v4 31 | - name: Setup Perl environment 32 | uses: shogo82148/actions-setup-perl@v1 33 | with: 34 | perl-version: ${{ matrix.perl }} 35 | working-directory: perl 36 | - name: Run tests 37 | working-directory: perl 38 | run: make test 39 | # Run author tests second so as to not 'contaminate' the environment 40 | # with dependencies listed as author deps when they should have been 41 | # listed as general deps 42 | - name: Run author tests 43 | working-directory: perl 44 | run: make authortest 45 | -------------------------------------------------------------------------------- /.github/workflows/test-php.yml: -------------------------------------------------------------------------------- 1 | name: test-php 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - php/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | paths: 16 | - php/** 17 | - testdata/** 18 | - .github/** 19 | workflow_call: 20 | 21 | permissions: 22 | contents: read 23 | 24 | jobs: 25 | test-php: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | php: ['8.1', '8.2', '8.3', '8.4'] 30 | composer-mode: ['high-deps'] 31 | include: 32 | - php: '8.1' 33 | composer-mode: 'low-deps' 34 | 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up PHP 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: "${{ matrix.php }}" 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Discover composer cache directory 47 | id: composer-cache 48 | run: echo "dir=$(composer config cache-dir)" >> $GITHUB_OUTPUT 49 | working-directory: php 50 | 51 | - name: Cache composer 52 | uses: actions/cache@v4 53 | with: 54 | path: "${{ steps.composer-cache.outputs.dir }}" 55 | key: composer 56 | 57 | - name: Install dependencies 58 | id: install 59 | working-directory: php 60 | run: | 61 | if [[ "${{ matrix.composer-mode }}" = "low-deps" ]]; then 62 | composer update --prefer-lowest 63 | else 64 | composer update 65 | fi 66 | 67 | - name: Lint coding standards 68 | if: always() && steps.install.outcome == 'success' 69 | working-directory: php 70 | run: vendor/bin/php-cs-fixer check --diff --show-progress=none 71 | env: 72 | PHP_CS_FIXER_IGNORE_ENV: '1' 73 | 74 | - name: Run tests 75 | if: always() && steps.install.outcome == 'success' 76 | working-directory: php 77 | run: vendor/bin/phpunit 78 | 79 | - name: Run static analysis 80 | if: always() && steps.install.outcome == 'success' 81 | working-directory: php 82 | run: vendor/bin/phpstan analyze 83 | -------------------------------------------------------------------------------- /.github/workflows/test-python.yml: -------------------------------------------------------------------------------- 1 | name: test-python 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - python/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | - renovate/** 16 | paths: 17 | - python/** 18 | - testdata/** 19 | - .github/** 20 | workflow_call: 21 | 22 | jobs: 23 | test-python: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: 29 | - ubuntu-latest 30 | python-version: ["3.x","pypy-3.10"] 31 | defaults: 32 | run: 33 | working-directory: python 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | architecture: x64 41 | - name: Show Python version 42 | run: python --version 43 | - name: Install Python package dependencies 44 | run: | 45 | python -m pip install pip setuptools wheel build twine 46 | pip install -U -r py.requirements/ci.github.testing.txt 47 | pip install -e . 48 | - name: Run tests 49 | run: pytest 50 | - name: Build package 51 | run: | 52 | python -m build 53 | twine check --strict dist/* 54 | -------------------------------------------------------------------------------- /.github/workflows/test-ruby.yml: -------------------------------------------------------------------------------- 1 | name: test-ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/** 8 | paths: 9 | - ruby/** 10 | - testdata/** 11 | - .github/** 12 | pull_request: 13 | branches: 14 | - main 15 | - renovate/** 16 | paths: 17 | - ruby/** 18 | - testdata/** 19 | - .github/** 20 | workflow_call: 21 | 22 | jobs: 23 | test-ruby: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: 29 | - ubuntu-latest 30 | ruby: ['2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 31 | include: 32 | - os: windows-latest 33 | ruby: '3.3' 34 | - os: macos-latest 35 | ruby: '3.3' 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: ${{ matrix.ruby }} 41 | bundler-cache: true 42 | rubygems: '3.0.8' 43 | working-directory: ruby 44 | - run: bundle exec rspec 45 | working-directory: ruby 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | The implementation is based on a modified version of Edsger Dijkstra's 4 | [Shunting Yard algorithm](https://en.wikipedia.org/wiki/Shunting-yard_algorithm) 5 | that produces an expression tree instead of a postfix notation. 6 | 7 | For example this expression: 8 | 9 | expression = "not @a or @b and not @c or not @d or @e and @f" 10 | 11 | Would parse into this expression tree: 12 | 13 | # Get the root of the tree - an Expression object. 14 | expressionNode = parser.parse(expression) 15 | 16 | or 17 | / \ 18 | or and 19 | / \ / \ 20 | or not @e @f 21 | / \ \ 22 | not and @d 23 | / / \ 24 | @a @b not 25 | \ 26 | @c 27 | 28 | The root node of tree can then be evaluated for different combinations of tags. 29 | For example: 30 | 31 | result = expressionNode.evaluate(["@a", "@c", "@d"]) # => false 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Cucumber Ltd and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![test-go](https://github.com/cucumber/tag-expressions/actions/workflows/test-go.yml/badge.svg)](https://github.com/cucumber/tag-expressions/actions/workflows/test-go.yml) 2 | [![test-java](https://github.com/cucumber/tag-expressions/actions/workflows/test-java.yml/badge.svg)](https://github.com/cucumber/tag-expressions/actions/workflows/test-java.yml) 3 | [![test-javascript](https://github.com/cucumber/tag-expressions/actions/workflows/test-javascript.yml/badge.svg)](https://github.com/cucumber/tag-expressions/actions/workflows/test-javascript.yml) 4 | [![test-perl](https://github.com/cucumber/tag-expressions/actions/workflows/test-perl.yml/badge.svg)](https://github.com/cucumber/tag-expressions/actions/workflows/test-perl.yml) 5 | [![test-python](https://github.com/cucumber/tag-expressions/actions/workflows/test-python.yml/badge.svg)](https://github.com/cucumber/tag-expressions/actions/workflows/test-python.yml) 6 | [![test-ruby](https://github.com/cucumber/tag-expressions/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/cucumber/tag-expressions/actions/workflows/test-ruby.yml) 7 | 8 | # Tag Expressions 9 | 10 | Tag Expressions is a simple query language for tags. The simplest tag expression is 11 | simply a single tag, for example: 12 | 13 | @smoke 14 | 15 | A slightly more elaborate expression may combine tags, for example: 16 | 17 | @smoke and not @ui 18 | 19 | Tag Expressions are used for two purposes: 20 | 21 | 1. Run a subset of scenarios (using the `--tags expression` option of the [command line](https://cucumber.io/docs/cucumber/api/#running-cucumber)) 22 | 2. Specify that a hook should only run for a subset of scenarios (using [conditional hooks](https://cucumber.io/docs/cucumber/api/#hooks)) 23 | 24 | Tag Expressions are [boolean expressions](https://en.wikipedia.org/wiki/Boolean_expression) 25 | of tags with the logical operators `and`, `or` and `not`. 26 | 27 | For more complex Tag Expressions you can use parenthesis for clarity, or to change operator precedence: 28 | 29 | (@smoke or @ui) and (not @slow) 30 | 31 | ## Escaping 32 | 33 | If you need to use one of the reserved characters `(`, `)`, `\` or ` ` (whitespace) in a tag, 34 | you can escape it with a `\`. Examples: 35 | 36 | | Gherkin Tag | Escaped Tag Expression | 37 | | ------------- | ---------------------- | 38 | | @x(y) | @x\\(y\\) | 39 | | @x\y | @x\\\\y | 40 | 41 | ## Migrating from old style tags 42 | 43 | Older versions of Cucumber used a different syntax for tags. The list below 44 | provides some examples illustrating how to migrate to tag expressions. 45 | 46 | | Old style command line | Cucumber Expressions style command line | 47 | | ----------------------------- | --------------------------------------- | 48 | | --tags @dev | --tags @dev | 49 | | --tags ~@dev | --tags "not @dev" | 50 | | --tags @foo,@bar | --tags "@foo or @bar" | 51 | | --tags @foo --tags @bar | --tags "@foo and bar" | 52 | | --tags ~@foo --tags @bar,@zap | --tags "not @foo and (@bar or @zap)" | 53 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | See [.github/RELEASING](https://github.com/cucumber/.github/blob/main/RELEASING.md). 2 | -------------------------------------------------------------------------------- /go/.gitignore: -------------------------------------------------------------------------------- 1 | .built 2 | .compared 3 | .deps 4 | .dist 5 | .dist-compressed 6 | .go-get 7 | .gofmt 8 | .linted 9 | .tested* 10 | acceptance/ 11 | bin/ 12 | dist/ 13 | dist_compressed/ 14 | *.bin 15 | *.iml 16 | # upx dist/cucumber-gherkin-openbsd-386 fails with a core dump 17 | core.*.!usr!bin!upx-ucl 18 | .idea 19 | -------------------------------------------------------------------------------- /go/Makefile: -------------------------------------------------------------------------------- 1 | GO_SOURCE_FILES := $(wildcard *.go) 2 | TEST_FILES := $(wildcard ../testdata/*.yml) 3 | 4 | default: .linted .tested 5 | .PHONY: default 6 | 7 | .linted: $(GO_SOURCE_FILES) 8 | gofmt -w $^ 9 | touch $@ 10 | 11 | .tested: $(GO_SOURCE_FILES) $(TEST_FILES) 12 | go test ./... 13 | touch $@ 14 | 15 | update-dependencies: 16 | go get -u && go mod tidy 17 | .PHONY: update-dependencies 18 | 19 | clean: 20 | rm -rf .linted .tested 21 | .PHONY: clean 22 | -------------------------------------------------------------------------------- /go/README.md: -------------------------------------------------------------------------------- 1 | # Cucumber Tag Expressions for Go 2 | 3 | 4 | [The docs are here](https://cucumber.io/docs/cucumber/api/#tag-expressions). 5 | -------------------------------------------------------------------------------- /go/errors_test.go: -------------------------------------------------------------------------------- 1 | package tagexpressions 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v3" 6 | "io/ioutil" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type ErrorTest struct { 13 | Expression string `yaml:"expression"` 14 | Error string `yaml:"error"` 15 | } 16 | 17 | func TestErrors(t *testing.T) { 18 | contents, err := ioutil.ReadFile("../testdata/errors.yml") 19 | require.NoError(t, err) 20 | var tests []ErrorTest 21 | err = yaml.Unmarshal(contents, &tests) 22 | require.NoError(t, err) 23 | 24 | for _, test := range tests { 25 | name := fmt.Sprintf("fails to parse \"%s\" with \"%s\"", test.Expression, test.Error) 26 | t.Run(name, func(t *testing.T) { 27 | _, err := Parse(test.Expression) 28 | require.Error(t, err) 29 | require.Equal(t, test.Error, err.Error()) 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /go/evaluations_test.go: -------------------------------------------------------------------------------- 1 | package tagexpressions 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v3" 6 | "io/ioutil" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type Evaluation struct { 14 | Expression string `yaml:"expression"` 15 | Tests []Test `yaml:"tests"` 16 | } 17 | 18 | type Test struct { 19 | Variables []string `yaml:"variables"` 20 | Result bool `yaml:"result"` 21 | } 22 | 23 | func TestEvaluations(t *testing.T) { 24 | contents, err := ioutil.ReadFile("../testdata/evaluations.yml") 25 | require.NoError(t, err) 26 | var evaluations []Evaluation 27 | err = yaml.Unmarshal(contents, &evaluations) 28 | require.NoError(t, err) 29 | 30 | for _, evaluation := range evaluations { 31 | for _, test := range evaluation.Tests { 32 | variables := strings.Join(test.Variables, ", ") 33 | name := fmt.Sprintf("evaluates [%s] to %t", variables, test.Result) 34 | t.Run(name, func(t *testing.T) { 35 | expression, err := Parse(evaluation.Expression) 36 | require.NoError(t, err) 37 | 38 | result := expression.Evaluate(test.Variables) 39 | require.Equal(t, test.Result, result) 40 | }) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cucumber/tag-expressions/go/v6 2 | 3 | require ( 4 | github.com/stretchr/testify v1.10.0 5 | gopkg.in/yaml.v3 v3.0.1 6 | ) 7 | 8 | require ( 9 | github.com/davecgh/go-spew v1.1.1 // indirect 10 | github.com/kr/pretty v0.2.0 // indirect 11 | github.com/pmezard/go-difflib v1.0.0 // indirect 12 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 13 | ) 14 | 15 | go 1.19 16 | -------------------------------------------------------------------------------- /go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= 5 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 6 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 7 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 8 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 12 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 13 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 14 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 16 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 17 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 18 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 19 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 20 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 21 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 22 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 23 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 24 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 25 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 26 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 29 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 32 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | -------------------------------------------------------------------------------- /go/parser.go: -------------------------------------------------------------------------------- 1 | package tagexpressions 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | const OPERAND = "operand" 11 | const OPERATOR = "operator" 12 | 13 | type Evaluatable interface { 14 | Evaluate(variables []string) bool 15 | ToString() string 16 | } 17 | 18 | func Parse(infix string) (Evaluatable, error) { 19 | tokens, err := tokenize(infix) 20 | if err != nil { 21 | return nil, err 22 | } 23 | if len(tokens) == 0 { 24 | return &trueExpr{}, nil 25 | } 26 | expressions := &EvaluatableStack{} 27 | operators := &StringStack{} 28 | expectedTokenType := OPERAND 29 | 30 | for _, token := range tokens { 31 | if isUnary(token) { 32 | if err := check(infix, expectedTokenType, OPERAND); err != nil { 33 | return nil, err 34 | } 35 | operators.Push(token) 36 | expectedTokenType = OPERAND 37 | } else if isBinary(token) { 38 | if err := check(infix, expectedTokenType, OPERATOR); err != nil { 39 | return nil, err 40 | } 41 | for operators.Len() > 0 && 42 | isOp(operators.Peek()) && 43 | ((ASSOC[token] == "left" && PREC[token] <= PREC[operators.Peek()]) || 44 | (ASSOC[token] == "right" && PREC[token] < PREC[operators.Peek()])) { 45 | pushExpr(operators.Pop(), expressions) 46 | } 47 | operators.Push(token) 48 | expectedTokenType = OPERAND 49 | } else if "(" == token { 50 | if err := check(infix, expectedTokenType, OPERAND); err != nil { 51 | return nil, err 52 | } 53 | operators.Push(token) 54 | expectedTokenType = OPERAND 55 | } else if ")" == token { 56 | if err := check(infix, expectedTokenType, OPERATOR); err != nil { 57 | return nil, err 58 | } 59 | for operators.Len() > 0 && operators.Peek() != "(" { 60 | pushExpr(operators.Pop(), expressions) 61 | } 62 | if operators.Len() == 0 { 63 | return nil, fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched ).", infix) 64 | } 65 | if operators.Peek() == "(" { 66 | operators.Pop() 67 | } 68 | expectedTokenType = OPERATOR 69 | } else { 70 | if err := check(infix, expectedTokenType, OPERAND); err != nil { 71 | return nil, err 72 | } 73 | pushExpr(token, expressions) 74 | expectedTokenType = OPERATOR 75 | } 76 | } 77 | 78 | for operators.Len() > 0 { 79 | if operators.Peek() == "(" { 80 | return nil, fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched (.", infix) 81 | } 82 | pushExpr(operators.Pop(), expressions) 83 | } 84 | 85 | return expressions.Pop(), nil 86 | } 87 | 88 | var ASSOC = map[string]string{ 89 | "or": "left", 90 | "and": "left", 91 | "not": "right", 92 | } 93 | 94 | var PREC = map[string]int{ 95 | "(": -2, 96 | ")": -1, 97 | "or": 0, 98 | "and": 1, 99 | "not": 2, 100 | } 101 | 102 | func tokenize(expr string) ([]string, error) { 103 | var tokens []string 104 | var token bytes.Buffer 105 | 106 | escaped := false 107 | for _, c := range expr { 108 | if escaped { 109 | if c == '(' || c == ')' || c == '\\' || unicode.IsSpace(c) { 110 | token.WriteRune(c) 111 | escaped = false 112 | } else { 113 | return nil, fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Illegal escape before \"%s\".", expr, string(c)) 114 | } 115 | } else if c == '\\' { 116 | escaped = true 117 | } else if c == '(' || c == ')' || unicode.IsSpace(c) { 118 | if token.Len() > 0 { 119 | tokens = append(tokens, token.String()) 120 | token.Reset() 121 | } 122 | if !unicode.IsSpace(c) { 123 | tokens = append(tokens, string(c)) 124 | } 125 | } else { 126 | token.WriteRune(c) 127 | } 128 | } 129 | if token.Len() > 0 { 130 | tokens = append(tokens, token.String()) 131 | } 132 | 133 | return tokens, nil 134 | } 135 | 136 | func isUnary(token string) bool { 137 | return "not" == token 138 | } 139 | 140 | func isBinary(token string) bool { 141 | return "or" == token || "and" == token 142 | } 143 | 144 | func isOp(token string) bool { 145 | _, ok := ASSOC[token] 146 | return ok 147 | } 148 | 149 | func check(infix, expectedTokenType, tokenType string) error { 150 | if expectedTokenType != tokenType { 151 | return fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Expected %s.", infix, expectedTokenType) 152 | } 153 | return nil 154 | } 155 | 156 | func pushExpr(token string, stack *EvaluatableStack) { 157 | if token == "and" { 158 | rightAndExpr := stack.Pop() 159 | stack.Push(&andExpr{ 160 | leftExpr: stack.Pop(), 161 | rightExpr: rightAndExpr, 162 | }) 163 | } else if token == "or" { 164 | rightOrExpr := stack.Pop() 165 | stack.Push(&orExpr{ 166 | leftExpr: stack.Pop(), 167 | rightExpr: rightOrExpr, 168 | }) 169 | } else if token == "not" { 170 | stack.Push(¬Expr{expr: stack.Pop()}) 171 | } else { 172 | stack.Push(&literalExpr{value: token}) 173 | } 174 | } 175 | 176 | type literalExpr struct { 177 | value string 178 | } 179 | 180 | func (l *literalExpr) Evaluate(variables []string) bool { 181 | for _, variable := range variables { 182 | if variable == l.value { 183 | return true 184 | } 185 | } 186 | return false 187 | } 188 | 189 | func (l *literalExpr) ToString() string { 190 | s1 := l.value 191 | s2 := strings.Replace(s1, "\\", "\\\\", -1) 192 | s3 := strings.Replace(s2, "(", "\\(", -1) 193 | s4 := strings.Replace(s3, ")", "\\)", -1) 194 | return strings.Replace(s4, " ", "\\ ", -1) 195 | } 196 | 197 | type orExpr struct { 198 | leftExpr Evaluatable 199 | rightExpr Evaluatable 200 | } 201 | 202 | func (o *orExpr) Evaluate(variables []string) bool { 203 | return o.leftExpr.Evaluate(variables) || o.rightExpr.Evaluate(variables) 204 | } 205 | 206 | func (o *orExpr) ToString() string { 207 | return fmt.Sprintf("( %s or %s )", o.leftExpr.ToString(), o.rightExpr.ToString()) 208 | } 209 | 210 | type andExpr struct { 211 | leftExpr Evaluatable 212 | rightExpr Evaluatable 213 | } 214 | 215 | func (a *andExpr) Evaluate(variables []string) bool { 216 | return a.leftExpr.Evaluate(variables) && a.rightExpr.Evaluate(variables) 217 | } 218 | 219 | func (a *andExpr) ToString() string { 220 | return fmt.Sprintf("( %s and %s )", a.leftExpr.ToString(), a.rightExpr.ToString()) 221 | } 222 | 223 | func isBinaryOperator(e Evaluatable) bool { 224 | _, isBinaryAnd := e.(*andExpr) 225 | _, isBinaryOr := e.(*orExpr) 226 | return isBinaryAnd || isBinaryOr 227 | } 228 | 229 | type notExpr struct { 230 | expr Evaluatable 231 | } 232 | 233 | func (n *notExpr) Evaluate(variables []string) bool { 234 | return !n.expr.Evaluate(variables) 235 | } 236 | 237 | func (n *notExpr) ToString() string { 238 | if isBinaryOperator(n.expr) { 239 | // -- HINT: Binary Operators already have already '( ... )'. 240 | return fmt.Sprintf("not %s", n.expr.ToString()) 241 | } 242 | return fmt.Sprintf("not ( %s )", n.expr.ToString()) 243 | } 244 | 245 | type trueExpr struct{} 246 | 247 | func (t *trueExpr) Evaluate(variables []string) bool { 248 | return true 249 | } 250 | 251 | func (t *trueExpr) ToString() string { 252 | return "true" 253 | } 254 | -------------------------------------------------------------------------------- /go/parsing_test.go: -------------------------------------------------------------------------------- 1 | package tagexpressions 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v3" 6 | "io/ioutil" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type ParsingTest struct { 13 | Expression string `yaml:"expression"` 14 | Formatted string `yaml:"formatted"` 15 | } 16 | 17 | func TestParsing(t *testing.T) { 18 | contents, err := ioutil.ReadFile("../testdata/parsing.yml") 19 | require.NoError(t, err) 20 | var tests []ParsingTest 21 | err = yaml.Unmarshal(contents, &tests) 22 | require.NoError(t, err) 23 | 24 | for _, test := range tests { 25 | name := fmt.Sprintf("parses \"%s\" into \"%s\"", test.Expression, test.Formatted) 26 | t.Run(name, func(t *testing.T) { 27 | expression, err := Parse(test.Expression) 28 | require.NoError(t, err) 29 | 30 | require.Equal(t, test.Formatted, expression.ToString()) 31 | 32 | expressionAgain, err := Parse(expression.ToString()) 33 | require.NoError(t, err) 34 | 35 | require.Equal(t, test.Formatted, expressionAgain.ToString()) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /go/stack.go: -------------------------------------------------------------------------------- 1 | package tagexpressions 2 | 3 | type InterfaceStack struct { 4 | elements []interface{} 5 | } 6 | 7 | func (i *InterfaceStack) Len() int { 8 | return len(i.elements) 9 | } 10 | 11 | func (i *InterfaceStack) Peek() interface{} { 12 | if i.Len() == 0 { 13 | panic("cannot peek") 14 | } 15 | return i.elements[i.Len()-1] 16 | } 17 | 18 | func (i *InterfaceStack) Pop() interface{} { 19 | if i.Len() == 0 { 20 | panic("cannot pop") 21 | } 22 | value := i.elements[i.Len()-1] 23 | i.elements = i.elements[:i.Len()-1] 24 | return value 25 | } 26 | 27 | func (i *InterfaceStack) Push(value interface{}) { 28 | i.elements = append(i.elements, value) 29 | } 30 | 31 | type StringStack struct { 32 | interfaceStack InterfaceStack 33 | } 34 | 35 | func (s *StringStack) Len() int { 36 | return s.interfaceStack.Len() 37 | } 38 | 39 | func (s *StringStack) Peek() string { 40 | return s.interfaceStack.Peek().(string) 41 | } 42 | 43 | func (s *StringStack) Pop() string { 44 | return s.interfaceStack.Pop().(string) 45 | } 46 | 47 | func (s *StringStack) Push(value string) { 48 | s.interfaceStack.Push(value) 49 | } 50 | 51 | type EvaluatableStack struct { 52 | interfaceStack InterfaceStack 53 | } 54 | 55 | func (e *EvaluatableStack) Len() int { 56 | return e.interfaceStack.Len() 57 | } 58 | 59 | func (e *EvaluatableStack) Peek() Evaluatable { 60 | return e.interfaceStack.Peek().(Evaluatable) 61 | } 62 | 63 | func (e *EvaluatableStack) Pop() Evaluatable { 64 | return e.interfaceStack.Pop().(Evaluatable) 65 | } 66 | 67 | func (e *EvaluatableStack) Push(value Evaluatable) { 68 | e.interfaceStack.Push(value) 69 | } 70 | -------------------------------------------------------------------------------- /java/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | target/ 4 | release.properties 5 | pom.xml.releaseBackup 6 | pom.xml.versionsBackup 7 | dependency-reduced-pom.xml 8 | .classpath 9 | .deps 10 | .project 11 | .settings/ 12 | .tested* 13 | .compared 14 | .built 15 | # Approval tests 16 | acceptance/ 17 | -------------------------------------------------------------------------------- /java/README.md: -------------------------------------------------------------------------------- 1 | [![Maven Central](https://img.shields.io/maven-central/v/io.cucumber/tag-expressions.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22io.cucumber%22%20AND%20a:%22tag-expressions%22) 2 | 3 | # Cucumber Tag Expressions for Java 4 | 5 | [The docs are here](https://cucumber.io/docs/cucumber/api/#tag-expressions). 6 | -------------------------------------------------------------------------------- /java/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | io.cucumber 7 | cucumber-parent 8 | 4.3.7 9 | 10 | 11 | tag-expressions 12 | 6.2.1-SNAPSHOT 13 | jar 14 | Cucumber Tag Expressions 15 | Parses boolean infix expressions 16 | https://github.com/cucumber/tag-expressions 17 | 18 | 19 | 1748176122 20 | io.cucumber.tagexpressions 21 | 22 | 23 | 24 | scm:git:git://github.com/cucumber/tag-expressions.git 25 | scm:git:git@github.com:cucumber/tag-expressions.git 26 | git://github.com/cucumber/tag-expressions.git 27 | HEAD 28 | 29 | 30 | 31 | 32 | 33 | 34 | org.junit 35 | junit-bom 36 | 5.13.1 37 | pom 38 | import 39 | 40 | 41 | 42 | 43 | 44 | 45 | org.hamcrest 46 | hamcrest 47 | 3.0 48 | test 49 | 50 | 51 | org.junit.jupiter 52 | junit-jupiter-engine 53 | test 54 | 55 | 56 | org.junit.jupiter 57 | junit-jupiter-params 58 | test 59 | 60 | 61 | org.yaml 62 | snakeyaml 63 | 2.4 64 | test 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /java/src/main/java/io/cucumber/tagexpressions/Expression.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.tagexpressions; 2 | 3 | import java.util.List; 4 | 5 | public interface Expression { 6 | boolean evaluate(List variables); 7 | } 8 | -------------------------------------------------------------------------------- /java/src/main/java/io/cucumber/tagexpressions/TagExpressionException.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.tagexpressions; 2 | 3 | public final class TagExpressionException extends RuntimeException { 4 | TagExpressionException(String message, Object... args) { 5 | super(String.format(message, args)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /java/src/test/java/io/cucumber/tagexpressions/ErrorsTest.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.tagexpressions; 2 | 3 | import org.junit.jupiter.params.ParameterizedTest; 4 | import org.junit.jupiter.params.provider.MethodSource; 5 | import org.yaml.snakeyaml.Yaml; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Paths; 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | import static java.nio.file.Files.newInputStream; 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | import static org.junit.jupiter.api.Assertions.assertThrows; 15 | 16 | class ErrorsTest { 17 | 18 | private static List> acceptance_tests_pass() throws IOException { 19 | return new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "errors.yml")), List.class); 20 | } 21 | 22 | @ParameterizedTest 23 | @MethodSource 24 | void acceptance_tests_pass(Map expectation) { 25 | TagExpressionException e = assertThrows(TagExpressionException.class, 26 | () -> TagExpressionParser.parse(expectation.get("expression"))); 27 | 28 | assertEquals(expectation.get("error"), e.getMessage()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /java/src/test/java/io/cucumber/tagexpressions/EvaluationsTest.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.tagexpressions; 2 | 3 | import org.junit.jupiter.params.ParameterizedTest; 4 | import org.junit.jupiter.params.provider.MethodSource; 5 | import org.yaml.snakeyaml.Yaml; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Paths; 9 | import java.util.List; 10 | import java.util.Map; 11 | import java.util.stream.Collectors; 12 | 13 | import static java.nio.file.Files.newInputStream; 14 | import static org.junit.jupiter.api.Assertions.assertEquals; 15 | 16 | class EvaluationsTest { 17 | static class Expectation { 18 | final String expression; 19 | final List variables; 20 | final boolean result; 21 | 22 | public Expectation(String expression, List variables, boolean result) { 23 | this.expression = expression; 24 | this.variables = variables; 25 | this.result = result; 26 | } 27 | } 28 | 29 | private static List acceptance_tests_pass() throws IOException { 30 | List> evaluations = new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "evaluations.yml")), List.class); 31 | return evaluations.stream().flatMap(map -> { 32 | String expression = (String) map.get("expression"); 33 | List> tests = (List>) map.get("tests"); 34 | return tests.stream().map(test -> { 35 | List variables = (List) test.get("variables"); 36 | boolean result = (boolean) test.get("result"); 37 | return new Expectation(expression, variables, result); 38 | }); 39 | }).collect(Collectors.toList()); 40 | } 41 | 42 | @ParameterizedTest 43 | @MethodSource 44 | void acceptance_tests_pass(Expectation expectation) { 45 | Expression expr = TagExpressionParser.parse(expectation.expression); 46 | expr.evaluate(expectation.variables); 47 | assertEquals(expectation.result, expr.evaluate(expectation.variables)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /java/src/test/java/io/cucumber/tagexpressions/ParsingTest.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.tagexpressions; 2 | 3 | import org.junit.jupiter.params.ParameterizedTest; 4 | import org.junit.jupiter.params.provider.MethodSource; 5 | import org.yaml.snakeyaml.Yaml; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Paths; 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | import static java.nio.file.Files.newInputStream; 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | 15 | class ParsingTest { 16 | 17 | private static List> acceptance_tests_pass() throws IOException { 18 | return new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "parsing.yml")), List.class); 19 | } 20 | 21 | @ParameterizedTest 22 | @MethodSource 23 | void acceptance_tests_pass(Map expectation) { 24 | Expression expr = TagExpressionParser.parse(expectation.get("expression")); 25 | String formatted = expectation.get("formatted"); 26 | assertEquals(formatted, expr.toString()); 27 | 28 | Expression expr2 = TagExpressionParser.parse(formatted); 29 | assertEquals(formatted, expr2.toString()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /javascript/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .idea/ 3 | .nyc_output/ 4 | coverage/ 5 | node_modules/ 6 | yarn.lock 7 | *.log 8 | .deps 9 | .tested* 10 | .linted 11 | .built* 12 | .compared 13 | .codegen 14 | acceptance/ 15 | storybook-static 16 | *-go 17 | *.iml 18 | .vscode-test 19 | 20 | # stryker temp files 21 | .stryker-tmp 22 | reports 23 | -------------------------------------------------------------------------------- /javascript/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "loader": "ts-node/esm", 3 | "extension": ["ts"], 4 | "recursive": true 5 | } 6 | -------------------------------------------------------------------------------- /javascript/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /javascript/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Code coverage 2 | 3 | Just run the tests: 4 | 5 | npm test 6 | 7 | The build will fail if coverage drops below 100%. 8 | The report is in `coverage/lcov-report/index.html`. 9 | -------------------------------------------------------------------------------- /javascript/README.md: -------------------------------------------------------------------------------- 1 | # Cucumber Tag Expressions for JavaScript 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/cucumber/tag-expressions-javascript.svg)](https://greenkeeper.io/) 4 | 5 | 6 | [The docs are here](https://cucumber.io/docs/cucumber/api/#tag-expressions). 7 | 8 | ## Example 9 | 10 | ```js 11 | import {TagExpressionParser} from '@cucumber/tag-expressions' 12 | const parser = new TagExpressionParser() 13 | 14 | const expressionNode = parser.parse('@tagA and @tagB') 15 | 16 | expressionNode.evaluate(["@tagA", "@tagB"]) // => true 17 | expressionNode.evaluate(["@tagA", "@tagC"]) // => false 18 | ``` 19 | -------------------------------------------------------------------------------- /javascript/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; 2 | import _import from "eslint-plugin-import"; 3 | import simpleImportSort from "eslint-plugin-simple-import-sort"; 4 | import n from "eslint-plugin-n"; 5 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 6 | import globals from "globals"; 7 | import tsParser from "@typescript-eslint/parser"; 8 | import path from "node:path"; 9 | import { fileURLToPath } from "node:url"; 10 | import js from "@eslint/js"; 11 | import { FlatCompat } from "@eslint/eslintrc"; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | const compat = new FlatCompat({ 16 | baseDirectory: __dirname, 17 | recommendedConfig: js.configs.recommended, 18 | allConfig: js.configs.all 19 | }); 20 | 21 | export default [...fixupConfigRules(compat.extends( 22 | "eslint:recommended", 23 | "plugin:import/typescript", 24 | "plugin:@typescript-eslint/eslint-recommended", 25 | "plugin:@typescript-eslint/recommended", 26 | )), { 27 | plugins: { 28 | import: fixupPluginRules(_import), 29 | "simple-import-sort": simpleImportSort, 30 | n, 31 | "@typescript-eslint": fixupPluginRules(typescriptEslint), 32 | }, 33 | 34 | languageOptions: { 35 | globals: { 36 | ...globals.browser, 37 | ...globals.node, 38 | }, 39 | 40 | parser: tsParser, 41 | ecmaVersion: 5, 42 | sourceType: "module", 43 | 44 | parserOptions: { 45 | project: "tsconfig.json", 46 | }, 47 | }, 48 | 49 | rules: { 50 | "import/no-cycle": "error", 51 | "n/no-extraneous-import": "error", 52 | "@typescript-eslint/ban-ts-ignore": "off", 53 | "@typescript-eslint/ban-ts-comment": "off", 54 | "@typescript-eslint/explicit-module-boundary-types": "off", 55 | "@typescript-eslint/explicit-function-return-type": "off", 56 | "@typescript-eslint/no-use-before-define": "off", 57 | "@typescript-eslint/interface-name-prefix": "off", 58 | "@typescript-eslint/member-delimiter-style": "off", 59 | "@typescript-eslint/no-explicit-any": "error", 60 | "@typescript-eslint/no-non-null-assertion": "error", 61 | "simple-import-sort/imports": "error", 62 | "simple-import-sort/exports": "error", 63 | }, 64 | }, { 65 | files: ["test/**"], 66 | 67 | rules: { 68 | "@typescript-eslint/no-non-null-assertion": "off", 69 | }, 70 | }]; -------------------------------------------------------------------------------- /javascript/package.cjs.json: -------------------------------------------------------------------------------- 1 | {"type": "commonjs"} 2 | -------------------------------------------------------------------------------- /javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cucumber/tag-expressions", 3 | "version": "6.2.0", 4 | "description": "Cucumber Tag Expression parser", 5 | "type": "module", 6 | "main": "dist/cjs/src/index.js", 7 | "types": "dist/cjs/src/index.d.ts", 8 | "files": [ 9 | "dist/cjs", 10 | "dist/esm" 11 | ], 12 | "module": "dist/esm/src/index.js", 13 | "jsnext:main": "dist/esm/src/index.js", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/esm/src/index.js", 17 | "require": "./dist/cjs/src/index.js" 18 | } 19 | }, 20 | "scripts": { 21 | "build:cjs": "tsc --build tsconfig.build-cjs.json && cp package.cjs.json dist/cjs/package.json", 22 | "build:esm": "tsc --build tsconfig.build-esm.json", 23 | "build": "npm run build:cjs && npm run build:esm", 24 | "test": "mocha && npm run test:cjs", 25 | "test:cjs": "npm run build:cjs && mocha --no-config dist/cjs/test", 26 | "stryker": "TAG_EXPRESSIONS_TEST_DATA_DIR=$(pwd)/../testdata stryker run", 27 | "prepublishOnly": "npm run build", 28 | "fix": "eslint --max-warnings 0 --fix src test && prettier --write src test", 29 | "lint": "eslint --max-warnings 0 src test && prettier --check src test" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git://github.com/cucumber/tag-expressions.git" 34 | }, 35 | "keywords": [ 36 | "cucumber" 37 | ], 38 | "author": "Cucumber Limited ", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/cucumber/tag-expressions/issues" 42 | }, 43 | "homepage": "https://github.com/cucumber/tag-expressions", 44 | "devDependencies": { 45 | "@eslint/compat": "^1.2.7", 46 | "@eslint/eslintrc": "^3.3.0", 47 | "@eslint/js": "^9.21.0", 48 | "@stryker-mutator/core": "9.0.1", 49 | "@stryker-mutator/mocha-runner": "9.0.1", 50 | "@stryker-mutator/typescript-checker": "9.0.1", 51 | "@types/js-yaml": "^4.0.3", 52 | "@types/mocha": "10.0.10", 53 | "@types/node": "22.15.30", 54 | "@typescript-eslint/eslint-plugin": "^8.24.1", 55 | "@typescript-eslint/parser": "^8.24.1", 56 | "eslint": "^9.21.0", 57 | "eslint-plugin-import": "^2.31.0", 58 | "eslint-plugin-n": "^17.15.1", 59 | "eslint-plugin-simple-import-sort": "^12.1.1", 60 | "globals": "^16.0.0", 61 | "js-yaml": "^4.1.0", 62 | "mocha": "11.6.0", 63 | "prettier": "^3.5.2", 64 | "pretty-quick": "4.2.2", 65 | "ts-node": "10.9.2", 66 | "typescript": "5.7.3" 67 | }, 68 | "directories": { 69 | "test": "test" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /javascript/src/index.ts: -------------------------------------------------------------------------------- 1 | const OPERAND = 'operand' 2 | const OPERATOR = 'operator' 3 | const PREC: { [key: string]: number } = { 4 | '(': -2, 5 | ')': -1, 6 | or: 0, 7 | and: 1, 8 | not: 2, 9 | } 10 | const ASSOC: { [key: string]: string } = { 11 | or: 'left', 12 | and: 'left', 13 | not: 'right', 14 | } 15 | 16 | /** 17 | * Parses infix boolean expression (using Dijkstra's Shunting Yard algorithm) 18 | * and builds a tree of expressions. The root node of the expression is returned. 19 | * 20 | * This expression can be evaluated by passing in an array of literals that resolve to true 21 | */ 22 | export default function parse(infix: string): Node { 23 | const tokens = tokenize(infix) 24 | if (tokens.length === 0) { 25 | return new True() 26 | } 27 | const expressions: Node[] = [] 28 | const operators: string[] = [] 29 | let expectedTokenType = OPERAND 30 | 31 | tokens.forEach(function (token) { 32 | if (isUnary(token)) { 33 | check(expectedTokenType, OPERAND) 34 | operators.push(token) 35 | expectedTokenType = OPERAND 36 | } else if (isBinary(token)) { 37 | check(expectedTokenType, OPERATOR) 38 | while ( 39 | operators.length > 0 && 40 | isOp(peek(operators)) && 41 | ((ASSOC[token] === 'left' && PREC[token] <= PREC[peek(operators)]) || 42 | (ASSOC[token] === 'right' && PREC[token] < PREC[peek(operators)])) 43 | ) { 44 | pushExpr(pop(operators), expressions) 45 | } 46 | operators.push(token) 47 | expectedTokenType = OPERAND 48 | } else if ('(' === token) { 49 | check(expectedTokenType, OPERAND) 50 | operators.push(token) 51 | expectedTokenType = OPERAND 52 | } else if (')' === token) { 53 | check(expectedTokenType, OPERATOR) 54 | while (operators.length > 0 && peek(operators) !== '(') { 55 | pushExpr(pop(operators), expressions) 56 | } 57 | if (operators.length === 0) { 58 | throw new Error( 59 | `Tag expression "${infix}" could not be parsed because of syntax error: Unmatched ).` 60 | ) 61 | } 62 | if (peek(operators) === '(') { 63 | pop(operators) 64 | } 65 | expectedTokenType = OPERATOR 66 | } else { 67 | check(expectedTokenType, OPERAND) 68 | pushExpr(token, expressions) 69 | expectedTokenType = OPERATOR 70 | } 71 | }) 72 | 73 | while (operators.length > 0) { 74 | if (peek(operators) === '(') { 75 | throw new Error( 76 | `Tag expression "${infix}" could not be parsed because of syntax error: Unmatched (.` 77 | ) 78 | } 79 | pushExpr(pop(operators), expressions) 80 | } 81 | 82 | return pop(expressions) 83 | 84 | function check(expectedTokenType: string, tokenType: string) { 85 | if (expectedTokenType !== tokenType) { 86 | throw new Error( 87 | `Tag expression "${infix}" could not be parsed because of syntax error: Expected ${expectedTokenType}.` 88 | ) 89 | } 90 | } 91 | } 92 | 93 | function tokenize(expr: string): string[] { 94 | const tokens = [] 95 | let isEscaped = false 96 | let token: string[] = [] 97 | for (let i = 0; i < expr.length; i++) { 98 | const c = expr.charAt(i) 99 | if (isEscaped) { 100 | if (c === '(' || c === ')' || c === '\\' || /\s/.test(c)) { 101 | token.push(c) 102 | isEscaped = false 103 | } else { 104 | throw new Error( 105 | `Tag expression "${expr}" could not be parsed because of syntax error: Illegal escape before "${c}".` 106 | ) 107 | } 108 | } else if (c === '\\') { 109 | isEscaped = true 110 | } else if (c === '(' || c === ')' || /\s/.test(c)) { 111 | if (token.length > 0) { 112 | tokens.push(token.join('')) 113 | token = [] 114 | } 115 | if (!/\s/.test(c)) { 116 | tokens.push(c) 117 | } 118 | } else { 119 | token.push(c) 120 | } 121 | } 122 | if (token.length > 0) { 123 | tokens.push(token.join('')) 124 | } 125 | return tokens 126 | } 127 | 128 | function isUnary(token: string) { 129 | return 'not' === token 130 | } 131 | 132 | function isBinary(token: string) { 133 | return 'or' === token || 'and' === token 134 | } 135 | 136 | function isOp(token: string) { 137 | return ASSOC[token] !== undefined 138 | } 139 | 140 | function peek(stack: string[]) { 141 | return stack[stack.length - 1] 142 | } 143 | 144 | function pop(stack: T[]): T { 145 | if (stack.length === 0) { 146 | throw new Error('empty stack') 147 | } 148 | return stack.pop() as T 149 | } 150 | 151 | function pushExpr(token: string, stack: Node[]) { 152 | if (token === 'and') { 153 | const rightAndExpr = pop(stack) 154 | stack.push(new And(pop(stack), rightAndExpr)) 155 | } else if (token === 'or') { 156 | const rightOrExpr = pop(stack) 157 | stack.push(new Or(pop(stack), rightOrExpr)) 158 | } else if (token === 'not') { 159 | stack.push(new Not(pop(stack))) 160 | } else { 161 | stack.push(new Literal(token)) 162 | } 163 | } 164 | 165 | interface Node { 166 | evaluate(variables: string[]): boolean 167 | } 168 | 169 | class Literal implements Node { 170 | constructor(private readonly value: string) {} 171 | 172 | public evaluate(variables: string[]) { 173 | return variables.indexOf(this.value) !== -1 174 | } 175 | 176 | public toString() { 177 | return this.value 178 | .replace(/\\/g, '\\\\') 179 | .replace(/\(/g, '\\(') 180 | .replace(/\)/g, '\\)') 181 | .replace(/\s/g, '\\ ') 182 | } 183 | } 184 | 185 | class Or implements Node { 186 | constructor( 187 | private readonly leftExpr: Node, 188 | private readonly rightExpr: Node 189 | ) {} 190 | 191 | public evaluate(variables: string[]) { 192 | return this.leftExpr.evaluate(variables) || this.rightExpr.evaluate(variables) 193 | } 194 | 195 | public toString() { 196 | return '( ' + this.leftExpr.toString() + ' or ' + this.rightExpr.toString() + ' )' 197 | } 198 | } 199 | 200 | class And implements Node { 201 | constructor( 202 | private readonly leftExpr: Node, 203 | private readonly rightExpr: Node 204 | ) {} 205 | 206 | public evaluate(variables: string[]) { 207 | return this.leftExpr.evaluate(variables) && this.rightExpr.evaluate(variables) 208 | } 209 | 210 | public toString() { 211 | return '( ' + this.leftExpr.toString() + ' and ' + this.rightExpr.toString() + ' )' 212 | } 213 | } 214 | 215 | class Not implements Node { 216 | constructor(private readonly expr: Node) {} 217 | 218 | public evaluate(variables: string[]) { 219 | return !this.expr.evaluate(variables) 220 | } 221 | 222 | public toString() { 223 | if (this.expr instanceof And || this.expr instanceof Or) { 224 | // -- HINT: Binary Operators already have already '( ... )'. 225 | return 'not ' + this.expr.toString() 226 | } 227 | // -- OTHERWISE: 228 | return 'not ( ' + this.expr.toString() + ' )' 229 | } 230 | } 231 | 232 | class True implements Node { 233 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 234 | public evaluate(variables: string[]) { 235 | return true 236 | } 237 | 238 | public toString() { 239 | return 'true' 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /javascript/stryker.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", 3 | "packageManager": "npm", 4 | "reporters": [ 5 | "html", 6 | "clear-text", 7 | "progress", 8 | "json" 9 | ], 10 | "cleanTempDir": false, 11 | "testRunner": "mocha", 12 | "buildCommand": "npm run build:cjs", 13 | "mochaOptions": { 14 | "spec": [ "dist/cjs/test/**/*.js" ] 15 | }, 16 | "checkers": ["typescript"], 17 | "tsconfigFile": "tsconfig.json", 18 | "coverageAnalysis": "perTest" 19 | } 20 | -------------------------------------------------------------------------------- /javascript/test/errors.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import fs from 'fs' 3 | import yaml from 'js-yaml' 4 | 5 | import parse from '../src/index.js' 6 | import { testDataDir } from './testDataDir.js' 7 | 8 | type ErrorTest = { 9 | expression: string 10 | error: string 11 | } 12 | 13 | const tests = yaml.load(fs.readFileSync(`${testDataDir}/errors.yml`, 'utf-8')) as ErrorTest[] 14 | 15 | describe('Errors', () => { 16 | for (const test of tests) { 17 | it(`fails to parse "${test.expression}" with "${test.error}"`, () => { 18 | assert.throws(() => parse(test.expression), { message: test.error }) 19 | }) 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /javascript/test/evaluations.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import fs from 'fs' 3 | import yaml from 'js-yaml' 4 | 5 | import parse from '../src/index.js' 6 | import { testDataDir } from './testDataDir.js' 7 | 8 | type Evaluation = { 9 | expression: string 10 | tests: { 11 | variables: string[] 12 | result: boolean 13 | }[] 14 | } 15 | 16 | const evaluationsTest = yaml.load( 17 | fs.readFileSync(`${testDataDir}/evaluations.yml`, 'utf-8') 18 | ) as Evaluation[] 19 | 20 | describe('Evaluations', () => { 21 | for (const evaluation of evaluationsTest) { 22 | describe(evaluation.expression, () => { 23 | for (const test of evaluation.tests) { 24 | it(`evaluates [${test.variables.join(', ')}] to ${test.result}`, () => { 25 | const node = parse(evaluation.expression) 26 | assert.strictEqual(node.evaluate(test.variables), test.result) 27 | }) 28 | } 29 | }) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /javascript/test/parsing.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import fs from 'fs' 3 | import yaml from 'js-yaml' 4 | 5 | import parse from '../src/index.js' 6 | import { testDataDir } from './testDataDir.js' 7 | 8 | type ParsingTest = { 9 | expression: string 10 | formatted: string 11 | } 12 | 13 | const tests = yaml.load(fs.readFileSync(`${testDataDir}/parsing.yml`, 'utf-8')) as ParsingTest[] 14 | 15 | describe('Parsing', () => { 16 | for (const test of tests) { 17 | it(`parses "${test.expression}" into "${test.formatted}"`, () => { 18 | const expression = parse(test.expression) 19 | assert.strictEqual(expression.toString(), test.formatted) 20 | 21 | const expressionAgain = parse(expression.toString()) 22 | assert.strictEqual(expressionAgain.toString(), test.formatted) 23 | }) 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /javascript/test/testDataDir.ts: -------------------------------------------------------------------------------- 1 | export const testDataDir = process.env.TAG_EXPRESSIONS_TEST_DATA_DIR || '../testdata' 2 | -------------------------------------------------------------------------------- /javascript/tsconfig.build-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "dist/cjs", 5 | "target": "ES5", 6 | "module": "CommonJS", 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /javascript/tsconfig.build-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "ES2019" 6 | ], 7 | "target": "ES6", 8 | "module": "ES6", 9 | "outDir": "dist/esm" 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /javascript/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "rootDir": ".", 9 | "noEmit": false 10 | }, 11 | "include": [ 12 | "src", 13 | "test" 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /javascript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": true, 5 | "sourceMap": true, 6 | "allowJs": false, 7 | "resolveJsonModule": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "downlevelIteration": true, 11 | "skipLibCheck": true, 12 | "strictNullChecks": true, 13 | "experimentalDecorators": true, 14 | "module": "ESNext", 15 | "lib": [ 16 | "ES6", 17 | "dom" 18 | ], 19 | "target": "ES6", 20 | "moduleResolution": "node", 21 | "allowSyntheticDefaultImports": true, 22 | "noEmit": true, 23 | "jsx": "react" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /perl/.gitignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .built 3 | Cucumber-TagExpressions-* 4 | perl5/ 5 | .cpanfile_dependencies 6 | cpanfile.snapshot 7 | CHANGELOG.md 8 | -------------------------------------------------------------------------------- /perl/Makefile: -------------------------------------------------------------------------------- 1 | include default.mk 2 | 3 | test: .cpanfile_dependencies 4 | AUTHOR_TESTING=1 prove -l 5 | .PHONY: test 6 | 7 | authortest: .cpanfile_dev_dependencies 8 | AUTHOR_TESTING=1 prove -l 9 | .PHONY: authortest 10 | 11 | clean: 12 | rm -rf Cucumber-* .cpanfile_dependencies .built CHANGELOG.md 13 | .PHONY: clean 14 | -------------------------------------------------------------------------------- /perl/VERSION: -------------------------------------------------------------------------------- 1 | 6.2.0 2 | -------------------------------------------------------------------------------- /perl/cpanfile: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | requires 'perl' => '5.14.4'; 4 | requires 'Moo'; 5 | 6 | # Although List::Util is a core module (better: it's dual life), it only 7 | # grew support for 'any' and 'all' in 1.33, which doesn't come with some 8 | # of the older Perl versions. Specifying the number explicitly makes older 9 | # Perls install this module from CPAN while allowing newer Perls to use the 10 | # version they come with. 11 | requires 'List::Util' => '1.69'; # for 'any' and 'all' functions. 12 | 13 | on 'test' => sub { 14 | requires 'Test2::V0'; 15 | requires 'Test2::Tools::Exception'; 16 | requires 'YAML'; 17 | }; 18 | -------------------------------------------------------------------------------- /perl/default.mk: -------------------------------------------------------------------------------- 1 | # Please update /.templates/perl/default.mk and sync: 2 | # 3 | # source scripts/functions.sh && rsync_files 4 | # 5 | SHELL := /usr/bin/env bash 6 | ALPINE := $(shell which apk 2> /dev/null) 7 | 8 | ### Common targets for all functionalities implemented on Perl 9 | 10 | default: test 11 | .PHONY: default 12 | 13 | CHANGELOG.md: ../CHANGELOG.md 14 | cp ../CHANGELOG.md CHANGELOG.md 15 | 16 | distribution: predistribution 17 | PERL5LIB=${PERL5LIB} PATH=$$PATH:${PERL5PATH} dzil test --release 18 | PERL5LIB=${PERL5LIB} PATH=$$PATH:${PERL5PATH} dzil build 19 | .PHONY: distribution 20 | 21 | publish: predistribution 22 | PERL5LIB=${PERL5LIB} PATH=$$PATH:${PERL5PATH} dzil release 23 | .PHONY: publish 24 | 25 | update-version: 26 | ifdef NEW_VERSION 27 | echo $(NEW_VERSION) > VERSION 28 | else 29 | @echo -e "\033[0;31mNEW_VERSION is not defined. Can't update version :-(\033[0m" 30 | exit 1 31 | endif 32 | .PHONY: update-version 33 | 34 | .cpanfile_dependencies: cpanfile 35 | cpanm --with-test --notest --installdeps . 36 | touch $@ 37 | 38 | .cpanfile_dev_dependencies: cpanfile 39 | cpanm --with-devel --notest --installdeps . 40 | touch $@ 41 | 42 | predistribution: dist-clean test CHANGELOG.md 43 | # --notest to keep the number of dependencies low: it doesn't install the 44 | # testing dependencies of the dependencies. 45 | cpanm --notest --local-lib ./perl5 --installdeps --with-develop . 46 | cpanm --notest --local-lib ./perl5 'Dist::Zilla' 47 | PERL5LIB=${PERL5LIB} PATH=$$PATH:${PERL5PATH} dzil authordeps --missing | cpanm --notest --local-lib ./perl5 48 | PERL5LIB=${PERL5LIB} PATH=$$PATH:${PERL5PATH} dzil clean 49 | @(git status --porcelain 2>/dev/null | grep "^??" | perl -ne\ 50 | 'die "The `release` target includes all files in the working directory. Please remove [$$_], or add it to .gitignore if it should be included\n" if s!.+ perl/(.+?)\n!$$1!') 51 | .PHONY: predistribution 52 | 53 | pre-release: update-version 54 | .PHONY: pre-release 55 | 56 | post-release: 57 | .PHONY: post-release 58 | 59 | dist-clean: clean 60 | rm -rf ./perl5 61 | 62 | .PHONY: dist-clean 63 | -------------------------------------------------------------------------------- /perl/dist.ini: -------------------------------------------------------------------------------- 1 | ; The name of the 'dist' (the base name of the release tarball) 2 | name = Cucumber-TagExpressions 3 | ; A short description of the content of the dist 4 | abstract = A library for parsing and evaluating cucumber tag expressions (filters) 5 | ; The main module presents the primary page shown on MetaCPAN.org for the dist 6 | main_module = lib/Cucumber/TagExpressions.pm 7 | ; A list of authors, one author per 'author=' row 8 | author = Erik Huelsmann 9 | author = Cucumber Ltd 10 | license = MIT 11 | is_trial = 0 12 | copyright_holder = Erik Huelsmann, Cucumber Ltd 13 | 14 | [MetaResources] 15 | bugtracker.web = https://github.com/cucumber/tag-expressions/issues 16 | repository.url = https://github.com/cucumber/tag-expressions.git 17 | repository.web = https://github.com/cucumber/tag-expressions 18 | repository.type = git 19 | 20 | [@Filter] 21 | -bundle=@Basic 22 | -remove=Readme 23 | -remove=ConfirmRelease 24 | -remove=License 25 | -remove=GatherDir 26 | 27 | [MetaJSON] 28 | [MetaProvides::Package] 29 | [PkgVersion] 30 | [Prereqs::FromCPANfile] 31 | [Git::GatherDir] 32 | exclude_filename=default.mk 33 | exclude_filename=Makefile 34 | exclude_filename=VERSION 35 | 36 | [GatherFile] 37 | ; explicitly add unversioned files 38 | root=../ 39 | filename=CHANGELOG.md 40 | filename=LICENSE 41 | filename=README.md 42 | 43 | [Hook::VersionProvider] 44 | . = my $v = `cat ./VERSION`; chomp( $v ); $v; 45 | -------------------------------------------------------------------------------- /perl/lib/Cucumber/TagExpressions.pm: -------------------------------------------------------------------------------- 1 | 2 | package Cucumber::TagExpressions; 3 | 4 | =head1 NAME 5 | 6 | Cucumber::TagExpressions - Tag expression parser 7 | 8 | =head1 SYNOPSIS 9 | 10 | use Cucumber::TagExpressions; 11 | 12 | my $expr = Cucumber::TagExpressions->parse( '@a and @b' ); 13 | if ( $expr->evaluate( qw/x y z/ ) ) { 14 | say "The evaluation returned false"; 15 | } 16 | 17 | 18 | =head1 DESCRIPTION 19 | 20 | Cucumber tag expressions allow users to define the subset of Gherkin 21 | scenarios they want to run. This library parses the expression and 22 | returns an evaluator object which can be used to test the tags specified 23 | on a scenario against the filter expression. 24 | 25 | =head1 CLASS METHODS 26 | 27 | =cut 28 | 29 | use strict; 30 | use warnings; 31 | 32 | use Cucumber::TagExpressions::Node; 33 | 34 | sub _expect_token { 35 | my ( $state, $token ) = @_; 36 | 37 | my $actual = _get_token( $state ); 38 | die "Expecting token '$token' but found '$actual'" 39 | if $token ne $actual; 40 | } 41 | 42 | sub _consume_char { 43 | my ( $state, $allow_eof ) = @_; 44 | 45 | if ( length($state->{text}) <= $state->{pos} ) { 46 | return if $allow_eof; 47 | die "Unexpected end of string parsing tag expression: $state->{text}"; 48 | } 49 | return substr( $state->{text}, $state->{pos}++, 1 ); 50 | } 51 | 52 | sub _get_token { 53 | my ( $state ) = @_; 54 | 55 | return delete $state->{saved_token} if defined $state->{saved_token}; 56 | 57 | my $token = ''; 58 | while (1) { 59 | my $char = _consume_char( $state, 1 ); 60 | return ($token ? $token : undef) 61 | if not defined $char; 62 | 63 | if ( $char =~ m/\s/ ) { 64 | if ( $token ) { 65 | return $token; 66 | } 67 | else { 68 | next; 69 | } 70 | } 71 | elsif ( $char eq '(' or $char eq ')' ) { 72 | if ( $token ) { 73 | _save_token( $state, $char ); 74 | return $token; 75 | } 76 | else { 77 | return $char; 78 | } 79 | } 80 | if ( $char eq "\\" ) { 81 | $char = _consume_char( $state, 1 ) // ''; 82 | if ( $char eq '(' or $char eq ')' 83 | or $char eq "\\" or $char =~ /\s/ ) { 84 | $token .= $char; 85 | } 86 | else { 87 | die qq{Tag expression "$state->{text}" could not be parsed because of syntax error: Illegal escape before "$char".}; 88 | } 89 | } 90 | else { 91 | $token .= $char; 92 | } 93 | } 94 | } 95 | 96 | sub _save_token { 97 | my ( $state, $token ) = @_; 98 | 99 | $state->{saved_token} = $token; 100 | } 101 | 102 | sub _term_expr { 103 | my ( $state ) = @_; 104 | 105 | my $token = _get_token( $state ); 106 | 107 | die 'Unexpected end of input parsing tag expression' 108 | if not defined $token; 109 | 110 | if ( $token eq '(' ) { 111 | my $expr = _expr( $state ); 112 | my $token = _get_token( $state ); 113 | 114 | if ( not $token or $token ne ')' ) { 115 | die qq{Tag expression "$state->{text}" could not be parsed because of syntax error: Unmatched (.} 116 | } 117 | 118 | return $expr; 119 | } 120 | elsif ( $token eq 'not' ) { 121 | return Cucumber::TagExpressions::NotNode->new( 122 | expression => _term_expr( $state ) 123 | ); 124 | } 125 | else { 126 | if ( $token eq 'and' or $token eq 'or' or $token eq 'not' ) { 127 | die qq{Tag expression "$state->{text}" could not be parsed because of syntax error: Expected operand."}; 128 | } 129 | return Cucumber::TagExpressions::LiteralNode->new( tag => $token ); 130 | } 131 | } 132 | 133 | sub _expr { 134 | my ( $state ) = @_; 135 | 136 | my @terms = ( _term_expr( $state ) ); 137 | while ( my $token = _get_token( $state ) ) { 138 | if ( not defined $token or $token eq ')' ) { 139 | _save_token( $state, $token ); 140 | last; 141 | } 142 | if ( not ( $token eq 'or' 143 | or $token eq 'and' ) ) { 144 | die qq{Tag expression "$state->{text}" could not be parsed because of syntax error: Expected operator.} 145 | } 146 | 147 | my $term = _term_expr( $state ); 148 | if ( $token eq 'and' ) { 149 | # immediately combine _and_ terms 150 | push @terms, 151 | Cucumber::TagExpressions::AndNode->new( 152 | terms => [ pop(@terms), $term ] 153 | ); 154 | } 155 | else { 156 | # collect _or_ terms 157 | push @terms, $term; 158 | } 159 | } 160 | 161 | if ( scalar(@terms) > 1 ) { 162 | return Cucumber::TagExpressions::OrNode->new( 163 | terms => \@terms 164 | ); 165 | } 166 | # don't wrap a single-term expression in an Or node 167 | return $terms[0]; 168 | } 169 | 170 | =head2 $class->parse( $expression ) 171 | 172 | Parses the string specified in C<$expression> and returns a 173 | L instance. 174 | 175 | =cut 176 | 177 | sub parse { 178 | my ( $class, $text ) = @_; 179 | 180 | return Cucumber::TagExpressions::ExpressionNode->new( 181 | sub_expression => undef 182 | ) 183 | if $text =~ /^\s*$/; # match the empty string or space-only string as "constant true" 184 | my $state = { pos => 0, text => $text, saved_token => undef }; 185 | my $expr = _expr( $state ); 186 | 187 | my $token = _get_token( $state ); 188 | 189 | if ( defined $token ) { 190 | if ( $token eq ')' ) { 191 | die qq{Tag expression "$state->{text}" could not be parsed because of syntax error: Unmatched ).}; 192 | } 193 | 194 | die "Junk at end of expression: $token"; 195 | } 196 | 197 | return Cucumber::TagExpressions::ExpressionNode->new( 198 | sub_expression => $expr 199 | ); 200 | } 201 | 202 | 1; 203 | 204 | __END__ 205 | 206 | =head1 LICENSE 207 | 208 | Please see the included LICENSE for the canonical version. In summary: 209 | 210 | The MIT License (MIT) 211 | 212 | Copyright (c) 2021 Erik Huelsmann 213 | Copyright (c) 2021 Cucumber Ltd 214 | 215 | This work is loosely derived from prior work of the same library for Ruby, 216 | called C. 217 | 218 | =cut 219 | 220 | -------------------------------------------------------------------------------- /perl/lib/Cucumber/TagExpressions/Node.pm: -------------------------------------------------------------------------------- 1 | 2 | package Cucumber::TagExpressions::Node; 3 | 4 | =head1 NAME 5 | 6 | Cucumber::TagExpressions::Node - Cucumber Tag expression components 7 | 8 | =head1 SYNOPSIS 9 | 10 | use Cucumber::TagExpressions; 11 | 12 | my $expr = Cucumber::TagExpressions->parse( '@a and @b' ); 13 | if ( $expr->evaluate( qw/x y z/ ) ) { 14 | say "The evaluation returned false"; 15 | } 16 | 17 | =head1 DESCRIPTION 18 | 19 | This module defines the components making up the tag expressions. 20 | 21 | =head1 METHODS 22 | 23 | =cut 24 | 25 | use Moo; 26 | # 'use Moo' implies 'use strict; use warnings;' 27 | 28 | =head2 evaluate( @tags ) 29 | 30 | Returns C when the tag set specified in C<$tags> satisfies the 31 | condition(s) of the expression, C otherwise. 32 | 33 | C<@tags> can be a list of tags to be used in the expression. It can 34 | also be a reference to a hash with the keys being the tags and the 35 | values being considered boolean values indicating whether the tag (key) 36 | is considered part of the tagset (true) or not (false). 37 | 38 | =cut 39 | 40 | sub evaluate { 41 | die 'Abstract superclass; override "evaluate" method'; 42 | } 43 | 44 | 45 | =head2 stringify 46 | 47 | Returns a string representation of the expression node. 48 | 49 | =cut 50 | 51 | sub stringify { } 52 | 53 | =head1 NODE CLASSES 54 | 55 | =cut 56 | 57 | package Cucumber::TagExpressions::LiteralNode { 58 | 59 | =head2 Cucumber::TagExpressions::LiteralNode 60 | 61 | =head3 DESCRIPTION 62 | 63 | This node class returns C if the literal tag is specified as part of 64 | the tag-list in the expression evaluation. 65 | 66 | =head3 ATTRIBUTES 67 | 68 | =head4 tag 69 | 70 | The tag to test presence for. 71 | 72 | =cut 73 | 74 | use Moo; 75 | # 'use Moo' implies 'use strict; use warnings;' 76 | extends 'Cucumber::TagExpressions::Node'; 77 | 78 | has tag => ( is => 'ro', required => 1 ); 79 | 80 | sub evaluate { 81 | my ( $self, $tags ) = @_; 82 | 83 | return $tags->{ $self->tag }; 84 | } 85 | 86 | sub stringify { 87 | my ( $self ) = @_; 88 | 89 | return ($self->tag =~ s/([ ()\\])/\\$1/gr); 90 | } 91 | } 92 | 93 | package Cucumber::TagExpressions::AndNode { 94 | 95 | =head2 Cucumber::TagExpressions::AndNode 96 | 97 | =head3 DESCRIPTION 98 | 99 | This node class type evaluates one or more sub-expressions ("terms") and 100 | returns C if any of the terms does. It returns C if all of 101 | the terms return C. 102 | 103 | =head3 ATTRIBUTES 104 | 105 | =head4 terms 106 | 107 | The sub-expressions to evaluate. 108 | 109 | =cut 110 | 111 | use Moo; 112 | # 'use Moo' implies 'use strict; use warnings;' 113 | extends 'Cucumber::TagExpressions::Node'; 114 | 115 | use List::Util qw( all reduce ); 116 | 117 | has terms => ( is => 'ro', required => 1 ); 118 | 119 | sub evaluate { 120 | my ( $self, $tags ) = @_; 121 | 122 | return all { $_->evaluate( $tags ) } @{ $self->terms }; 123 | } 124 | 125 | sub stringify { 126 | my ( $self ) = @_; 127 | 128 | return 129 | reduce { '( ' . $a . ' and ' . $b . ' )' } 130 | map { $_->stringify } 131 | @{ $self->terms }; 132 | } 133 | } 134 | 135 | package Cucumber::TagExpressions::OrNode { 136 | 137 | =head2 Cucumber::TagExpressions::OrNode 138 | 139 | =head3 DESCRIPTION 140 | 141 | This node class type evaluates one or more sub-expressions ("terms") and 142 | returns C if any of the terms does. It returns C if all of 143 | the terms return C. 144 | 145 | =head3 ATTRIBUTES 146 | 147 | =head4 terms 148 | 149 | The sub-expressions to evaluate. 150 | 151 | =cut 152 | 153 | use Moo; 154 | # 'use Moo' implies 'use strict; use warnings;' 155 | extends 'Cucumber::TagExpressions::Node'; 156 | 157 | use List::Util qw( any reduce ); 158 | 159 | has terms => ( is => 'ro', required => 1 ); 160 | 161 | sub evaluate { 162 | my ( $self, $tags ) = @_; 163 | 164 | return any { $_->evaluate( $tags ) } @{ $self->terms }; 165 | } 166 | 167 | sub stringify { 168 | my ( $self ) = @_; 169 | 170 | return 171 | reduce { '( ' . $a . ' or ' . $b . ' )' } 172 | map { $_->stringify } 173 | @{ $self->terms }; 174 | } 175 | } 176 | 177 | package Cucumber::TagExpressions::NotNode { 178 | 179 | =head2 Cucumber::TagExpressions::NotNode 180 | 181 | =head3 DESCRIPTION 182 | 183 | This class wraps one of the other node class types, negating its 184 | result on evaluation. 185 | 186 | =head3 ATTRIBUTES 187 | 188 | =head4 expression 189 | 190 | The wrapped node class instance for which to negate the result. 191 | 192 | =cut 193 | 194 | use Moo; 195 | # 'use Moo' implies 'use strict; use warnings;' 196 | extends 'Cucumber::TagExpressions::Node'; 197 | 198 | has expression => ( is => 'ro', required => 1 ); 199 | 200 | sub evaluate { 201 | my ( $self, $tags ) = @_; 202 | 203 | return not $self->expression->evaluate( $tags ); 204 | } 205 | 206 | sub stringify { 207 | my ( $self ) = @_; 208 | if ($self->expression->isa('Cucumber::TagExpressions::AndNode') || 209 | $self->expression->isa('Cucumber::TagExpressions::OrNode')) { 210 | # -- HINT: Binary Operators already have already '( ... )'. 211 | return 'not ' . $self->expression->stringify; 212 | } 213 | return 'not ( ' . $self->expression->stringify . ' )'; 214 | } 215 | } 216 | 217 | package Cucumber::TagExpressions::ExpressionNode { 218 | 219 | =head2 Cucumber::TagExpressions::ExpressionNode 220 | 221 | =head3 DESCRIPTION 222 | 223 | This class models the outer-most node in the tag expression; it wraps all 224 | other nodes and is the entry-point for tag expression evaluation. 225 | 226 | =head3 ATTRIBUTES 227 | 228 | =head4 sub_expression 229 | 230 | An instance of one of the other node class types. 231 | 232 | =cut 233 | 234 | use Moo; 235 | # 'use Moo' implies 'use strict; use warnings;' 236 | extends 'Cucumber::TagExpressions::Node'; 237 | 238 | has sub_expression => ( is => 'ro', required => 1 ); 239 | 240 | sub evaluate { 241 | my ( $self, @tags ) = @_; 242 | my $tags = (ref $tags[0] and ref $tags[0] eq 'HASH') ? $tags[0] 243 | : { map { $_ => 1 } @tags }; 244 | 245 | return 1==1 if not defined $self->sub_expression; 246 | return not not $self->sub_expression->evaluate( $tags ); 247 | } 248 | 249 | sub stringify { 250 | my ( $self ) = @_; 251 | 252 | return 'true' if not defined $self->sub_expression; 253 | return $self->sub_expression->stringify; 254 | } 255 | } 256 | 257 | 258 | 1; 259 | 260 | __END__ 261 | 262 | =head1 LICENSE 263 | 264 | Please see the included LICENSE for the canonical version. In summary: 265 | 266 | The MIT License (MIT) 267 | 268 | Copyright (c) 2021 Erik Huelsmann 269 | Copyright (c) 2021 Cucumber Ltd 270 | 271 | This work is loosely derived from prior work of the same library for Ruby, 272 | called C. 273 | 274 | =cut 275 | 276 | -------------------------------------------------------------------------------- /perl/t/02-evaluate.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Test2::V0; 7 | 8 | use Cucumber::TagExpressions; 9 | 10 | 11 | my @good = ( 12 | { expr => '@a\ or @b', 13 | tests => [ { tags => [ '@a\ ' ], outcome => !!0 }, 14 | { tags => [ '@a ' ], outcome => 1 }, 15 | { tags => [ '@b' ], outcome => 1 }, 16 | { tags => [ '@a' ], outcome => !!0 }, 17 | ] }, 18 | { expr => "\@a\\\\", tests => [] }, 19 | { expr => '@a', 20 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 21 | { tags => [ qw/@b/ ], outcome => !!0 }, 22 | { tags => [ qw/@a @b/ ], outcome => 1 }, 23 | ] }, 24 | { expr => 'not @a', 25 | tests => [ { tags => [ qw/@a/ ], outcome => !!0 }, 26 | { tags => [ qw/@b/ ], outcome => 1 }, 27 | { tags => [ qw/@a @b/ ], outcome => !!0 }, 28 | ] }, 29 | { expr => '@a and @b', 30 | tests => [ { tags => [ qw/@a/ ], outcome => !!0 }, 31 | { tags => [ qw/@b/ ], outcome => !!0 }, 32 | { tags => [ qw/@a @b/ ], outcome => 1 }, 33 | ] }, 34 | { expr => 'not (@a and @b)', 35 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 36 | { tags => [ qw/@b/ ], outcome => 1 }, 37 | { tags => [ qw/@a @b/ ], outcome => !!0 }, 38 | ] }, 39 | { expr => '@a or @b', 40 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 41 | { tags => [ qw/@b/ ], outcome => 1 }, 42 | { tags => [ qw/@a @b/ ], outcome => 1 }, 43 | ] }, 44 | { expr => 'not @a or @b', 45 | tests => [ { tags => [ qw/@a/ ], outcome => !!0 }, 46 | { tags => [ qw/@b/ ], outcome => 1 }, 47 | { tags => [ qw/@a @b/ ], outcome => 1 }, 48 | { tags => [ qw/@q/ ], outcome => 1 }, 49 | ] }, 50 | { expr => '@a and not @b', 51 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 52 | { tags => [ qw/@b/ ], outcome => !!0 }, 53 | { tags => [ qw/@a @b/ ], outcome => !!0 }, 54 | { tags => [ qw/@q/ ], outcome => !!0 }, 55 | { tags => [ qw/@a @q/ ], outcome => 1 }, 56 | ] }, 57 | { expr => '@a or @b and @c', 58 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 59 | { tags => [ qw/@b/ ], outcome => !!0 }, 60 | { tags => [ qw/@a @b/ ], outcome => 1 }, 61 | { tags => [ qw/@a @c/ ], outcome => 1 }, 62 | { tags => [ qw/@b @c/ ], outcome => 1 }, 63 | ] }, 64 | { expr => '@a and @b or not @c', 65 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 66 | { tags => [ qw/@b/ ], outcome => 1 }, 67 | { tags => [ qw/@c/ ], outcome => !!0 }, 68 | { tags => [ qw/@q/ ], outcome => 1 }, 69 | { tags => [ qw/@a @b/ ], outcome => 1 }, 70 | { tags => [ qw/@a @c/ ], outcome => !!0 }, 71 | { tags => [ qw/@b @c/ ], outcome => !!0 }, 72 | ] }, 73 | { expr => '@a or ((@b or @c) and (@d or @e))', 74 | tests => [ { tags => [ qw/@a/ ], outcome => 1 }, 75 | { tags => [ qw/@b/ ], outcome => !!0 }, 76 | { tags => [ qw/@d/ ], outcome => !!0 }, 77 | { tags => [ qw/@b @d/ ], outcome => 1 }, 78 | { tags => [ qw/@q/ ], outcome => !!0 }, 79 | ] }, 80 | { expr => "\@a\\\\b", 81 | tests => [ { tags => [ "\@a\\b" ], outcome => 1 }, 82 | { tags => [ '@ab' ], outcome => !!0 }, 83 | { tags => [ qw/@a/ ], outcome => !!0 }, 84 | ] }, 85 | ); 86 | 87 | for my $ex (@good) { 88 | my $e; 89 | ok( lives { 90 | $e = Cucumber::TagExpressions->parse($ex->{expr}); 91 | }, "Parsing $ex->{expr}") 92 | or note($@); 93 | 94 | for my $test ( @{ $ex->{tests} } ) { 95 | my @tags = @{ $test->{tags} }; 96 | is( $e->evaluate(@tags), $test->{outcome}, 97 | "Expr $ex->{expr}; Tags: @tags; Outcome: $test->{outcome} " ) 98 | or diag( "Parsed expression: " . $e->stringify ); 99 | } 100 | } 101 | 102 | 103 | my %bad_syntax = ( 104 | '@a @b' => q{Expected operator.}, 105 | '@a not' => q{Expected operator.}, 106 | '@a or' => 'Unexpected end of input parsing tag expression', 107 | '@a not @b' => q{Expected operator.}, 108 | '@a or (' => 'Unexpected end of input parsing tag expression', 109 | '@a and @b)' => q{Unmatched ).}, 110 | "\@a\\" => q{Illegal escape before ""}, 111 | ); 112 | 113 | for my $expr (keys %bad_syntax) { 114 | like( dies { Cucumber::TagExpressions->parse($expr); }, 115 | qr/\Q$bad_syntax{$expr}\E/, 116 | "Parsing bad expression '$expr'" ); 117 | } 118 | 119 | 120 | done_testing; 121 | -------------------------------------------------------------------------------- /perl/t/03-shared-tests.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | 4 | use strict; 5 | use warnings; 6 | 7 | use Test2::V0; 8 | use Test2::Tools::Exception qw(lives dies); 9 | use YAML qw(LoadFile); 10 | 11 | 12 | use Cucumber::TagExpressions; 13 | 14 | plan skip_all => 'AUTHOR_TESTING not enabled' 15 | if not $ENV{AUTHOR_TESTING}; 16 | 17 | my $cases = LoadFile('../testdata/evaluations.yml'); 18 | 19 | for my $case ( @{ $cases } ) { 20 | my $evaluator; 21 | ok( 22 | lives { 23 | $evaluator = Cucumber::TagExpressions->parse( $case->{expression} ); 24 | }, 25 | qq{Parsing "$case->{expression}"} ) 26 | or diag($@); 27 | 28 | ok( $evaluator, "Have an evaluator object from the parser" ); 29 | if ($evaluator) { 30 | for my $test ( @{ $case->{tests} } ) { 31 | my $result = $evaluator->evaluate( @{ $test->{variables} } ); 32 | is( $result ? "true" : "false", 33 | $test->{result}, 34 | "Evaluating $case->{expression} with variables @{$test->{variables}}" ); 35 | } 36 | } 37 | } 38 | 39 | 40 | $cases = LoadFile('../testdata/errors.yml'); 41 | 42 | for my $case ( @{ $cases } ) { 43 | like( 44 | dies { 45 | Cucumber::TagExpressions->parse( $case->{expression} ); 46 | }, 47 | qr/\Q$case->{error}\E/, 48 | qq{Parsing "$case->{expression}"} ); 49 | } 50 | 51 | 52 | $cases = LoadFile('../testdata/parsing.yml'); 53 | 54 | for my $case ( @{ $cases } ) { 55 | my $evaluator; 56 | ok( 57 | lives { 58 | $evaluator = Cucumber::TagExpressions->parse( $case->{expression} ); 59 | }, 60 | qq{Parsing "$case->{expression}"} ) 61 | or diag($@); 62 | 63 | is( $evaluator->stringify, 64 | $case->{formatted}, 65 | "Stringified parser for $case->{expression}" ); 66 | } 67 | 68 | done_testing; 69 | -------------------------------------------------------------------------------- /php/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Cucumber Ltd and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /php/README.md: -------------------------------------------------------------------------------- 1 | # Cucumber Tag Expressions 2 | 3 | This is a PHP implementation of the [Cucumber Tag Expressions parser](https://github.com/cucumber/tag-expressions) 4 | 5 | ## Requirements 6 | 7 | * PHP 8.1 8 | 9 | ## Installation 10 | 11 | Install using [composer](https://getcomposer.org). 12 | 13 | ```shell 14 | composer require cucumber/tag-expressions 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```php 20 | use Cucumber\TagExpressions\TagExpressionParser; 21 | 22 | $expression = TagExpressionParser::parse('@smoke and not @ui'); 23 | 24 | $result = $expression->evaluate(['@smoke', '@cli']) 25 | ``` 26 | -------------------------------------------------------------------------------- /php/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cucumber/tag-expressions", 3 | "description": "Cucumber Tag Expressions parser", 4 | "author": "Cucumber Limited ", 5 | "license": "MIT", 6 | "type": "library", 7 | "autoload": { 8 | "psr-4": { 9 | "Cucumber\\TagExpressions\\": "src/" 10 | } 11 | }, 12 | "autoload-dev": { 13 | "psr-4": { 14 | "Cucumber\\TagExpressions\\Tests\\": "tests/" 15 | } 16 | }, 17 | "require": { 18 | "php": "^8.1", 19 | "ext-mbstring": "*" 20 | }, 21 | "require-dev": { 22 | "friendsofphp/php-cs-fixer": "^3.75", 23 | "phpstan/phpstan": "^2.1", 24 | "phpstan/phpstan-phpunit": "^2.0", 25 | "phpunit/phpunit": "^10.5.46 || ^11.5", 26 | "symfony/yaml": "^6.4 || ^7.2" 27 | }, 28 | "homepage": "https://github.com/cucumber/tag-expressions", 29 | "support": { 30 | "issues": "https://github.com/cucumber/tag-expressions/issues" 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /php/src/Associativity.php: -------------------------------------------------------------------------------- 1 | self::LEFT, 20 | 'not' => self::RIGHT, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /php/src/Expression.php: -------------------------------------------------------------------------------- 1 | $variables 9 | */ 10 | public function evaluate(array $variables): bool; 11 | } 12 | -------------------------------------------------------------------------------- /php/src/Expression/AndExpression.php: -------------------------------------------------------------------------------- 1 | left->evaluate($variables) && $this->right->evaluate($variables); 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return '( ' . $this->left . ' and ' . $this->right . ' )'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /php/src/Expression/LiteralExpression.php: -------------------------------------------------------------------------------- 1 | value, $variables, true); 20 | } 21 | 22 | public function __toString(): string 23 | { 24 | $value = str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $this->value); 25 | 26 | return preg_replace('/\s/', '\\\\ ', $value) ?? $value; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /php/src/Expression/NotExpression.php: -------------------------------------------------------------------------------- 1 | expr->evaluate($variables); 20 | } 21 | 22 | public function __toString(): string 23 | { 24 | if ($this->expr instanceof AndExpression || $this->expr instanceof OrExpression) { 25 | // -- HINT: Binary Operators already have already ' ( ... ) '. 26 | return 'not ' . $this->expr; 27 | } 28 | 29 | return 'not ( ' . $this->expr . ' )'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /php/src/Expression/OrExpression.php: -------------------------------------------------------------------------------- 1 | left->evaluate($variables) || $this->right->evaluate($variables); 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return '( ' . $this->left . ' or ' . $this->right . ' )'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /php/src/Expression/TrueExpression.php: -------------------------------------------------------------------------------- 1 | parseInfix(); 16 | } 17 | 18 | private const ESCAPING_CHAR = '\\'; 19 | private const PREC = [ 20 | '(' => -2, 21 | ')' => -1, 22 | 'or' => 0, 23 | 'and' => 1, 24 | 'not' => 2, 25 | ]; 26 | 27 | private function __construct( 28 | private readonly string $infix, 29 | ) { 30 | } 31 | 32 | private function parseInfix(): Expression 33 | { 34 | $tokens = self::tokenize($this->infix); 35 | if (\count($tokens) === 0) { 36 | return new TrueExpression(); 37 | } 38 | /** @var list $operators */ 39 | $operators = []; 40 | $expressions = []; 41 | $expectedTokenType = TokenType::Operand; 42 | 43 | foreach ($tokens as $token) { 44 | if ($token === 'not') { 45 | $this->check($expectedTokenType, TokenType::Operand); 46 | $operators[] = $token; 47 | $expectedTokenType = TokenType::Operand; 48 | } elseif ($token === 'and' || $token === 'or') { 49 | $this->check($expectedTokenType, TokenType::Operator); 50 | while (\count($operators) > 0 && self::isOperator(self::peek($operators)) && ( 51 | (Associativity::forOperator($token) === Associativity::LEFT && self::PREC[$token] <= self::PREC[self::peek($operators)]) 52 | || (Associativity::forOperator($token) === Associativity::RIGHT && self::PREC[$token] < self::PREC[self::peek($operators)]) 53 | )) { 54 | $this->pushExpr($this->pop($operators), $expressions); 55 | } 56 | // TODO check associativity 57 | $operators[] = $token; 58 | $expectedTokenType = TokenType::Operand; 59 | } elseif ($token === '(') { 60 | $this->check($expectedTokenType, TokenType::Operand); 61 | $operators[] = $token; 62 | $expectedTokenType = TokenType::Operand; 63 | } elseif ($token === ')') { 64 | $this->check($expectedTokenType, TokenType::Operator); 65 | while (\count($operators) > 0 && self::peek($operators) !== '(') { 66 | $this->pushExpr($this->pop($operators), $expressions); 67 | } 68 | 69 | if (\count($operators) === 0) { 70 | throw new TagExpressionException(\sprintf('Tag expression "%s" could not be parsed because of syntax error: Unmatched ).', $this->infix)); 71 | } 72 | 73 | if (self::peek($operators) === '(') { 74 | $this->pop($operators); 75 | } 76 | 77 | $expectedTokenType = TokenType::Operator; 78 | } else { 79 | $this->check($expectedTokenType, TokenType::Operand); 80 | $this->pushExpr($token, $expressions); 81 | $expectedTokenType = TokenType::Operator; 82 | } 83 | } 84 | 85 | while (\count($operators) > 0) { 86 | if (self::peek($operators) === '(') { 87 | throw new TagExpressionException(\sprintf('Tag expression "%s" could not be parsed because of syntax error: Unmatched (.', $this->infix)); 88 | } 89 | 90 | $this->pushExpr($this->pop($operators), $expressions); 91 | } 92 | 93 | return $this->pop($expressions); 94 | } 95 | 96 | /** 97 | * @return list 98 | */ 99 | private static function tokenize(string $expr): array 100 | { 101 | $tokens = []; 102 | $isEscaped = false; 103 | $token = ''; 104 | 105 | for ($i = 0; $i < \strlen($expr); ++$i) { 106 | $c = $expr[$i]; 107 | if ($isEscaped) { 108 | if ($c === '(' || $c === ')' || $c === '\\' || preg_match('/\s/', $c)) { 109 | $token .= $c; 110 | $isEscaped = false; 111 | } else { 112 | throw new TagExpressionException(\sprintf('Tag expression "%s" could not be parsed because of syntax error: Illegal escape before "%s".', $expr, $c)); 113 | } 114 | } elseif ($c === self::ESCAPING_CHAR) { 115 | $isEscaped = true; 116 | } elseif ($c === '(' || $c === ')' || preg_match('/\s/', $c)) { 117 | if ($token !== '') { 118 | $tokens[] = $token; 119 | $token = ''; 120 | } 121 | 122 | if ($c === '(' || $c === ')') { 123 | $tokens[] = $c; 124 | } 125 | } else { 126 | $token .= $c; 127 | } 128 | } 129 | 130 | if ($token !== '') { 131 | $tokens[] = $token; 132 | } 133 | 134 | return $tokens; 135 | } 136 | 137 | private function check(TokenType $expectedTokenType, TokenType $tokenType): void 138 | { 139 | if ($expectedTokenType !== $tokenType) { 140 | throw new TagExpressionException(\sprintf('Tag expression "%s" could not be parsed because of syntax error: Expected %s.', $this->infix, strtolower($expectedTokenType->name))); 141 | } 142 | } 143 | 144 | /** 145 | * @param non-empty-list $stack 146 | */ 147 | private static function peek(array $stack): string 148 | { 149 | return $stack[\count($stack) - 1]; 150 | } 151 | 152 | /** 153 | * @template T 154 | * 155 | * @param list $stack 156 | * 157 | * @return T 158 | */ 159 | private function pop(array &$stack): mixed 160 | { 161 | $value = array_pop($stack); 162 | 163 | if ($value === null) { 164 | throw new TagExpressionException(\sprintf('Tag expression "%s" could not be parsed because of an empty stack.', $this->infix)); 165 | } 166 | 167 | return $value; 168 | } 169 | 170 | /** 171 | * @param list $stack 172 | */ 173 | private function pushExpr(string $token, array &$stack): void 174 | { 175 | switch ($token) { 176 | case 'and': 177 | $rightAndExpr = $this->pop($stack); 178 | $stack[] = new AndExpression($this->pop($stack), $rightAndExpr); 179 | break; 180 | 181 | case 'or': 182 | $rightOrExpr = $this->pop($stack); 183 | $stack[] = new OrExpression($this->pop($stack), $rightOrExpr); 184 | break; 185 | 186 | case 'not': 187 | $stack[] = new NotExpression($this->pop($stack)); 188 | break; 189 | 190 | default: 191 | $stack[] = new LiteralExpression($token); 192 | } 193 | } 194 | 195 | /** 196 | * @phpstan-assert-if-true 'and'|'or'|'not' $token 197 | */ 198 | private static function isOperator(string $token): bool 199 | { 200 | return 'and' === $token || 'or' === $token || 'not' === $token; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /php/src/TokenType.php: -------------------------------------------------------------------------------- 1 | = 2.7 or Python >= 3.6 is installed 7 | * pip is installed (should be bundled with python nowadays) 8 | 9 | Check if ``python`` and ``pip`` is installed:: 10 | 11 | python --version 12 | pip --version 13 | 14 | 15 | Developer Workflow 16 | ------------------------------------------------------------------------------- 17 | 18 | PROCEDURE: 19 | 20 | * OPTIONAL STEP: Create an isolated virtual-environment for Python 21 | * STEP: Install the Python packages with "pip" (in virtual-env or $HOME). 22 | * STEP: Run the tests (ensure: ALL-TESTs are PASSING initially) 23 | * Loop (until done): 24 | * Edit sources to extend the implementation or fix the implementation. 25 | * STEP: Run the tests (verify: that changes are OK) 26 | 27 | * OPTIONAL STEP: Check test coverage 28 | * OPTIONAL STEP: Check test with multiple Python versions by using `tox`_ 29 | OTHERWISE: This is also check by the CI github-actions workflow "test-python". 30 | 31 | 32 | PROCEDURE: Basics without a virtual-environment 33 | ------------------------------------------------------------------------------- 34 | 35 | :: 36 | 37 | $ cd ${THIS_DIR} 38 | $ pip install -r py.requirements/all.txt 39 | # -- HINT: Python packages are installed under the $HOME directory of the user. 40 | 41 | # -- STEP: Run the tests with "pytest" either in terse or verbose mode. 42 | # OUTPUTS: build/testing/report.html, build/testing/report.xml 43 | $ pytest 44 | $ pytest --verbose 45 | 46 | # -- STEP: Determine the test.coverage and generate test reports. 47 | # OUTPUT: build/coverage.html/index.html 48 | $ coverage run -m pytest 49 | $ coverage combine; coverage report; coverage html 50 | 51 | 52 | 53 | PROCEDURE: By using "make" (on UNIX platforms, like: Linux, macOS, ...) 54 | ------------------------------------------------------------------------------- 55 | 56 | .. code-block:: bash 57 | 58 | # -- HINTS: 59 | # The Make default-target: 60 | # * Ensures that all packages are installed 61 | # * Runs the tests 62 | $ make 63 | 64 | # -- STEP: Install/Update all Python packages (explicitly). 65 | # ALTERNATIVE: make update-dependencies 66 | $ make install-packages 67 | 68 | # -- STEP: Run the tests with "pytest" either in terse or verbose mode. 69 | # OUTPUTS: build/testing/report.html, build/testing/report.xml 70 | $ make test 71 | $ make test PYTEST_ARGS="--verbose" 72 | 73 | # -- STEP: Determine the test.coverage and generate test reports. 74 | # OUTPUT: build/coverage.html/index.html 75 | $ make test.coverage 76 | 77 | # -- OPTIONAL: Cleanup afterwards 78 | $ make clean 79 | 80 | 81 | PROCEDURE: By using "invoke" build system (on all platforms) 82 | ------------------------------------------------------------------------------- 83 | 84 | :Supports: ALL PLATFORMS (Python based) 85 | 86 | .. code-block:: bash 87 | 88 | # -- PREPARE: Ensure that all required Python packages are installed. 89 | $ pip install -r py.requirements/all.txt 90 | 91 | # -- LIST ALL TASKS: 92 | $ invoke --list 93 | 94 | # -- STEP: Run the tests with "pytest". 95 | # OUTPUTS: build/testing/report.html, build/testing/report.xml 96 | $ invoke test 97 | 98 | # -- STEP: Determine the test.coverage and generate test reports. 99 | # OUTPUT: build/coverage.html/index.html 100 | $ invoke test.coverage 101 | 102 | # -- OPTIONAL: Cleanup afterwards 103 | # HINT: cleanup.all cleans up everything (even virtual-environments, etc.) 104 | $ invoke cleanup 105 | $ invoke cleanup.all 106 | 107 | # -- KNOWN PROBLEM: On Python 3.10, using "invoke" runs into a problem. 108 | # SEE ISSUE: #820 (on: https://github.com/pyinvoke/invoke/issues/ ) 109 | 110 | 111 | USE CASE: Create a virtual-environment with "virtualenv" on UNIX 112 | ------------------------------------------------------------------------------- 113 | 114 | :Covers: Linux, macOS, "Windows Subsystem for Linux" (WSL), ... 115 | 116 | If virtualenv is not installed, install it (CASE: bash shell):: 117 | 118 | $ pip install virtualenv 119 | 120 | Afterwards: 121 | 122 | 1. Create a virtual environment 123 | 2. Activate the virtual environment (case: bash or similar) 124 | 3. Install all required python packages 125 | 126 | .. code-block:: bash 127 | 128 | $ virtualenv .venv 129 | $ source .venv/bin/activate 130 | $ pip install -r py.requirements/all.txt 131 | 132 | # -- HINT: Afterwards, to deactivate the virtual-environment, use: 133 | $ deactivate 134 | 135 | SEE ALSO: 136 | 137 | * https://virtualenv.pypa.io/en/latest/user_guide.html 138 | 139 | 140 | USE CASE: Create a virtual-environment with "virtualenv" on Windows 141 | ------------------------------------------------------------------------------- 142 | 143 | If virtualenv is not installed, install it by using the Windows cmd shell:: 144 | 145 | cmd> pip install virtualenv 146 | 147 | Afterwards: 148 | 149 | 1. Create a virtual environment in the cmd shell 150 | 2. Activate the virtual environment 151 | 3. Install all required python packages 152 | 153 | .. code-block:: cmd 154 | 155 | cmd> virtualenv .venv 156 | cmd> call .venv/Scripts/activate 157 | cmd> pip install -r py.requirements/all.txt 158 | 159 | SEE ALSO: 160 | 161 | * https://virtualenv.pypa.io/en/latest/user_guide.html 162 | 163 | 164 | USE CASE: Without virtual-environment 165 | ------------------------------------------------------------------------------- 166 | 167 | Ensure that all required Python packages are installed:: 168 | 169 | $ pip install -r py.requirements/all.txt 170 | 171 | HINT: The Python packages are installed under the HOME directory of the user. 172 | 173 | 174 | USE CASE: Run the Tests 175 | ------------------------------------------------------------------------------- 176 | 177 | :PRECONDITION: Python packages are installed 178 | 179 | `pytest`_ is used as test runner (and test framework) in Python. 180 | Run the tests with:: 181 | 182 | $ pytest # Run tests in terse mode. 183 | $ pytest -v # Run tests in verbose mode. 184 | $ pytest --html=report.html # Run tests and create HTML test report. 185 | 186 | Test for HTML (and JUnit XML) are generated at the end of the test run: 187 | 188 | * ``build/testing/report.html`` 189 | * ``build/testing/report.xml`` 190 | 191 | SEE ALSO: 192 | 193 | * https://pytest.org/ 194 | * https://pypi.org/project/pytest-html 195 | 196 | .. _pytest: https://pytest.org/ 197 | 198 | 199 | USE CASE: Running the Tests with tox 200 | ------------------------------------------------------------------------------- 201 | 202 | Tox allows to run tests against different python versions in isolated 203 | virtual environments, one for each version. 204 | 205 | To run the tests, use:: 206 | 207 | $ tox -e py39 # Run tests in a virtual environment with python3.9 208 | $ tox -e py27 # Run tests in a virtual environment with python2.7 209 | 210 | SEE ALSO: 211 | 212 | * https://tox.wiki/ 213 | * https://pypi.org/project/tox 214 | 215 | 216 | USE CASE: Use Static Code Analyzers to detect Problems 217 | ------------------------------------------------------------------------------- 218 | 219 | Perform checks with the following commands:: 220 | 221 | $ pylint cucumber_tag_expressions/ # Run pylint checks. 222 | $ bandit cucumber_tag_expressions/ # Run bandit security checks. 223 | 224 | ALTERNATIVE: Run tools in a tox environment:: 225 | 226 | $ tox -e pylint # Run pylint checks. 227 | $ tox -e bandit # Run bandit security checks. 228 | 229 | SEE ALSO: 230 | 231 | * https://pylint.readthedocs.io/ 232 | * https://bandit.readthedocs.io/ 233 | * https://prospector.landscape.io/ 234 | 235 | 236 | USE CASE: Cleanup the Workspace 237 | ------------------------------------------------------------------------------- 238 | 239 | To cleanup the local workspace and development environment, use:: 240 | 241 | $ invoke cleanup # Cleanup common temporary files. 242 | $ invoke cleanup.all # Cleanup everything (.venv, .tox, ...) 243 | 244 | or:: 245 | 246 | $ make clean 247 | 248 | 249 | USE CASE: Use "dotenv" to simplify Setup of Environment Variables 250 | ------------------------------------------------------------------------------- 251 | 252 | `direnv`_ simplifies the setup and cleanup of environment variables. 253 | If `direnv`_ is set up: 254 | 255 | * On entering this directory: Environment variables from ``.envrc`` file are set up. 256 | * On leaving this directory: The former environment is restored. 257 | 258 | OPTIONAL PARTS (currently disabled): 259 | 260 | * ``.envrc.use_pep0528.disabled``: Support ``__pypackages__/$(PYTHON_VERSION)/`` search paths. 261 | * ``.envrc.use_venv.disabled``: Auto-create a virtual-environment and activate it. 262 | 263 | Each optional part can be enabled by removing the ``.disabled`` file name suffix. 264 | EXAMPLE: Rename ``.envrc.use_venv.disabled`` to ``.envrc.use_venv`` to enable it. 265 | 266 | SEE ALSO: 267 | 268 | * https://direnv.net/ 269 | * https://peps.python.org/pep-0582/ -- Python local packages directory 270 | 271 | .. _direnv: https://direnv.net/ 272 | -------------------------------------------------------------------------------- /python/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include .bumpversion.cfg 4 | include .coveragerc 5 | include .editorconfig 6 | include .pylintrc 7 | include *.py 8 | include *.rst 9 | include *.md 10 | include *.txt 11 | include *.ini 12 | include *.cfg 13 | include *.yaml 14 | include Makefile 15 | 16 | recursive-include py.requirements *.txt 17 | recursive-include tasks *.py *.txt *.rst 18 | recursive-include tests *.py *.txt 19 | recursive-include bin *.sh *.py *.cmd 20 | recursive-include scripts *.py *.sh 21 | # -- PREPARED: 22 | # recursive-include docs *.rst *.txt *.py 23 | 24 | prune .tox 25 | prune .venv* 26 | -------------------------------------------------------------------------------- /python/Makefile: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # GNU MAKEFILE: Cucumber tag-expressions for Python 3 | # ============================================================================= 4 | # NOTE: install-packages/bootstrap requires ON-LINE access. 5 | # REQUIRES: Python >= 2.7 is installed 6 | # DESCRIPTION: 7 | # This makefile uses "pip" to automatically install the required packages: 8 | # 9 | # * CASE 1: If a Python a virtual environment was set up and activated, 10 | # the packages are installed into the virtual environment. 11 | # 12 | # * CASE 2: The packages are installed in the $HOME directory. 13 | # On macOS: $HOME/Library/Python/$(PYTHON_VERSION)/lib 14 | # HINT: May need PATH="$HOME/Library/Python/$(PYTHON_VERSION)/bin:${PATH}" 15 | # 16 | # SEE ALSO: 17 | # * https://pip.pypa.io/en/stable/ 18 | # ============================================================================= 19 | 20 | .PHONY: clean clean-all default test test.coverage tox 21 | 22 | # ----------------------------------------------------------------------------- 23 | # PROJECT CONFIGURATION: 24 | # ----------------------------------------------------------------------------- 25 | PYTHON ?= python3 26 | PYTHON_VERSION := $(shell $(PYTHON) -c "import sys; print(sys.version.split()[0])") 27 | PIP := $(PYTHON) -mpip 28 | PIP_INSTALL_OPTIONS ?= --quiet 29 | PIP_RUN := 30 | 31 | PY_REQUIREMENT_FILES := py.requirements/all.txt 32 | 33 | INSTALL_PACKAGES_DONE_MARKER_FILE := build/.done.install_packages.python_v$(PYTHON_VERSION) 34 | INSTALL_PACKAGES_DONE_MARKER_DIR := $(dir $(INSTALL_PACKAGES_DONE_MARKER_FILE)) 35 | 36 | PYTHONPATH ?= $(CURDIR) 37 | export PYTHONPATH 38 | 39 | # ----------------------------------------------------------------------------- 40 | # MAKE RULES: 41 | # ----------------------------------------------------------------------------- 42 | .PHONY: all clean install-packages test test.coverage tox 43 | 44 | # -- DIAGNOSTICS: 45 | $(info USING: PYTHON_VERSION=$(PYTHON_VERSION)) 46 | $(info USING: PYTHONPATH=$(PYTHONPATH)) 47 | 48 | all: .ensure.packages_are_installed test 49 | 50 | .ensure.packages_are_installed: $(INSTALL_PACKAGES_DONE_MARKER_FILE) 51 | install-packages $(INSTALL_PACKAGES_DONE_MARKER_FILE): $(PY_REQUIREMENT_FILES) 52 | @echo "INSTALL-PACKAGES: ..." 53 | test -d $(INSTALL_PACKAGES_DONE_MARKER_DIR) || mkdir -p $(INSTALL_PACKAGES_DONE_MARKER_DIR) 54 | $(PIP) install $(PIP_INSTALL_OPTIONS) $(addprefix -r ,$(PY_REQUIREMENT_FILES)) 55 | touch $(INSTALL_PACKAGES_DONE_MARKER_FILE) 56 | @echo "INSTALL-PACKAGES: done" 57 | @echo "" 58 | 59 | clean: 60 | -rm -f $(INSTALL_PACKAGES_DONE_MARKER_FILE) 61 | -rm -rf build dist .tox .venv* .dotenv/ 62 | -rm -rf get-pip.py 63 | -py.cleanup 64 | 65 | test: $(INSTALL_PACKAGES_DONE_MARKER_FILE) 66 | $(PIP_RUN) pytest $(PYTEST_ARGS) 67 | 68 | test.coverage: $(INSTALL_PACKAGES_DONE_MARKER_FILE) 69 | $(PIP_RUN) coverage run -m pytest $(PYTEST_ARGS); coverage combine; coverage report; coverage html 70 | 71 | tox: $(INSTALL_PACKAGES_DONE_MARKER_FILE) 72 | $(PIP_RUN) tox $(TOX_ARGS) 73 | 74 | include default.mk 75 | 76 | # -- ADD DEPENDENCY: 77 | update-dependencies: .ensure.packages_are_installed 78 | -------------------------------------------------------------------------------- /python/README.rst: -------------------------------------------------------------------------------- 1 | Cucumber Tag Expressions for Python 2 | =============================================================================== 3 | 4 | .. |badge.CI_status| image:: https://github.com/cucumber/tag-expressions/actions/workflows/test-python.yml/badge.svg 5 | :target: https://github.com/cucumber/tag-expressions/actions/workflows/test-python.yml 6 | :alt: CI Build Status 7 | 8 | .. |badge.latest_version| image:: https://img.shields.io/pypi/v/cucumber-tag-expressions.svg 9 | :target: https://pypi.python.org/pypi/cucumber-tag-expressions 10 | :alt: Latest Version 11 | 12 | .. |badge.license| image:: https://img.shields.io/pypi/l/cucumber-tag-expressions.svg 13 | :target: https://pypi.python.org/pypi/cucumber-tag-expressions 14 | :alt: License 15 | 16 | .. |badge.downloads| image:: https://img.shields.io/pypi/dm/cucumber-tag-expressions.svg 17 | :target: https://pypi.python.org/pypi/cucumber-tag-expressions 18 | :alt: Downloads 19 | 20 | .. |logo| image:: https://github.com/cucumber-ltd/brand/raw/master/images/png/notm/cucumber-black/cucumber-black-128.png 21 | 22 | 23 | |badge.CI_status| |badge.latest_version| |badge.license| |badge.downloads| 24 | 25 | Cucumber tag-expressions for Python. 26 | 27 | |logo| 28 | 29 | Cucumber tag-expressions provide readable boolean expressions 30 | to select features and scenarios marked with tags in Gherkin files 31 | in an easy way:: 32 | 33 | # -- SIMPLE TAG-EXPRESSION EXAMPLES: 34 | @a and @b 35 | @a or @b 36 | not @a 37 | 38 | # -- MORE TAG-EXPRESSION EXAMPLES: 39 | @a and not @b 40 | (@a or @b) and not @c 41 | 42 | SEE ALSO: 43 | 44 | * https://cucumber.io/docs/cucumber/api/#tag-expressions 45 | 46 | Getting Started 47 | ----------------------------------------------------------------- 48 | 49 | Cucumber Tag Expressions is available as `cucumber-tag-expressions `_ on PyPI. 50 | 51 | .. code-block:: console 52 | 53 | pip install cucumber-tag-expressions 54 | 55 | Parse tag expressions and evaluate them against a set of tags. 56 | 57 | .. code-block:: python 58 | 59 | >>> from cucumber_tag_expressions import parse 60 | >>> # Tagged with @fast 61 | >>> fast = parse("@fast") 62 | >>> fast({"@fast", "@wip"}) 63 | True 64 | >>> fast({"@performance", "@slow"}) 65 | False 66 | >>> # Tagged with @wip and not @slow 67 | >>> wip_not_slow = parse("@wip and not @slow") 68 | >>> wip_not_slow({"@wip", "@home"}) 69 | True 70 | >>> wip_not_slow({"wet", "warm", "raining"}) 71 | False 72 | >>> # Tagged with both `@fast` and `@integration` 73 | >>> fast_integration = parse("@integration and @fast") 74 | >>> fast_integration({"@integration", "@fast", "@other"}) 75 | True 76 | >>> fast_integration({"@system", "@fast"}) 77 | False 78 | >>> # Tagged with either @login or @registration 79 | >>> auth_pages = parse("@login or @registration") 80 | >>> auth_pages({"@account", "@login"}) 81 | True 82 | >>> auth_pages({"@admin", "@account"}) 83 | False 84 | 85 | Test Runner Usage 86 | ----------------------------------------------------------------- 87 | 88 | A cucumber test runner selects some scenarios by using tag-expressions and runs them: 89 | 90 | .. code:: sh 91 | 92 | # -- TAG-EXPRESSION: @one and @two 93 | # EXPECTED: Selects and runs scenario "Three". 94 | $ my_cucumber_test_runner --tags="@one and @two" features/example.feature 95 | ... 96 | 97 | # -- TAG-EXPRESSION: @one or @two 98 | # EXPECTED: Selects and runs scenarios "One", "Two" and "Three". 99 | $ my_cucumber_test_runner --tags="@one or @two" features/example.feature 100 | ... 101 | 102 | by using the following feature file: 103 | 104 | .. code:: gherkin 105 | 106 | # -- FILE: features/example.feature 107 | Feature: Tag-Expressions Example 108 | 109 | @one 110 | Scenario: One 111 | Given a step passes 112 | 113 | @two 114 | Scenario: Two 115 | Given another step passes 116 | 117 | @one @two 118 | Scenario: Three 119 | Given some step passes 120 | 121 | Scenario: Four 122 | Given another step passes 123 | -------------------------------------------------------------------------------- /python/cucumber_tag_expressions/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """Python implementation of `Cucumber Tag Expressions`_. 3 | 4 | Tag expressions are used in cucumber, behave and other BDD frameworks 5 | to select features, scenarios, etc. in `Gherkin`_ files. 6 | These selected items are normally included in a test run. 7 | 8 | .. _Cucumber Tag Expressions: 9 | https://cucumber.io/docs/cucumber/api/#tag-expressions 10 | 11 | .. _Gherkin: 12 | https://cucumber.io/docs/gherkin/reference/ 13 | """ 14 | 15 | from __future__ import absolute_import 16 | from .parser import parse, TagExpressionParser, TagExpressionError 17 | -------------------------------------------------------------------------------- /python/cucumber_tag_expressions/model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=missing-docstring 3 | """Model classes to evaluate parsed boolean tag expressions. 4 | 5 | Examples: 6 | >>> expression = And(Literal("a"), Literal("b")) 7 | >>> expression({"a", "b"}) 8 | True 9 | >>> expression({"a", "b"}) 10 | True 11 | >>> expression({"a"}) 12 | False 13 | >>> expression({}) 14 | False 15 | 16 | >>> expression = Or(Literal("a"), Literal("b")) 17 | >>> expression({"a", "b"}) 18 | True 19 | >>> expression({"a"}) 20 | True 21 | >>> expression({}) 22 | False 23 | 24 | >>> expression = Not(Literal("a")) 25 | >>> expression({"a"}) 26 | False 27 | >>> expression({"other"}) 28 | True 29 | >>> expression({}) 30 | True 31 | 32 | >>> expression = And(Or(Literal("a"), Literal("b")), Literal("c")) 33 | >>> expression({"a", "c"}) 34 | True 35 | >>> expression({"c", "other"}) 36 | False 37 | >>> expression({}) 38 | False 39 | """ 40 | 41 | import re 42 | 43 | 44 | # ----------------------------------------------------------------------------- 45 | # TAG-EXPRESSION MODEL CLASSES: 46 | # ----------------------------------------------------------------------------- 47 | class Expression(object): 48 | """Abstract base class for boolean expression terms of a tag expression 49 | (or representing a parsed tag expression (evaluation-tree)). 50 | """ 51 | # pylint: disable=too-few-public-methods 52 | 53 | def evaluate(self, values): 54 | """Evaluate whether expression matches values. 55 | 56 | Args: 57 | values (Iterable[str]): Tag names to evaluate. 58 | 59 | Returns: 60 | bool: Whether expression evaluates with values. 61 | """ 62 | raise NotImplementedError() 63 | 64 | def __call__(self, values): 65 | """Call operator to make an expression object callable. 66 | 67 | Args: 68 | values (Iterable[str]): Tag names to evaluate. 69 | 70 | Returns: 71 | bool: True if expression is true, False otherwise 72 | """ 73 | return bool(self.evaluate(values)) 74 | 75 | 76 | class Literal(Expression): 77 | """Used as placeholder for a tag in a boolean tag expression.""" 78 | # pylint: disable=too-few-public-methods 79 | def __init__(self, name): 80 | """Initialise literal with tag name. 81 | 82 | Args: 83 | name (str): Tag name to represent as a literal. 84 | """ 85 | super(Literal, self).__init__() 86 | self.name = name 87 | 88 | def evaluate(self, values): 89 | truth_value = self.name in set(values) 90 | return bool(truth_value) 91 | 92 | def __str__(self): 93 | return re.sub(r'(\s)', r'\\\1', 94 | self.name.replace('\\', '\\\\'). 95 | replace('(', '\\(').replace(')', '\\)')) 96 | 97 | def __repr__(self): 98 | return "Literal('%s')" % self.name 99 | 100 | 101 | class And(Expression): 102 | """Boolean-and operation (as binary operation). 103 | 104 | NOTE: Class supports more than two arguments. 105 | """ 106 | # pylint: disable=too-few-public-methods 107 | def __init__(self, *terms): 108 | """Create Boolean-AND expression. 109 | 110 | Args: 111 | terms (Iterable[Expression]): List of boolean expressions to AND. 112 | 113 | Returns: 114 | None 115 | """ 116 | super(And, self).__init__() 117 | self.terms = terms 118 | 119 | def evaluate(self, values): 120 | values_ = set(values) 121 | for term in self.terms: 122 | truth_value = term.evaluate(values_) 123 | if not truth_value: 124 | # -- SHORTCUT: Any false makes the expression false. 125 | return False 126 | # -- OTHERWISE: All terms are true. 127 | return True 128 | # -- ALTERNATIVE: 129 | # return all([term.evaluate(values_) for term in self.terms]) 130 | 131 | def __str__(self): 132 | if not self.terms: 133 | return "" # noqa 134 | expression_text = " and ".join([str(term) for term in self.terms]) 135 | return "( %s )" % expression_text 136 | 137 | def __repr__(self): 138 | return "And(%s)" % ", ".join([repr(term) for term in self.terms]) 139 | 140 | 141 | class Or(Expression): 142 | """Boolean-or operation (as binary operation). 143 | 144 | NOTE: Class supports more than two arguments. 145 | """ 146 | # pylint: disable=too-few-public-methods 147 | 148 | def __init__(self, *terms): 149 | """Create Boolean-OR expression. 150 | 151 | Args: 152 | terms (Iterable[Expression]): List of boolean expressions to OR. 153 | 154 | Returns: 155 | None 156 | """ 157 | super(Or, self).__init__() 158 | self.terms = terms 159 | 160 | def evaluate(self, values): 161 | values_ = set(values) 162 | for term in self.terms: 163 | truth_value = term.evaluate(values_) 164 | if truth_value: 165 | # -- SHORTCUT: Any true makes the expression true. 166 | return True 167 | # -- OTHERWISE: All terms are false. 168 | return False 169 | # -- ALTERNATIVE: 170 | # return any([term.evaluate(values_) for term in self.terms]) 171 | 172 | def __str__(self): 173 | if not self.terms: 174 | return "" # noqa 175 | expression_text = " or ".join([str(term) for term in self.terms]) 176 | return "( %s )" % expression_text 177 | 178 | def __repr__(self): 179 | return "Or(%s)" % ", ".join([repr(term) for term in self.terms]) 180 | 181 | 182 | class Not(Expression): 183 | """Boolean-not operation (as unary operation).""" 184 | # pylint: disable=too-few-public-methods 185 | 186 | def __init__(self, term): 187 | """Create Boolean-AND expression. 188 | 189 | Args: 190 | term (Expression): Boolean expression to negate. 191 | 192 | Returns: 193 | None 194 | """ 195 | super(Not, self).__init__() 196 | self.term = term 197 | 198 | def evaluate(self, values): 199 | values_ = set(values) 200 | return not self.term.evaluate(values_) 201 | 202 | def __str__(self): 203 | schema = "not ( {0} )" 204 | if isinstance(self.term, (And, Or)): 205 | # -- REASON: Binary operators have parenthesis already. 206 | schema = "not {0}" 207 | return schema.format(self.term) 208 | 209 | def __repr__(self): 210 | return "Not(%r)" % self.term 211 | 212 | 213 | class True_(Expression): # pylint: disable=invalid-name 214 | """Boolean expression that is always true.""" 215 | # pylint: disable=too-few-public-methods 216 | 217 | def evaluate(self, values): 218 | """Evaluates to True. 219 | 220 | Args: 221 | values (Any): Required by API though not used. 222 | 223 | Returns: 224 | Literal[True] 225 | """ 226 | return True 227 | 228 | def __str__(self): 229 | return "true" 230 | 231 | def __repr__(self): 232 | return "True_()" 233 | -------------------------------------------------------------------------------- /python/default.mk: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/questions/2483182/recursive-wildcards-in-gnu-make 2 | rwildcard=$(foreach d,$(wildcard $(1:=/*)),$(call rwildcard,$d,$2) $(filter $(subst *,%,$2),$d)) 3 | IS_TESTDATA = $(findstring -testdata,${CURDIR}) 4 | SETUP_PY = $(shell find . -name "setup.py") 5 | 6 | update-dependencies: 7 | @echo "\033[0;32m$(@): DONE\033[0m" 8 | .PHONY: update-dependencies 9 | 10 | pre-release: update-version update-dependencies clean default 11 | .PHONY: pre-release 12 | 13 | update-version: 14 | ifdef NEW_VERSION 15 | ifneq (,$(SETUP_PY)) 16 | sed -i \ 17 | -e "s/\(version *= *\"\)[0-9]*\.[0-9]*\.[0-9]*\(\"\)/\1$(NEW_VERSION)\2/" \ 18 | "setup.py" 19 | endif 20 | else 21 | @echo -e "\033[0;31mNEW_VERSION is not defined. Can't update version :-(\033[0m" 22 | exit 1 23 | endif 24 | .PHONY: update-version 25 | 26 | publish: 27 | ifeq ($(IS_TESTDATA),-testdata) 28 | # no-op 29 | else 30 | python2 setup.py sdist 31 | python2 -m twine upload dist/* 32 | endif 33 | .PHONY: publish 34 | 35 | post-release: 36 | @echo "No post-release needed for python" 37 | .PHONY: post-release 38 | 39 | ### COMMON stuff for all platforms 40 | 41 | BERP_VERSION = 1.3.0 42 | BERP_GRAMMAR = gherkin.berp 43 | 44 | define berp-generate-parser = 45 | -! dotnet tool list --tool-path /usr/bin | grep "berp\s*$(BERP_VERSION)" && dotnet tool update Berp --version $(BERP_VERSION) --tool-path /usr/bin 46 | berp -g $(BERP_GRAMMAR) -t $< -o $@ --noBOM 47 | endef 48 | -------------------------------------------------------------------------------- /python/invoke.yaml: -------------------------------------------------------------------------------- 1 | # ===================================================== 2 | # INVOKE CONFIGURATION FOR: cucumber-tag-expressions 3 | # ===================================================== 4 | # USAGE: invoke --list 5 | # USAGE: invoke 6 | # DESCRIPTION: 7 | # invoke is a small, Python based build system. 8 | # 9 | # SEE ALSO: 10 | # * https://www.pyinvoke.org 11 | # ===================================================== 12 | 13 | project: 14 | name: cucumber-tag-expressions 15 | 16 | run: 17 | echo: true 18 | 19 | cleanup: 20 | extra_directories: 21 | - "build" 22 | - "dist" 23 | 24 | cleanup_all: 25 | extra_directories: 26 | - .direnv 27 | - .hypothesis 28 | - .pytest_cache 29 | - __pypackages__ 30 | extra_files: 31 | - "*.lock" 32 | - .pdm.toml 33 | - get-pip.py 34 | -------------------------------------------------------------------------------- /python/py.requirements/README.txt: -------------------------------------------------------------------------------- 1 | Python Package Requirements for pip 2 | ============================================================================== 3 | 4 | This directory contains python package requirements for this package. 5 | These requirement files are used by: 6 | 7 | * pip 8 | * tox 9 | 10 | SEE ALSO: 11 | * https://pip.pypa.io/ 12 | * https://packaging.python.org 13 | 14 | -------------------------------------------------------------------------------- /python/py.requirements/all.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # BEHAVE: PYTHON PACKAGE REQUIREMENTS: All requirements 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * https://pip.pypa.io/ 9 | # * https://packaging.python.org 10 | # ============================================================================ 11 | 12 | -r basic.txt 13 | -r packaging.txt 14 | -r develop.txt 15 | -r ../tasks/py.requirements.txt 16 | -------------------------------------------------------------------------------- /python/py.requirements/basic.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # BEHAVE: PYTHON PACKAGE REQUIREMENTS: Normal usage/installation (minimal) 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | enum34; python_version < '3.4' 12 | -------------------------------------------------------------------------------- /python/py.requirements/ci.github.testing.txt: -------------------------------------------------------------------------------- 1 | -r basic.txt 2 | -r testing.txt 3 | -------------------------------------------------------------------------------- /python/py.requirements/develop.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS FOR: For development only 3 | # ============================================================================ 4 | # RESCUE: 5 | # curl -sSL https://bootstrap.pypa.io/pip/2.7/get-pip.py -o get-pip.py 6 | # python get-pip.py 7 | # 8 | # SEE: https://github.com/pypa/get-pip 9 | # ============================================================================ 10 | 11 | # -- BASIC DEVELOPMENT ENVIRONMENT: 12 | pip >= 9.0.1 13 | virtualenv >=15.0.1,<=20.4.4; python_version <= '2.7' 14 | virtualenv >=20.4.5; python_version >= '3.0' 15 | 16 | # -- RELEASE MANAGEMENT: Push package to pypi. 17 | twine >= 1.13.0 18 | -r packaging.txt 19 | 20 | # -- DEVELOPMENT SUPPORT: 21 | # -- PYTHON2/3 COMPATIBILITY: pypa/modernize 22 | # python-futurize 23 | modernize >= 0.5 24 | 25 | # -- STATIC CODE ANALYSIS: 26 | # PREPARED: prospector >= 0.12.7 27 | pylint >= 1.7 28 | bandit >= 1.4; python_version >= '3.7' 29 | ruff 30 | 31 | # -- LOCAL CI (for Python): 32 | # Test with different Python versions. 33 | # SEE: https://tox.wiki/ 34 | tox >=2.9,<4.0 35 | tox >=2.9,<4.0 # -- HINT: tox >= 4.0 has breaking changes. 36 | virtualenv < 20.22.0 # -- SUPPORT FOR: Python 2.7, Python <= 3.6 37 | 38 | # -- REQUIRES: testing, docs, invoke-task requirements 39 | # PREPARED: -r docs.txt 40 | -r testing.txt 41 | -r ../tasks/py.requirements.txt 42 | -------------------------------------------------------------------------------- /python/py.requirements/packaging.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS: packaging support 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | # -- PACKAGING SUPPORT: 12 | build >= 0.5.1 13 | setuptools 14 | setuptools-scm 15 | wheel 16 | 17 | # -- DISABLED: 18 | # setuptools >= 64.0.0; python_version >= '3.5' 19 | # setuptools < 45.0.0; python_version < '3.5' # DROP: Python2, Python 3.4 support. 20 | # setuptools_scm >= 8.0.0; python_version >= '3.7' 21 | # setuptools_scm < 8.0.0; python_version < '3.7' 22 | -------------------------------------------------------------------------------- /python/py.requirements/testing.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS FOR: For testing only 3 | # ============================================================================ 4 | 5 | -r basic.txt 6 | 7 | # -- TESTING: Unit tests and behave self-tests. 8 | pytest < 5.0; python_version < '3.0' # pytest >= 4.2 9 | pytest >= 5.0; python_version >= '3.0' 10 | 11 | pytest-html >= 1.19.0,<2.0; python_version < '3.0' 12 | pytest-html >= 2.0; python_version >= '3.0' 13 | 14 | PyYAML >= 5.4.1 15 | pathlib; python_version <= '3.4' 16 | 17 | # -- DEVELOPMENT SUPPORT: Testing 18 | coverage >= 4.2 19 | -------------------------------------------------------------------------------- /python/pyproject.toml: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # PACKAGING: cucumber-tag-expressions 3 | # ============================================================================= 4 | # SEE ALSO: 5 | # * https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html 6 | # * https://pypi.org/classifiers/ 7 | # 8 | # PREPARED: 9 | # * https://setuptools-scm.readthedocs.io/en/latest/usage/ 10 | # ============================================================================= 11 | # PYTHON3: requires = ["setuptools>=64", "setuptools_scm>=8", "wheel"] 12 | 13 | [build-system] 14 | requires = ["setuptools", "setuptools_scm", "wheel"] 15 | build-backend = "setuptools.build_meta" 16 | 17 | 18 | [project] 19 | name = "cucumber-tag-expressions" 20 | authors = [ 21 | {name = "Jens Engel", email = "jenisys@noreply.github.com"}, 22 | ] 23 | description = "Provides a tag-expression parser and evaluation logic for cucumber/behave" 24 | version = "6.2.0" 25 | # PREPARED: dynamic = ["version"] 26 | keywords= ["BDD", "testing", "cucumber", "tag-expressions", "behave"] 27 | license = {text = "MIT"} 28 | readme = "README.rst" 29 | requires-python = ">=2.7" 30 | classifiers = [ 31 | "Development Status :: 5 - Production/Stable", 32 | "Environment :: Console", 33 | "Intended Audience :: Developers", 34 | "Operating System :: OS Independent", 35 | "Programming Language :: Python :: 2", 36 | "Programming Language :: Python :: 2.7", 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.5", 39 | "Programming Language :: Python :: 3.6", 40 | "Programming Language :: Python :: 3.7", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: 3.11", 45 | "Programming Language :: Python :: 3.12", 46 | "Programming Language :: Python :: Implementation :: CPython", 47 | "Programming Language :: Python :: Implementation :: PyPy", 48 | "Topic :: Software Development :: Testing", 49 | "Topic :: Software Development :: Libraries :: Python Modules", 50 | "License :: OSI Approved :: MIT License", 51 | ] 52 | dependencies = [ 53 | "enum34; python_version < '3.4'" 54 | ] 55 | 56 | 57 | [project.urls] 58 | Homepage = "https://github.com/cucumber/tag-expressions" 59 | Download = "https://pypi.org/project/cucumber-tag-expressions" 60 | Repository = "https://github.com/cucumber/tag-expressions" 61 | Issues = "https://github.com/cucumber/tag-expressions/issues/" 62 | 63 | 64 | [project.optional-dependencies] 65 | develop = [ 66 | "setuptools", 67 | "setuptools-scm", 68 | "wheel", 69 | "build >= 0.5.1", 70 | "twine >= 1.13.0", 71 | "coverage", 72 | "pytest < 5.0; python_version < '3.0'", 73 | "pytest >= 5.0; python_version >= '3.0'", 74 | "pytest-html >= 1.19.0", 75 | "tox >=4.26,<4.27", 76 | "pylint", 77 | "ruff", 78 | 79 | # -- INVOKE SUPPORT: 80 | "invoke >= 1.7.3", 81 | "six >= 1.16.0", 82 | "path >= 13.1.0; python_version >= '3.5'", 83 | "path.py >= 11.5.0; python_version < '3.5'", 84 | # -- PYTHON2 BACKPORTS: 85 | "pathlib; python_version <= '3.4'", 86 | "backports.shutil_which; python_version <= '3.3'", 87 | "pycmd", 88 | ] 89 | testing = [ 90 | "pytest < 5.0; python_version < '3.0'", # >= 4.2 91 | "pytest >= 5.0; python_version >= '3.0'", 92 | "pytest-html >= 1.19.0", 93 | "PyYAML >= 5.4.1", 94 | "pathlib; python_version <= '3.4'", 95 | ] 96 | 97 | 98 | [tool.distutils.bdist_wheel] 99 | universal = true 100 | 101 | 102 | # ----------------------------------------------------------------------------- 103 | # PACKAGING TOOL SPECIFIC PARTS: 104 | # ----------------------------------------------------------------------------- 105 | [tool.setuptools] 106 | platforms = ["any"] 107 | zip-safe = true 108 | 109 | [tool.setuptools.packages.find] 110 | where = ["."] 111 | include = ["cucumber_tag_expressions*"] 112 | exclude = ["tests*"] 113 | namespaces = false 114 | 115 | # -- PREPARED: SETUPTOOLS-SCM: Generate version info from git-tag(s). 116 | # GIT-TAG MATCHER: Only use git-tags that start with "v", like: "v6.1.0" 117 | # [tool.setuptools_scm] 118 | # git_describe_command = "git describe --match 'v[0-9]*'" 119 | # root = ".." 120 | # version_file = "cucumber_tag_expressions/_version.py" 121 | 122 | 123 | # ============================================================================= 124 | # OTHER TOOLS 125 | # ============================================================================= 126 | # ----------------------------------------------------------------------------- 127 | # PYLINT: 128 | # ----------------------------------------------------------------------------- 129 | [tool.pylint.messages_control] 130 | disable = "C0330, C0326" 131 | 132 | [tool.pylint.format] 133 | max-line-length = "100" 134 | -------------------------------------------------------------------------------- /python/pytest.ini: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTEST CONFIGURATION FILE 3 | # ============================================================================ 4 | # NOTE: 5 | # Can also be defined in in tox.ini or pytest.ini file. 6 | # 7 | # SEE ALSO: 8 | # * http://pytest.org/ 9 | # * http://pytest.org/latest/customize.html 10 | # * http://pytest.org/latest/usage.html 11 | # ============================================================================ 12 | # MORE OPTIONS: 13 | # addopts = 14 | # python_classes=*Test 15 | # python_functions=test_* 16 | # ============================================================================ 17 | 18 | [pytest] 19 | minversion = 3.2 20 | testpaths = tests cucumber_tag_expressions README.rst 21 | python_files = test_*.py 22 | addopts = 23 | --metadata PACKAGE_UNDER_TEST tag-expressions 24 | --html=build/testing/report.html --self-contained-html 25 | --junit-xml=build/testing/report.xml 26 | --doctest-modules 27 | 28 | # -- BACKWARD COMPATIBILITY: pytest < 2.8 29 | # norecursedirs = .git .tox attic build dist py.requirements tmp* _WORKSPACE 30 | -------------------------------------------------------------------------------- /python/scripts/ensurepip_python27.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "DOWNLOADING: https://bootstrap.pypa.io/pip/2.7/get-pip.py ..." 4 | curl -sSL https://bootstrap.pypa.io/pip/2.7/get-pip.py -o get-pip.py 5 | python get-pip.py 6 | -------------------------------------------------------------------------------- /python/scripts/pytest_cmd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -- FOR PYTHON 2,7: Run pytest as Python module 3 | # REASON: "pytest" seems no longer to be installed in "bin/" directory. 4 | 5 | from __future__ import absolute_import, print_function 6 | import sys 7 | import pytest 8 | 9 | if __name__ == "__main__": 10 | sys.exit(pytest.main()) 11 | -------------------------------------------------------------------------------- /python/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=wrong-import-position, wrong-import-order 3 | """ 4 | Invoke build script. 5 | Show all tasks with:: 6 | 7 | invoke -l 8 | 9 | .. seealso:: 10 | 11 | * http://pyinvoke.org 12 | * https://github.com/pyinvoke/invoke 13 | """ 14 | 15 | from __future__ import absolute_import 16 | 17 | # ----------------------------------------------------------------------------- 18 | # BOOTSTRAP PATH: Use provided vendor bundle if "invoke" is not installed 19 | # ----------------------------------------------------------------------------- 20 | from . import _setup # pylint: disable=wrong-import-order 21 | import os.path 22 | import sys 23 | 24 | INVOKE_MINVERSION = "1.7.0" 25 | _setup.setup_path() 26 | _setup.require_invoke_minversion(INVOKE_MINVERSION) 27 | 28 | TOPDIR = os.path.join(os.path.dirname(__file__), "..") 29 | TOPDIR = os.path.abspath(TOPDIR) 30 | sys.path.insert(0, TOPDIR) 31 | 32 | # -- MONKEYPATCH: path module 33 | # HINT: path API was changed in a non-backward compatible way. 34 | from ._path import monkeypatch_path_if_needed 35 | monkeypatch_path_if_needed() 36 | 37 | 38 | # ----------------------------------------------------------------------------- 39 | # IMPORTS: 40 | # ----------------------------------------------------------------------------- 41 | import sys 42 | from invoke import Collection 43 | 44 | # -- TASK-LIBRARY: 45 | import invoke_cleanup as cleanup 46 | from . import test 47 | 48 | # ----------------------------------------------------------------------------- 49 | # TASKS: 50 | # ----------------------------------------------------------------------------- 51 | # None 52 | 53 | 54 | # ----------------------------------------------------------------------------- 55 | # TASK CONFIGURATION: 56 | # ----------------------------------------------------------------------------- 57 | namespace = Collection() 58 | namespace.add_collection(Collection.from_module(cleanup), name="cleanup") 59 | namespace.add_collection(Collection.from_module(test)) 60 | 61 | cleanup.cleanup_tasks.add_task(cleanup.clean_python) 62 | 63 | # -- INJECT: clean configuration into this namespace 64 | namespace.configure(cleanup.namespace.configuration()) 65 | if sys.platform.startswith("win"): 66 | # -- OVERRIDE SETTINGS: For platform=win32, ... (Windows) 67 | from ._compat_shutil import which 68 | run_settings = dict(echo=True, pty=False, shell=which("cmd")) 69 | namespace.configure({"run": run_settings}) 70 | else: 71 | namespace.configure({"run": dict(echo=True, pty=True)}) 72 | -------------------------------------------------------------------------------- /python/tasks/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Provides "invoke" script when invoke is not installed. 4 | Note that this approach uses the "tasks/_vendor/invoke.zip" bundle package. 5 | 6 | Usage:: 7 | 8 | # -- INSTEAD OF: invoke command 9 | # Show invoke version 10 | python -m tasks --version 11 | 12 | # List all tasks 13 | python -m tasks -l 14 | 15 | .. seealso:: 16 | 17 | * http://pyinvoke.org 18 | * https://github.com/pyinvoke/invoke 19 | 20 | 21 | Examples for Invoke Scripts using the Bundle 22 | ------------------------------------------------------------------------------- 23 | 24 | For UNIX like platforms: 25 | 26 | .. code-block:: sh 27 | 28 | #!/bin/sh 29 | #!/bin/bash 30 | # RUN INVOKE: From bundled ZIP file (with Bourne shell/bash script). 31 | # FILE: invoke.sh (in directory that contains tasks/ directory) 32 | 33 | HERE=$(dirname $0) 34 | export INVOKE_TASKS_USE_VENDOR_BUNDLES="yes" 35 | 36 | python ${HERE}/tasks/_vendor/invoke.zip $* 37 | 38 | 39 | For Windows platform: 40 | 41 | .. code-block:: bat 42 | 43 | @echo off 44 | REM RUN INVOKE: From bundled ZIP file (with Windows Batchfile). 45 | REM FILE: invoke.cmd (in directory that contains tasks/ directory) 46 | 47 | setlocal 48 | set HERE=%~dp0 49 | set INVOKE_TASKS_USE_VENDOR_BUNDLES="yes" 50 | if not defined PYTHON set PYTHON=python 51 | 52 | %PYTHON% %HERE%tasks/_vendor/invoke.zip "%*" 53 | """ 54 | 55 | from __future__ import absolute_import 56 | import os 57 | import sys 58 | 59 | # ----------------------------------------------------------------------------- 60 | # BOOTSTRAP PATH: Use provided vendor bundle if "invoke" is not installed 61 | # ----------------------------------------------------------------------------- 62 | # NOTE: tasks/__init__.py performs sys.path setup. 63 | os.environ["INVOKE_TASKS_USE_VENDOR_BUNDLES"] = "yes" 64 | 65 | # ----------------------------------------------------------------------------- 66 | # AUTO-MAIN: 67 | # ----------------------------------------------------------------------------- 68 | if __name__ == "__main__": 69 | from invoke.main import program 70 | sys.exit(program.run()) 71 | -------------------------------------------------------------------------------- /python/tasks/_compat_shutil.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=unused-import 3 | # PYTHON VERSION COMPATIBILITY HELPER 4 | 5 | try: 6 | from shutil import which # -- SINCE: Python 3.3 7 | except ImportError: 8 | from backports.shutil_which import which 9 | -------------------------------------------------------------------------------- /python/tasks/_dry_run.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Basic support to use a --dry-run mode w/ invoke tasks. 4 | 5 | .. code-block:: 6 | 7 | from ._dry_run import DryRunContext 8 | 9 | @task 10 | def destroy_something(ctx, path, dry_run=False): 11 | if dry_run: 12 | ctx = DryRunContext(ctx) 13 | 14 | # -- DRY-RUN MODE: Only echos commands. 15 | ctx.run("rm -rf {}".format(path)) 16 | """ 17 | 18 | from __future__ import print_function 19 | 20 | class DryRunContext(object): 21 | PREFIX = "DRY-RUN: " 22 | SCHEMA = "{prefix}{command}" 23 | SCHEMA_WITH_KWARGS = "{prefix}{command} (with kwargs={kwargs})" 24 | 25 | def __init__(self, ctx=None, prefix=None, schema=None): 26 | if prefix is None: 27 | prefix = self.PREFIX 28 | if schema is None: 29 | schema = self.SCHEMA 30 | 31 | self.ctx = ctx 32 | self.prefix = prefix 33 | self.schema = schema 34 | 35 | def run(self, command, **kwargs): 36 | message = self.schema.format(command=command, 37 | prefix=self.prefix, 38 | kwargs=kwargs) 39 | print(message) 40 | 41 | 42 | def sudo(self, command, **kwargs): 43 | command2 = "sudo %s" % command 44 | self.run(command2, **kwargs) 45 | -------------------------------------------------------------------------------- /python/tasks/_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fixes :mod:`path` breaking API changes. 3 | 4 | Newer versions of :mod:`path` no longer support: 5 | 6 | * :func:`Path.abspath()`: Use :func:`Path.absolute()` 7 | * :func:`Path.isdir()`: Use :func:`Path.is_dir()` 8 | * :func:`Path.isfile()`: Use :func:`Path.is_file()` 9 | * ... 10 | 11 | .. seealso:: https://github.com/jaraco/path 12 | """ 13 | 14 | from path import Path 15 | 16 | 17 | # ----------------------------------------------------------------------------- 18 | # MONKEYPATCH (if needed) 19 | # ----------------------------------------------------------------------------- 20 | def monkeypatch_path_if_needed(): 21 | if not hasattr(Path, "abspath"): 22 | Path.abspath = Path.absolute 23 | if not hasattr(Path, "isdir"): 24 | Path.isdir = Path.is_dir 25 | if not hasattr(Path, "isfile"): 26 | Path.isfile = Path.is_file 27 | 28 | 29 | # ----------------------------------------------------------------------------- 30 | # MODULE SETUP 31 | # ----------------------------------------------------------------------------- 32 | # DISABLED: monkeypatch_path_if_needed() 33 | -------------------------------------------------------------------------------- /python/tasks/_setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Decides if vendor bundles are used or not. 4 | Setup python path accordingly. 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | import os.path 9 | import sys 10 | 11 | # ----------------------------------------------------------------------------- 12 | # DEFINES: 13 | # ----------------------------------------------------------------------------- 14 | HERE = os.path.dirname(__file__) 15 | TASKS_VENDOR_DIR = os.path.join(HERE, "_vendor") 16 | INVOKE_BUNDLE = os.path.join(TASKS_VENDOR_DIR, "invoke.zip") 17 | INVOKE_BUNDLE_VERSION = "0.21.0" 18 | 19 | DEBUG_SYSPATH = False 20 | 21 | 22 | # ----------------------------------------------------------------------------- 23 | # EXCEPTIONS: 24 | # ----------------------------------------------------------------------------- 25 | class VersionRequirementError(SystemExit): 26 | pass 27 | 28 | # ----------------------------------------------------------------------------- 29 | # FUNCTIONS: 30 | # ----------------------------------------------------------------------------- 31 | def setup_path(invoke_minversion=None): 32 | """Setup python search and add ``TASKS_VENDOR_DIR`` (if available).""" 33 | # print("INVOKE.tasks: setup_path") 34 | if not os.path.isdir(TASKS_VENDOR_DIR): 35 | # SILENCE: print("SKIP: TASKS_VENDOR_DIR=%s is missing" % TASKS_VENDOR_DIR) 36 | return 37 | elif os.path.abspath(TASKS_VENDOR_DIR) in sys.path: 38 | # -- SETUP ALREADY DONE: 39 | # return 40 | pass 41 | 42 | use_vendor_bundles = os.environ.get("INVOKE_TASKS_USE_VENDOR_BUNDLES", "no") 43 | if need_vendor_bundles(invoke_minversion): 44 | use_vendor_bundles = "yes" 45 | 46 | if use_vendor_bundles == "yes": 47 | syspath_insert(0, os.path.abspath(TASKS_VENDOR_DIR)) 48 | if setup_path_for_bundle(INVOKE_BUNDLE, pos=1): 49 | import invoke 50 | bundle_path = os.path.relpath(INVOKE_BUNDLE, os.getcwd()) 51 | print("USING: %s (version: %s)" % (bundle_path, invoke.__version__)) 52 | else: 53 | # -- BEST-EFFORT: May rescue something 54 | syspath_append(os.path.abspath(TASKS_VENDOR_DIR)) 55 | setup_path_for_bundle(INVOKE_BUNDLE, pos=len(sys.path)) 56 | 57 | if DEBUG_SYSPATH: 58 | for index, p in enumerate(sys.path): 59 | print(" %d. %s" % (index, p)) 60 | 61 | 62 | def require_invoke_minversion(min_version, verbose=False): 63 | """Ensures that :mod:`invoke` has at the least the :param:`min_version`. 64 | Otherwise, 65 | 66 | :param min_version: Minimal acceptable invoke version (as string). 67 | :param verbose: Indicates if invoke.version should be shown. 68 | :raises: VersionRequirementError=SystemExit if requirement fails. 69 | """ 70 | # -- REQUIRES: sys.path is setup and contains invoke 71 | try: 72 | import invoke 73 | invoke_version = invoke.__version__ 74 | except ImportError: 75 | invoke_version = "__NOT_INSTALLED" 76 | 77 | if invoke_version < min_version: 78 | message = "REQUIRE: invoke.version >= %s (but was: %s)" % \ 79 | (min_version, invoke_version) 80 | message += "\nUSE: pip install invoke>=%s" % min_version 81 | raise VersionRequirementError(message) 82 | 83 | # pylint: disable=invalid-name 84 | INVOKE_VERSION = os.environ.get("INVOKE_VERSION", None) 85 | if verbose and not INVOKE_VERSION: 86 | os.environ["INVOKE_VERSION"] = invoke_version 87 | print("USING: invoke.version=%s" % invoke_version) 88 | 89 | 90 | def need_vendor_bundles(invoke_minversion=None): 91 | invoke_minversion = invoke_minversion or "0.0.0" 92 | need_vendor_answers = [] 93 | need_vendor_answers.append(need_vendor_bundle_invoke(invoke_minversion)) 94 | # -- REQUIRE: path.py 95 | try: 96 | import path 97 | need_bundle = False 98 | except ImportError: 99 | need_bundle = True 100 | need_vendor_answers.append(need_bundle) 101 | 102 | # -- DIAG: print("INVOKE: need_bundle=%s" % need_bundle1) 103 | # return need_bundle1 or need_bundle2 104 | return any(need_vendor_answers) 105 | 106 | 107 | def need_vendor_bundle_invoke(invoke_minversion="0.0.0"): 108 | # -- REQUIRE: invoke 109 | try: 110 | import invoke 111 | need_bundle = invoke.__version__ < invoke_minversion 112 | if need_bundle: 113 | del sys.modules["invoke"] 114 | del invoke 115 | except ImportError: 116 | need_bundle = True 117 | except Exception: # pylint: disable=broad-except 118 | need_bundle = True 119 | return need_bundle 120 | 121 | 122 | # ----------------------------------------------------------------------------- 123 | # UTILITY FUNCTIONS: 124 | # ----------------------------------------------------------------------------- 125 | def setup_path_for_bundle(bundle_path, pos=0): 126 | if os.path.exists(bundle_path): 127 | syspath_insert(pos, os.path.abspath(bundle_path)) 128 | return True 129 | return False 130 | 131 | 132 | def syspath_insert(pos, path): 133 | if path in sys.path: 134 | sys.path.remove(path) 135 | sys.path.insert(pos, path) 136 | 137 | 138 | def syspath_append(path): 139 | if path in sys.path: 140 | sys.path.remove(path) 141 | sys.path.append(path) 142 | 143 | -------------------------------------------------------------------------------- /python/tasks/py.requirements.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # INVOKE PYTHON PACKAGE REQUIREMENTS: For invoke tasks 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | invoke >= 1.7.3 12 | six >= 1.16.0 13 | 14 | invoke-cleanup @ git+https://github.com/jenisys/invoke-cleanup@v0.3.7 15 | 16 | # -- HINT: path.py => path (python-install-package was renamed for python3) 17 | path >= 13.1.0; python_version >= '3.5' 18 | path.py >= 11.5.0; python_version < '3.5' 19 | 20 | # -- PYTHON2 BACKPORTS: 21 | pathlib; python_version <= '3.4' 22 | backports.shutil_which; python_version <= '3.3' 23 | 24 | # -- CLEANUP SUPPORT: py.cleanup 25 | pycmd 26 | -------------------------------------------------------------------------------- /python/tasks/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Invoke test tasks. 4 | """ 5 | 6 | from __future__ import print_function 7 | from invoke import task, Collection 8 | 9 | # -- TASK-LIBRARY: 10 | from invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files 11 | 12 | 13 | # --------------------------------------------------------------------------- 14 | # TASKS 15 | # --------------------------------------------------------------------------- 16 | @task(name="all", help={ 17 | "args": "Command line args for test run.", 18 | }) 19 | def test_all(ctx, args="", options=""): 20 | """Run all tests (default).""" 21 | pytest(ctx, args, options=options) 22 | 23 | 24 | @task 25 | def clean(ctx, dry_run=False): 26 | """Cleanup (temporary) test artifacts.""" 27 | directories = ctx.test.clean.directories or [] 28 | files = ctx.test.clean.files or [] 29 | cleanup_dirs(directories, dry_run=dry_run) 30 | cleanup_files(files, dry_run=dry_run) 31 | 32 | 33 | @task 34 | def pytest(ctx, args="", options=""): 35 | """Run unit tests.""" 36 | args = args or ctx.pytest.args 37 | options = options or ctx.pytest.options 38 | ctx.run("pytest {options} {args}".format(options=options, args=args)) 39 | 40 | 41 | @task(help={ 42 | "args": "Tests to run (empty: all)", 43 | "report": "Coverage report format to use (report, html, xml)", 44 | }) 45 | def coverage(ctx, args="", report="report", append=False): 46 | """Determine test coverage (run pytest, behave)""" 47 | append = append or ctx.coverage.append 48 | report_formats = ctx.coverage.report_formats or [] 49 | if report not in report_formats: 50 | report_formats.insert(0, report) 51 | opts = [] 52 | if append: 53 | opts.append("--append") 54 | 55 | pytest_args = args 56 | if isinstance(pytest_args, list): 57 | pytest_args = " ".join(pytest_args) 58 | 59 | # -- RUN TESTS WITH COVERAGE: 60 | ctx.run("coverage run {options} -m pytest {args}".format( 61 | args=pytest_args, options=" ".join(opts))) 62 | 63 | # -- POST-PROCESSING: 64 | ctx.run("coverage combine") 65 | for report_format in report_formats: 66 | ctx.run("coverage {report_format}".format(report_format=report_format)) 67 | 68 | 69 | # --------------------------------------------------------------------------- 70 | # TASK MANAGEMENT / CONFIGURATION 71 | # --------------------------------------------------------------------------- 72 | namespace = Collection(clean, pytest, coverage) 73 | namespace.add_task(test_all, default=True) 74 | namespace.configure({ 75 | "test": { 76 | "clean": { 77 | "directories": [ 78 | ".cache", "assets", # -- TEST RUNS 79 | ], 80 | "files": [ 81 | ".coverage", ".coverage.*", 82 | "report.html", 83 | ], 84 | }, 85 | }, 86 | "pytest": { 87 | "scopes": ["tests"], 88 | "args": "", 89 | "options": "", # -- NOTE: Overide in configfile "invoke.yaml" 90 | }, 91 | "coverage": { 92 | "append": False, 93 | "report_formats": ["report", "html"], 94 | }, 95 | }) 96 | 97 | # -- ADD CLEANUP TASK: 98 | cleanup_tasks.add_task(clean, "clean_test") 99 | cleanup_tasks.configure(namespace.configuration()) 100 | -------------------------------------------------------------------------------- /python/tests/data/README.md: -------------------------------------------------------------------------------- 1 | This director contains "data-driven" tests from the "../../../testdata/*.yaml" directory. 2 | 3 | DATA FILES: 4 | 5 | * errors.yml 6 | * evaluations.yml 7 | * parsing.yml 8 | -------------------------------------------------------------------------------- /python/tests/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/tag-expressions/d13d4596b6e6939f9cd971a63990d6c8e8b9bc12/python/tests/data/__init__.py -------------------------------------------------------------------------------- /python/tests/data/test_errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from __future__ import absolute_import, print_function 3 | from collections import namedtuple 4 | from pathlib import Path 5 | 6 | from cucumber_tag_expressions.parser import TagExpressionParser, TagExpressionError 7 | import pytest 8 | import yaml 9 | 10 | 11 | # ----------------------------------------------------------------------------- 12 | # DATA-FILE CONSTANTS: 13 | # ----------------------------------------------------------------------------- 14 | HERE = Path(__file__).parent.absolute() 15 | TESTDATA_DIRECTORY = HERE/"../../../testdata" 16 | TESTDATA_FILE = TESTDATA_DIRECTORY/"errors.yml" 17 | 18 | 19 | # ----------------------------------------------------------------------------- 20 | # DATA-FILE DRIVEN TEST SUPPORT: 21 | # ----------------------------------------------------------------------------- 22 | # - expression: '@a @b or' 23 | # error: 'Tag expression "@a @b or" could not be parsed because of syntax error: Expected operator.' 24 | DTestData4Error = namedtuple("DTestData4Error", ("expression", "error")) 25 | 26 | def read_testdata(data_filename): 27 | testdata_items = [] 28 | with open(str(data_filename)) as f: 29 | for item in yaml.safe_load(f): 30 | assert isinstance(item, dict) 31 | data_item = DTestData4Error(item["expression"], item["error"]) 32 | testdata_items.append(data_item) 33 | return testdata_items 34 | 35 | 36 | 37 | # ----------------------------------------------------------------------------- 38 | # TEST SUITE: 39 | # ----------------------------------------------------------------------------- 40 | this_testdata = read_testdata(TESTDATA_FILE) 41 | 42 | @pytest.mark.skip(reason="TOO MANY DIFFERENCES: Error message here are more specific (IMHO)") 43 | @pytest.mark.parametrize("expression, error", this_testdata) 44 | def test_errors_with_datafile(expression, error): 45 | with pytest.raises(TagExpressionError) as exc_info: 46 | _ = TagExpressionParser().parse(expression) 47 | 48 | exc_text = exc_info.exconly() 49 | print(exc_text) 50 | assert error in exc_text 51 | -------------------------------------------------------------------------------- /python/tests/data/test_evaluations.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from __future__ import absolute_import, print_function 3 | from collections import namedtuple 4 | from pathlib import Path 5 | 6 | from cucumber_tag_expressions.parser import TagExpressionParser 7 | import pytest 8 | import yaml 9 | 10 | 11 | # ----------------------------------------------------------------------------- 12 | # DATA-FILE CONSTANTS: 13 | # ----------------------------------------------------------------------------- 14 | HERE = Path(__file__).parent.absolute() 15 | TESTDATA_DIRECTORY = HERE/"../../../testdata" 16 | TESTDATA_FILE = TESTDATA_DIRECTORY/"evaluations.yml" 17 | 18 | 19 | # ----------------------------------------------------------------------------- 20 | # DATA-FILE DRIVEN TEST SUPPORT: 21 | # ----------------------------------------------------------------------------- 22 | # - expression: 'not x' 23 | # tests: 24 | # - variables: ['x'] 25 | # result: false 26 | # - variables: ['y'] 27 | # result: true 28 | DTestData4Evaluation = namedtuple("DTestData4Evaluation", ("expression", "tests")) 29 | DTestVarsAndResult = namedtuple("DTestVarsAndResult", ("variables", "result")) 30 | 31 | def read_testdata(data_filename): 32 | testdata_items = [] 33 | with open(str(data_filename)) as f: 34 | for item in yaml.safe_load(f): 35 | assert isinstance(item, dict) 36 | tests = [] 37 | for test in item["tests"]: 38 | test_variables = test["variables"] 39 | test_result = test["result"] 40 | tests.append(DTestVarsAndResult(test_variables, test_result)) 41 | data_item = DTestData4Evaluation(item["expression"], tests) 42 | testdata_items.append(data_item) 43 | return testdata_items 44 | 45 | 46 | 47 | # ----------------------------------------------------------------------------- 48 | # TEST SUITE: 49 | # ----------------------------------------------------------------------------- 50 | this_testdata = read_testdata(TESTDATA_FILE) 51 | 52 | @pytest.mark.parametrize("expression, tests", this_testdata) 53 | def test_parsing_with_datafile(expression, tests): 54 | print("expression := {0}".format(expression)) 55 | tag_expression = TagExpressionParser().parse(expression) 56 | for test_data in tests: 57 | print("test.variables= {0}".format(test_data.variables)) 58 | print("test.result = {0}".format(test_data.result)) 59 | actual_result = tag_expression.evaluate(test_data.variables) 60 | assert actual_result == test_data.result 61 | -------------------------------------------------------------------------------- /python/tests/data/test_parsing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from __future__ import absolute_import, print_function 3 | from collections import namedtuple 4 | from pathlib import Path 5 | 6 | from cucumber_tag_expressions.parser import TagExpressionParser 7 | import pytest 8 | import yaml 9 | 10 | 11 | # ----------------------------------------------------------------------------- 12 | # DATA-FILE CONSTANTS: 13 | # ----------------------------------------------------------------------------- 14 | HERE = Path(__file__).parent.absolute() 15 | TESTDATA_DIRECTORY = HERE/"../../../testdata" 16 | TESTDATA_FILE = TESTDATA_DIRECTORY/"parsing.yml" 17 | 18 | 19 | # ----------------------------------------------------------------------------- 20 | # DATA-FILE DRIVEN TEST SUPPORT: 21 | # ----------------------------------------------------------------------------- 22 | DTestData4Parsing = namedtuple("DTestData4Parsing", ("expression", "formatted")) 23 | 24 | def read_testdata(data_filename): 25 | testdata_items = [] 26 | with open(str(data_filename)) as f: 27 | for item in yaml.safe_load(f): 28 | assert isinstance(item, dict) 29 | data_item = DTestData4Parsing(item["expression"], item["formatted"]) 30 | testdata_items.append(data_item) 31 | return testdata_items 32 | 33 | 34 | 35 | # ----------------------------------------------------------------------------- 36 | # TEST SUITE: 37 | # ----------------------------------------------------------------------------- 38 | this_testdata = read_testdata(TESTDATA_FILE) 39 | 40 | @pytest.mark.parametrize("expression, formatted", this_testdata) 41 | def test_parsing_with_datafile(expression, formatted): 42 | tag_expression = TagExpressionParser().parse(expression) 43 | actual_text = str(tag_expression) 44 | assert actual_text == formatted 45 | -------------------------------------------------------------------------------- /python/tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/tag-expressions/d13d4596b6e6939f9cd971a63990d6c8e8b9bc12/python/tests/functional/__init__.py -------------------------------------------------------------------------------- /python/tests/functional/test_tag_expression.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=bad-whitespace 3 | """ 4 | Functional tests for ``TagExpressionParser.parse()`` and ``Expression.evaluate()``. 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | from cucumber_tag_expressions.parser import \ 9 | TagExpressionParser, TagExpressionError 10 | import pytest 11 | 12 | 13 | # ----------------------------------------------------------------------------- 14 | # TEST SUITE: TagExpressionParser.parse() and Expression.evaluate(tags) chain 15 | # ----------------------------------------------------------------------------- 16 | class TestTagExpression(object): 17 | # correct_test_data = [ 18 | # ("a and b", "( a and b )"), 19 | # ("a or (b)", "( a or b )"), 20 | # ("not a", "not ( a )"), 21 | # ("( a and b ) or ( c and d )", "( ( a and b ) or ( c and d ) )"), 22 | # ("not a or b and not c or not d or e and f", 23 | # "( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )"), 24 | # ] 25 | # -- FROM: Java 26 | # {"a and b", "( a and b )"}, 27 | # {"a or b", "( a or b )"}, 28 | # {"not a", "not ( a )"}, 29 | # {"( a and b ) or ( c and d )", "( ( a and b ) or ( c and d ) )"}, 30 | # {"not a or b and not c or not d or e and f", 31 | # "( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )"}, 32 | # {"not a\\(1\\) or b and not c or not d or e and f", 33 | # "( ( ( not ( a\\(1\\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )"} 34 | 35 | @pytest.mark.parametrize("tag_expression_text, expected, tags, case", [ 36 | ("", True, [], "no_tags"), 37 | ("", True, ["a"], "one tag: a"), 38 | ("", True, ["other"], "one tag: other"), 39 | ]) 40 | def test_empty_expression_is_true(self, tag_expression_text, expected, tags, case): 41 | tag_expression = TagExpressionParser.parse(tag_expression_text) 42 | assert expected == tag_expression.evaluate(tags) 43 | 44 | 45 | @pytest.mark.parametrize("tag_expression_text, expected, tags, case", [ 46 | ("not a", False, ["a", "other"], "two tags: a, other"), 47 | ("not a", False, ["a"], "one tag: a"), 48 | ("not a", True, ["other"], "one tag: other"), 49 | ("not a", True, [], "no_tags"), 50 | ]) 51 | def test_not_operation(self, tag_expression_text, expected, tags, case): 52 | tag_expression = TagExpressionParser.parse(tag_expression_text) 53 | assert expected == tag_expression.evaluate(tags) 54 | 55 | 56 | def test_complex_example(self): 57 | tag_expression_text = "not @a or @b and not @c or not @d or @e and @f" 58 | tag_expression = TagExpressionParser.parse(tag_expression_text) 59 | assert False == tag_expression.evaluate("@a @c @d".split()) 60 | 61 | def test_with_escaped_chars(self): 62 | # -- SOURCE: TagExpressionParserTest.java 63 | # Expression expr = parser.parse("((not @a\\(1\\) or @b\\(2\\)) and not @c\\(3\\) or not @d\\(4\\) or @e\\(5\\) and @f\\(6\\))"); 64 | # assertFalse(expr.evaluate(asList("@a(1) @c(3) @d(4)".split(" ")))); 65 | # assertTrue(expr.evaluate(asList("@b(2) @e(5) @f(6)".split(" ")))); 66 | print("NOT-SUPPORTED-YET") 67 | 68 | @pytest.mark.parametrize("tag_part", ["not", "and", "or"]) 69 | def test_fails_when_only_operators_are_used(self, tag_part): 70 | with pytest.raises(TagExpressionError): 71 | # -- EXAMPLE: text = "or or" 72 | text = "{part} {part}".format(part=tag_part) 73 | TagExpressionParser.parse(text) 74 | # -- JAVA-TESTS-END-HERE: TagExpressionParserTest.java 75 | 76 | 77 | @pytest.mark.parametrize("tag_expression_text, expected, tags, case", [ 78 | ("a and b", True, ["a", "b"], "both tags"), 79 | ("a and b", True, ["a", "b", "other"], "both tags and more"), 80 | ("a and b", False, ["a"], "one tag: a"), 81 | ("a and b", False, ["b"], "one tag: b"), 82 | ("a and b", False, ["other"], "one tag: other"), 83 | ("a and b", False, [], "no_tags"), 84 | ]) 85 | def test_and_operation(self, tag_expression_text, expected, tags, case): 86 | tag_expression = TagExpressionParser.parse(tag_expression_text) 87 | assert expected == tag_expression.evaluate(tags) 88 | 89 | @pytest.mark.parametrize("tag_expression_text, expected, tags, case", [ 90 | ("a or b", True, ["a", "b"], "both tags"), 91 | ("a or b", True, ["a", "b", "other"], "both tags and more"), 92 | ("a or b", True, ["a"], "one tag: a"), 93 | ("a or b", True, ["b"], "one tag: b"), 94 | ("a or b", False, ["other"], "one tag: other"), 95 | ("a or b", False, [], "no_tags"), 96 | ]) 97 | def test_or_operation(self, tag_expression_text, expected, tags, case): 98 | tag_expression = TagExpressionParser.parse(tag_expression_text) 99 | assert expected == tag_expression.evaluate(tags) 100 | 101 | @pytest.mark.parametrize("tag_expression_text, expected, tags, case", [ 102 | ("a", True, ["a", "other"], "two tags: a, other"), 103 | ("a", True, ["a"], "one tag: a"), 104 | ("a", False, ["other"], "one tag: other"), 105 | ("a", False, [], "no_tags"), 106 | ]) 107 | def test_literal(self, tag_expression_text, expected, tags, case): 108 | tag_expression = TagExpressionParser.parse(tag_expression_text) 109 | assert expected == tag_expression.evaluate(tags) 110 | 111 | # NOTE: CANDIDATE for property-based testing 112 | @pytest.mark.parametrize("tag_expression_text, expected, tags, case", [ 113 | ("a and b", True, ["a", "b"], "two tags: a, b"), 114 | ("a and b", False, ["a"], "one tag: a"), 115 | ("a and b", False, [], "no_tags"), 116 | ("a or b", True, ["a", "b"], "two tags: a, b"), 117 | ("a or b", True, ["b"], "one tag: b"), 118 | ("a or b", False, [], "no_tags"), 119 | ("a and b or c", True, ["a", "b", "c"], "three tags: a, b, c"), 120 | ("a and b or c", True, ["a", "other", "c"], "three tags: a, other, c"), 121 | ("a and b or c", True, ["a", "b", "other"], "three tags: a, b, other"), 122 | ("a and b or c", True, ["a", "b"], "two tags: a, b"), 123 | ("a and b or c", True, ["a", "c"], "two tags: a, c"), 124 | ("a and b or c", False, ["a"], "one tag: a"), 125 | ("a and b or c", True, ["c"], "one tag: c"), 126 | ("a and b or c", False, [], "not tags"), 127 | ]) 128 | def test_not_not_expression_sameas_expression(self, tag_expression_text, expected, tags, case): 129 | not2_tag_expression_text = "not not "+ tag_expression_text 130 | tag_expression1 = TagExpressionParser.parse(tag_expression_text) 131 | tag_expression2 = TagExpressionParser.parse(not2_tag_expression_text) 132 | value1 = tag_expression1.evaluate(tags) 133 | value2 = tag_expression2.evaluate(tags) 134 | assert value1 == value2 135 | assert expected == value1 136 | -------------------------------------------------------------------------------- /python/tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/tag-expressions/d13d4596b6e6939f9cd971a63990d6c8e8b9bc12/python/tests/unit/__init__.py -------------------------------------------------------------------------------- /python/tests/unit/test_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=bad-whitespace 3 | 4 | from cucumber_tag_expressions.model import Literal, And, Or, Not, True_ 5 | import pytest 6 | 7 | 8 | # ----------------------------------------------------------------------------- 9 | # TEST SUITE: Model Classes 10 | # ----------------------------------------------------------------------------- 11 | class TestAndOperation(object): 12 | 13 | @pytest.mark.parametrize("expected, tags, case", [ 14 | (False, [], "no_tags"), 15 | (False, ["a"], "one tag: a"), 16 | (False, ["b"], "one tag: b"), 17 | (False, ["other"], "one tag: other"), 18 | ( True, ["a", "b"], "both tags"), 19 | ( True, ["b", "a"], "both tags (reversed)"), 20 | (False, ["a", "b2"], "two tags: a, b2 (similar)"), 21 | (False, ["a", "other"], "two tags: a, other"), 22 | ]) 23 | def test_evaluate2(self, expected, tags, case): 24 | expression = And(Literal("a"), Literal("b")) 25 | assert expression.evaluate(tags) == expected 26 | 27 | @pytest.mark.parametrize("expected, tags, case", [ 28 | (False, [], "no_tags"), 29 | (False, ["a"], "one tag: a"), 30 | (False, ["b"], "one tag: b"), 31 | (False, ["other"], "one tag: other"), 32 | (False, ["a", "b"], "two tags: a, b"), 33 | (False, ["a", "c"], "two tags: a, c"), 34 | (False, ["a", "other"], "two tags: a, other"), 35 | ( True, ["a", "b", "c"], "all tags: a, b, c"), 36 | (False, ["other", "b", "c"], "three tags: other, b, c"), 37 | ]) 38 | def test_evaluate3(self, expected, tags, case): 39 | expression = And(Literal("a"), Literal("b"), Literal("c")) 40 | assert expression.evaluate(tags) == expected 41 | 42 | @pytest.mark.parametrize("expected, expression", [ 43 | ("( a and b )", And(Literal("a"), Literal("b"))), 44 | ("( a and b and c )", And(Literal("a"), Literal("b"), Literal("c"))), 45 | ("( a )", And(Literal("a"))), 46 | ]) 47 | def test_convert_to_string(self, expected, expression): 48 | assert expected == str(expression) 49 | 50 | 51 | class TestOrOperation(object): 52 | @pytest.mark.parametrize("expected, tags, case", [ 53 | (False, [], "no_tags"), 54 | ( True, ["a"], "one tag: a"), 55 | ( True, ["b"], "one tag: b"), 56 | (False, ["other"], "one tag: other"), 57 | ( True, ["a", "b"], "both tags"), 58 | ( True, ["b", "a"], "both tags (reversed)"), # CASE: Ordering 59 | ( True, ["a", "b2"], "two tags: a, b2"), # CASE: SIMILARITY 60 | ( True, ["a", "other"], "two tags: a, other"), 61 | (False, ["other1", "other2"], "two tags: other1, other2"), 62 | ]) 63 | def test_evaluate2(self, expected, tags, case): 64 | expression = Or(Literal("a"), Literal("b")) 65 | assert expression.evaluate(tags) == expected 66 | 67 | @pytest.mark.parametrize("expected, tags, case", [ 68 | (False, [], "no_tags"), 69 | ( True, ["a"], "one tag: a"), 70 | ( True, ["b"], "one tag: b"), 71 | ( True, ["c"], "one tag: c"), 72 | (False, ["other"], "one tag: other"), 73 | ( True, ["a", "b"], "two tags: a, b"), 74 | ( True, ["a", "c"], "two tags: a, c"), 75 | ( True, ["a", "other"], "two tags: a, other"), 76 | ( True, ["a", "b", "c"], "all tags"), 77 | ( True, ["other", "b", "c"], "three tags: other, b, c"), 78 | (False, ["other", "other2"], "two tahs: other1, other2"), 79 | ]) 80 | def test_evaluate3(self, expected, tags, case): 81 | expression = Or(Literal("a"), Literal("b"), Literal("c")) 82 | assert expression.evaluate(tags) == expected 83 | 84 | 85 | class TestNotOperation(object): 86 | @pytest.mark.parametrize("expected, tags, case", [ 87 | ( True, [], "no_tags"), 88 | (False, ["a"], "one tag: a"), 89 | ( True, ["other"], "one tag: other"), 90 | (False, ["a", "other"], "two tags: a, other"), 91 | (False, ["other", "a"], "two tags: other, a (reversed)"), 92 | ( True, ["other1", "other2"], "two tags: other1, other2"), 93 | ]) 94 | def test_evaluate1(self, expected, tags, case): 95 | expression = Not(Literal("a")) 96 | assert expression.evaluate(tags) == expected 97 | 98 | # -- HINT: Not with binary operator was using double-parenthesis in the past 99 | @pytest.mark.parametrize("expected, expression", [ 100 | ("not ( a and b )", Not(And(Literal("a"), Literal("b"))) ), 101 | ("not ( a or b )", Not(Or(Literal("a"), Literal("b"))) ), 102 | ("( a and not ( b or c ) )", And(Literal("a"), Not(Or(Literal("b"), Literal("c")))) ), 103 | ]) 104 | def test_convert_to_string(self, expected, expression): 105 | assert expected == str(expression) 106 | 107 | class TestTrueOperation(object): 108 | @pytest.mark.parametrize("expected, tags, case", [ 109 | ( True, [], "no_tags"), 110 | ( True, ["a"], "one tag: a"), 111 | ( True, ["other"], "one tag: other"), 112 | ]) 113 | def test_evaluate1(self, expected, tags, case): 114 | expression = True_() 115 | assert expression.evaluate(tags) == expected 116 | 117 | 118 | class TestComposedExpression(object): 119 | @pytest.mark.parametrize("expected, tags, case", [ 120 | ( True, [], "no_tags"), 121 | ( True, ["a"], "one tag: a"), 122 | ( True, ["b"], "one tag: b"), 123 | ( True, ["other"], "one tag: other"), 124 | (False, ["a", "b"], "two tags: a, b"), 125 | (False, ["b", "a"], " two tags: b, a (ordering)"), 126 | ( True, ["a", "b2"], "two tags: a, b2 (similar)"), 127 | ( True, ["a", "other"], "two tags: a, other"), 128 | ]) 129 | def test_evaluate_not__a_and_b(self, expected, tags, case): 130 | expression = Not(And(Literal("a"), Literal("b"))) 131 | assert expression.evaluate(tags) == expected 132 | 133 | @pytest.mark.parametrize("expected, tags, case", [ 134 | ( True, [], "no_tags"), 135 | (False, ["a"], "one tag: a"), 136 | (False, ["b"], "one tag: b"), 137 | ( True, ["other"], "one tag: other"), 138 | (False, ["a", "b"], "two tags: a, b"), 139 | (False, ["b", "a"], "two tags: b, a (ordering)"), 140 | (False, ["a", "b2"], "two tags: a, b2 (similar)"), 141 | (False, ["a", "other"], "two tags: other"), 142 | ]) 143 | def test_evaluate_not__a_or_b(self, expected, tags, case): 144 | expression = Not(Or(Literal("a"), Literal("b"))) 145 | assert expression.evaluate(tags) == expected 146 | 147 | @pytest.mark.parametrize("expected, tags, case", [ 148 | ( True, [], "no_tags"), 149 | (False, ["a"], "one tag: a"), 150 | ( True, ["b"], "one tag: b"), 151 | ( True, ["other"], "one tag: other"), 152 | ( True, ["a", "b"], "two tags: a, b"), 153 | ( True, ["b", "a"], "two tags: b, a (ordering)"), 154 | (False, ["a", "other"], "two tags: a, other"), 155 | ]) 156 | def test_evaluate_not_a_or_b(self, expected, tags, case): 157 | expression = Or(Not(Literal("a")), Literal("b")) 158 | assert expression.evaluate(tags) == expected 159 | 160 | @pytest.mark.parametrize("expected, tags, case", [ 161 | ( True, [], "no_tags"), 162 | ( True, ["a"], "one tag: a"), 163 | ( True, ["b"], "one tag: b"), 164 | ( True, ["other"], "one tag: other"), 165 | (False, ["a", "b"], "two tags: a, b"), 166 | ( True, ["a", "other"], "two tags: a, other"), # CASE: Other 167 | (False, ["b", "a"], "two tags: b, a (ordering)"), 168 | ]) 169 | def test_evaluate_not_a_or_not_b(self, expected, tags, case): 170 | expression = Or(Not(Literal("a")), Not(Literal("b"))) 171 | assert expression.evaluate(tags) == expected 172 | 173 | -------------------------------------------------------------------------------- /python/tox.ini: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # TOX CONFIGURATION: 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # 6 | # Use tox to run tasks (tests, ...) in a clean virtual environment. 7 | # 8 | # USAGE: 9 | # 10 | # tox -e py310 # Runs tox with python 3.10 11 | # tox -e py27 # Runs tox with python 2.7 12 | # tox # Runs tox with all installed python versions. 13 | # tox -r -e py27 # Recreates virtual environment and runs tox with python 2.7 14 | # tox -a -v # Shows all test-environments w/ their descriptions. 15 | # 16 | # SEE ALSO: 17 | # * https://tox.readthedocs.io/en/latest/config.html 18 | # ============================================================================ 19 | # -- ONLINE USAGE: 20 | # PIP_INDEX_URL = http://pypi.python.org/simple 21 | # OOPS: pypy3 fails in virtualenv creation when python2 interpreter is used. 22 | 23 | [tox] 24 | minversion = 2.8 25 | envlist = py312, py311, py310, py27, py39, pypy3 26 | skip_missing_interpreters = true 27 | isolated_build = true 28 | 29 | # ----------------------------------------------------------------------------- 30 | # TEST ENVIRONMENTS: 31 | # ----------------------------------------------------------------------------- 32 | [testenv] 33 | changedir = {toxinidir} 34 | commands= 35 | pytest {posargs:tests} 36 | deps= 37 | -r {toxinidir}/py.requirements/testing.txt 38 | passenv = 39 | PYTHONPATH={toxinidir} 40 | 41 | 42 | # -- SPECIAL CASE:Script(s) do not seem to be installed. 43 | # RELATED: https://github.com/pypa/virtualenv/issues/2284 -- macOS 12 Monterey related 44 | # DISABLED: install_command = pip install {opts} {packages} 45 | [testenv:py27] 46 | changedir = {toxinidir} 47 | commands= 48 | python -mpytest {posargs:tests} 49 | deps= 50 | -r {toxinidir}/py.requirements/testing.txt 51 | passenv = 52 | PYTHONPATH={toxinidir} 53 | 54 | # -- VIRTUAL-ENVIRONMENT SETUP PROCEDURE: For python 2.7 55 | # virtualenv -p python2.7 .venv_py27 56 | # source .venv_py27 57 | # scripts/ensurepip_python27.sh 58 | # python -mpip install -r py.requirements/basic.txt 59 | # python -mpip install -r py.requirements/testing.txt 60 | 61 | 62 | [testenv:devenv] 63 | # basepython = python2.7 64 | # envdir = devenv 65 | description = Use package in development environment (develop-mode). 66 | usedevelop = True 67 | 68 | 69 | # ----------------------------------------------------------------------------- 70 | # TEST ENVIRONMENTS: For static code analysis, test coverage analysis, ... 71 | # ----------------------------------------------------------------------------- 72 | [testenv:pylint] 73 | description = Runs pylint (static code analysis) to detect any problems. 74 | changedir = {toxinidir} 75 | usedevelop = True 76 | commands= 77 | pylint {posargs:cucumber_tag_expressions/} 78 | deps= 79 | pylint>=1.7 80 | passenv = 81 | PYTHONPATH = {toxinidir} 82 | 83 | 84 | [testenv:bandit] 85 | description = Runs bandit (static code analysis) to detect security related problems. 86 | usedevelop = True 87 | changedir = {toxinidir} 88 | commands= 89 | bandit {posargs:cucumber_tag_expressions/} 90 | deps= 91 | bandit>=1.4 92 | passenv = 93 | PYTHONPATH = {toxinidir} 94 | 95 | 96 | [testenv:coverage] 97 | description = Generates test coverage report (html-report: build/coverage.html/). 98 | usedevelop = True 99 | changedir = {toxinidir} 100 | commands= 101 | coverage run -m pytest {posargs:tests} 102 | coverage combine 103 | coverage report 104 | coverage html 105 | deps= 106 | {[testenv]deps} 107 | passenv = 108 | PYTHONPATH = {toxinidir} 109 | 110 | 111 | # ----------------------------------------------------------------------------- 112 | # TEST ENVIRONMENTS: Clean rooms 113 | # ----------------------------------------------------------------------------- 114 | [testenv:cleanroom2] 115 | basepython = python2 116 | changedir = {envdir} 117 | commands= 118 | {toxinidir}/scripts/toxcmd.py copytree ../../tests . 119 | {toxinidir}/scripts/toxcmd.py copy ../../pytest.ini . 120 | pytest {posargs:tests} 121 | passenv = 122 | PYTHONPATH = .:{envdir} 123 | 124 | 125 | [testenv:cleanroom3] 126 | basepython = python3 127 | changedir = {envdir} 128 | commands= 129 | {toxinidir}/scripts/toxcmd.py copytree ../../tests . 130 | {toxinidir}/scripts/toxcmd.py copy ../../pytest.ini . 131 | {toxinidir}/scripts/toxcmd.py 2to3 -w -n --no-diffs tests 132 | pytest {posargs:tests} 133 | passenv = 134 | PYTHONPATH = .:{envdir} 135 | 136 | # --------------------------------------------------------------------------- 137 | # SELDOM-USED TEST ENVIRONMENTS: 138 | # --------------------------------------------------------------------------- 139 | [testenv:jy27] 140 | description = Runs tests with jython2.7. 141 | basepython= jython 142 | -------------------------------------------------------------------------------- /ruby/.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | coverage/ 3 | acceptance/ 4 | *-go 5 | *.iml 6 | -------------------------------------------------------------------------------- /ruby/.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | -------------------------------------------------------------------------------- /ruby/.rubocop.yml: -------------------------------------------------------------------------------- 1 | # TODO: Re-enable once tag-expressions > 7.0 2 | #require: 3 | # - rubocop-performance 4 | # - rubocop-rake 5 | # - rubocop-rspec 6 | 7 | inherit_from: .rubocop_todo.yml 8 | 9 | inherit_mode: 10 | merge: 11 | - Exclude 12 | 13 | AllCops: 14 | TargetRubyVersion: 2.3 15 | # TODO: Re-enable once rubocop > 1.10 16 | # NewCops: enable 17 | 18 | # Disabled on our repo's to enable polyglot-release 19 | # TODO: Re-enable once rubocop > 1.40 20 | #Gemspec/RequireMFA: 21 | # Enabled: false 22 | 23 | Layout/LineLength: 24 | Max: 200 25 | 26 | Style/Documentation: 27 | Enabled: false 28 | 29 | Style/RegexpLiteral: 30 | EnforcedStyle: slashes 31 | AllowInnerSlashes: true 32 | 33 | # TODO: Re-enable once rubocop-rspec > 1.20 34 | #RSpec/MessageSpies: 35 | # EnforcedStyle: receive 36 | -------------------------------------------------------------------------------- /ruby/.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2024-02-22 14:16:42 +0000 using RuboCop version 0.79.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | ## TODO: Nov '23 -> 9 files inspected, 14 offenses detected 10 | ## TODO: Feb '24 -> 10 files inspected, 6 offenses detected 11 | 12 | # Offense count: 1 13 | Metrics/AbcSize: 14 | Max: 27 15 | 16 | # Offense count: 1 17 | # Configuration parameters: CountComments. 18 | Metrics/ClassLength: 19 | Max: 122 20 | 21 | # Offense count: 1 22 | Metrics/CyclomaticComplexity: 23 | Max: 13 24 | 25 | # Offense count: 2 26 | # Configuration parameters: CountComments, ExcludedMethods. 27 | Metrics/MethodLength: 28 | Max: 24 29 | 30 | # Offense count: 1 31 | Metrics/PerceivedComplexity: 32 | Max: 14 33 | -------------------------------------------------------------------------------- /ruby/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /ruby/README.md: -------------------------------------------------------------------------------- 1 | # Cucumber Tag Expressions for Ruby 2 | 3 | [The docs are here](https://cucumber.io/docs/cucumber/api/#tag-expressions). 4 | -------------------------------------------------------------------------------- /ruby/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('lib', __dir__) 4 | 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: ['spec'] 9 | -------------------------------------------------------------------------------- /ruby/VERSION: -------------------------------------------------------------------------------- 1 | 6.2.0 2 | -------------------------------------------------------------------------------- /ruby/cucumber-tag-expressions.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | version = File.read(File.expand_path('VERSION', __dir__)).strip 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'cucumber-tag-expressions' 7 | s.version = version 8 | s.authors = ['Andrea Nodari', 'Aslak Hellesøy'] 9 | s.description = 'Cucumber tag expressions for ruby' 10 | s.summary = "#{s.name}-#{s.version}" 11 | s.email = 'cukes@googlegroups.com' 12 | s.homepage = 'https://cucumber.io/docs/cucumber/api/#tag-expressions' 13 | s.platform = Gem::Platform::RUBY 14 | s.license = 'MIT' 15 | s.required_ruby_version = '>= 2.3' 16 | s.required_rubygems_version = '>= 3.0.8' 17 | 18 | s.metadata = { 19 | 'bug_tracker_uri' => 'https://github.com/cucumber/cucumber/issues', 20 | 'changelog_uri' => 'https://github.com/cucumber/tag-expressions/blob/main/CHANGELOG.md', 21 | 'documentation_uri' => 'https://cucumber.io/docs/cucumber/api/#tag-expressions', 22 | 'mailing_list_uri' => 'https://groups.google.com/forum/#!forum/cukes', 23 | 'source_code_uri' => 'https://github.com/cucumber/tag-expressions/tree/main/ruby' 24 | } 25 | 26 | s.add_development_dependency 'rake', '~> 13.1' 27 | s.add_development_dependency 'rspec', '~> 3.11' 28 | s.add_development_dependency 'rubocop', '~> 0.93.0' 29 | 30 | s.files = Dir['README.md', 'LICENSE', 'lib/**/*'] 31 | s.rdoc_options = ['--charset=UTF-8'] 32 | s.require_path = 'lib' 33 | end 34 | -------------------------------------------------------------------------------- /ruby/lib/cucumber/tag_expressions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cucumber/tag_expressions/parser' 4 | -------------------------------------------------------------------------------- /ruby/lib/cucumber/tag_expressions/expressions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cucumber 4 | module TagExpressions 5 | # Literal expression node 6 | class Literal 7 | def initialize(value) 8 | @value = value 9 | end 10 | 11 | def evaluate(variables) 12 | variables.include?(@value) 13 | end 14 | 15 | def to_s 16 | @value 17 | .gsub(/\\/, '\\\\\\\\') 18 | .gsub(/\(/, '\\(') 19 | .gsub(/\)/, '\\)') 20 | .gsub(/\s/, '\\ ') 21 | end 22 | end 23 | 24 | # Not expression node 25 | class Not 26 | def initialize(expression) 27 | @expression = expression 28 | end 29 | 30 | def evaluate(variables) 31 | !@expression.evaluate(variables) 32 | end 33 | 34 | def to_s 35 | if @expression.is_a?(And) || @expression.is_a?(Or) 36 | # -- HINT: Binary operations already provide "( ... )" 37 | "not #{@expression}" 38 | else 39 | "not ( #{@expression} )" 40 | end 41 | end 42 | end 43 | 44 | # Or expression node 45 | class Or 46 | def initialize(left, right) 47 | @left = left 48 | @right = right 49 | end 50 | 51 | def evaluate(variables) 52 | @left.evaluate(variables) || @right.evaluate(variables) 53 | end 54 | 55 | def to_s 56 | "( #{@left} or #{@right} )" 57 | end 58 | end 59 | 60 | # And expression node 61 | class And 62 | def initialize(left, right) 63 | @left = left 64 | @right = right 65 | end 66 | 67 | def evaluate(variables) 68 | @left.evaluate(variables) && @right.evaluate(variables) 69 | end 70 | 71 | def to_s 72 | "( #{@left} and #{@right} )" 73 | end 74 | end 75 | 76 | class True 77 | def evaluate(_variables) 78 | true 79 | end 80 | 81 | def to_s 82 | 'true' 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /ruby/lib/cucumber/tag_expressions/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cucumber/tag_expressions/expressions' 4 | 5 | module Cucumber 6 | module TagExpressions 7 | class Parser 8 | def initialize 9 | @expressions = [] 10 | @operators = [] 11 | end 12 | 13 | def parse(infix_expression) 14 | expected_token_type = :operand 15 | tokens = tokenize(infix_expression) 16 | return True.new if tokens.empty? 17 | 18 | tokens.each do |token| 19 | expected_token_type = handle_sequential_tokens(token, infix_expression, expected_token_type) 20 | end 21 | 22 | while @operators.any? 23 | raise %{Tag expression "#{infix_expression}" could not be parsed because of syntax error: Unmatched (.} if @operators.last == '(' 24 | 25 | push_expression(pop(@operators)) 26 | end 27 | expression = pop(@expressions) 28 | @expressions.empty? ? expression : raise('Not empty') 29 | end 30 | 31 | private 32 | 33 | def assoc_of(token, value) 34 | operator_types.dig(token, :assoc) == value 35 | end 36 | 37 | def lower_precedence?(operation) 38 | (assoc_of(operation, :left) && precedence(operation) <= precedence(@operators.last)) || 39 | (assoc_of(operation, :right) && precedence(operation) < precedence(@operators.last)) 40 | end 41 | 42 | def operator?(token) 43 | %i[unary_operator binary_operator].include?(operator_types.dig(token, :type)) 44 | end 45 | 46 | def precedence(token) 47 | operator_types.dig(token, :precedence) 48 | end 49 | 50 | def tokenize(infix_expression) 51 | tokens = [] 52 | escaped = false 53 | token = +'' 54 | infix_expression.chars.each do |char| 55 | if escaped 56 | unless char == '(' || char == ')' || char == '\\' || whitespace?(char) 57 | raise %(Tag expression "#{infix_expression}" could not be parsed because of syntax error: Illegal escape before "#{char}".) 58 | end 59 | 60 | token += char 61 | escaped = false 62 | elsif char == '\\' 63 | escaped = true 64 | elsif char == '(' || char == ')' || whitespace?(char) 65 | if token.length.positive? 66 | tokens.push(token) 67 | token = +'' 68 | end 69 | tokens.push(char) unless whitespace?(char) 70 | else 71 | token += char 72 | end 73 | end 74 | tokens.push(token) if token.length.positive? 75 | tokens 76 | end 77 | 78 | def push_expression(token) 79 | case token 80 | when 'and' then @expressions.push(And.new(*pop(@expressions, 2))) 81 | when 'or' then @expressions.push(Or.new(*pop(@expressions, 2))) 82 | when 'not' then @expressions.push(Not.new(pop(@expressions))) 83 | else @expressions.push(Literal.new(token)) 84 | end 85 | end 86 | 87 | def handle_sequential_tokens(token, infix_expression, expected_token_type) 88 | if operator_types[token] 89 | send("handle_#{operator_types.dig(token, :type)}", infix_expression, token, expected_token_type) 90 | else 91 | handle_literal(infix_expression, token, expected_token_type) 92 | end 93 | end 94 | 95 | def handle_unary_operator(infix_expression, token, expected_token_type) 96 | check(infix_expression, expected_token_type, :operand) 97 | @operators.push(token) 98 | :operand 99 | end 100 | 101 | def handle_binary_operator(infix_expression, token, expected_token_type) 102 | check(infix_expression, expected_token_type, :operator) 103 | push_expression(pop(@operators)) while @operators.any? && operator?(@operators.last) && lower_precedence?(token) 104 | @operators.push(token) 105 | :operand 106 | end 107 | 108 | def handle_open_paren(infix_expression, token, expected_token_type) 109 | check(infix_expression, expected_token_type, :operand) 110 | @operators.push(token) 111 | :operand 112 | end 113 | 114 | def handle_close_paren(infix_expression, _token, expected_token_type) 115 | check(infix_expression, expected_token_type, :operator) 116 | push_expression(pop(@operators)) while @operators.any? && @operators.last != '(' 117 | raise %{Tag expression "#{infix_expression}" could not be parsed because of syntax error: Unmatched ).} if @operators.empty? 118 | 119 | pop(@operators) if @operators.last == '(' 120 | :operator 121 | end 122 | 123 | def handle_literal(infix_expression, token, expected_token_type) 124 | check(infix_expression, expected_token_type, :operand) 125 | push_expression(token) 126 | :operator 127 | end 128 | 129 | def check(infix_expression, expected_token_type, token_type) 130 | return if expected_token_type == token_type 131 | 132 | raise %(Tag expression "#{infix_expression}" could not be parsed because of syntax error: Expected #{expected_token_type}.) 133 | end 134 | 135 | def pop(array, amount = 1) 136 | result = array.pop(amount) 137 | raise('Empty stack') if result.length != amount 138 | 139 | amount == 1 ? result.first : result 140 | end 141 | 142 | def operator_types 143 | { 144 | 'or' => { type: :binary_operator, precedence: 0, assoc: :left }, 145 | 'and' => { type: :binary_operator, precedence: 1, assoc: :left }, 146 | 'not' => { type: :unary_operator, precedence: 2, assoc: :right }, 147 | ')' => { type: :close_paren, precedence: -1 }, 148 | '(' => { type: :open_paren, precedence: 1 } 149 | } 150 | end 151 | 152 | def whitespace?(char) 153 | char.match(/\s/) 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /ruby/spec/errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | tests = YAML.load_file('../testdata/errors.yml') 4 | 5 | describe 'Errors' do 6 | tests.each do |test| 7 | let(:parser) { Cucumber::TagExpressions::Parser.new } 8 | 9 | it "fails to parse '#{test['expression']}' with '#{test['error']}'" do 10 | expect { parser.parse(test['expression']) }.to raise_error(test['error']) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /ruby/spec/evaluations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | evaluations = YAML.load_file('../testdata/evaluations.yml') 4 | 5 | describe 'Evaluations' do 6 | evaluations.each do |evaluation| 7 | context evaluation['expression'] do 8 | let(:parser) { Cucumber::TagExpressions::Parser.new } 9 | 10 | evaluation['tests'].each do |test| 11 | it "evaluates [#{test['variables'].join(', ')}] to #{test['result']}" do 12 | expect(parser.parse(evaluation['expression']).evaluate(test['variables'])).to eq(test['result']) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /ruby/spec/parsing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | tests = YAML.load_file('../testdata/parsing.yml') 4 | 5 | describe 'Parsing' do 6 | let(:parser) { Cucumber::TagExpressions::Parser.new } 7 | 8 | tests.each do |test| 9 | it "parses '#{test['expression']}' into '#{test['formatted']}'" do 10 | expect(parser.parse(test['expression']).to_s).to eq(test['formatted']) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /ruby/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cucumber/tag_expressions/parser' 4 | require 'yaml' 5 | -------------------------------------------------------------------------------- /testdata/errors.yml: -------------------------------------------------------------------------------- 1 | - expression: '@a @b or' 2 | error: 'Tag expression "@a @b or" could not be parsed because of syntax error: Expected operator.' 3 | - expression: '@a and (@b not)' 4 | error: 'Tag expression "@a and (@b not)" could not be parsed because of syntax error: Expected operator.' 5 | - expression: '@a and (@b @c) or' 6 | error: 'Tag expression "@a and (@b @c) or" could not be parsed because of syntax error: Expected operator.' 7 | - expression: '@a and or' 8 | error: 'Tag expression "@a and or" could not be parsed because of syntax error: Expected operand.' 9 | - expression: 'or or' 10 | error: 'Tag expression "or or" could not be parsed because of syntax error: Expected operand.' 11 | - expression: 'a and or' 12 | error: 'Tag expression "a and or" could not be parsed because of syntax error: Expected operand.' 13 | - expression: 'a b' 14 | error: 'Tag expression "a b" could not be parsed because of syntax error: Expected operator.' 15 | - expression: '( a and b ) )' 16 | error: 'Tag expression "( a and b ) )" could not be parsed because of syntax error: Unmatched ).' 17 | - expression: '( ( a and b )' 18 | error: 'Tag expression "( ( a and b )" could not be parsed because of syntax error: Unmatched (.' 19 | - expression: 'x or \y or z' 20 | error: 'Tag expression "x or \y or z" could not be parsed because of syntax error: Illegal escape before "y".' 21 | - expression: 'x\ or y' 22 | error: 'Tag expression "x\ or y" could not be parsed because of syntax error: Expected operator.' 23 | -------------------------------------------------------------------------------- /testdata/evaluations.yml: -------------------------------------------------------------------------------- 1 | - expression: 'not x' 2 | tests: 3 | - variables: ['x'] 4 | result: false 5 | - variables: ['y'] 6 | result: true 7 | - expression: 'x and y' 8 | tests: 9 | - variables: ['x', 'y'] 10 | result: true 11 | - variables: ['x'] 12 | result: false 13 | - variables: ['y'] 14 | result: false 15 | 16 | - expression: 'x or y' 17 | tests: 18 | - variables: [] 19 | result: false 20 | - variables: ['x', 'y'] 21 | result: true 22 | - variables: ['x'] 23 | result: true 24 | - variables: ['y'] 25 | result: true 26 | - expression: 'x\(1\) or y\(2\)' 27 | tests: 28 | - variables: ['x(1)'] 29 | result: true 30 | - variables: ['y(2)'] 31 | result: true 32 | - expression: 'x\\ or y\\\) or z\\' 33 | tests: 34 | - variables: ['x\'] 35 | result: true 36 | - variables: ['y\)'] 37 | result: true 38 | - variables: ['z\'] 39 | result: true 40 | - variables: ['x'] 41 | result: false 42 | - variables: ['y)'] 43 | result: false 44 | - variables: ['z'] 45 | result: false 46 | - expression: '\\x or y\\ or z\\' 47 | tests: 48 | - variables: ['\x'] 49 | result: true 50 | - variables: ['y\'] 51 | result: true 52 | - variables: ['z\'] 53 | result: true 54 | - variables: ['x'] 55 | result: false 56 | - variables: ['y'] 57 | result: false 58 | - variables: ['z'] 59 | result: false 60 | -------------------------------------------------------------------------------- /testdata/parsing.yml: -------------------------------------------------------------------------------- 1 | - expression: '' 2 | formatted: 'true' 3 | - expression: 'a and b' 4 | formatted: '( a and b )' 5 | - expression: 'a or b' 6 | formatted: '( a or b )' 7 | - expression: 'not a' 8 | formatted: 'not ( a )' 9 | - expression: 'a and b and c' 10 | formatted: '( ( a and b ) and c )' 11 | - expression: '( a and b ) or ( c and d )' 12 | formatted: '( ( a and b ) or ( c and d ) )' 13 | - expression: 'not a or b and not c or not d or e and f' 14 | formatted: '( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )' 15 | - expression: 'not a\(\) or b and not c or not d or e and f' 16 | formatted: '( ( ( not ( a\(\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )' 17 | 18 | - expression: 'not (a and b)' 19 | formatted: 'not ( a and b )' 20 | - expression: 'not (a or b)' 21 | formatted: 'not ( a or b )' 22 | - expression: 'not (a and b) and c or not (d or f)' 23 | formatted: '( ( not ( a and b ) and c ) or not ( d or f ) )' 24 | 25 | - expression: 'a\\ and b' 26 | formatted: '( a\\ and b )' 27 | - expression: '\\a and b' 28 | formatted: '( \\a and b )' 29 | - expression: 'a\\ and b' 30 | formatted: '( a\\ and b )' 31 | - expression: 'a and b\\' 32 | formatted: '( a and b\\ )' 33 | - expression: '( a and b\\\\)' 34 | formatted: '( a and b\\\\ )' 35 | - expression: 'a\\\( and b\\\)' 36 | formatted: '( a\\\( and b\\\) )' 37 | - expression: '(a and \\b)' 38 | formatted: '( a and \\b )' 39 | - expression: 'x or(y) ' 40 | formatted: '( x or y )' 41 | - expression: 'x\(1\) or(y\(2\))' 42 | formatted: '( x\(1\) or y\(2\) )' 43 | - expression: '\\x or y\\ or z\\' 44 | formatted: '( ( \\x or y\\ ) or z\\ )' 45 | - expression: 'x\\ or(y\\\)) or(z\\)' 46 | formatted: '( ( x\\ or y\\\) ) or z\\ )' 47 | - expression: 'x\ or y' 48 | formatted: '( x\ or y )' 49 | --------------------------------------------------------------------------------