diff --git a/.github/workflows/release-rubygem.yaml b/.github/workflows/release-rubygem.yaml
new file mode 100644
index 00000000..c4f47d15
--- /dev/null
+++ b/.github/workflows/release-rubygem.yaml
@@ -0,0 +1,29 @@
+name: Release RubyGems
+
+permissions: {}
+
+on:
+ push:
+ branches:
+ - release/*
+
+jobs:
+ publish-rubygem:
+ name: Publish Ruby Gem
+ runs-on: ubuntu-latest
+ environment: Release
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ - uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
+ with:
+ ruby-version: 4.0.5
+ working-directory: ruby
+ - uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0
+ - uses: cucumber/action-publish-rubygem@4e79bb9aed597c835e8438f57c04d0996ab80d72 # v2.0.0
+ with:
+ working-directory: ruby
diff --git a/.github/workflows/test-ruby.yaml b/.github/workflows/test-ruby.yaml
new file mode 100644
index 00000000..dadc993d
--- /dev/null
+++ b/.github/workflows/test-ruby.yaml
@@ -0,0 +1,48 @@
+name: test-ruby
+
+permissions: {}
+
+on:
+ push:
+ branches:
+ - main
+ - renovate/**
+ paths:
+ - ruby/**
+ - testdata/**
+ - .github/**
+ pull_request:
+ branches:
+ - main
+ - renovate/**
+ paths:
+ - ruby/**
+ - testdata/**
+ - .github/**
+
+jobs:
+ test-ruby:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: true
+ matrix:
+ os:
+ - ubuntu-latest
+ ruby: ['3.3', '3.4', '4.0']
+ include:
+ - os: macos-latest
+ ruby: '4.0'
+ - os: windows-latest
+ ruby: '4.0'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+ working-directory: ruby
+ - run: bundle exec rspec
+ working-directory: ruby
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afbb2764..98c53ffb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
+### Added
+- [Ruby] Add initial Ruby implementation and RubyGems release workflow, covering the needs of the rerun formatter only for now ([#98](https://github.com/cucumber/query/pull/98))
+
### Changed
- Prefer Pickle.Location with fallback to Lineage ([#175](https://github.com/cucumber/query/pull/175))
diff --git a/README.md b/README.md
index 4a33b541..72c840f3 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,9 @@
+
+
+
@@ -38,3 +41,4 @@ is rendering the [GherkinDocument](https://github.com/cucumber/messages/blob/mai
The Query library makes this easy by providing a function to look up the
status of a step, a scenario or an entire file.
+
diff --git a/ruby/.gitignore b/ruby/.gitignore
new file mode 100644
index 00000000..7ae6fcfb
--- /dev/null
+++ b/ruby/.gitignore
@@ -0,0 +1,2 @@
+Gemfile.lock
+*.gem
diff --git a/ruby/.rspec b/ruby/.rspec
new file mode 100644
index 00000000..3687797e
--- /dev/null
+++ b/ruby/.rspec
@@ -0,0 +1,2 @@
+--require spec_helper
+--color
diff --git a/ruby/.rubocop.yml b/ruby/.rubocop.yml
new file mode 100644
index 00000000..f587f878
--- /dev/null
+++ b/ruby/.rubocop.yml
@@ -0,0 +1,21 @@
+inherit_from: .rubocop_todo.yml
+
+plugins:
+ - rubocop-performance
+ - rubocop-rspec
+
+inherit_mode:
+ merge:
+ - Exclude
+
+AllCops:
+ TargetRubyVersion: 3.3
+ NewCops: enable
+
+# Stylistic preference for cucumber
+Gemspec/DevelopmentDependencies:
+ Enabled: false
+
+# Disabled on our repo's to enable polyglot-release
+Gemspec/RequireMFA:
+ Enabled: false
diff --git a/ruby/.rubocop_todo.yml b/ruby/.rubocop_todo.yml
new file mode 100644
index 00000000..aec52a07
--- /dev/null
+++ b/ruby/.rubocop_todo.yml
@@ -0,0 +1,133 @@
+# This configuration was generated by
+# `rubocop --auto-gen-config`
+# on 2025-09-05 19:34:38 UTC using RuboCop version 1.80.2.
+# The point is for the user to remove these configuration records
+# one by one as the offenses are removed from the code base.
+# Note that changes in the inspected code, or installation of new
+# versions of RuboCop, may require this file to be generated again.
+
+# Offense count: 1
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines
+Layout/EmptyLinesAroundModuleBody:
+ Exclude:
+ - 'spec/support/runner_helper.rb'
+
+# Offense count: 1
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: AllowInHeredoc.
+Layout/TrailingWhitespace:
+ Exclude:
+ - 'spec/cucumber/query/legacy/step_definitions_by_test_step_spec.rb'
+
+# Offense count: 8
+# Configuration parameters: AllowComments, AllowEmptyLambdas.
+Lint/EmptyBlock:
+ Exclude:
+ - 'spec/cucumber/query/legacy/hook_by_test_step_spec.rb'
+ - 'spec/cucumber/query/legacy/step_definitions_by_test_step_spec.rb'
+
+# Offense count: 8
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
+# NotImplementedExceptions: NotImplementedError
+Lint/UnusedMethodArgument:
+ Exclude:
+ - 'lib/cucumber/query/query.rb'
+
+# Offense count: 4
+# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
+Metrics/AbcSize:
+ Max: 43
+
+# Offense count: 1
+# Configuration parameters: AllowedMethods, AllowedPatterns.
+Metrics/CyclomaticComplexity:
+ Max: 14
+
+# Offense count: 2
+# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
+Metrics/ClassLength:
+ Max: 160
+
+# Offense count: 3
+# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
+Metrics/MethodLength:
+ Max: 24
+
+# Offense count: 1
+# Configuration parameters: AllowedMethods, AllowedPatterns.
+Metrics/PerceivedComplexity:
+ Max: 14
+
+# Offense count: 8
+Performance/MethodObjectAsBlock:
+ Exclude:
+ - 'lib/cucumber/query/legacy/hook_by_test_step.rb'
+ - 'lib/cucumber/query/legacy/pickle_by_test.rb'
+ - 'lib/cucumber/query/legacy/pickle_step_by_test_step.rb'
+ - 'lib/cucumber/query/legacy/step_definitions_by_test_step.rb'
+ - 'lib/cucumber/query/legacy/test_case_started_by_test_case.rb'
+
+# Offense count: 1
+# Configuration parameters: Prefixes, AllowedPatterns.
+# Prefixes: when, with, without
+RSpec/ContextWording:
+ Exclude:
+ - 'spec/cucumber/query/legacy/hook_by_test_step_spec.rb'
+
+# Offense count: 1
+# Configuration parameters: CountAsOne.
+RSpec/ExampleLength:
+ Max: 7
+
+# Offense count: 32
+# Configuration parameters: AssignmentOnly.
+RSpec/InstanceVariable:
+ Exclude:
+ - 'spec/cucumber/query/legacy/hook_by_test_step_spec.rb'
+ - 'spec/cucumber/query/legacy/pickle_by_test_spec.rb'
+ - 'spec/cucumber/query/legacy/pickle_step_by_test_step_spec.rb'
+ - 'spec/cucumber/query/legacy/step_definitions_by_test_step_spec.rb'
+ - 'spec/cucumber/query/legacy/test_case_started_by_test_case_spec.rb'
+
+# Offense count: 3
+RSpec/MultipleExpectations:
+ Max: 3
+
+# Offense count: 12
+# Configuration parameters: AllowedGroups.
+RSpec/NestedGroups:
+ Max: 5
+
+# Offense count: 8
+# Configuration parameters: AllowedConstants.
+Style/Documentation:
+ Exclude:
+ - 'lib/cucumber/query/helpers.rb'
+ - 'lib/cucumber/query/legacy/hook_by_test_step.rb'
+ - 'lib/cucumber/query/legacy/pickle_by_test.rb'
+ - 'lib/cucumber/query/legacy/pickle_step_by_test_step.rb'
+ - 'lib/cucumber/query/legacy/step_definitions_by_test_step.rb'
+ - 'lib/cucumber/query/legacy/test_case_started_by_test_case.rb'
+ - 'lib/cucumber/query/legacy/test_run_started.rb'
+ - 'lib/cucumber/query/query.rb'
+
+# Offense count: 4
+# This cop supports unsafe autocorrection (--autocorrect-all).
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: always, always_true, never
+Style/FrozenStringLiteralComment:
+ Exclude:
+ - 'lib/cucumber/query/helpers.rb'
+ - 'lib/cucumber/query/query.rb'
+ - 'spec/support/gherkin_helper.rb'
+ - 'spec/support/runner_helper.rb'
+
+# Offense count: 8
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
+# URISchemes: http, https
+Layout/LineLength:
+ Max: 198
diff --git a/ruby/Gemfile b/ruby/Gemfile
new file mode 100644
index 00000000..7f4f5e95
--- /dev/null
+++ b/ruby/Gemfile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+gemspec
diff --git a/ruby/LICENSE b/ruby/LICENSE
new file mode 100644
index 00000000..d23a133d
--- /dev/null
+++ b/ruby/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Cucumber Ltd and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ruby/VERSION b/ruby/VERSION
new file mode 100644
index 00000000..946789e6
--- /dev/null
+++ b/ruby/VERSION
@@ -0,0 +1 @@
+16.0.0
diff --git a/ruby/cucumber-query.gemspec b/ruby/cucumber-query.gemspec
new file mode 100644
index 00000000..c2244113
--- /dev/null
+++ b/ruby/cucumber-query.gemspec
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+Gem::Specification.new do |s|
+ s.name = 'cucumber-query'
+ s.version = File.read(File.expand_path('VERSION', __dir__)).strip
+ s.authors = ['Luke Hill']
+ s.description = 'Cucumber Query - query messages'
+ s.summary = "#{s.name}-#{s.version}"
+ s.email = 'cukebot@cucumber.io'
+ s.homepage = 'https://github.com/cucumber/query'
+ s.platform = Gem::Platform::RUBY
+ s.license = 'MIT'
+ s.required_ruby_version = '>= 3.3'
+ s.required_rubygems_version = '>= 3.2.8'
+
+ s.metadata = {
+ 'bug_tracker_uri' => 'https://github.com/cucumber/query/issues',
+ 'changelog_uri' => 'https://github.com/cucumber/query/blob/main/CHANGELOG.md',
+ 'documentation_uri' => 'https://github.com/cucumber/query/blob/main/README.md',
+ 'mailing_list_uri' => 'https://groups.google.com/forum/#!forum/cukes',
+ 'source_code_uri' => 'https://github.com/cucumber/query/blob/main/ruby'
+ }
+
+ s.add_dependency 'cucumber-messages', '> 34', '< 36'
+
+ s.add_development_dependency 'rspec', '~> 3.13'
+ s.add_development_dependency 'rubocop', '~> 1.88.0'
+ s.add_development_dependency 'rubocop-performance', '~> 1.26.1'
+ s.add_development_dependency 'rubocop-rspec', '~> 3.10.2'
+
+ s.files = Dir['LICENSE', 'VERSION', 'lib/**/*']
+ s.rdoc_options = ['--charset=UTF-8']
+ s.require_path = 'lib'
+end
diff --git a/ruby/lib/cucumber/query.rb b/ruby/lib/cucumber/query.rb
new file mode 100644
index 00000000..40d5da7b
--- /dev/null
+++ b/ruby/lib/cucumber/query.rb
@@ -0,0 +1,307 @@
+# frozen_string_literal: true
+
+require 'cucumber/messages'
+
+require_relative 'repository'
+
+# Given one Cucumber Message, find another.
+#
+# Queries can be made while the test run is incomplete - and this will naturally return incomplete results
+# see {Cucumber Messages - Message Overview}[https://github.com/cucumber/messages?tab=readme-ov-file#message-overview]
+#
+module Cucumber
+ # Provides lookup methods for related Cucumber messages stored in the `Repository` class.
+ class Query
+ attr_reader :repository
+ private :repository
+
+ include Cucumber::Messages::Helpers::TimeConversion
+ include Cucumber::Messages::Helpers::TestStepResultComparator
+
+ def initialize(repository)
+ @repository = repository
+ end
+
+ def count_test_cases_started
+ find_all_test_case_started.length
+ end
+
+ def find_all_pickles
+ repository.pickle_by_id.values
+ end
+
+ def find_all_pickle_steps
+ repository.pickle_step_by_id.values
+ end
+
+ def find_all_step_definitions
+ repository.step_definition_by_id.values
+ end
+
+ # This finds all test cases from the following conditions (UNION)
+ # -> Test cases that have started, but not yet finished
+ # -> Test cases that have started, finished, but that will NOT be retried
+ def find_all_test_case_started
+ repository.test_case_started_by_id.values.select do |test_case_started|
+ test_case_finished = find_test_case_finished_by(test_case_started)
+ test_case_finished.nil? || !test_case_finished.will_be_retried
+ end
+ end
+
+ # This finds all test cases that have finished AND will not be retried
+ def find_all_test_case_finished
+ repository.test_case_finished_by_test_case_started_id.values.reject(&:will_be_retried)
+ end
+
+ def find_all_test_cases
+ repository.test_case_by_id.values
+ end
+
+ def find_all_test_run_hook_started
+ repository.test_run_hook_started_by_id.values
+ end
+
+ def find_all_test_run_hook_finished
+ repository.test_run_hook_finished_by_test_run_hook_started_id.values
+ end
+
+ def find_all_test_step_started
+ repository.test_steps_started_by_test_case_started_id.values.flatten
+ end
+
+ def find_all_test_step_finished
+ repository.test_steps_finished_by_test_case_started_id.values.flatten
+ end
+
+ def find_all_test_steps
+ repository.test_step_by_id.values
+ end
+
+ # This method will be called with 1 of these 3 messages
+ # [TestStep || TestRunHookStarted || TestRunHookFinished]
+ def find_hook_by(message)
+ ensure_only_message_types!(
+ message,
+ [Cucumber::Messages::TestStep, Cucumber::Messages::TestRunHookStarted, Cucumber::Messages::TestRunHookFinished],
+ '#find_hook_by'
+ )
+
+ if message.is_a?(Cucumber::Messages::TestRunHookFinished)
+ test_run_hook_started_message = find_test_run_hook_started_by(message)
+ test_run_hook_started_message ? find_hook_by(test_run_hook_started_message) : nil
+ else
+ repository.hook_by_id[message.hook_id]
+ end
+ end
+
+ def find_meta
+ repository.meta
+ end
+
+ # This method will be called with 1 of these 2 messages
+ # [TestCaseStarted || TestCaseFinished]
+ def find_most_severe_test_step_result_by(message)
+ ensure_only_message_types!(
+ message,
+ [Cucumber::Messages::TestCaseStarted, Cucumber::Messages::TestCaseFinished],
+ '#find_most_severe_test_step_result_by'
+ )
+
+ if message.is_a?(Cucumber::Messages::TestCaseStarted)
+ find_test_steps_finished_by(message)
+ .map(&:test_step_result)
+ .max_by { |test_step_result| test_step_result_rankings[test_step_result.status] }
+ else
+ test_case_started_message = find_test_case_started_by(message)
+ test_case_started_message && find_most_severe_test_step_result_by(test_case_started_message)
+ end
+ end
+
+ # This method will be called with 1 of these 5 messages
+ # [TestCase || TestCaseStarted || TestCaseFinished || TestStepStarted || TestStepFinished]
+ def find_pickle_by(message)
+ ensure_only_message_types!(
+ message,
+ [
+ Cucumber::Messages::TestCase,
+ Cucumber::Messages::TestCaseStarted,
+ Cucumber::Messages::TestCaseFinished,
+ Cucumber::Messages::TestStepStarted,
+ Cucumber::Messages::TestStepFinished
+ ],
+ '#find_pickle_by'
+ )
+
+ test_case_message = message.is_a?(Cucumber::Messages::TestCase) ? message : find_test_case_by(message)
+ repository.pickle_by_id[test_case_message.pickle_id]
+ end
+
+ # This method will be called with only 1 message
+ # [TestStep]
+ def find_pickle_step_by(test_step)
+ ensure_only_message_types!(
+ test_step,
+ [Cucumber::Messages::TestStep],
+ '#find_pickle_step_by'
+ )
+
+ repository.pickle_step_by_id[test_step.pickle_step_id]
+ end
+
+ # This method will be called with only 1 message
+ # [PickleStep]
+ def find_step_by(pickle_step)
+ ensure_only_message_types!(
+ pickle_step,
+ [Cucumber::Messages::PickleStep],
+ '#find_step_by'
+ )
+
+ repository.step_by_id[pickle_step.ast_node_ids.first]
+ end
+
+ # This method will be called with only 1 message
+ # [TestStep]
+ def find_step_definitions_by(test_step)
+ ensure_only_message_types!(
+ test_step,
+ [Cucumber::Messages::TestStep],
+ '#find_step_definitions_by'
+ )
+
+ ids = test_step.step_definition_ids.nil? ? [] : test_step.step_definition_ids
+ ids.filter_map { |id| repository.step_definition_by_id[id] }
+ end
+
+ # This method will be called with 1 of these 4 messages
+ # [TestCaseStarted || TestCaseFinished || TestStepStarted || TestStepFinished]
+ def find_test_case_by(message)
+ ensure_only_message_types!(
+ message,
+ [
+ Cucumber::Messages::TestCaseStarted,
+ Cucumber::Messages::TestCaseFinished,
+ Cucumber::Messages::TestStepStarted,
+ Cucumber::Messages::TestStepFinished
+ ],
+ '#find_test_case_by'
+ )
+
+ test_case_started_message = message.is_a?(Cucumber::Messages::TestCaseStarted) ? message : find_test_case_started_by(message)
+ repository.test_case_by_id[test_case_started_message.test_case_id]
+ end
+
+ # This method will be called with 1 of these 3 messages
+ # [TestCaseFinished || TestStepStarted || TestStepFinished]
+ def find_test_case_started_by(message)
+ ensure_only_message_types!(
+ message,
+ [Cucumber::Messages::TestCaseFinished, Cucumber::Messages::TestStepStarted, Cucumber::Messages::TestStepFinished],
+ '#find_test_case_started_by'
+ )
+
+ repository.test_case_started_by_id[message.test_case_started_id]
+ end
+
+ # This method will be called with only 1 message
+ # [TestCaseStarted]
+ def find_test_case_finished_by(test_case_started)
+ ensure_only_message_types!(
+ test_case_started,
+ [Cucumber::Messages::TestCaseStarted],
+ '#find_test_case_finished_by'
+ )
+
+ repository.test_case_finished_by_test_case_started_id[test_case_started.id]
+ end
+
+ def find_test_run_duration
+ if repository.test_run_started.nil? || repository.test_run_finished.nil?
+ nil
+ else
+ timestamp_to_time(repository.test_run_finished.timestamp) - timestamp_to_time(repository.test_run_started.timestamp)
+ end
+ end
+
+ # This method will be called with only 1 message
+ # [TestRunHookFinished]
+ def find_test_run_hook_started_by(test_run_hook_finished)
+ ensure_only_message_types!(
+ test_run_hook_finished,
+ [Cucumber::Messages::TestRunHookFinished],
+ '#find_test_run_hook_started_by'
+ )
+
+ repository.test_run_hook_started_by_id[test_run_hook_finished.test_run_hook_started_id]
+ end
+
+ # This method will be called with only 1 message
+ # [TestRunHookStarted]
+ def find_test_run_hook_finished_by(test_run_hook_started)
+ ensure_only_message_types!(
+ test_run_hook_started,
+ [Cucumber::Messages::TestRunHookStarted],
+ '#find_test_run_hook_finished_by'
+ )
+
+ repository.test_run_hook_finished_by_test_run_hook_started_id[test_run_hook_started.id]
+ end
+
+ def find_test_run_started
+ repository.test_run_started
+ end
+
+ def find_test_run_finished
+ repository.test_run_finished
+ end
+
+ # This method will be called with 1 of these 2 messages
+ # [TestStepStarted || TestStepFinished]
+ def find_test_step_by(message)
+ ensure_only_message_types!(
+ message,
+ [Cucumber::Messages::TestStepStarted, Cucumber::Messages::TestStepFinished],
+ '#find_test_step_by'
+ )
+
+ repository.test_step_by_id[message.test_step_id]
+ end
+
+ # This method will be called with 1 of these 2 messages
+ # [TestCaseStarted || TestCaseFinished]
+ def find_test_steps_started_by(message)
+ ensure_only_message_types!(
+ message,
+ [Cucumber::Messages::TestCaseStarted, Cucumber::Messages::TestCaseFinished],
+ '#find_test_steps_started_by'
+ )
+
+ key = message.is_a?(Cucumber::Messages::TestCaseStarted) ? message.id : message.test_case_started_id
+ repository.test_steps_started_by_test_case_started_id.fetch(key, [])
+ end
+
+ # This method will be called with 1 of these 2 messages
+ # [TestCaseStarted || TestCaseFinished]
+ def find_test_steps_finished_by(message)
+ ensure_only_message_types!(
+ message,
+ [Cucumber::Messages::TestCaseStarted, Cucumber::Messages::TestCaseFinished],
+ '#find_test_steps_finished_by'
+ )
+
+ if message.is_a?(Cucumber::Messages::TestCaseStarted)
+ repository.test_steps_finished_by_test_case_started_id.fetch(message.id, [])
+ else
+ test_case_started_message = find_test_case_started_by(message)
+ test_case_started_message.nil? ? [] : find_test_steps_finished_by(test_case_started_message)
+ end
+ end
+
+ private
+
+ def ensure_only_message_types!(supplied_message, permissible_message_types, method_name)
+ raise ArgumentError, "Supplied argument is not a Cucumber Message. Argument: #{supplied_message.class}" unless supplied_message.is_a?(Cucumber::Messages::Message)
+ raise ArgumentError, "Supplied message type '#{supplied_message.class}' is not permitted to be used when calling #{method_name}" unless permissible_message_types.include?(supplied_message.class)
+ end
+ end
+end
diff --git a/ruby/lib/cucumber/repository.rb b/ruby/lib/cucumber/repository.rb
new file mode 100644
index 00000000..793296b2
--- /dev/null
+++ b/ruby/lib/cucumber/repository.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+module Cucumber
+ # In memory repository i.e. a thread based link to cucumber-query
+ class Repository
+ attr_accessor :meta, :test_run_started, :test_run_finished
+ attr_reader :attachments_by_test_case_started_id, :attachments_by_test_run_hook_started_id,
+ :hook_by_id,
+ :pickle_by_id, :pickle_step_by_id,
+ :step_by_id, :step_definition_by_id,
+ :test_case_by_id, :test_case_started_by_id, :test_case_finished_by_test_case_started_id,
+ :test_run_hook_started_by_id, :test_run_hook_finished_by_test_run_hook_started_id,
+ :test_step_by_id, :test_steps_started_by_test_case_started_id, :test_steps_finished_by_test_case_started_id
+
+ def initialize
+ @attachments_by_test_case_started_id = Hash.new { |hash, key| hash[key] = [] }
+ @attachments_by_test_run_hook_started_id = Hash.new { |hash, key| hash[key] = [] }
+ @hook_by_id = {}
+ @pickle_by_id = {}
+ @pickle_step_by_id = {}
+ @step_by_id = {}
+ @step_definition_by_id = {}
+ @test_case_by_id = {}
+ @test_case_started_by_id = {}
+ @test_case_finished_by_test_case_started_id = {}
+ @test_run_hook_started_by_id = {}
+ @test_run_hook_finished_by_test_run_hook_started_id = {}
+ @test_step_by_id = {}
+ @test_steps_started_by_test_case_started_id = Hash.new { |hash, key| hash[key] = [] }
+ @test_steps_finished_by_test_case_started_id = Hash.new { |hash, key| hash[key] = [] }
+ end
+
+ def update(envelope)
+ message_category = envelope.type[:contained_message]
+ message_value = envelope.public_send(message_category)
+ send("update_#{message_category}", message_value)
+ end
+
+ private
+
+ # Missing handlers
+
+ def method_missing(method_name, *args, &)
+ if method_name.to_s.start_with?('update_')
+ Kernel.warn("Attempting to update the repository with #{method_name}. Please raise this as a missing message handler")
+ Kernel.warn('Create an issue here: https://github.com/cucumber/query/issues')
+ nil
+ else
+ super
+ end
+ end
+
+ def respond_to_missing?(method_name, include_private = false)
+ method_name.to_s.start_with?('update_') || super
+ end
+
+ def update_source(_source)
+ :no_op # Not Implemented Yet. But method will be inherently called from `#update`
+ end
+
+ def update_undefined_parameter_type(_undefined_parameter_type)
+ :no_op # Not Implemented Yet. But method will be inherently called from `#update`
+ end
+
+ def update_meta(meta)
+ self.meta = meta
+ end
+
+ def update_attachment(attachment)
+ attachments_by_test_case_started_id[attachment.test_case_started_id] << attachment if attachment.test_case_started_id
+ attachments_by_test_run_hook_started_id[attachment.test_run_hook_started_id] << attachment if attachment.test_run_hook_started_id
+ end
+
+ def update_feature(feature)
+ feature.children.each do |feature_child|
+ update_steps(feature_child.background.steps) if feature_child.background
+ update_scenario(feature_child.scenario) if feature_child.scenario
+ next unless feature_child.rule
+
+ feature_child.rule.children.each do |rule_child|
+ update_steps(rule_child.background.steps) if rule_child.background
+ update_scenario(rule_child.scenario) if rule_child.scenario
+ end
+ end
+ end
+
+ def update_gherkin_document(gherkin_document)
+ update_feature(gherkin_document.feature) if gherkin_document.feature
+ end
+
+ def update_hook(hook)
+ hook_by_id[hook.id] = hook
+ end
+
+ def update_pickle(pickle)
+ pickle_by_id[pickle.id] = pickle
+ pickle.steps.each { |pickle_step| pickle_step_by_id[pickle_step.id] = pickle_step }
+ end
+
+ def update_scenario(scenario)
+ update_steps(scenario.steps)
+ end
+
+ def update_steps(steps)
+ steps.each { |step| step_by_id[step.id] = step }
+ end
+
+ def update_step_definition(step_definition)
+ step_definition_by_id[step_definition.id] = step_definition
+ end
+
+ def update_test_case(test_case)
+ test_case_by_id[test_case.id] = test_case
+ test_case.test_steps.each { |test_step| test_step_by_id[test_step.id] = test_step }
+ end
+
+ def update_test_case_started(test_case_started)
+ test_case_started_by_id[test_case_started.id] = test_case_started
+ end
+
+ def update_test_case_finished(test_case_finished)
+ test_case_finished_by_test_case_started_id[test_case_finished.test_case_started_id] = test_case_finished
+ end
+
+ def update_test_run_started(test_run_started)
+ self.test_run_started = test_run_started
+ end
+
+ def update_test_run_finished(test_run_finished)
+ self.test_run_finished = test_run_finished
+ end
+
+ def update_test_run_hook_started(test_run_hook_started)
+ test_run_hook_started_by_id[test_run_hook_started.id] = test_run_hook_started
+ end
+
+ def update_test_run_hook_finished(test_run_hook_finished)
+ test_run_hook_finished_by_test_run_hook_started_id[test_run_hook_finished.test_run_hook_started_id] = test_run_hook_finished
+ end
+
+ def update_test_step_started(test_step_started)
+ test_steps_started_by_test_case_started_id[test_step_started.test_case_started_id] << test_step_started
+ end
+
+ def update_test_step_finished(test_step_finished)
+ test_steps_finished_by_test_case_started_id[test_step_finished.test_case_started_id] << test_step_finished
+ end
+ end
+end
diff --git a/ruby/spec/cucumber/query_spec.rb b/ruby/spec/cucumber/query_spec.rb
new file mode 100644
index 00000000..85eea83f
--- /dev/null
+++ b/ruby/spec/cucumber/query_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+RSpec.describe Cucumber::Query do
+ def testdata_path(name)
+ File.expand_path("../../../testdata/src/#{name}", __dir__)
+ end
+
+ def parse_ndjson_file(path)
+ Cucumber::Messages::Helpers::NdjsonToMessageEnumerator.new(File.read(path))
+ end
+
+ queries = {
+ 'countTestCasesStarted' => ->(query) { query.count_test_cases_started },
+ 'findAllPickles' => ->(query) { query.find_all_pickles.length },
+ 'findAllStepDefinitions' => ->(query) { query.find_all_step_definitions.length },
+ 'findAllTestCases' => ->(query) { query.find_all_test_cases.length },
+ 'findAllTestRunHookStarted' => ->(query) { query.find_all_test_run_hook_started.length },
+ 'findAllTestRunHookFinished' => ->(query) { query.find_all_test_run_hook_finished.length },
+ 'findAllTestSteps' => ->(query) { query.find_all_test_steps.length },
+ 'findMeta' => ->(query) { query.find_meta.implementation.name },
+ 'findPickleBy' => lambda do |query|
+ results = {}
+ results['testCaseStarted'] = query.find_all_test_case_started.map { |message| query.find_pickle_by(message).name }
+ results['testCaseFinished'] = query.find_all_test_case_finished.map { |message| query.find_pickle_by(message).name }
+ results['testStepStarted'] = query.find_all_test_step_started.map { |message| query.find_pickle_by(message).name }
+ results['testStepFinished'] = query.find_all_test_step_finished.map { |message| query.find_pickle_by(message).name }
+ results
+ end,
+ 'findTestCaseBy' => lambda do |query|
+ results = {}
+ results['testCaseStarted'] = query.find_all_test_case_started.map { |message| query.find_test_case_by(message).id }
+ results['testCaseFinished'] = query.find_all_test_case_finished.map { |message| query.find_test_case_by(message).id }
+ results['testStepStarted'] = query.find_all_test_step_started.map { |message| query.find_test_case_by(message).id }
+ results['testStepFinished'] = query.find_all_test_step_finished.map { |message| query.find_test_case_by(message).id }
+ results
+ end,
+ 'findTestStepBy' => lambda do |query|
+ results = {}
+ results['testStepStarted'] = query.find_all_test_step_started.map { |message| query.find_test_step_by(message).id }
+ results['testStepFinished'] = query.find_all_test_step_finished.map { |message| query.find_test_step_by(message).id }
+ results
+ end
+ }
+
+ sources = %w[
+ attachments
+ empty
+ examples-tables
+ global-hooks
+ global-hooks-attachments
+ hooks
+ minimal
+ rules
+ unknown-parameter-type
+ ]
+
+ subject(:query) { described_class.new(repository) }
+
+ let(:repository) { Cucumber::Repository.new }
+
+ sources.product(queries.to_a).each do |source_name, (query_name, query_proc)|
+ describe "Query: '#{query_name}'. testdata source: '#{source_name}'" do
+ let(:source) { testdata_path("#{source_name}.ndjson") }
+ let(:expected_results_file) { testdata_path("#{source_name}.#{query_name}.results.json") }
+ let(:messages) { parse_ndjson_file(source) }
+
+ before do
+ messages.each { |message| repository.update(message) }
+ end
+
+ it 'returns the expected query result' do
+ evaluated_query = query_proc.call(query)
+ expected_query_result = JSON.parse(File.read(expected_results_file))
+
+ expect(evaluated_query).to eq(expected_query_result)
+ end
+ end
+ end
+end
diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb
new file mode 100644
index 00000000..77322ec5
--- /dev/null
+++ b/ruby/spec/spec_helper.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require 'cucumber/query'
+
+require 'json'
diff --git a/testdata/README.md b/testdata/README.md
index ec760193..7416c957 100644
--- a/testdata/README.md
+++ b/testdata/README.md
@@ -4,7 +4,7 @@ Query uses the examples from the [cucumber compatibility kit](https://github.com
for acceptance testing. These examples consist of `.ndjson` files created by
the [`fake-cucumber` reference implementation](https://github.com/cucumber/fake-cucumber).
-* The `.njdon` files are copied in by running `npm install`.
+* The `.ndjson` files are copied in by running `npm install`.
* The expected `.xml` files are created by running the tests
We ensure the `.ndjson` files stay up to date by running `npm install` in CI