├── .0pdd.yml ├── .gitattributes ├── .github └── workflows │ ├── actionlint.yml │ ├── codecov.yml │ ├── copyrights.yml │ ├── markdown-lint.yml │ ├── pdd.yml │ ├── rake.yml │ ├── reuse.yml │ ├── typos.yml │ ├── xcop.yml │ └── yamllint.yml ├── .gitignore ├── .markdownlint.yml ├── .pdd ├── .rubocop.yml ├── .rultor.yml ├── .simplecov ├── CITATION.cff ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── LICENSES └── MIT.txt ├── README.md ├── REUSE.toml ├── Rakefile ├── assets ├── puzzles.xsd ├── puzzles.xsl └── puzzles_json.xsl ├── bin └── pdd ├── cucumber.yml ├── features ├── applies_rules.feature ├── avoiding_duplicates.feature ├── catches_broken_puzzles.feature ├── cli.feature ├── gem_package.feature ├── html_output.feature ├── json_output.feature ├── parsing.feature ├── rake.feature ├── remove.feature ├── step_definitions │ └── steps.rb ├── support │ └── env.rb ├── unicode.feature └── uses_config.feature ├── lib ├── pdd.rb └── pdd │ ├── puzzle.rb │ ├── rake_task.rb │ ├── rule │ ├── duplicates.rb │ ├── estimates.rb │ ├── roles.rb │ └── text.rb │ ├── source.rb │ ├── sources.rb │ └── version.rb ├── pdd.gemspec ├── test ├── test__helper.rb ├── test_duplicates.rb ├── test_estimates.rb ├── test_many.rb ├── test_pdd.rb ├── test_rake_task.rb ├── test_roles.rb ├── test_source.rb ├── test_source_todo.rb ├── test_sources.rb └── test_text.rb ├── test_assets ├── aladdin.jpg ├── article.pdf ├── cambria.woff ├── elegant-objects.png ├── favicon.ico └── puzzles │ ├── 1-04e35eb3 │ ├── 132-bc1dfafe │ ├── 1425-59819ae3 │ ├── 42-0d933cc0 │ ├── 44-660e9d6f │ ├── 55-947a180a │ ├── 71-8097fa26 │ ├── 91-ecb9aa47 │ └── 93-641fe341 └── utils └── glob.rb /.0pdd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | errors: 5 | - yegor256@gmail.com 6 | # alerts: 7 | # github: 8 | # - yegor256 9 | 10 | tags: 11 | - pdd 12 | - bug 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Check out all text files in UNIX format, with LF as end of line 2 | # Don't change this file. If you have any ideas about it, please 3 | # submit a separate issue about it and we'll discuss. 4 | 5 | * text=auto eol=lf 6 | *.java ident 7 | *.xml ident 8 | *.jpg binary 9 | *.png binary 10 | *.pdf binary 11 | *.woff binary 12 | *.ico binary 13 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: actionlint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | actionlint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Download actionlint 20 | id: get_actionlint 21 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 22 | shell: bash 23 | - name: Check workflow files 24 | run: ${{ steps.get_actionlint.outputs.executable }} -color 25 | shell: bash 26 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: codecov 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | codecov: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - run: sudo apt-get install --yes libmagic-dev 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: 3.3 23 | bundler-cache: true 24 | - run: bundle config set --global path "$(pwd)/vendor/bundle" 25 | - run: bundle install --no-color 26 | - run: bundle exec rake 27 | - uses: codecov/codecov-action@v5 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/copyrights.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: copyrights 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | copyrights: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: yegor256/copyrights-action@0.0.8 20 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: markdown-lint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | markdown-lint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: DavidAnson/markdownlint-cli2-action@v20.0.0 20 | -------------------------------------------------------------------------------- /.github/workflows/pdd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: pdd 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | pdd: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: volodya-lombrozo/pdd-action@master 20 | -------------------------------------------------------------------------------- /.github/workflows/rake.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: rake 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | rake: 15 | strategy: 16 | matrix: 17 | os: [ubuntu-24.04] 18 | ruby: [3.3] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: sudo apt-get install --yes libmagic-dev 23 | - uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - run: bundle config set --global path "$(pwd)/vendor/bundle" 28 | - run: bundle install --no-color 29 | - run: bundle exec rake 30 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: reuse 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | reuse: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: fsfe/reuse-action@v5 20 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: typos 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | typos: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: crate-ci/typos@v1.32.0 20 | -------------------------------------------------------------------------------- /.github/workflows/xcop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: xcop 6 | "on": 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | xcop: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: g4s8/xcop-action@master 20 | -------------------------------------------------------------------------------- /.github/workflows/yamllint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: yamllint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | yamllint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: ibiqlik/action-yamllint@v3 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | .bundle/**/* 3 | .DS_Store 4 | .idea/ 5 | .idea/**/* 6 | .yardoc/ 7 | *.gem 8 | coverage/ 9 | coverage/**/* 10 | doc/ 11 | node_modules/ 12 | rdoc/ 13 | rdoc/**/* 14 | tmp/**/* 15 | vendor/ 16 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | default: true 5 | MD013: 6 | line_length: 160 7 | -------------------------------------------------------------------------------- /.pdd: -------------------------------------------------------------------------------- 1 | --source=. 2 | --verbose 3 | --exclude .idea/**/* 4 | --exclude .bundle/**/* 5 | --exclude target/**/* 6 | --exclude coverage/**/* 7 | --exclude test_assets/**/* 8 | --exclude README.md 9 | --exclude features/cli.feature 10 | --exclude features/parsing.feature 11 | --exclude features/catches_broken_puzzles.feature 12 | --exclude features/remove.feature 13 | --exclude features/uses_config.feature 14 | --exclude features/html_output.feature 15 | --exclude features/json_output.feature 16 | --exclude features/avoiding_duplicates.feature 17 | --exclude features/applies_rules.feature 18 | --exclude features/unicode.feature 19 | --exclude lib/pdd/source.rb 20 | --exclude test/test_source.rb 21 | --exclude test/test_source_todo.rb 22 | --exclude test/test_pdd.rb 23 | --exclude src/main/resources/images/**/* 24 | --rule min-words:20 25 | --rule min-estimate:15 26 | --rule max-estimate:90 27 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | AllCops: 5 | Exclude: 6 | - 'bin/**/*' 7 | - 'assets/**/*' 8 | - 'vendor/**/**' 9 | DisplayCopNames: true 10 | TargetRubyVersion: 2.3 11 | NewCops: enable 12 | SuggestExtensions: false 13 | plugins: 14 | - rubocop-rake 15 | - rubocop-minitest 16 | - rubocop-performance 17 | Minitest/EmptyLineBeforeAssertionMethods: 18 | Enabled: false 19 | Gemspec/RequiredRubyVersion: 20 | Enabled: false 21 | Layout/EmptyLineAfterGuardClause: 22 | Enabled: false 23 | Metrics/CyclomaticComplexity: 24 | Max: 10 25 | Metrics/PerceivedComplexity: 26 | Max: 15 27 | Layout/EndOfLine: 28 | EnforcedStyle: lf 29 | Metrics/ClassLength: 30 | Max: 360 31 | Layout/LineLength: 32 | Max: 90 33 | Metrics/MethodLength: 34 | Max: 35 35 | Metrics/AbcSize: 36 | Max: 40 37 | Style/MultilineBlockChain: 38 | Enabled: false 39 | Metrics/BlockLength: 40 | Max: 50 41 | Style/FrozenStringLiteralComment: 42 | Enabled: false 43 | -------------------------------------------------------------------------------- /.rultor.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | docker: 6 | image: yegor256/ruby 7 | assets: 8 | rubygems.yml: yegor256/home#assets/rubygems.yml 9 | install: | 10 | pdd -f /dev/null 11 | bundle install --no-color 12 | release: 13 | pre: false 14 | script: |- 15 | [[ "${tag}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit -1 16 | bundle exec rake 17 | rm -rf *.gem 18 | sed -i "s/0\.0\.0/${tag}/g" lib/pdd/version.rb 19 | git add lib/pdd/version.rb 20 | git commit -m "version set to ${tag}" 21 | gem build pdd.gemspec 22 | chmod 0600 ../rubygems.yml 23 | gem push *.gem --config-file ../rubygems.yml 24 | merge: 25 | script: |- 26 | LC_ALL=US-ASCII bundle exec rake 27 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | SimpleCov.formatter = if Gem.win_platform? 5 | SimpleCov::Formatter::MultiFormatter[ 6 | SimpleCov::Formatter::HTMLFormatter 7 | ] 8 | else 9 | SimpleCov::Formatter::MultiFormatter.new( 10 | SimpleCov::Formatter::HTMLFormatter 11 | ) 12 | end 13 | 14 | SimpleCov.start do 15 | add_filter '/test/' 16 | add_filter '/features/' 17 | minimum_coverage 90 18 | end 19 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Bugayenko" 5 | given-names: "Yegor" 6 | orcid: "https://orcid.org/0000-0001-6370-0678" 7 | - family-names: "Bakare" 8 | given-names: "Ayomide" 9 | - family-names: "Cheverda" 10 | given-names: "Arina" 11 | - family-names: "Farina" 12 | given-names: "Mirko" 13 | - family-names: "Kruglov" 14 | given-names: "Artem" 15 | - family-names: "Plaksin" 16 | given-names: "Yaroslav" 17 | - family-names: "Succi" 18 | given-names: "Giancarlo" 19 | - family-names: "Pedrycz" 20 | given-names: "Witold" 21 | title: "Automatically Prioritizing and Assigning Tasks from Code Repositories in Puzzle Driven Development" 22 | version: 0.21.0 23 | doi: 10.1145/3524842.3528512 24 | date-released: 2022-05-09 25 | url: "https://github.com/cqfn/pdd" 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | source 'https://rubygems.org' 5 | gemspec 6 | 7 | gem 'aruba', '~>2.3', require: false 8 | gem 'cucumber', '~>10.0', require: false 9 | gem 'minitest', '~>5.16', require: false 10 | gem 'minitest-reporters', '~>1.7', require: false 11 | gem 'rake', '~>13.0', require: false 12 | gem 'rdoc', '~>6.4', require: false 13 | gem 'rubocop', '~>1.60', require: false 14 | gem 'rubocop-minitest', '~>0.38', require: false 15 | gem 'rubocop-performance', '~>1.25', require: false 16 | gem 'rubocop-rake', '~>0.7', require: false 17 | gem 'simplecov', '~>0.22', require: false 18 | gem 'simplecov-cobertura', '~> 2.1' 19 | gem 'slop', '~>4.9', require: false 20 | gem 'xcop', '>0', require: false 21 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | pdd (0.0.0) 5 | backtrace (~> 0.1) 6 | nokogiri (~> 1.10) 7 | rainbow (~> 3.0) 8 | ruby-filemagic (~> 0.7) 9 | slop (~> 4.6) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | ansi (1.5.0) 15 | aruba (2.3.1) 16 | bundler (>= 1.17, < 3.0) 17 | contracts (>= 0.16.0, < 0.18.0) 18 | cucumber (>= 8.0, < 11.0) 19 | rspec-expectations (~> 3.4) 20 | thor (~> 1.0) 21 | ast (2.4.3) 22 | backtrace (0.4.1) 23 | base64 (0.3.0) 24 | bigdecimal (3.2.2) 25 | builder (3.3.0) 26 | contracts (0.17.2) 27 | cucumber (10.0.0) 28 | base64 (~> 0.2) 29 | builder (~> 3.2) 30 | cucumber-ci-environment (> 9, < 11) 31 | cucumber-core (> 15, < 17) 32 | cucumber-cucumber-expressions (> 17, < 19) 33 | cucumber-html-formatter (> 20.3, < 22) 34 | diff-lcs (~> 1.5) 35 | logger (~> 1.6) 36 | mini_mime (~> 1.1) 37 | multi_test (~> 1.1) 38 | sys-uname (~> 1.3) 39 | cucumber-ci-environment (10.0.1) 40 | cucumber-core (15.1.0) 41 | cucumber-gherkin (> 27, < 31) 42 | cucumber-messages (> 26, < 29) 43 | cucumber-tag-expressions (> 5, < 7) 44 | cucumber-cucumber-expressions (18.0.1) 45 | bigdecimal 46 | cucumber-gherkin (30.0.4) 47 | cucumber-messages (> 25, < 28) 48 | cucumber-html-formatter (21.12.0) 49 | cucumber-messages (> 19, < 28) 50 | cucumber-messages (27.2.0) 51 | cucumber-tag-expressions (6.1.2) 52 | date (3.4.1) 53 | diff-lcs (1.6.2) 54 | differ (0.1.2) 55 | docile (1.4.1) 56 | erb (5.0.1) 57 | ffi (1.17.2-arm64-darwin) 58 | ffi (1.17.2-x64-mingw-ucrt) 59 | ffi (1.17.2-x86_64-linux-gnu) 60 | json (2.12.2) 61 | language_server-protocol (3.17.0.5) 62 | lint_roller (1.1.0) 63 | logger (1.7.0) 64 | mini_mime (1.1.5) 65 | minitest (5.25.5) 66 | minitest-reporters (1.7.1) 67 | ansi 68 | builder 69 | minitest (>= 5.0) 70 | ruby-progressbar 71 | multi_test (1.1.0) 72 | nokogiri (1.18.8-arm64-darwin) 73 | racc (~> 1.4) 74 | nokogiri (1.18.8-x64-mingw-ucrt) 75 | racc (~> 1.4) 76 | nokogiri (1.18.8-x86_64-linux-gnu) 77 | racc (~> 1.4) 78 | parallel (1.27.0) 79 | parser (3.3.8.0) 80 | ast (~> 2.4.1) 81 | racc 82 | prism (1.4.0) 83 | psych (5.2.6) 84 | date 85 | stringio 86 | racc (1.8.1) 87 | rainbow (3.1.1) 88 | rake (13.3.0) 89 | rdoc (6.14.1) 90 | erb 91 | psych (>= 4.0.0) 92 | regexp_parser (2.10.0) 93 | rexml (3.4.1) 94 | rspec-expectations (3.13.5) 95 | diff-lcs (>= 1.2.0, < 2.0) 96 | rspec-support (~> 3.13.0) 97 | rspec-support (3.13.4) 98 | rubocop (1.77.0) 99 | json (~> 2.3) 100 | language_server-protocol (~> 3.17.0.2) 101 | lint_roller (~> 1.1.0) 102 | parallel (~> 1.10) 103 | parser (>= 3.3.0.2) 104 | rainbow (>= 2.2.2, < 4.0) 105 | regexp_parser (>= 2.9.3, < 3.0) 106 | rubocop-ast (>= 1.45.1, < 2.0) 107 | ruby-progressbar (~> 1.7) 108 | unicode-display_width (>= 2.4.0, < 4.0) 109 | rubocop-ast (1.45.1) 110 | parser (>= 3.3.7.2) 111 | prism (~> 1.4) 112 | rubocop-minitest (0.38.1) 113 | lint_roller (~> 1.1) 114 | rubocop (>= 1.75.0, < 2.0) 115 | rubocop-ast (>= 1.38.0, < 2.0) 116 | rubocop-performance (1.25.0) 117 | lint_roller (~> 1.1) 118 | rubocop (>= 1.75.0, < 2.0) 119 | rubocop-ast (>= 1.38.0, < 2.0) 120 | rubocop-rake (0.7.1) 121 | lint_roller (~> 1.1) 122 | rubocop (>= 1.72.1) 123 | ruby-filemagic (0.7.3) 124 | ruby-progressbar (1.13.0) 125 | simplecov (0.22.0) 126 | docile (~> 1.1) 127 | simplecov-html (~> 0.11) 128 | simplecov_json_formatter (~> 0.1) 129 | simplecov-cobertura (2.1.0) 130 | rexml 131 | simplecov (~> 0.19) 132 | simplecov-html (0.13.1) 133 | simplecov_json_formatter (0.1.4) 134 | slop (4.10.1) 135 | stringio (3.1.7) 136 | sys-uname (1.3.1) 137 | ffi (~> 1.1) 138 | thor (1.3.2) 139 | unicode-display_width (3.1.4) 140 | unicode-emoji (~> 4.0, >= 4.0.4) 141 | unicode-emoji (4.0.4) 142 | xcop (0.8.0) 143 | differ (~> 0.1.2) 144 | nokogiri (~> 1.10) 145 | rainbow (~> 3.0) 146 | slop (~> 4.4) 147 | 148 | PLATFORMS 149 | arm64-darwin-22 150 | arm64-darwin-23 151 | arm64-darwin-24 152 | x64-mingw-ucrt 153 | x86_64-linux 154 | 155 | DEPENDENCIES 156 | aruba (~> 2.3) 157 | cucumber (~> 10.0) 158 | minitest (~> 5.16) 159 | minitest-reporters (~> 1.7) 160 | pdd! 161 | rake (~> 13.0) 162 | rdoc (~> 6.4) 163 | rubocop (~> 1.60) 164 | rubocop-minitest (~> 0.38) 165 | rubocop-performance (~> 1.25) 166 | rubocop-rake (~> 0.7) 167 | simplecov (~> 0.22) 168 | simplecov-cobertura (~> 2.1) 169 | slop (~> 4.9) 170 | xcop (> 0) 171 | 172 | BUNDLED WITH 173 | 2.5.16 174 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2014-2025 Yegor Bugayenko 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 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2014-2025 Yegor Bugayenko 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 | # Collector of TODO Puzzles in Source Code 2 | 3 | [![EO principles respected here](https://www.elegantobjects.org/badge.svg)](https://www.elegantobjects.org) 4 | [![DevOps By Rultor.com](https://www.rultor.com/b/cqfn/pdd)](https://www.rultor.com/p/cqfn/pdd) 5 | [![We recommend RubyMine](https://www.elegantobjects.org/rubymine.svg)](https://www.jetbrains.com/ruby/) 6 | 7 | [![rake](https://github.com/cqfn/pdd/actions/workflows/rake.yml/badge.svg)](https://github.com/cqfn/pdd/actions/workflows/rake.yml) 8 | [![PDD status](https://www.0pdd.com/svg?name=cqfn/pdd)](https://www.0pdd.com/p?name=cqfn/pdd) 9 | [![codecov](https://codecov.io/gh/yegor256/pdd/branch/master/graph/badge.svg)](https://codecov.io/gh/yegor/pdd) 10 | [![Hits-of-Code](https://hitsofcode.com/github/cqfn/pdd)](https://hitsofcode.com/view/github/cqfn/pdd) 11 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/cqfn/pdd/blob/master/LICENSE.txt) 12 | [![Gem Version](https://badge.fury.io/rb/pdd.svg)](https://badge.fury.io/rb/pdd) 13 | [![Maintainability](https://api.codeclimate.com/v1/badges/c8e46256fdd8ddc817e5/maintainability)](https://codeclimate.com/github/cqfn/pdd/maintainability) 14 | [![Yard Docs](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/github/cqfn/pdd/master/frames) 15 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/1792d42f96fb45448e8d495ebc4348aa)](https://www.codacy.com/gh/cqfn/pdd/dashboard?utm_source=github.com&utm_medium=referral&utm_content=cqfn/pdd&utm_campaign=Badge_Grade) 16 | 17 | Read this article about 18 | [_Puzzle Driven Development_](http://www.yegor256.com/2009/03/04/pdd.html). 19 | Check also patent application 20 | [US 12/840,306](http://www.google.com/patents/US20120023476) 21 | 22 | Also, check [0pdd.com](https://www.0pdd.com): a hosted service, 23 | where this command line tool works for you. 24 | 25 | Read 26 | [_PDD in Action_](http://www.yegor256.com/2017/04/05/pdd-in-action.html) 27 | and watch [this webinar](https://www.youtube.com/watch?v=nsYGC2aUwfQ). 28 | 29 | First, make sure Ruby 2.6+ and [`libmagic`](#how-to-install-libmagic) 30 | are installed. Then, install our gem: 31 | 32 | ```bash 33 | gem install pdd 34 | ``` 35 | 36 | Run it locally and read its output: 37 | 38 | ```bash 39 | pdd --help 40 | ``` 41 | 42 | ## Usage 43 | 44 | You can exclude & include certain number of files from the search 45 | via these options: 46 | 47 | ```bash 48 | pdd --exclude glob 49 | ``` 50 | 51 | You can skip any file(s) with a name suffix that matches the pattern glob, 52 | using wildcard matching; a name suffix is either the whole 53 | path and name, or reg expr, for example: 54 | 55 | ```bash 56 | pdd --exclude src/**/*.java --exclude target/**/* 57 | pdd --exclude src/**/*.java # exclude .java files in src/ 58 | pdd --exclude src/**/* # exclude all files in src/ 59 | ``` 60 | 61 | You can include too: 62 | 63 | ```bash 64 | pdd --include glob 65 | ``` 66 | 67 | Search only files whose name matches glob, using wildcard matching 68 | as described under ``--exclude``. 69 | If contradictory ``--include`` and ``--exclude`` options are given, 70 | the last matching one wins. 71 | If no ``--include`` or ``--exclude`` options are given, all files 72 | from the working directory are included, example: 73 | 74 | ```bash 75 | pdd --include src/**/*.py # include only .py files in src/ 76 | pdd --include src/**/* # include all files in src/ 77 | ``` 78 | 79 | Full command format is (all parameters are optional): 80 | 81 | ```bash 82 | pdd [--verbose] [--quiet] [--remove] [--skip-gitignore] [--skip-errors] \ 83 | [--source ] [--file puzzles_file.xml] \ 84 | [--include src/**/*.py] \ 85 | [--format xml|html] [--rule min-words:5] [--exclude src/**/*.java] 86 | ``` 87 | 88 | | Parameter | Description | 89 | |-------------------------|---------------------------------------------------------------------------------------| 90 | | --verbose | Enable verbose (debug) mode. --file must be used in case of using this option | 91 | | --quiet | Disable logs | 92 | | --remove | Remove all found puzzles from the source code | 93 | | --skip-gitignore | Don't look into .gitignore for excludes | 94 | | --skip-errors | Suppress error as warning and skip badly formatted puzzles (do not skip broken rules) | 95 | | --source project-path | Source directory to parse ("." by default) | 96 | | --file puzzles.xml | File to save report into (xml or html) (displayed in console by default) | 97 | | --include *.py | Glob pattern to include (can be used several times) | 98 | | --exclude *.java | Glob pattern to exclude (can be used several times) | 99 | | --format xml | Format of the report xml or html (xml is default) | 100 | | --rule min-words:5 | Rule to apply (can be used several times), described later | 101 | 102 | :bulb: There is an option to create a .pdd file in your project and save all required parameters in it. 103 | You can see a file example in this project. 104 | 105 | ## How to Format? 106 | 107 | Every puzzle has to be formatted like this (pay attention 108 | to the leading space in every consecutive line): 109 | 110 | ```java 111 | /** 112 | * @todo #[issue#]<:[time]> <[description]> 113 | */ 114 | [related code] 115 | ``` 116 | 117 | `[]` - Replace with appropriate data (see text enclosed in brackets) 118 | 119 | `<>` - Optional (enclosed data can be left out) 120 | 121 | Example: 122 | 123 | ```java 124 | /** 125 | * @todo #234:15m/DEV This is something to do later 126 | * in one of the next releases. I can't figure out 127 | * how to implement it now, that's why the puzzle. 128 | * The text can be so long, as needed, just use 129 | * the same amount of spaces, as the second line. 130 | * This text will not be a part of the puzzle, as 131 | * it has less spaces. 132 | */ 133 | void sendEmail() { 134 | throw new UnsupportedOperationException(); 135 | } 136 | ``` 137 | 138 | If you use it in combination with [0pdd](https://www.0pdd.com), 139 | after processing this text, the issue titled 140 | "File.java:10-13: This is something to do later in one of ..." will be created. 141 | The specified markers will be included in the issues body 142 | along with some predefined text. If your comment is longer 143 | than 40 characters, it will be truncated in the title. 144 | 145 | Note: if you create several puzzle duplicates (same text after puzzle keyword), 146 | pdd will fail to parse puzzles and produce an error with duplicates list. 147 | 148 | There are 3 supported keywords, one of which must precede the mandatory 149 | puzzle marker. They are `@todo`, `TODO` and `TODO:`. 150 | 151 | As an example, it starts with `@todo`, followed by a space and a mandatory 152 | puzzle **marker**. Possible formats of puzzle markers 153 | (it doesn't matter what the line starts with and where it is located, 154 | as long as you have one of the 3 supported keywords right in front 155 | of the mandatory marker): 156 | 157 | ```text 158 | // @todo #224 Puzzle description 159 | # @todo #55:45min Puzzle description 160 | @todo #67/DES Puzzle description 161 | ;; @todo #678:40m/DEV Puzzle description 162 | // TODO #TEST-21:30min Puzzle description 163 | ``` 164 | 165 | Here `DES` and `DEV` are the roles of people who must fix these puzzles; 166 | `45min` and `40m` is the amount of time the puzzle should take; 167 | `224`, `55`, `67`, `678` and `TEST-21` are the IDs of the tickets 168 | these puzzles are coming from. 169 | 170 | Markers are absolutely necessary for all puzzles, because they allow 171 | us to build a hierarchical dependency tree of all puzzles, like 172 | [this one](https://www.0pdd.com/p?name=yegor256/takes), 173 | for example. Technically, of course, you can abuse the system 174 | and put a dummy `#1` marker everywhere. 175 | 176 | ### Multiline examples 177 | 178 | For multiline puzzles there are two important things: 179 | 180 | - **prefix** - any optional text followed by space before puzzle keyword (todo). 181 | It should be the same for all lines of puzzle description. 182 | - \ symbol can be used to logically divide puzzle description. 183 | prefix should be presented with it. 184 | 185 | Examples: 186 | 187 | ```xml 188 | 198 | ``` 199 | 200 | ```java 201 | /** 202 | * @todo #36 Multiline text can use the same prefix in all lines or the same 203 | * amount of spaces. 204 | * So this will be added to the puzzle description. If you want to divide the 205 | * puzzle logically by empty line, just add a backspace to that line 206 | * \ 207 | * and continue the text after. 208 | * 209 | * This line is not part of the puzzle, because the line before does not contain 210 | * prefix. 211 | */ 212 | ``` 213 | 214 | ## How to Configure Rules? 215 | 216 | You can specify post-parsing rules for your puzzles, in command line, 217 | for example: 218 | 219 | ```bash 220 | pdd --rule min-estimate:60 --rule max-estimate:120 221 | ``` 222 | 223 | These two parameters will add two post-parsing rules `min-estimate` 224 | and `max-estimate` with parameters. Each rule may have an optional 225 | parameter specified after a colon. 226 | 227 | Here is a list of rules available now: 228 | 229 | - `min-estimate:15` blocks all puzzles that don't have an estimate 230 | or their estimates are less than 15 minutes. 231 | 232 | - `max-estimate:120` blocks all puzzles with estimates over 120 minutes. 233 | 234 | - `available-roles:DEV,IMP,DES` specifies a list of roles that 235 | are allowed in puzzles. Puzzles without explicitly specified 236 | roles will be rejected. 237 | 238 | - `min-words:5` blocks puzzles with descriptions shorter than five words. 239 | 240 | - `max-duplicates:1` blocks more than one duplicate of any puzzle. 241 | This rule is used by default and you can't configure it at the moment, 242 | it must always be set to `1`. 243 | 244 | :bulb: You can put all command line options into `.pdd` file. The options from the 245 | file will be used first. Command line options may be added on top of them. 246 | See, how it is done in 247 | [yegor256/0pdd](https://github.com/yegor256/0pdd/blob/master/.pdd). 248 | 249 | ## How to read XML 250 | 251 | The XML produced will look approximately like this (here is a 252 | [real example](https://www.0pdd.com/snapshot?name=yegor256/takes)): 253 | 254 | ```xml 255 | 256 | 257 | 516 258 | 15 259 | DEV 260 | 516-ffc97ad1 261 | 61-63 262 | This has to be fixed later... 263 | src/test/java/org/takes/SomeTest.java 264 | Yegor Bugayenko 265 | yegor256@gmail.com 266 | 267 | 268 | 269 | ``` 270 | 271 | NOTE: puzzles are saved with utf-8 encoding 272 | 273 | [XSD Schema](http://pdd-xsd.teamed.io/0.19.4.xsd) is here. 274 | The most interesting parts of each puzzle are: 275 | 276 | - `ticket` is a ticket name puzzle marker starts from, in most 277 | cases it will be the number of GitHub issue. 278 | 279 | - `estimate` is the amount of minutes the puzzle is supposed to take. 280 | 281 | - `id` is a unique ID of the puzzle. It is calculated by the 282 | internal algorithm that takes into account only the text of the puzzle. 283 | Thus, if you move the puzzle from one file to another, the ID won't 284 | change. Also, changing the location of a puzzle inside a file 285 | won't change its ID. 286 | 287 | - `lines` is where the puzzle is found, inside the file. 288 | 289 | ## How to install libmagic 290 | 291 | For Debian/Ubuntu: 292 | 293 | ```bash 294 | apt install libmagic-dev 295 | ``` 296 | 297 | For macOS: 298 | 299 | ```bash 300 | brew install libmagic 301 | ``` 302 | 303 | Unfortunately, there is no easy way to install on Windows, try to use 304 | [WSL](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux) or 305 | [Docker](https://www.docker.com/). 306 | 307 | ## How to contribute 308 | 309 | Read [these guidelines](https://www.yegor256.com/2014/04/15/github-guidelines.html). 310 | Make sure your build is green before you contribute 311 | your pull request. You will need to have 312 | [Ruby](https://www.ruby-lang.org/en/) 2.7+ and 313 | [Bundler](https://bundler.io/) installed. Then: 314 | 315 | ```bash 316 | bundle install 317 | bundle exec rake 318 | ``` 319 | 320 | Next, install and run overcommit to install hooks (required once) 321 | 322 | ```bash 323 | gem install overcommit -v '=0.58.0' 324 | overcommit --install 325 | ``` 326 | 327 | If it's clean and you don't see any error messages, submit your pull request. 328 | 329 | This is how you run the tool locally to test how it works: 330 | 331 | ```bash 332 | ./bin/pdd --help 333 | ``` 334 | 335 | To run a single unit test: 336 | 337 | ```bash 338 | bundle exec ruby test/test_many.rb 339 | ``` 340 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | version = 1 5 | [[annotations]] 6 | path = [ 7 | ".DS_Store", 8 | ".gitattributes", 9 | ".gitignore", 10 | ".overcommit.yml", 11 | ".pdd", 12 | "**.ico", 13 | "**.jpg", 14 | "**.json", 15 | "**.md", 16 | "**.pdf", 17 | "**.png", 18 | "**.txt", 19 | "**.woff", 20 | "**/.DS_Store", 21 | "**/.gitignore", 22 | "**/.pdd", 23 | "**/*.csv", 24 | "**/*.jpg", 25 | "**/*.json", 26 | "**/*.md", 27 | "**/*.pdf", 28 | "**/*.png", 29 | "**/*.svg", 30 | "**/*.txt", 31 | "**/*.vm", 32 | "**/CITATION.cff", 33 | "**/CNAME", 34 | "**/Gemfile.lock", 35 | "CITATION.cff", 36 | "Gemfile.lock", 37 | "README.md", 38 | "renovate.json", 39 | "test_assets/puzzles/**", 40 | ] 41 | precedence = "override" 42 | SPDX-FileCopyrightText = "Copyright (c) 2025 Yegor Bugayenko" 43 | SPDX-License-Identifier = "MIT" 44 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'rubygems' 5 | require 'rake' 6 | require 'rdoc' 7 | require 'rake/clean' 8 | 9 | def name 10 | @name ||= File.basename(Dir['*.gemspec'].first, '.*') 11 | end 12 | 13 | def version 14 | Gem::Specification.load(Dir['*.gemspec'].first).version 15 | end 16 | 17 | task default: %i[clean test features rubocop xcop] 18 | 19 | require 'rake/testtask' 20 | desc 'Run all unit tests' 21 | Rake::TestTask.new(:test) do |test| 22 | Rake::Cleaner.cleanup_files(['coverage']) 23 | test.libs << 'lib' << 'test' 24 | test.pattern = 'test/**/test_*.rb' 25 | test.verbose = false 26 | end 27 | 28 | require 'rdoc/task' 29 | desc 'Build RDoc documentation' 30 | Rake::RDocTask.new do |rdoc| 31 | rdoc.rdoc_dir = 'rdoc' 32 | rdoc.title = "#{name} #{version}" 33 | rdoc.rdoc_files.include('README*') 34 | rdoc.rdoc_files.include('lib/**/*.rb') 35 | end 36 | 37 | require 'rubocop/rake_task' 38 | desc 'Run RuboCop on all directories' 39 | RuboCop::RakeTask.new(:rubocop) do |task| 40 | task.fail_on_error = true 41 | end 42 | 43 | require 'xcop/rake_task' 44 | desc 'Validate all XML/XSL/XSD/HTML files for formatting' 45 | Xcop::RakeTask.new :xcop do |task| 46 | task.includes = ['**/*.xml', '**/*.xsl', '**/*.xsd', '**/*.html'] 47 | task.excludes = ['target/**/*', 'coverage/**/*', 'vendor/**/*'] 48 | end 49 | 50 | require 'pdd/rake_task' 51 | desc 'Collecting and parsing all puzzles in project' 52 | PDD::RakeTask.new :pdd do |task| 53 | task.includes = ['**/*'] 54 | end 55 | 56 | require 'cucumber/rake/task' 57 | Cucumber::Rake::Task.new(:features) do |t| 58 | t.cucumber_opts = %w[features --strict-undefined] 59 | Rake::Cleaner.cleanup_files(['coverage']) 60 | end 61 | Cucumber::Rake::Task.new(:'features:html') do |t| 62 | t.profile = 'html_report' 63 | end 64 | Cucumber::Rake::Task.new(:'features:json') do |t| 65 | t.profile = 'json_report' 66 | end 67 | -------------------------------------------------------------------------------- /assets/puzzles.xsd: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /assets/puzzles.xsl: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | <!DOCTYPE html> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <xsl:text>PDD Summary Report</xsl:text> 17 | 18 | 38 | 39 | 40 |
41 |

PDD Summary

42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 60 | 63 | 66 | 69 | 70 | 71 | 72 | 73 | 74 |
55 | id 56 | 58 | ticket 59 | 61 | body 62 | 64 | estimate 65 | 67 | role 68 |
75 |
76 | 77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | : 91 | 92 | 93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | -------------------------------------------------------------------------------- /assets/puzzles_json.xsl: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | { 9 | "puzzles": [ 10 | 11 | 12 | ] 13 | } 14 | 15 | 16 | 17 | "version": " 18 | 19 | ", 20 | 21 | "date": " 22 | 23 | ", 24 | 25 | 26 | { 27 | 28 | "id": " 29 | 30 | ", 31 | 32 | "ticket": " 33 | 34 | ", 35 | 36 | "file": " 37 | 38 | ", 39 | 40 | "lines": " 41 | 42 | ", 43 | 44 | "body": " 45 | 46 | ", 47 | 48 | "estimate": " 49 | 50 | ", 51 | 52 | "role": " 53 | 54 | " 55 | 56 | } 57 | , 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /bin/pdd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 3 | # SPDX-License-Identifier: MIT 4 | 5 | $stdout.sync = true 6 | 7 | require 'backtrace' 8 | require 'shellwords' 9 | require 'English' 10 | require 'find' 11 | require 'slop' 12 | require 'nokogiri' 13 | require 'rainbow' 14 | require_relative '../lib/pdd' 15 | require_relative '../lib/pdd/version' 16 | require_relative '../lib/pdd/source' 17 | 18 | Rainbow.enabled = ENV['PDD_ENV'] == 'development' 19 | 20 | begin 21 | args = [] 22 | if File.exist?('.pdd') 23 | cfg = File.new('.pdd') 24 | body = File.read(cfg) 25 | extra = body.split(/\s+/).map(&:strip) 26 | args += extra 27 | puts "Found #{body.split("\n").length} lines in #{File.absolute_path(cfg)}" 28 | end 29 | args += ARGV 30 | 31 | begin 32 | opts = Slop.parse(args, strict: true, help: true) do |o| 33 | o.banner = "Usage (#{PDD::VERSION}): pdd [options]" 34 | o.bool '-h', '--help', 'Show these instructions' 35 | o.bool '-v', '--verbose', 'Enable verbose mode (a lot of logging)' 36 | o.bool '-q', '--quiet', 'Enable quiet mode (almost no logging)' 37 | o.bool '--remove', 'Remove all found puzzles from the source code' 38 | o.bool '--skip-gitignore', 'Don\'t look into .gitignore for excludes' 39 | o.bool '--skip-errors', 'Suppress error as warning and skip badly 40 | formatted puzzles' 41 | o.bool '-i', '--version', 'Show current version' do 42 | puts PDD::VERSION 43 | exit 44 | end 45 | o.string '-s', '--source', 'Source directory to parse ("." by default)' 46 | o.string '-f', '--file', 'File to save report into' 47 | o.array '-e', '--exclude', 'Glob pattern to exclude, e.g. "**/*.jpg"', 48 | default: [] 49 | o.array '-n', '--include', 'Glob pattern to include, e.g. "**/*.jpg"', 50 | default: [] 51 | o.string '-t', '--format', 'Format of the report (xml|html|json)' 52 | o.array( 53 | '-r', '--rule', 'Rule to apply (can be used many times)', 54 | delimiter: ';' 55 | ) 56 | end 57 | rescue Slop::Error => e 58 | raise StandardError, "#{e.message}, try --help" 59 | end 60 | 61 | if opts.help? 62 | puts opts 63 | puts "This is our README to learn more: \ 64 | https://github.com/cqfn/pdd/blob/master/README.md" 65 | exit 66 | end 67 | 68 | if opts.verbose? && !opts.file? 69 | raise '-f is mandatory when using -v, try --help for more information' 70 | end 71 | 72 | if opts['skip-gitignore'] && File.exist?('.gitignore') 73 | cfg = File.new('.gitignore') 74 | body = '' 75 | File.foreach(cfg) { |line| body << line unless line.start_with?('#') } 76 | extra = body.split(/\s+/).map(&:strip) 77 | opts['skip-gitignore'] = extra 78 | PDD.log.info "Found #{body.split("\n").length} lines in #{File.absolute_path(cfg)}" 79 | end 80 | 81 | Encoding.default_external = Encoding::UTF_8 82 | Encoding.default_internal = Encoding::UTF_8 83 | file = opts.file? ? File.new(opts[:file], 'w') : $stdout 84 | xml = PDD::Base.new(opts).xml 85 | output = xml 86 | if opts[:format] 87 | if opts[:format] == 'html' 88 | xslt = File.join( 89 | File.dirname(File.dirname(__FILE__)), 90 | 'assets', 'puzzles.xsl' 91 | ) 92 | output = Nokogiri::XSLT(File.read(xslt)).transform(Nokogiri::XML(xml)) 93 | elsif opts[:format] == 'json' 94 | xslt = File.join( 95 | File.dirname(File.dirname(__FILE__)), 96 | 'assets', 'puzzles_json.xsl' 97 | ) 98 | # result is not xml, so use apply 99 | output = Nokogiri::XSLT(File.read(xslt)).apply_to(Nokogiri::XML(xml)) 100 | elsif opts[:format] != 'xml' 101 | raise 'Invalid format, use html or xml or json' 102 | end 103 | end 104 | file << output 105 | if opts.remove? 106 | home = opts[:source] || Dir.pwd 107 | PDD.log.info "Removing puzzles from #{home}..." 108 | files = {} 109 | Nokogiri::XML(xml).xpath('/puzzles/puzzle').each do |p| 110 | file = p.xpath('file/text()').to_s 111 | files[file] = [] if files[file].nil? 112 | files[file] << p.xpath('lines/text()').to_s.split('-').map(&:to_i) 113 | end 114 | files.each do |src, all| 115 | f = File.join(home, src) 116 | File.write( 117 | f, 118 | File.readlines(f).reject.each_with_index do |_t, i| 119 | all.any? { |pair| i + (1.between?(pair[0], pair[1]) ? 1 : 0) } 120 | end.join 121 | ) 122 | PDD.log.info "#{all.count} puzzles removed from #{src}" 123 | end 124 | end 125 | rescue SystemExit => e 126 | puts e.message unless e.success? 127 | PDD.log.info "Exit code is #{e.status}" 128 | exit(e.status) 129 | rescue PDD::Error => e 130 | PDD.log.error "#{Rainbow('ERROR').red}: #{e.message} 131 | If you can't understand the cause of this issue or you don't know \ 132 | how to fix it, please submit a GitHub issue, we will try to help you: \ 133 | https://github.com/cqfn/pdd/issues. This tool is still in its beta \ 134 | version and we will appreciate your feedback. Here is where you can find \ 135 | more documentation: https://github.com/cqfn/pdd/blob/master/README.md." 136 | PDD.log.info 'Exit code is 1' 137 | exit(1) 138 | rescue StandardError => e 139 | PDD.log.error "#{Rainbow('ERROR').red} (#{e.class.name}): #{e.message}" 140 | PDD.log.error Backtrace.new(e).to_s 141 | PDD.log.info 'Exit code is 255' 142 | exit(255) 143 | end 144 | -------------------------------------------------------------------------------- /cucumber.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | default: --format pretty 5 | travis: --format progress 6 | html_report: --format progress --format html --out=features_report.html 7 | json_report: --format progress --format json --out=features_report.json 8 | -------------------------------------------------------------------------------- /features/applies_rules.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Applies Post-Parsing Rules 4 | As a source code writer I want to be sure that 5 | certain post-parsing rules are applied 6 | 7 | Scenario: Throwing exception on big estimates 8 | Given I have a "Sample.java" file with content: 9 | """ 10 | @todo #13:180m This puzzle has too big estimate 11 | """ 12 | When I run bin/pdd with "--rule=max-estimate:90" 13 | Then Exit code is not zero 14 | Then Stdout contains "bigger than 90 minutes" 15 | 16 | Scenario: Throwing exception on small estimates 17 | Given I have a "Sample.java" file with content: 18 | """ 19 | @todo #13:15min This puzzle has too small estimate 20 | """ 21 | When I run bin/pdd with "--rule=min-estimate:30" 22 | Then Exit code is not zero 23 | Then Stdout contains "lower than 30 minutes" 24 | 25 | Scenario: Throwing exception on duplicates 26 | Given I have a "Sample.java" file with content: 27 | """ 28 | @todo #13:15min The text 29 | @todo #13:15min The text 30 | """ 31 | When I run bin/pdd with "" 32 | Then Exit code is not zero 33 | Then Stdout contains "there are 2 duplicate" 34 | 35 | Scenario: Throwing exception on duplicates 36 | Given I have a "Sample.java" file with content: 37 | """ 38 | @todo #13/DEV:15min Some text first 39 | @todo #13/TEST:15min The text second 40 | """ 41 | When I run bin/pdd with "--rule=available-roles:DEV,ARC" 42 | Then Exit code is not zero 43 | Then Stdout contains "defines role TEST" 44 | 45 | Scenario: Throwing exception on touching max-duplicates rule 46 | Given I have a "Sample.java" file with content: 47 | """ 48 | @todo #334:15m This is the puzzle 49 | @todo #35:30m This is the puzzle 50 | """ 51 | When I run bin/pdd with "--rule=max-duplicates:3" 52 | Then Exit code is not zero 53 | -------------------------------------------------------------------------------- /features/avoiding_duplicates.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Avoiding Duplicate Puzzles 4 | As a source code writer I want to be sure that 5 | XML output doesn't contain any duplicates 6 | 7 | Scenario: Throwing exception on duplicates 8 | Given I have a "Sample.java" file with content: 9 | """ 10 | public class Main { 11 | /** 12 | * @todo #13 A simple puzzle 13 | * @todo #15 A simple puzzle 14 | */ 15 | public void main(String[] args) { 16 | // later 17 | } 18 | } 19 | """ 20 | When I run pdd it fails with "errors, see log above" 21 | -------------------------------------------------------------------------------- /features/catches_broken_puzzles.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Catches Broken Puzzles 4 | As a source code writer I want to be sure that 5 | broken puzzles won't be processed and will 6 | cause runtime errors 7 | 8 | Scenario: Throwing exception on broken puzzles 9 | Given I have a "Sample.java" file with content: 10 | """ 11 | public class Main { 12 | /** 13 | * Some other documentation 14 | * text that is not relevant to 15 | * the puzzle below. 16 | * @todo This puzzle has an incorrect format 17 | * because it doesn't have a ticket number 18 | */ 19 | public void main(String[] args) { 20 | // later 21 | } 22 | } 23 | """ 24 | When I run pdd it fails with "Sample.java:6" 25 | -------------------------------------------------------------------------------- /features/cli.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Command Line Processing 4 | As a source code writer I want to be able to 5 | call PDD as a command line tool 6 | 7 | Scenario: Help can be printed 8 | When I run bin/pdd with "-h" 9 | Then Exit code is zero 10 | And Stdout contains "-v, --verbose" 11 | 12 | Scenario: Version can be printed 13 | When I run bin/pdd with "--version" 14 | Then Exit code is zero 15 | 16 | Scenario: Simple puzzles collecting 17 | Given I have a "Sample.java" file with content: 18 | """ 19 | public class Main { 20 | /** 21 | * @todo #13 Привет, Let's do it later, dude 22 | * or maybe even never :) 23 | */ 24 | public void main(String[] args) { 25 | // later 26 | } 27 | } 28 | """ 29 | When I run bin/pdd with "-v -s . -f out.xml" 30 | Then Exit code is zero 31 | And Stdout contains "Reading from root dir ." 32 | And XML file "out.xml" matches "/puzzles[count(puzzle)=1]" 33 | And XML file "out.xml" matches "//puzzle[starts-with(body,'Привет, Let')]" 34 | 35 | Scenario: Using basic rules 36 | Given I have a "sample.java" file with content: 37 | """ 38 | Nothing 39 | """ 40 | When I run bin/pdd with "-v -s . -f out.xml --rule min-words:20 --rule=available-roles:DEV,ARC,PO" 41 | Then Exit code is zero 42 | 43 | Scenario: Simple puzzles collecting into stdout 44 | Given I have a "Sample.txt" file with content: 45 | """ 46 | ~~ 47 | ~~ @todo #44 First 48 | ~~ and 49 | ~~ second 50 | """ 51 | When I run bin/pdd with "> out.xml" 52 | Then Exit code is zero 53 | And Stdout is empty 54 | And XML file "out.xml" matches "/puzzles[count(puzzle)=1]" 55 | 56 | Scenario: Excluding unnecessary files 57 | Given I have a "a/b/c/test.txt" file with content: 58 | """ 59 | ~~ @todo #44 some puzzle to be excluded 60 | """ 61 | And I have a "f/g/h/hello.md" file with content: 62 | """ 63 | ~~ @todo #44 some puzzle to be excluded as well 64 | """ 65 | When I run bin/pdd with "-e f/g/**/*.md --exclude a/**/*.txt > out.xml" 66 | Then Exit code is zero 67 | And XML file "out.xml" matches "/puzzles[count(puzzle)=0]" 68 | 69 | Scenario: Excluding unnecessary files from .gitignore 70 | Given I have a "a/b/c/test.txt" file with content: 71 | """ 72 | ~~ @todo #44 some puzzle to be excluded 73 | """ 74 | And I have a "f/g/h/hello.md" file with content: 75 | """ 76 | ~~ @todo #45 some puzzle to be excluded as well 77 | """ 78 | And I have a ".gitignore" file with content: 79 | """ 80 | # This is the list of patterns 81 | a/**/* 82 | !/f 83 | """ 84 | When I run bin/pdd with "--skip-gitignore > out.xml" 85 | Then Exit code is zero 86 | And XML file "out.xml" matches "/puzzles/puzzle[./ticket='45']" 87 | And XML file "out.xml" matches "/puzzles[count(puzzle)=1]" 88 | 89 | Scenario: Excluding unnecessary files from .gitignore and ignore comments 90 | Given I have a "a/b/c/test.txt" file with content: 91 | """ 92 | ~~ @todo #44 some puzzle to be excluded 93 | """ 94 | And I have a "f/g/h/hello.md" file with content: 95 | """ 96 | ~~ @todo #45 some puzzle to be excluded as well 97 | """ 98 | And I have a ".gitignore" file with content: 99 | """ 100 | # This is the list of patterns 101 | # a/**/* 102 | f/**/* 103 | """ 104 | When I run bin/pdd with "--skip-gitignore > out.xml" 105 | Then Exit code is zero 106 | And XML file "out.xml" matches "/puzzles/puzzle[./ticket='44']" 107 | And XML file "out.xml" matches "/puzzles[count(puzzle)=1]" 108 | 109 | Scenario: Files from .gitignore is not excluded by default 110 | Given I have a "a/b/c/test.txt" file with content: 111 | """ 112 | ~~ @todo #44 some puzzle to be excluded 113 | """ 114 | And I have a "f/g/h/hello.md" file with content: 115 | """ 116 | ~~ @todo #45 some puzzle to be excluded as well 117 | """ 118 | And I have a ".gitignore" file with content: 119 | """ 120 | # This is the list of patterns 121 | a/**/* 122 | !/f 123 | """ 124 | When I run bin/pdd with "> out.xml" 125 | Then Exit code is zero 126 | And XML file "out.xml" matches "/puzzles/puzzle[./ticket='44']" 127 | And XML file "out.xml" matches "/puzzles/puzzle[./ticket='45']" 128 | And XML file "out.xml" matches "/puzzles[count(puzzle)=2]" 129 | 130 | Scenario: Rejects unknown options 131 | Given I have a "test.txt" file with content: 132 | """ 133 | """ 134 | When I run bin/pdd with "--some-unknown-option" 135 | Then Exit code is not zero 136 | -------------------------------------------------------------------------------- /features/gem_package.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Gem Package 4 | As a source code writer I want to be able to 5 | package the Gem into .gem file 6 | 7 | Scenario: Gem can be packaged 8 | Given It is Unix 9 | Given I have a "execs.rb" file with content: 10 | """ 11 | #!/usr/bin/env ruby 12 | require 'rubygems' 13 | spec = Gem::Specification::load('./spec.rb') 14 | fail 'no executables' if spec.executables.empty? 15 | """ 16 | When I run bash with 17 | """ 18 | cd pdd 19 | gem build pdd.gemspec 20 | gem specification --ruby pdd-*.gem > ../spec.rb 21 | cd .. 22 | ruby execs.rb 23 | """ 24 | Then Exit code is zero 25 | -------------------------------------------------------------------------------- /features/html_output.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: HTML output 4 | As a source code writer I want to be able to 5 | call PDD as a command line tool, and retrieve an 6 | HTML report 7 | 8 | Scenario: HTML report building 9 | Given I have a "Sample.java" file with content: 10 | """ 11 | public class Main { 12 | /** 13 | * @todo #13 Let's do it later, dude 14 | * or maybe even never :) 15 | */ 16 | public void main(String[] args) { 17 | // later 18 | } 19 | } 20 | """ 21 | When I run bin/pdd with "-v -s . -f out.html --format=html" 22 | Then Exit code is zero 23 | And Stdout contains "Reading from root dir ." 24 | And XML file "out.html" matches "/html/body" 25 | -------------------------------------------------------------------------------- /features/json_output.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: JSON output 4 | As a source code writer I want to be able to 5 | call PDD as a command line tool, and retrieve an 6 | JSON report 7 | 8 | Scenario: JSON report building 9 | Given I have a "Sample.java" file with content: 10 | """ 11 | public class Main { 12 | /** 13 | * @todo #13 Let's do json 14 | * or maybe not json ":)" 15 | */ 16 | public void main(String[] args) { 17 | // later 18 | } 19 | } 20 | """ 21 | When I run bin/pdd with "-v -s . -f out.json --format=json" 22 | Then Exit code is zero 23 | And Stdout contains "Reading from root dir ." 24 | And Text File "out.json" contains "Let's do json or maybe not json “:)“" 25 | -------------------------------------------------------------------------------- /features/parsing.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Parsing 4 | As a source code writer I want to be able to 5 | collect all puzzles from all my text files and 6 | present them in XML format 7 | 8 | Scenario: Simple puzzles collecting 9 | Given I have a "Sample.java" file with content: 10 | """ 11 | public class Main { 12 | /** 13 | * @todo #13 Let's do it later, dude 14 | * or maybe even never :) 15 | */ 16 | public void main(String[] args) { 17 | // later 18 | } 19 | } 20 | """ 21 | When I run pdd 22 | Then XML matches "/puzzles[count(puzzle)=1]" 23 | And XML matches "//puzzle[file='Sample.java']" 24 | And XML matches "//puzzle[ticket='13']" 25 | And XML matches "//puzzle[lines='3-4']" 26 | And XML matches "//puzzle[starts-with(body,'Let')]" 27 | And XML matches "//puzzle[role='DEV']" 28 | And XML matches "//puzzle[estimate='0']" 29 | 30 | Scenario: Simple puzzle within comment block 31 | Given I have a "test/a/b/Sample.java" file with content: 32 | """ 33 | public class Main { 34 | /** 35 | * Some other documentation 36 | * text that is not relevant to 37 | * the puzzle below. 38 | * @todo #13 This puzzle has a correct format 39 | * It doesn't start with a space on 40 | * the second and the third lines 41 | */ 42 | public void main(String[] args) { 43 | // later 44 | } 45 | } 46 | """ 47 | When I run pdd 48 | Then XML matches "/puzzles[count(puzzle)=1]" 49 | And XML matches "//puzzle[file='test/a/b/Sample.java']" 50 | And XML matches "//puzzle[ticket='13']" 51 | And XML matches "//puzzle[lines='6-8']" 52 | And XML matches "//puzzle[starts-with(body,'This')]" 53 | And XML matches "//puzzle[role='DEV']" 54 | And XML matches "//puzzle[estimate='0']" 55 | 56 | Scenario: Multiple puzzles in one file 57 | Given I have a "test/a/b/c/Sample.java" file with content: 58 | """ 59 | public class Main { 60 | /** 61 | * @todo #13 This one later 62 | * @todo #ABC-67:15min And this one ever later 63 | * @todo #F-78-3:2h/DEV This is for a developer 64 | * who will join us later 65 | * @todo #44 This puzzle has a correct format 66 | * even though it doesn't start with a space on 67 | * the second and the third lines 68 | */ 69 | public void main(String[] args) { 70 | // later 71 | } 72 | } 73 | """ 74 | When I run pdd 75 | Then XML matches "/puzzles[count(puzzle)=4]" 76 | And XML matches "//puzzle[ticket='13' and lines='3-3']" 77 | And XML matches "//puzzle[ticket='13' and body='This one later']" 78 | And XML matches "//puzzle[ticket='ABC-67' and lines='4-4']" 79 | And XML matches "//puzzle[ticket='F-78-3' and lines='5-6']" 80 | And XML matches "//puzzle[ticket='ABC-67' and estimate='15']" 81 | And XML matches "//puzzle[ticket='F-78-3' and estimate='120']" 82 | And XML matches "//puzzle[ticket='44' and lines='7-9']" 83 | -------------------------------------------------------------------------------- /features/rake.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Rake Task 4 | As a source code writer I want to be able to 5 | run PDD from Rakefile 6 | Scenario: PDD can be used in Rakefile 7 | Given It is Unix 8 | And I have a "Rakefile" file with content: 9 | """ 10 | require 'pdd/rake_task' 11 | PDD::RakeTask.new(:pdd) do |task| 12 | task.includes = ['a.txt'] 13 | end 14 | """ 15 | And I have a "a.txt" file with content: 16 | """ 17 | \x40todo #55 hello! 18 | """ 19 | 20 | When I run bash with "rake pdd" 21 | Then Exit code is zero 22 | -------------------------------------------------------------------------------- /features/remove.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Removing Puzzles 4 | As a source code writer I want to be able to 5 | remove PDD puzzles from source code 6 | 7 | Scenario: Removing puzzles from code 8 | Given I have a "a/test.txt" file with content: 9 | """ 10 | Hello, 11 | # @todo #42 Bye! 12 | # Bye! 13 | The End. 14 | """ 15 | When I run bin/pdd with "-v --remove -f /dev/null" 16 | Then Exit code is zero 17 | And Stdout contains "1 puzzles removed from a/test.txt" 18 | -------------------------------------------------------------------------------- /features/step_definitions/steps.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'nokogiri' 5 | require 'tmpdir' 6 | require 'slop' 7 | require 'English' 8 | require_relative '../../lib/pdd' 9 | 10 | Before do 11 | @cwd = Dir.pwd 12 | @dir = Dir.mktmpdir('test') 13 | FileUtils.mkdir_p(@dir) 14 | Dir.chdir(@dir) 15 | @opts = Slop.parse ['-q', '-s', @dir] do |o| 16 | o.bool '-v', '--verbose' 17 | o.bool '-q', '--quiet' 18 | o.string '-s', '--source' 19 | end 20 | end 21 | 22 | After do 23 | Dir.chdir(@cwd) 24 | FileUtils.rm_rf(@dir) 25 | end 26 | 27 | Given(/skip test/) do 28 | skip_this_scenario 29 | end 30 | 31 | Given(/^I have a "([^"]*)" file with content:$/) do |file, text| 32 | FileUtils.mkdir_p(File.dirname(file)) unless File.exist?(file) 33 | File.open(file, 'w:ASCII-8BIT') do |f| 34 | f.write(text.gsub('\\xFF', 0xFF.chr)) 35 | end 36 | end 37 | 38 | When(/^I run pdd$/) do 39 | @xml = Nokogiri::XML.parse(PDD::Base.new(@opts).xml) 40 | end 41 | 42 | Then(/^XML matches "([^"]+)"$/) do |xpath| 43 | raise "XML doesn't match \"#{xpath}\":\n#{@xml}" if @xml.xpath(xpath).empty? 44 | end 45 | 46 | When(/^I run pdd it fails with "([^"]*)"$/) do |txt| 47 | begin 48 | PDD::Base.new(@opts).xml 49 | passed = true 50 | rescue PDD::Error => e 51 | unless e.message.include?(txt) 52 | raise "PDD failed but exception doesn't contain \"#{txt}\": #{e.message}" 53 | end 54 | end 55 | raise "PDD didn't fail" if passed 56 | end 57 | 58 | When(%r{^I run bin/pdd with "([^"]*)"$}) do |arg| 59 | home = File.join(File.dirname(__FILE__), '../..') 60 | @stdout = `ruby -I#{home}/lib #{home}/bin/pdd #{arg}` 61 | @exitstatus = $CHILD_STATUS.exitstatus 62 | end 63 | 64 | Then(/^Stdout contains "([^"]*)"$/) do |txt| 65 | raise 'STDOUT is empty!' if @stdout.empty? 66 | raise "STDOUT doesn't contain '#{txt}':\n#{@stdout}" unless @stdout.include?(txt) 67 | end 68 | 69 | Then(/^Stdout is empty$/) do 70 | raise "STDOUT is not empty:\n#{@stdout}" unless @stdout == '' 71 | end 72 | 73 | Then(/^XML file "([^"]+)" matches "([^"]+)"$/) do |file, xpath| 74 | raise "File #{file} doesn't exit" unless File.exist?(file) 75 | 76 | xml = Nokogiri::XML.parse(File.read(file)) 77 | xml.remove_namespaces! 78 | raise "XML file #{file} doesn't match \"#{xpath}\":\n#{xml}" if xml.xpath(xpath).empty? 79 | end 80 | 81 | Then(/^Text File "([^"]+)" contains "([^"]+)"$/) do |file, substring| 82 | raise "File #{file} doesn't exist" unless File.exist?(file) 83 | 84 | content = File.read(file) 85 | raise "File #{file} doesn't contain \"#{substring}\":\n#{content}" \ 86 | if content.index(substring).nil? 87 | end 88 | 89 | Then(/^Exit code is zero$/) do 90 | raise "Non-zero exit code #{@exitstatus}" unless @exitstatus.zero? 91 | end 92 | 93 | Then(/^Exit code is not zero$/) do 94 | raise 'Zero exit code' if @exitstatus.zero? 95 | end 96 | 97 | When(/^I run bash with$/) do |text| 98 | FileUtils.copy_entry(@cwd, File.join(@dir, 'pdd')) 99 | @stdout = `#{text}` 100 | @exitstatus = $CHILD_STATUS.exitstatus 101 | end 102 | 103 | When(/^I run bash with "([^"]*)"$/) do |text| 104 | FileUtils.copy_entry(@cwd, File.join(@dir, 'pdd')) 105 | @stdout = `#{text}` 106 | @exitstatus = $CHILD_STATUS.exitstatus 107 | end 108 | 109 | Given(/^It is Unix$/) do 110 | pending if Gem.win_platform? 111 | end 112 | 113 | Given(/^It is Windows$/) do 114 | pending unless Gem.win_platform? 115 | end 116 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'simplecov' 5 | require_relative '../../lib/pdd' 6 | require 'aruba/cucumber' 7 | -------------------------------------------------------------------------------- /features/unicode.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Unicode 4 | As a source code writer I want to be able to 5 | work with Unicode files 6 | 7 | Scenario: Unicode on ASCII locale 8 | Given It is Unix 9 | Given I have a "test.txt" file with content: 10 | """ 11 | # @todo #44 привет, друзья 12 | """ 13 | When I run bash with 14 | """ 15 | LANG=C ruby -Ipdd/lib pdd/bin/pdd test.txt -v -f=/dev/null -e=pdd/**/* 16 | """ 17 | Then Exit code is zero 18 | 19 | Scenario: Skip file with broken Unicode 20 | Given It is Unix 21 | Given I have a "test.txt" file with content: 22 | """ 23 | \xBF test 24 | # @todo #44 \xFF hey 25 | \xFF test again 26 | """ 27 | When I run bin/pdd with "--exclude=test.txt -v -f=/dev/null" 28 | Then Stdout contains "Excluding test.txt" 29 | Then Exit code is zero 30 | -------------------------------------------------------------------------------- /features/uses_config.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Using .pdd config file 4 | As a source code writer I want to be able to 5 | call PDD as a command line tool and configure 6 | it via .pdd configuration file 7 | 8 | Scenario: Simple puzzles collecting 9 | Given I have a "Sample.java" file with content: 10 | """ 11 | @todo #13 Let's do it later, dude 12 | """ 13 | And I have a ".pdd" file with content: 14 | """ 15 | --verbose 16 | --source=. 17 | --file=out.xml 18 | """ 19 | When I run bin/pdd with "" 20 | Then Exit code is zero 21 | And Stdout contains "Reading from root dir ." 22 | And XML file "out.xml" matches "/puzzles[count(puzzle)=1]" 23 | -------------------------------------------------------------------------------- /lib/pdd.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'nokogiri' 5 | require 'logger' 6 | require 'time' 7 | require 'rainbow' 8 | require_relative 'pdd/version' 9 | require_relative 'pdd/rule/estimates' 10 | require_relative 'pdd/rule/text' 11 | require_relative 'pdd/rule/duplicates' 12 | require_relative 'pdd/rule/roles' 13 | 14 | # PDD main module. 15 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 16 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 17 | # License:: MIT 18 | module PDD 19 | # If it breaks. 20 | class Error < StandardError 21 | end 22 | 23 | # If it violates XSD schema. 24 | class SchemaError < Error 25 | end 26 | 27 | RULES = { 28 | 'min-estimate' => PDD::Rule::Estimate::Min, 29 | 'max-estimate' => PDD::Rule::Estimate::Max, 30 | 'min-words' => PDD::Rule::Text::MinWords, 31 | 'max-duplicates' => PDD::Rule::MaxDuplicates, 32 | 'available-roles' => PDD::Rule::Roles::Available 33 | }.freeze 34 | 35 | # Get logger. 36 | def self.log 37 | unless defined?(@logger) 38 | @logger = Logger.new($stdout) 39 | @logger.formatter = proc { |severity, _, _, msg| 40 | case severity 41 | when 'ERROR' 42 | "#{Rainbow(severity).red}: #{msg}\n" 43 | when 'WARN' 44 | "#{Rainbow(severity).orange}: #{msg}\n" 45 | else 46 | "#{msg}\n" 47 | end 48 | } 49 | @logger.level = Logger::WARN 50 | end 51 | @logger 52 | end 53 | 54 | class << self 55 | attr_writer :logger 56 | attr_accessor :opts 57 | end 58 | 59 | # Code base abstraction 60 | class Base 61 | # Ctor. 62 | # +opts+:: Options 63 | def initialize(opts) 64 | @opts = opts 65 | PDD.opts = opts 66 | PDD.log.level = Logger::INFO if @opts[:verbose] 67 | PDD.log.level = Logger::ERROR if @opts[:quiet] 68 | PDD.log.info "My version is #{PDD::VERSION}" 69 | PDD.log.info "Ruby version is #{RUBY_VERSION} at #{RUBY_PLATFORM}" 70 | end 71 | 72 | # Generate XML. 73 | def xml 74 | dir = @opts[:source] || Dir.pwd 75 | PDD.log.info "Reading from root dir #{dir}" 76 | require_relative 'pdd/sources' 77 | sources = Sources.new(File.expand_path(dir)) 78 | sources.exclude((@opts[:exclude] || []) + (@opts['skip-gitignore'] || [])) 79 | sources.include(@opts[:include]) 80 | sanitize( 81 | rules( 82 | Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| 83 | xml << "" 84 | xml.puzzles(attrs) do 85 | sources.fetch.each do |source| 86 | source.puzzles.each do |puzzle| 87 | PDD.log.info "Puzzle #{puzzle.props[:id]} " \ 88 | "#{puzzle.props[:estimate]}/#{puzzle.props[:role]} " \ 89 | "at #{puzzle.props[:file]}" 90 | render puzzle, xml 91 | end 92 | end 93 | end 94 | end.to_xml 95 | ) 96 | ) 97 | end 98 | 99 | private 100 | 101 | def attrs 102 | { 103 | 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 104 | 'xsi:noNamespaceSchemaLocation' => "#{host('xsd')}/#{PDD::VERSION}.xsd", 105 | 'version' => PDD::VERSION, 106 | 'date' => Time.now.utc.iso8601 107 | } 108 | end 109 | 110 | def host(suffix) 111 | "http://pdd-#{suffix}.teamed.io" 112 | end 113 | 114 | def xsl 115 | "#{host('xsl')}/#{PDD::VERSION}.xsl" 116 | end 117 | 118 | def render(puzzle, xml) 119 | props = puzzle.props 120 | xml.puzzle do 121 | props.map do |k, v| 122 | xml.send(:"#{k}", v) 123 | end 124 | end 125 | end 126 | 127 | def rules(xml) 128 | doc = Nokogiri::XML(xml) 129 | total = 0 130 | list = @opts[:rule] || [] 131 | unless list.none? { |r| r.start_with?('max-duplicates:') } 132 | raise PDD::Error, 'You can\'t modify max-duplicates, it\'s always 1' 133 | end 134 | 135 | list.push('max-duplicates:1').map do |r| 136 | name, value = r.split(':') 137 | rule = RULES[name] 138 | raise "Rule '#{name}' doesn't exist" if rule.nil? 139 | 140 | rule.new(doc, value).errors.each do |e| 141 | PDD.log.error e 142 | total += 1 143 | end 144 | end 145 | raise PDD::Error, "#{total} errors, see log above" unless total.zero? 146 | 147 | xml 148 | end 149 | 150 | def sanitize(xml) 151 | xsd = Nokogiri::XML::Schema( 152 | File.read(File.join(File.dirname(__FILE__), '../assets/puzzles.xsd')) 153 | ) 154 | errors = xsd.validate(Nokogiri::XML(xml)).map(&:message) 155 | errors.each { |e| PDD.log.error e } 156 | PDD.log.error(xml) unless errors.empty? 157 | raise SchemaError, errors.join('; ') unless errors.empty? 158 | 159 | xml 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/pdd/puzzle.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | module PDD 5 | # Puzzle. 6 | class Puzzle 7 | # Ctor. 8 | # +props+:: Properties 9 | def initialize(props) 10 | @props = props 11 | end 12 | attr_reader :props 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/pdd/rake_task.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'rake' 5 | require 'rake/tasklib' 6 | require 'minitest/autorun' 7 | require 'nokogiri' 8 | require 'tmpdir' 9 | require 'slop' 10 | require 'pdd' 11 | 12 | # PDD Rake task 13 | module PDD 14 | # Rake task 15 | class RakeTask < Rake::TaskLib 16 | attr_accessor :name, :fail_on_error, :includes, :license, :quiet 17 | 18 | def initialize(*args, &task_block) 19 | super() 20 | @name = args.shift || :pdd 21 | @includes = [] 22 | @excludes = [] 23 | @license = nil 24 | @quiet = false 25 | desc 'Run PDD' unless ::Rake.application.last_description 26 | task(name, *args) do |_, task_args| 27 | RakeFileUtils.send(:verbose, true) do 28 | yield(*[self, task_args].slice(0, task_block.arity)) if block_given? 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/pdd/rule/duplicates.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | module PDD 5 | module Rule 6 | # Rule for max duplicates. 7 | class MaxDuplicates 8 | # Ctor. 9 | # +xml+:: XML with puzzles 10 | def initialize(xml, max) 11 | @xml = xml 12 | @max = max.to_i 13 | end 14 | 15 | def errors 16 | @xml 17 | .xpath('//puzzle') 18 | .group_by { |p| p.xpath('body/text()').to_s } 19 | .map do |_, puzzles| 20 | next nil if puzzles.count <= @max 21 | 22 | "there are #{puzzles.count} duplicate(s) of the same puzzle: " + 23 | puzzles.map do |p| 24 | "#{p.xpath('file/text()')}:#{p.xpath('lines/text()')}" 25 | end.join(', ') + 26 | ", while maximum #{@max} duplicate is allowed" 27 | end.compact 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pdd/rule/estimates.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | module PDD 5 | module Rule 6 | module Estimate 7 | # Rule for min estimate. 8 | class Min 9 | # Ctor. 10 | # +xml+:: XML with puzzles 11 | def initialize(xml, min) 12 | @xml = xml 13 | @min = min.to_i 14 | end 15 | 16 | def errors 17 | @xml.xpath("//puzzle[number(estimate) < #{@min}]").map do |p| 18 | "Puzzle #{p.xpath('file/text()')}:#{p.xpath('lines/text()')} " \ 19 | "has an estimate of #{p.xpath('estimate/text()')} minutes, " \ 20 | "which is lower than #{@min} minutes" 21 | end 22 | end 23 | end 24 | 25 | # Rule for max estimate. 26 | class Max 27 | # Ctor. 28 | # +xml+:: XML with puzzles 29 | def initialize(xml, min) 30 | @xml = xml 31 | @min = min.to_i 32 | end 33 | 34 | def errors 35 | @xml.xpath("//puzzle[number(estimate) > #{@min}]").map do |p| 36 | "Puzzle #{p.xpath('file/text()')}:#{p.xpath('lines/text()')} " \ 37 | "has an estimate of #{p.xpath('estimate/text()')} minutes, " \ 38 | "which is bigger than #{@min} minutes" 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/pdd/rule/roles.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | module PDD 5 | module Rule 6 | module Roles 7 | # Rule for available roles checking. 8 | class Available 9 | # Ctor. 10 | # +xml+:: XML with puzzles 11 | def initialize(xml, roles) 12 | @xml = xml 13 | @roles = roles.split(',') 14 | end 15 | 16 | def errors 17 | @xml.xpath('//puzzle').map do |p| 18 | role = p.xpath('role/text()').to_s 19 | next nil if @roles.include?(role) 20 | 21 | "puzzle #{p.xpath('file/text()')}:#{p.xpath('lines/text()')}" + 22 | if role.empty? 23 | " doesn't define any role" \ 24 | ", while one of these roles is required: #{@roles}" 25 | else 26 | " defines role #{role}" \ 27 | ", while only these roles are allowed: #{@roles}" 28 | end 29 | end.compact 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/pdd/rule/text.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | module PDD 5 | module Rule 6 | module Text 7 | # Rule for minimum length of description. 8 | class MinWords 9 | # Ctor. 10 | # +xml+:: XML with puzzles 11 | def initialize(xml, min) 12 | @xml = xml 13 | @min = min.to_i 14 | end 15 | 16 | def errors 17 | @xml.xpath('//puzzle').map do |p| 18 | words = p.xpath('body/text()').to_s.split.size 19 | next nil if words >= @min 20 | 21 | "Puzzle #{p.xpath('file/text()')}:#{p.xpath('lines/text()')} " \ 22 | "has a very short description of just #{words} words while " \ 23 | "a minimum of #{@min} is required" 24 | end.compact 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/pdd/source.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'digest/md5' 5 | require 'net/http' 6 | require 'json' 7 | require 'shellwords' 8 | require_relative '../pdd' 9 | require_relative '../pdd/puzzle' 10 | 11 | module PDD 12 | MARKERS = ["\x40todo", 'TODO:?'].freeze 13 | # Source. 14 | class Source 15 | # Ctor. 16 | # +file+:: Absolute file name with source code 17 | # +path+:: Path to show (without full file name) 18 | def initialize(file, path) 19 | @file = file 20 | @path = path 21 | end 22 | 23 | def match_markers(line) 24 | if line.downcase.include? 'todo' 25 | /[^\s]\x40todo/.match(line) do |_| 26 | raise Error, get_no_leading_space_error("\x40todo") 27 | end 28 | /\x40todo(?!\s+#)/.match(line) do |_| 29 | raise Error, get_no_puzzle_marker_error("\x40todo") 30 | end 31 | /\x40todo\s+#\s/.match(line) do |_| 32 | raise Error, get_space_after_hash_error("\x40todo") 33 | end 34 | /[^\s]TODO:?/.match(line) do |_| 35 | raise Error, get_no_leading_space_error('TODO') 36 | end 37 | /TODO(?=[:\s])(?!:?\s+#)/.match(line) do |_| 38 | raise Error, get_no_puzzle_marker_error('TODO') 39 | end 40 | /TODO:?\s+#\s/.match(line) do |_| 41 | raise Error, get_space_after_hash_error('TODO') 42 | end 43 | a = [%r{(.*(?:^|\s))(?:\x40todo|TODO:|TODO)\s+#([\w\-.:/]+)\s+(.+)}.match(line)] 44 | a.compact 45 | else 46 | [] 47 | end 48 | end 49 | 50 | # Fetch all puzzles. 51 | def puzzles 52 | PDD.log.info "Reading #{@path} ..." 53 | puzzles = [] 54 | lines = File.readlines(@file, encoding: 'UTF-8') 55 | lines.each_with_index do |line, idx| 56 | begin 57 | match_markers(line).each do |m| 58 | puzzles << puzzle(lines.drop(idx + 1), m, idx) 59 | end 60 | rescue Error, ArgumentError => e 61 | message = "#{e.class} at #{@path}:#{idx + 1}: #{e.message}" 62 | raise Error, message unless PDD.opts && PDD.opts['skip-errors'] 63 | end 64 | end 65 | puzzles 66 | end 67 | 68 | private 69 | 70 | def get_no_leading_space_error(todo) 71 | "#{todo} must have a leading space to become \ 72 | a puzzle, as this page explains: https://github.com/cqfn/pdd#how-to-format" 73 | end 74 | 75 | def get_no_puzzle_marker_error(todo) 76 | "#{todo} found, but puzzle can't be parsed, \ 77 | most probably because #{todo} is not followed by a puzzle marker, \ 78 | as this page explains: https://github.com/cqfn/pdd#how-to-format" 79 | end 80 | 81 | def get_space_after_hash_error(todo) 82 | "#{todo} found, but there is an unexpected space \ 83 | after the hash sign, it should not be there, \ 84 | see https://github.com/cqfn/pdd#how-to-format" 85 | end 86 | 87 | # Fetch puzzle 88 | def puzzle(lines, match, idx) 89 | col_idx = match[0].length - match[0].lstrip.length 90 | tail = tail(lines, match[1], col_idx) 91 | body = "#{match[3]} #{tail.join(' ')}".gsub(/\s+/, ' ').strip 92 | body = body.chomp('*/-->').strip 93 | marker = marker(match[2]) 94 | Puzzle.new( 95 | marker.merge( 96 | id: "#{marker[:ticket]}-#{Digest::MD5.hexdigest(body)[0..7]}", 97 | lines: "#{idx + 1}-#{idx + tail.size + 1}", 98 | body: body, 99 | file: @path 100 | ).merge(git(idx + 1)) 101 | ) 102 | end 103 | 104 | # Parse a marker. 105 | def marker(text) 106 | re = %r{([\w\-.]+)(?::(\d+)(?:(m|h)[a-z]*)?)?(?:/([A-Z]+))?} 107 | match = re.match(text) 108 | if match.nil? 109 | raise "Invalid puzzle marker \"#{text}\", most probably formatted \ 110 | against the rules explained here: https://github.com/cqfn/pdd#how-to-format" 111 | end 112 | { 113 | ticket: match[1], 114 | estimate: minutes(match[2], match[3]), 115 | role: match[4].nil? ? 'DEV' : match[4] 116 | } 117 | end 118 | 119 | # Parse minutes. 120 | def minutes(num, units) 121 | min = num.nil? ? 0 : Integer(num) 122 | min *= 60 if !units.nil? && units.start_with?('h') 123 | min 124 | end 125 | 126 | # Fetch puzzle tail (all lines after the first one) 127 | def tail(lines, prefix, start) 128 | return [] if lines.empty? 129 | prefix = " #{' ' * start}" if prefix.empty? # fallback to space indentation 130 | tail_prefix = puzzle_tail_prefix(lines, prefix) 131 | tail = lines 132 | .take_while { |t| puzzle_text?(t, tail_prefix, prefix) } 133 | .map do |t| 134 | content = t[tail_prefix.length, t.length]&.lstrip 135 | puzzle_empty_line?(content, '') ? '' : content 136 | end 137 | tail.pop if tail[-1].eql?('') 138 | tail 139 | end 140 | 141 | def puzzle_tail_prefix(lines, prefix) 142 | return prefix if lines.empty? 143 | i = 0 144 | while i < lines.length 145 | unless puzzle_empty_line?(lines[i], prefix) 146 | return lines[i].start_with?("#{prefix} ") ? "#{prefix} " : prefix 147 | end 148 | i += 1 149 | end 150 | prefix 151 | end 152 | 153 | def puzzle_text?(line, prefix, intro_prefix) 154 | return false unless match_markers(line).none? 155 | line.start_with?(prefix) || puzzle_empty_line?(line, intro_prefix) 156 | end 157 | 158 | def puzzle_empty_line?(line, prefix) 159 | return true if line.nil? 160 | line.start_with?(prefix) && line.gsub(prefix, '').chomp.strip.eql?('\\') 161 | end 162 | 163 | # @todo #75:30min Let's make it possible to fetch Subversion data 164 | # in a similar way as we are doing with Git. We should also just 165 | # skip it if it's not SVN. 166 | 167 | # Git information at the line 168 | def git(pos) 169 | dir = Shellwords.escape(File.dirname(@file)) 170 | name = Shellwords.escape(File.basename(@file)) 171 | git = "cd #{dir} && git" 172 | if `#{git} rev-parse --is-inside-work-tree 2>/dev/null`.strip == 'true' 173 | cmd = "#{git} blame -L #{pos},#{pos} --porcelain #{name}" 174 | login = `#{cmd}`.split("\n").map do |line| 175 | case line 176 | when /^author / 177 | [:author, line.sub(/^author /, '')] 178 | when /^author-mail [^@]+@[^.]+\..+/ 179 | [:email, line.sub(/^author-mail <(.+)>$/, '\1')] 180 | when /^author-time / 181 | [ 182 | :time, 183 | Time.at( 184 | line.sub(/^author-time ([0-9]+)$/, '\1').to_i 185 | ).utc.iso8601 186 | ] 187 | end 188 | end.compact.to_h 189 | add_github_login(login) 190 | else 191 | {} 192 | end 193 | end 194 | 195 | def add_github_login(info) 196 | login = find_github_login(info) 197 | info[:author] = "@#{login}" unless login.empty? 198 | info 199 | end 200 | 201 | def get_json(query) 202 | uri = URI.parse(query) 203 | http = Net::HTTP.new(uri.hostname, uri.port) 204 | http.use_ssl = uri.scheme == 'https' 205 | req = Net::HTTP::Get.new(uri.request_uri) 206 | req.set_content_type('application/json') 207 | res = http.request(req) 208 | JSON.parse res.body 209 | end 210 | 211 | def find_github_user(info) 212 | email, author = info.values_at(:email, :author) 213 | # if email is not defined, changes have not been committed 214 | return if email.nil? 215 | 216 | base_uri = 'https://api.github.com/search/users?per_page=1' 217 | query = base_uri + "&q=#{email}+in:email" 218 | json = get_json query 219 | # find user by name instead since users can make github email private 220 | unless json['total_count'].positive? 221 | return if author.nil? 222 | 223 | query = base_uri + "&q=#{author}+in:fullname" 224 | json = get_json query 225 | end 226 | json['items'].first 227 | end 228 | 229 | def find_github_login(info) 230 | user = find_github_user info 231 | user['login'] 232 | rescue StandardError 233 | '' 234 | end 235 | end 236 | 237 | # Verbose Source. 238 | class VerboseSource 239 | # Ctor. 240 | # +file+:: Absolute file name with source code 241 | # +source+:: Instance of source 242 | def initialize(file, source) 243 | @file = file 244 | @source = source 245 | end 246 | 247 | # Fetch all puzzles. 248 | def puzzles 249 | @source.puzzles 250 | rescue Error => e 251 | raise Error, "#{@file}; #{e.message}" 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/pdd/sources.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'rainbow' 5 | require 'English' 6 | require 'filemagic' 7 | require_relative 'source' 8 | require_relative '../../utils/glob' 9 | 10 | module PDD 11 | # Code base abstraction 12 | class Sources 13 | # Ctor. 14 | # +dir+:: Directory with source code files 15 | def initialize(dir) 16 | @dir = File.absolute_path(dir) 17 | @exclude = ['.git/**/*'] 18 | @include = [] 19 | end 20 | 21 | # Fetch all sources. 22 | def fetch 23 | exclude_paths = @exclude.map do |ptn| 24 | Glob.new(File.join(@dir, ptn)).to_regexp 25 | end 26 | files = Dir.glob( 27 | File.join(@dir, '**/*'), File::FNM_DOTMATCH 28 | ).reject do |f| 29 | File.directory?(f) || exclude_paths.any? { |ptn| f.match(ptn) } 30 | end 31 | files += Dir.glob( 32 | @include.map { |ptn| File.join(@dir, ptn) } 33 | ).reject { |f| File.directory?(f) } 34 | files = files.uniq # remove duplicates 35 | files.reject { |f| binary?(f) }.map do |file| 36 | path = file[@dir.length + 1, file.length] 37 | VerboseSource.new(path, Source.new(file, path)) 38 | end 39 | end 40 | 41 | def exclude(paths) 42 | paths = [] if paths.nil? 43 | paths = [paths] unless paths.is_a?(Array) 44 | @exclude.push(*paths) 45 | paths&.each do |path| 46 | PDD.log.info "#{Rainbow('Excluding').orange} #{path}" 47 | end 48 | self 49 | end 50 | 51 | def include(paths) 52 | paths = [] if paths.nil? 53 | paths = [paths] unless paths.is_a?(Array) 54 | @include.push(*paths) 55 | paths&.each do |path| 56 | PDD.log.info "#{Rainbow('Including').blue} #{path}" 57 | end 58 | self 59 | end 60 | 61 | private 62 | 63 | def binary?(file) 64 | if text_file?(file) 65 | false 66 | else 67 | PDD.log.info "#{file} is a binary file (#{File.size(file)} bytes)" 68 | true 69 | end 70 | end 71 | 72 | def text_file?(file) 73 | fm = FileMagic.new(FileMagic::MAGIC_MIME) 74 | type = fm.file(file) 75 | type =~ %r{^(text/|application/javascript)} 76 | ensure 77 | fm.close 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/pdd/version.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | # PDD main module. 5 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 6 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 7 | # License:: MIT 8 | module PDD 9 | VERSION = '0.0.0'.freeze 10 | end 11 | -------------------------------------------------------------------------------- /pdd.gemspec: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'English' 5 | 6 | lib = File.expand_path('lib', __dir__) 7 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 8 | require_relative 'lib/pdd/version' 9 | 10 | Gem::Specification.new do |s| 11 | if s.respond_to? :required_rubygems_version= 12 | s.required_rubygems_version = 13 | Gem::Requirement.new('>= 0') 14 | end 15 | s.required_ruby_version = '>=2' 16 | s.name = 'pdd' 17 | s.version = PDD::VERSION 18 | s.license = 'MIT' 19 | s.summary = 'Puzzle Driven Development collector' 20 | s.description = 'Collects PDD puzzles from a source code base' 21 | s.authors = ['Yegor Bugayenko'] 22 | s.email = 'yegor256@gmail.com' 23 | s.homepage = 'https://github.com/cqfn/pdd' 24 | s.files = `git ls-files | grep -v -E '^(test/|\\.|renovate)'`.split($RS) 25 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 26 | s.rdoc_options = ['--charset=UTF-8'] 27 | s.extra_rdoc_files = ['README.md', 'LICENSE.txt'] 28 | s.add_dependency 'backtrace', '~>0.1' 29 | s.add_dependency 'nokogiri', '~>1.10' 30 | s.add_dependency 'rainbow', '~>3.0' 31 | s.add_dependency 'ruby-filemagic', '~>0.7' 32 | s.add_dependency 'slop', '~>4.6' 33 | s.metadata['rubygems_mfa_required'] = 'true' 34 | end 35 | -------------------------------------------------------------------------------- /test/test__helper.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | $stdout.sync = true 5 | 6 | require 'simplecov' 7 | require 'simplecov-cobertura' 8 | unless SimpleCov.running 9 | SimpleCov.command_name('test') 10 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 11 | [ 12 | SimpleCov::Formatter::HTMLFormatter, 13 | SimpleCov::Formatter::CoberturaFormatter 14 | ] 15 | ) 16 | SimpleCov.minimum_coverage 100 17 | SimpleCov.minimum_coverage_by_file 100 18 | SimpleCov.start do 19 | add_filter 'test/' 20 | add_filter 'vendor/' 21 | add_filter 'target/' 22 | track_files 'lib/**/*.rb' 23 | track_files '*.rb' 24 | end 25 | end 26 | 27 | require 'minitest/autorun' 28 | require 'minitest/reporters' 29 | Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new] 30 | 31 | require_relative '../lib/pdd' 32 | 33 | def stub_source_find_github_user(file, path = '') 34 | source = PDD::Source.new(file, path) 35 | verbose_source = PDD::VerboseSource.new(file, source) 36 | fake = proc do |info = {}| 37 | email, author = info.values_at(:email, :author) 38 | { 'login' => 'yegor256' } if email == 'yegor256@gmail.com' || 39 | author == 'Yegor Bugayenko' 40 | end 41 | source.stub :find_github_user, fake do 42 | yield verbose_source 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/test_duplicates.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'nokogiri' 6 | require_relative '../lib/pdd/rule/duplicates' 7 | 8 | # PDD::Rule::MaxDuplicates class test. 9 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 10 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 11 | # License:: MIT 12 | class TestMaxDuplicates < Minitest::Test 13 | def test_max_duplicates 14 | rule = PDD::Rule::MaxDuplicates.new( 15 | Nokogiri::XML::Document.parse( 16 | 'test 17 | test' 18 | ), 1 19 | ) 20 | refute_empty rule.errors, 'why it is empty?' 21 | end 22 | 23 | def test_max_duplicates_without_errors 24 | rule = PDD::Rule::MaxDuplicates.new( 25 | Nokogiri::XML::Document.parse( 26 | 'hello' 27 | ), 1 28 | ) 29 | assert_empty rule.errors, 'it has to be empty!' 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/test_estimates.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'nokogiri' 6 | require_relative '../lib/pdd/rule/estimates' 7 | 8 | # PDD::Rule::Estimate module tests. 9 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 10 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 11 | # License:: MIT 12 | class TestEstimates < Minitest::Test 13 | def test_min 14 | rule = PDD::Rule::Estimate::Min.new( 15 | Nokogiri::XML::Document.parse( 16 | '15' 17 | ), 30 18 | ) 19 | refute_empty rule.errors, 'why it is empty?' 20 | end 21 | 22 | def test_max 23 | rule = PDD::Rule::Estimate::Max.new( 24 | Nokogiri::XML::Document.parse( 25 | '30' 26 | ), 15 27 | ) 28 | refute_empty rule.errors, 'why it is empty?' 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/test_many.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'tmpdir' 6 | require_relative '../lib/pdd' 7 | require_relative '../lib/pdd/sources' 8 | 9 | # Test many puzzles to make sure their IDs are correct. 10 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 11 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 12 | # License:: MIT 13 | class TestMany < Minitest::Test 14 | def test_parsing 15 | Dir['./test_assets/puzzles/**'].each do |p| 16 | name = File.basename(p) 17 | list = PDD::Source.new("./test_assets/puzzles/#{name}", 'hey').puzzles 18 | assert_equal 1, list.size 19 | puzzle = list.first 20 | puts "#{name}: \"#{puzzle.props[:body]}\"" 21 | next if name.start_with?('_') 22 | assert_equal name, puzzle.props[:id] 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_pdd.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'nokogiri' 6 | require 'tmpdir' 7 | require 'slop' 8 | require_relative '../lib/pdd' 9 | 10 | # PDD main module test. 11 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 12 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 13 | # License:: MIT 14 | class TestPDD < Minitest::Test 15 | def test_basic 16 | Dir.mktmpdir 'test' do |dir| 17 | opts = opts(['-q', '-s', dir, '-e', '**/*.png', '-r', 'max-estimate:15']) 18 | File.write(File.join(dir, 'a.txt'), "\x40todo #55 hello!") 19 | matches( 20 | Nokogiri::XML(PDD::Base.new(opts).xml), 21 | [ 22 | '/processing-instruction("xml-stylesheet")[contains(.,".xsl")]', 23 | '/puzzles/@version', 24 | '/puzzles/@date', 25 | '/puzzles[count(puzzle)=1]', 26 | '/puzzles/puzzle[file="a.txt"]' 27 | ] 28 | ) 29 | end 30 | end 31 | 32 | def test_rules_failure 33 | Dir.mktmpdir 'test' do |dir| 34 | opts = opts(['-q', '-s', dir, '-e', '**/*.png', '-r', 'min-estimate:30']) 35 | File.write(File.join(dir, 'a.txt'), "\x40todo #90 hello!") 36 | assert_raises PDD::Error do 37 | PDD::Base.new(opts).xml 38 | end 39 | end 40 | end 41 | 42 | def test_git_repo 43 | skip if Gem.win_platform? 44 | Dir.mktmpdir 'test' do |dir| 45 | opts = opts(['-q', '-s', dir]) 46 | raise unless system(" 47 | set -e 48 | cd '#{dir}' 49 | git init --quiet . 50 | git config user.email test@teamed.io 51 | git config user.name 'Mr. Tester' 52 | mkdir 'a long dir name' 53 | cd 'a long dir name' 54 | mkdir 'a kid' 55 | cd 'a kid' 56 | echo '\x40todo #1 this is some puzzle' > '.это файл.txt' 57 | cd ../.. 58 | git add -f . 59 | git commit --no-verify --quiet -am 'first version' 60 | ") 61 | 62 | matches( 63 | Nokogiri::XML(PDD::Base.new(opts).xml), 64 | [ 65 | '/puzzles[count(puzzle)=1]', 66 | '/puzzles/puzzle[id]', 67 | '/puzzles/puzzle[file="a long dir name/a kid/.это файл.txt"]', 68 | '/puzzles/puzzle[author="Mr. Tester"]', 69 | '/puzzles/puzzle[email="test@teamed.io"]', 70 | '/puzzles/puzzle[time]' 71 | ] 72 | ) 73 | end 74 | end 75 | 76 | private 77 | 78 | def opts(args) 79 | Slop.parse args do |o| 80 | o.bool '-v', '--verbose' 81 | o.bool '-q', '--quiet' 82 | o.bool '--skip-errors' 83 | o.string '-s', '--source' 84 | o.array '-e', '--exclude' 85 | o.array '-r', '--rule' 86 | end 87 | end 88 | 89 | def matches(xml, xpaths) 90 | xpaths.each do |xpath| 91 | raise "doesn't match '#{xpath}': #{xml}" unless xml.xpath(xpath).size == 1 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/test_rake_task.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'tmpdir' 6 | require 'rake' 7 | require_relative '../lib/pdd/rake_task' 8 | 9 | # Test for RakeTask 10 | class TestRakeTask < Minitest::Test 11 | def test_basic 12 | Dir.mktmpdir 'test' do |dir| 13 | file = File.join(dir, 'a.txt') 14 | File.write(file, "\x40todo #55 hello!") 15 | PDD::RakeTask.new(:pdd1) do |task| 16 | task.quiet = true 17 | end 18 | Rake::Task['pdd1'].invoke 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/test_roles.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'nokogiri' 6 | require_relative '../lib/pdd/rule/roles' 7 | 8 | # PDD::Rule::Role module tests. 9 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 10 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 11 | # License:: MIT 12 | class TestRoles < Minitest::Test 13 | def test_incorrect_role 14 | rule = PDD::Rule::Roles::Available.new( 15 | Nokogiri::XML::Document.parse( 16 | 'D' 17 | ), 'A,B,C' 18 | ) 19 | refute_empty rule.errors, 'why it is empty?' 20 | end 21 | 22 | def test_correct_role 23 | rule = PDD::Rule::Roles::Available.new( 24 | Nokogiri::XML::Document.parse( 25 | 'F' 26 | ), 'F,E,G' 27 | ) 28 | assert_empty rule.errors, 'why it is not empty?' 29 | end 30 | 31 | def test_empty_role 32 | rule = PDD::Rule::Roles::Available.new( 33 | Nokogiri::XML::Document.parse( 34 | '' 35 | ), 'T,R,L' 36 | ) 37 | refute_empty rule.errors, 'why it is empty?' 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/test_source.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'tmpdir' 6 | require_relative '../lib/pdd' 7 | require_relative '../lib/pdd/sources' 8 | require_relative 'test__helper' 9 | 10 | # Source test. 11 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 12 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 13 | # License:: MIT 14 | class TestSource < Minitest::Test 15 | def test_parsing 16 | Dir.mktmpdir 'test' do |dir| 17 | file = File.join(dir, 'a.txt') 18 | File.write( 19 | file, 20 | " 21 | * \x40todo #44 привет, 22 | * how are you\t\tdoing? 23 | * -something else 24 | Something else 25 | ~~ \x40todo #ABC-3 this is another puzzle 26 | ~~ and it also has to work 27 | " 28 | ) 29 | stub_source_find_github_user(file, 'hey') do |source| 30 | list = source.puzzles 31 | assert_equal 2, list.size 32 | puzzle = list.first 33 | assert_equal '2-3', puzzle.props[:lines] 34 | assert_equal 'привет, how are you doing?', 35 | puzzle.props[:body] 36 | assert_equal '44', puzzle.props[:ticket] 37 | assert_nil puzzle.props[:author] 38 | assert_nil puzzle.props[:email] 39 | assert_nil puzzle.props[:time] 40 | end 41 | end 42 | end 43 | 44 | def test_parsing_leading_spaces 45 | Dir.mktmpdir 'test' do |dir| 46 | file = File.join(dir, 'a.txt') 47 | File.write( 48 | file, 49 | " 50 | * \x40todo #56:30min this is a 51 | * multi-line 52 | * comment! 53 | " 54 | ) 55 | stub_source_find_github_user(file, 'hey') do |source| 56 | list = source.puzzles 57 | assert_equal 1, list.size 58 | puzzle = list.first 59 | assert_equal '2-4', puzzle.props[:lines] 60 | assert_equal 'this is a multi-line comment!', puzzle.props[:body] 61 | assert_equal '56', puzzle.props[:ticket] 62 | end 63 | end 64 | end 65 | 66 | def test_no_prefix_multiline_puzzle_block 67 | Dir.mktmpdir 'test' do |dir| 68 | file = File.join(dir, 'a.txt') 69 | File.write( 70 | file, 71 | " 72 | 76 | " 77 | ) 78 | stub_source_find_github_user(file, 'hey') do |source| 79 | PDD.opts = nil 80 | assert_equal 1, source.puzzles.size 81 | puzzle = source.puzzles.last 82 | assert_equal '3-4', puzzle.props[:lines] 83 | assert_equal 'correctly formatted multi-line puzzle, with no ' \ 84 | 'comment prefix before todo marker', puzzle.props[:body] 85 | assert_equal '01', puzzle.props[:ticket] 86 | end 87 | end 88 | end 89 | 90 | def test_space_indented_multiline_puzzle_block 91 | Dir.mktmpdir 'test' do |dir| 92 | file = File.join(dir, 'a.txt') 93 | File.write( 94 | file, 95 | " 96 | # \x40todo #99:30min hello 97 | # good bye 98 | # hello again 99 | " 100 | ) 101 | stub_source_find_github_user(file, 'hey') do |source| 102 | PDD.opts = nil 103 | assert_equal 1, source.puzzles.size 104 | puzzle = source.puzzles.last 105 | assert_equal '2-3', puzzle.props[:lines] 106 | assert_equal 'hello good bye', puzzle.props[:body] 107 | assert_equal '99', puzzle.props[:ticket] 108 | end 109 | end 110 | end 111 | 112 | def test_multiple_puzzles_single_comment_block 113 | Dir.mktmpdir 'test' do |dir| 114 | file = File.join(dir, 'a.txt') 115 | File.write( 116 | file, 117 | " 118 | /* 119 | * \x40todo #1 First one with 120 | * a few lines 121 | * \x40todo #1 Second one also 122 | * with a few lines of text 123 | */ 124 | " 125 | ) 126 | stub_source_find_github_user(file, 'hey') do |source| 127 | PDD.opts = nil 128 | assert_equal 2, source.puzzles.size 129 | puzzle = source.puzzles.last 130 | assert_equal '5-6', puzzle.props[:lines] 131 | assert_equal 'Second one also with a few lines of text', puzzle.props[:body] 132 | assert_equal '1', puzzle.props[:ticket] 133 | end 134 | end 135 | end 136 | 137 | def test_succeed_despite_bad_puzzles 138 | Dir.mktmpdir 'test' do |dir| 139 | file = File.join(dir, 'a.txt') 140 | File.write( 141 | file, 142 | " 143 | * \x40todo #44 this is a correctly formatted puzzle, 144 | * with a second line without a leading space 145 | Another badly formatted puzzle 146 | * \x40todo this bad puzzle misses ticket name/number 147 | Something else 148 | * \x40todo #123 This puzzle is correctly formatted 149 | " 150 | ) 151 | PDD.opts = { 'skip-errors' => true } 152 | stub_source_find_github_user(file, 'hey') do |source| 153 | list = source.puzzles 154 | PDD.opts = nil 155 | assert_equal 2, list.size 156 | puzzle = list.first 157 | assert_equal '2-3', puzzle.props[:lines] 158 | assert_equal 'this is a correctly formatted puzzle, with a second ' \ 159 | 'line without a leading space', puzzle.props[:body] 160 | assert_equal '44', puzzle.props[:ticket] 161 | end 162 | end 163 | end 164 | 165 | def test_succeed_utf8_encoded_body 166 | Dir.mktmpdir 'test' do |dir| 167 | file = File.join(dir, 'a.txt') 168 | File.write( 169 | file, 170 | " 171 | * \x40todo #44 Привет, мир, мне кофе 172 | * вторая линия 173 | " 174 | ) 175 | list = PDD::VerboseSource.new(file, PDD::Source.new(file, 'hey')).puzzles 176 | assert_equal 1, list.size 177 | puzzle = list.first 178 | assert_equal '2-3', puzzle.props[:lines] 179 | assert_equal 'Привет, мир, мне кофе вторая линия', puzzle.props[:body] 180 | assert_equal '44', puzzle.props[:ticket] 181 | end 182 | end 183 | 184 | def test_failing_on_incomplete_puzzle 185 | Dir.mktmpdir 't5' do |dir| 186 | file = File.join(dir, 'ff.txt') 187 | File.write( 188 | file, 189 | " 190 | * \x40todo this puzzle misses ticket name/number 191 | " 192 | ) 193 | error = assert_raises PDD::Error do 194 | stub_source_find_github_user(file, 'ff', &:puzzles) 195 | end 196 | refute_nil error.to_s.index("\x40todo is not followed by") 197 | end 198 | end 199 | 200 | def test_failing_on_broken_unicode 201 | Dir.mktmpdir 'test' do |dir| 202 | file = File.join(dir, 'xx.txt') 203 | File.write(file, " * \\x40todo #44 this is a broken unicode: #{0x92.chr}") 204 | assert_raises PDD::Error do 205 | stub_source_find_github_user(file, 'xx', &:puzzles) 206 | end 207 | end 208 | end 209 | 210 | def test_failing_on_invalid_puzzle_without_hash_sign 211 | Dir.mktmpdir 'test' do |dir| 212 | file = File.join(dir, 'a.txt') 213 | File.write( 214 | file, 215 | " 216 | * \x40todo 44 this puzzle is not formatted correctly 217 | " 218 | ) 219 | error = assert_raises PDD::Error do 220 | stub_source_find_github_user(file, 'hey', &:puzzles) 221 | end 222 | refute_nil error.message.index('is not followed by a puzzle marker') 223 | end 224 | end 225 | 226 | def test_failing_on_puzzle_without_leading_space 227 | Dir.mktmpdir 'test' do |dir| 228 | file = File.join(dir, 'hey.txt') 229 | File.write( 230 | file, 231 | " 232 | *\x40todo #999 this is an incorrectly formatted puzzle! 233 | " 234 | ) 235 | error = assert_raises PDD::Error do 236 | stub_source_find_github_user(file, 'x', &:puzzles) 237 | end 238 | refute_nil error.message.index("\x40todo must have a leading space") 239 | end 240 | end 241 | 242 | def test_failing_on_puzzle_with_space_after_dash 243 | Dir.mktmpdir 'test' do |dir| 244 | file = File.join(dir, 'hey-you.txt') 245 | File.write( 246 | file, 247 | " 248 | * \x40todo # 123 This puzzle has an unnecessary space before the dash 249 | " 250 | ) 251 | error = assert_raises PDD::Error do 252 | stub_source_find_github_user(file, 'x', &:puzzles) 253 | end 254 | refute_nil error.message.index('an unexpected space') 255 | end 256 | end 257 | 258 | def test_reads_git_author 259 | skip if Gem.win_platform? 260 | Dir.mktmpdir 'test' do |dir| 261 | raise unless system(" 262 | set -e 263 | cd '#{dir}' 264 | git init --quiet . 265 | git config user.email test@teamed.io 266 | git config user.name test_unknown 267 | echo '\x40todo #1 this is the puzzle' > a.txt 268 | git add a.txt 269 | git commit --no-verify --quiet -am 'first version' 270 | ") 271 | 272 | stub_source_find_github_user(File.join(dir, 'a.txt')) do |source| 273 | list = source.puzzles 274 | assert_equal 1, list.size 275 | puzzle = list.first 276 | assert_equal '1-de87adc8', puzzle.props[:id] 277 | assert_equal '1-1', puzzle.props[:lines] 278 | assert_equal 'this is the puzzle', puzzle.props[:body] 279 | assert_equal 'test_unknown', puzzle.props[:author] 280 | assert_equal 'test@teamed.io', puzzle.props[:email] 281 | assert_match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/, 282 | puzzle.props[:time]) 283 | end 284 | end 285 | end 286 | 287 | def test_skips_invalid_git_mail 288 | skip if Gem.win_platform? 289 | Dir.mktmpdir 'test' do |dir| 290 | raise unless system(" 291 | set -e 292 | cd '#{dir}' 293 | git init --quiet . 294 | git config user.email invalid-email 295 | git config user.name test 296 | echo '\x40todo #1 this is the puzzle' > a.txt 297 | git add a.txt 298 | git commit --no-verify --quiet -am 'first version' 299 | ") 300 | 301 | stub_source_find_github_user(File.join(dir, 'a.txt')) do |source| 302 | list = source.puzzles 303 | assert_equal 1, list.size 304 | puzzle = list.first 305 | assert_equal '1-de87adc8', puzzle.props[:id] 306 | assert_equal '1-1', puzzle.props[:lines] 307 | assert_equal 'this is the puzzle', puzzle.props[:body] 308 | assert_equal 'test', puzzle.props[:author] 309 | assert_nil puzzle.props[:email] 310 | assert_match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/, 311 | puzzle.props[:time]) 312 | end 313 | end 314 | end 315 | 316 | def test_uses_github_login 317 | skip if Gem.win_platform? 318 | Dir.mktmpdir 'test' do |dir| 319 | raise unless system(" 320 | cd '#{dir}' 321 | git init --quiet . 322 | git config user.email yegor256@gmail.com 323 | git config user.name test 324 | echo '\x40todo #1 this is the puzzle' > a.txt 325 | git add a.txt 326 | git commit --no-verify --quiet -am 'first version' 327 | ") 328 | 329 | stub_source_find_github_user(File.join(dir, 'a.txt')) do |source| 330 | list = source.puzzles 331 | assert_equal 1, list.size 332 | puzzle = list.first 333 | assert_equal '@yegor256', puzzle.props[:author] 334 | end 335 | end 336 | end 337 | 338 | def test_skips_uncommitted_changes 339 | skip if Gem.win_platform? 340 | Dir.mktmpdir 'test' do |dir| 341 | raise unless system(" 342 | cd '#{dir}' 343 | git init --quiet . 344 | git config user.email yegor256@gmail.com 345 | git config user.name test 346 | echo 'hi' > a.txt 347 | git add a.txt 348 | git commit --no-verify --quiet -am 'first version' 349 | echo '\x40todo #1 this is a puzzle uncommitted' > a.txt 350 | ") 351 | 352 | stub_source_find_github_user(File.join(dir, 'a.txt')) do |source| 353 | list = source.puzzles 354 | assert_equal 1, list.size 355 | puzzle = list.first 356 | assert_nil puzzle.props[:email] 357 | assert_equal 'Not Committed Yet', puzzle.props[:author] 358 | end 359 | end 360 | end 361 | 362 | def test_skips_thymeleaf_close_tag 363 | Dir.mktmpdir 'test' do |dir| 364 | file = File.join(dir, 'a.txt') 365 | File.write( 366 | file, 367 | '' 368 | ) 369 | stub_source_find_github_user(file, 'hey') do |source| 370 | list = source.puzzles 371 | assert_equal 1, list.size 372 | puzzle = list.first 373 | assert_equal '1-1', puzzle.props[:lines] 374 | assert_equal 'puzzle info', puzzle.props[:body] 375 | assert_equal '123', puzzle.props[:ticket] 376 | end 377 | end 378 | end 379 | end 380 | -------------------------------------------------------------------------------- /test/test_source_todo.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'tmpdir' 6 | require_relative '../lib/pdd' 7 | require_relative '../lib/pdd/sources' 8 | require_relative 'test__helper' 9 | 10 | class TestSourceTodo < Minitest::Test 11 | def check_valid_puzzle(text, lines, body, ticket, count = 1) 12 | Dir.mktmpdir 'test' do |dir| 13 | file = File.join(dir, 'a.txt') 14 | File.write(file, text) 15 | stub_source_find_github_user(file, 'hey') do |source| 16 | list = source.puzzles 17 | assert_equal count, list.size 18 | puzzle = list.first 19 | assert_equal lines, puzzle.props[:lines] 20 | assert_equal body, puzzle.props[:body] 21 | assert_equal ticket, puzzle.props[:ticket] 22 | end 23 | end 24 | end 25 | 26 | def check_invalid_puzzle(text, error_msg) 27 | Dir.mktmpdir 'test' do |dir| 28 | file = File.join(dir, 'a.txt') 29 | File.write(file, text) 30 | error = assert_raises PDD::Error do 31 | stub_source_find_github_user(file, 'hey', &:puzzles) 32 | end 33 | refute_nil error.message.index(error_msg) 34 | end 35 | end 36 | 37 | def check_missing_puzzle(text) 38 | Dir.mktmpdir 'test' do |dir| 39 | file = File.join(dir, 'a.txt') 40 | File.write(file, text) 41 | begin 42 | stub_source_find_github_user(file, 'hey') do |source| 43 | list = source.puzzles 44 | assert_equal 0, list.size 45 | end 46 | rescue PDD::Error => e 47 | assert_nil e, "Error is raised #{e.message}" 48 | end 49 | end 50 | end 51 | 52 | def test_todo_parsing 53 | check_valid_puzzle( 54 | " 55 | // @todo #45 task description 56 | ", 57 | '2-2', 58 | 'task description', 59 | '45' 60 | ) 61 | end 62 | 63 | def test_todo_parsing_multi_line 64 | check_valid_puzzle( 65 | " 66 | // @todo #45 task description 67 | // second line 68 | ", 69 | '2-3', 70 | 'task description second line', 71 | '45' 72 | ) 73 | end 74 | 75 | def test_todo_utf8_encoded_body 76 | check_valid_puzzle( 77 | " 78 | // TODO #45 Привет, мир, мне кофе 79 | // вторая линия 80 | ", 81 | '2-3', 82 | 'Привет, мир, мне кофе вторая линия', 83 | '45' 84 | ) 85 | end 86 | 87 | def test_todo_colon_parsing 88 | check_valid_puzzle( 89 | " 90 | // TODO: #45 task description 91 | ", 92 | '2-2', 93 | 'task description', 94 | '45' 95 | ) 96 | end 97 | 98 | def test_todo_backslash_escape 99 | check_valid_puzzle( 100 | " 101 | // TODO #45 task description with \\ 102 | ", 103 | '2-2', 104 | 'task description with \\', 105 | '45' 106 | ) 107 | end 108 | 109 | def test_multiple_todo_colon 110 | check_valid_puzzle( 111 | " 112 | // TODO: #45 task description 113 | // TODO: #46 another task description 114 | ", 115 | '2-2', 116 | 'task description', 117 | '45', 118 | 2 119 | ) 120 | end 121 | 122 | def test_todo_colon_parsing_multi_line 123 | check_valid_puzzle( 124 | " 125 | // TODO: #45 task description 126 | // second line 127 | ", 128 | '2-3', 129 | 'task description second line', 130 | '45' 131 | ) 132 | end 133 | 134 | def test_todo_colon_parsing_multi_line_with_empty_line 135 | check_valid_puzzle( 136 | " 137 | // TODO: #45 task description 138 | // 139 | // second line after empty line is not a puzzle text 140 | ", 141 | '2-2', 142 | 'task description', 143 | '45' 144 | ) 145 | end 146 | 147 | def test_todo_colon_parsing_multi_line_with_empty_line_and_space 148 | check_valid_puzzle( 149 | ' 150 | // TODO: #46 task description 151 | // \ 152 | // second line after empty line is a part of the puzzle in case of backslash exists 153 | ', 154 | '2-4', 155 | 'task description second line after empty line is a part ' \ 156 | 'of the puzzle in case of backslash exists', 157 | '46' 158 | ) 159 | end 160 | 161 | def test_todo_colon_parsing_double_puzzle_with_empty_line 162 | check_valid_puzzle( 163 | ' 164 | // TODO: #46 task description for first 165 | // \ 166 | // TODO: #47 task description 167 | ', 168 | '2-2', 169 | 'task description for first', 170 | '46', 171 | 2 172 | ) 173 | end 174 | 175 | def test_todo_parsing_puzzle_javadoc_with_empty_line 176 | check_valid_puzzle( 177 | ' 178 | /** 179 | * TODO: #46 task description 180 | * \ 181 | */ 182 | * some text 183 | ', 184 | '3-3', 185 | 'task description', 186 | '46' 187 | ) 188 | end 189 | 190 | def test_todo_parsing_puzzle_last_empty_line 191 | check_valid_puzzle( 192 | ' 193 | /** 194 | * TODO: #47 task description 195 | * \ 196 | ', 197 | '3-3', 198 | 'task description', 199 | '47' 200 | ) 201 | end 202 | 203 | def test_todo_colon_parsing_multi_line_random_prefix 204 | check_valid_puzzle( 205 | ' 206 | ~~ 207 | ~~ @todo #44 First 208 | ~~ and 209 | ~~ second 210 | ', 211 | '3-4', 212 | 'First and', 213 | '44' 214 | ) 215 | end 216 | 217 | def test_todo_failing_no_ticket 218 | check_invalid_puzzle( 219 | " 220 | * TODO this puzzle misses ticket name/number 221 | ", 222 | 'TODO is not followed by' 223 | ) 224 | end 225 | 226 | def test_todo_colon_failing_no_ticket 227 | check_invalid_puzzle( 228 | " 229 | * TODO: this puzzle misses ticket name/number 230 | ", 231 | 'TODO is not followed by' 232 | ) 233 | end 234 | 235 | def test_todo_failing_space_after_hash 236 | check_invalid_puzzle( 237 | " 238 | * TODO # 45 this puzzle has space after hash 239 | ", 240 | 'TODO found, but there is an unexpected space after the hash sign' 241 | ) 242 | end 243 | 244 | def test_todo_colon_failing_space_after_hash 245 | check_invalid_puzzle( 246 | " 247 | * TODO: # 45 this puzzle has space after hash 248 | ", 249 | 'TODO found, but there is an unexpected space after the hash sign' 250 | ) 251 | end 252 | 253 | def test_todo_failing_no_space_before 254 | check_invalid_puzzle( 255 | " 256 | *TODO #45 this puzzle has no space before todo 257 | ", 258 | 'TODO must have a leading space' 259 | ) 260 | end 261 | 262 | def test_todo_colon_failing_no_space_before 263 | check_invalid_puzzle( 264 | " 265 | *TODO: #45 this puzzle has no space before todo 266 | ", 267 | 'TODO must have a leading space' 268 | ) 269 | end 270 | 271 | def test_todo_not_puzzle 272 | check_missing_puzzle( 273 | " 274 | TODOS_DIR=$PWD 275 | " 276 | ) 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /test/test_sources.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'fileutils' 6 | require 'tmpdir' 7 | require_relative '../test/test__helper' 8 | require_relative '../lib/pdd/sources' 9 | 10 | # Sources test. 11 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 12 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 13 | # License:: MIT 14 | class TestSources < Minitest::Test 15 | def test_iterator 16 | in_temp(['a.txt', 'b/c.txt']) do |dir| 17 | list = PDD::Sources.new(dir).fetch 18 | assert_equal 2, list.size 19 | end 20 | end 21 | 22 | def test_ignores_binary_files 23 | in_temp([]) do |dir| 24 | [ 25 | 'README.md', 26 | '.git/index', 27 | 'test_assets/elegant-objects.png', 28 | 'test_assets/aladdin.jpg', 29 | 'test_assets/article.pdf', 30 | 'test_assets/cambria.woff', 31 | 'test_assets/favicon.ico' 32 | ].each { |f| FileUtils.cp(File.join(Dir.pwd, f), dir) } 33 | list = PDD::Sources.new(dir).fetch 34 | assert_equal 1, list.size 35 | end 36 | end 37 | 38 | def test_detects_all_text_files 39 | in_temp([]) do |dir| 40 | exts = %w[(xsl java rb cpp apt js xml c go h txt)] 41 | exts.each do |ext| 42 | File.write(File.join(dir, "test.#{ext}"), 'text') 43 | end 44 | list = PDD::Sources.new(dir).fetch 45 | assert_equal( 46 | exts.size, list.size, 47 | "Files found: #{list}" 48 | ) 49 | end 50 | end 51 | 52 | def test_detects_xml_file 53 | in_temp(['a.xml']) do |dir| 54 | File.write(File.join(dir, 'a.xml'), '') 55 | list = PDD::Sources.new(dir).fetch 56 | assert_equal 1, list.size 57 | end 58 | end 59 | 60 | def test_detects_js_file 61 | in_temp(['a.js']) do |dir| 62 | File.write(File.join(dir, 'a.js'), "#!/usr/bin/env node\nconsole.log('Hi!');") 63 | list = PDD::Sources.new(dir).fetch 64 | assert_equal 1, list.size 65 | end 66 | end 67 | 68 | def test_excludes_by_pattern 69 | in_temp(['a/first.txt', 'b/c/d/second.txt']) do |dir| 70 | list = PDD::Sources.new(dir).exclude('b/c/d/second.txt').fetch 71 | assert_equal 1, list.size 72 | end 73 | end 74 | 75 | def test_excludes_recursively 76 | in_temp(['a/first.txt', 'b/c/second.txt', 'b/c/d/third.txt']) do |dir| 77 | list = PDD::Sources.new(dir).exclude('**/*').fetch 78 | assert_equal 0, list.size 79 | end 80 | end 81 | 82 | def test_includes_by_pattern 83 | in_temp(['a/first.txt', 'b/c/d/second.txt']) do |dir| 84 | list = PDD::Sources.new(dir).include('b/c/d/second.txt').fetch 85 | assert_equal 2, list.size 86 | end 87 | end 88 | 89 | def test_includes_recursively 90 | in_temp(['a/first.txt', 'b/c/second.txt', 'b/c/d/third.txt']) do |dir| 91 | sources = PDD::Sources.new(dir).exclude('b/c/**') 92 | sources.include('b/c/d/third.txt') 93 | list = sources.fetch 94 | assert_equal 2, list.size 95 | end 96 | end 97 | 98 | def test_fails_with_verbose_output 99 | in_temp do |dir| 100 | File.write(File.join(dir, 'z1.txt'), "\x40todobroken\n") 101 | error = assert_raises PDD::Error do 102 | PDD::Sources.new(dir).fetch[0].puzzles 103 | end 104 | assert error.message.start_with?('z1.txt; '), "here: #{error.message}" 105 | end 106 | end 107 | 108 | private 109 | 110 | def in_temp(files = []) 111 | Dir.mktmpdir 'x' do |dir| 112 | files.each do |path| 113 | file = File.join(dir, path) 114 | FileUtils.mkdir_p(File.dirname(file)) 115 | File.write(file, 'some test content') 116 | end 117 | yield dir 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/test_text.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | require 'minitest/autorun' 5 | require 'nokogiri' 6 | require_relative '../lib/pdd/rule/text' 7 | 8 | # PDD::Rule::Text module tests. 9 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 10 | # Copyright:: Copyright (c) 2014-2025 Yegor Bugayenko 11 | # License:: MIT 12 | class TestText < Minitest::Test 13 | def test_min_words 14 | rule = PDD::Rule::Text::MinWords.new( 15 | Nokogiri::XML::Document.parse( 16 | 'short text 17 | body with four words' 18 | ), 4 19 | ) 20 | assert_equal 1, rule.errors.size 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test_assets/aladdin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cqfn/pdd/90424f5f6b90c114fd65564c3d85dd6f77f843e6/test_assets/aladdin.jpg -------------------------------------------------------------------------------- /test_assets/article.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cqfn/pdd/90424f5f6b90c114fd65564c3d85dd6f77f843e6/test_assets/article.pdf -------------------------------------------------------------------------------- /test_assets/cambria.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cqfn/pdd/90424f5f6b90c114fd65564c3d85dd6f77f843e6/test_assets/cambria.woff -------------------------------------------------------------------------------- /test_assets/elegant-objects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cqfn/pdd/90424f5f6b90c114fd65564c3d85dd6f77f843e6/test_assets/elegant-objects.png -------------------------------------------------------------------------------- /test_assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cqfn/pdd/90424f5f6b90c114fd65564c3d85dd6f77f843e6/test_assets/favicon.ico -------------------------------------------------------------------------------- /test_assets/puzzles/1-04e35eb3: -------------------------------------------------------------------------------- 1 | /** 2 | * @todo #1:30min How are you? 3 | */ 4 | -------------------------------------------------------------------------------- /test_assets/puzzles/132-bc1dfafe: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // @todo #132 Just a few lines 4 | // before another comment 5 | // @comment This is not a puzzle, 6 | // but another bloc of text 7 | // 8 | // 9 | -------------------------------------------------------------------------------- /test_assets/puzzles/1425-59819ae3: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * 4 | * @todo #1425:30min Continue replacing usage of MatcherAssert.assertThat with 5 | * Assertion from cactoos-matchers. Keep PR short and limit the changes to 6 | * single package. Update this puzzle for the next package. 7 | * After all packages are done, add MatcherAssert to forbidden-apis.txt 8 | * 9 | */ 10 | -------------------------------------------------------------------------------- /test_assets/puzzles/42-0d933cc0: -------------------------------------------------------------------------------- 1 | # 2 | # @todo #42 This is a very 3 | # long puzzle of many lines and not always 4 | # well formatted... 5 | # 6 | # 7 | # 8 | -------------------------------------------------------------------------------- /test_assets/puzzles/44-660e9d6f: -------------------------------------------------------------------------------- 1 | 2 | // @todo #44 This puzzle 3 | // consists 4 | // of 5 | // \ 6 | // two 7 | // paragraphs 8 | 9 | // This text doesn't belong to the puzzle 10 | -------------------------------------------------------------------------------- /test_assets/puzzles/55-947a180a: -------------------------------------------------------------------------------- 1 | 2 | Some text goes before 3 | In a few lines 4 | 5 | // @todo #55 This is the puzzle; 6 | // Indented this way. 7 | 8 | Another piece of text after 9 | Again in a few lines 10 | -------------------------------------------------------------------------------- /test_assets/puzzles/71-8097fa26: -------------------------------------------------------------------------------- 1 | 2 | // @todo #71 This puzzle 3 | // consists 4 | // of just one paragraph 5 | // 6 | // this piece doesn't belong to the puzzle 7 | -------------------------------------------------------------------------------- /test_assets/puzzles/91-ecb9aa47: -------------------------------------------------------------------------------- 1 | # Text before 2 | 3 | # @todo #91 This puzzle 4 | # Looks weird 5 | # But it has to work 6 | # Even though 7 | # It's weird 8 | 9 | # Here is the text after 10 | -------------------------------------------------------------------------------- /test_assets/puzzles/93-641fe341: -------------------------------------------------------------------------------- 1 | # 2 | # @todo #93:30min This puzzle is very simple. It is indented right by more spaces than the other text. 3 | # This is the other text here, 4 | # which doesn't belong to the puzzle 5 | -------------------------------------------------------------------------------- /utils/glob.rb: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2014-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | # Utility glob class 5 | class Glob 6 | NO_LEADING_DOT = '(?=[^\.])'.freeze 7 | 8 | def initialize(glob_string) 9 | @glob_string = glob_string 10 | end 11 | 12 | # rubocop:disable Metrics/CyclomaticComplexity 13 | def to_regexp 14 | chars = @glob_string.gsub(%r{(\*\*/\*)|(\*\*)}, '*').chars 15 | in_curlies = 0, escaping = false 16 | chars.map do |char| 17 | if escaping 18 | escaping = false 19 | return char 20 | end 21 | case char 22 | when '*' 23 | '.*' 24 | when '?' 25 | '.' 26 | when '.' 27 | '\\.' 28 | when '{' 29 | in_curlies += 1 30 | '(' 31 | when '}' 32 | if in_curlies.positive? 33 | in_curlies -= 1 34 | return ')' 35 | end 36 | return char 37 | when ',' 38 | in_curlies.positive? ? '|' : char 39 | when '\\' 40 | escaping = true 41 | '\\' 42 | else 43 | char 44 | end 45 | end.join 46 | end 47 | # rubocop:enable Metrics/CyclomaticComplexity 48 | end 49 | --------------------------------------------------------------------------------