defmodule Gherkin.Feature do @moduledoc """ Represents a parsed Gherkin feature file (minimal subset). A Feature is the top-level element in a Gherkin file, containing a name, optional description, optional background, and one or more scenarios. It can also have tags that apply to all scenarios in the feature. """ defstruct name: "", description: "", background: nil, scenarios: [], tags: [] @type t :: %__MODULE__{ name: String.t(), description: String.t(), background: Gherkin.Background.t() | nil, scenarios: [Gherkin.Scenario.t()], tags: [String.t()] } end defmodule Gherkin.Background do @moduledoc """ Represents a Gherkin Background section. A Background contains steps that are run before each scenario in the feature. It allows you to define common setup steps that apply to all scenarios. """ defstruct steps: [] @type t :: %__MODULE__{ steps: [Gherkin.Step.t()] } end defmodule Gherkin.Scenario do @moduledoc """ Represents a Gherkin Scenario section. A Scenario is a concrete example that illustrates a business rule. It consists of a name, a list of steps, and optional tags for filtering. """ defstruct name: "", steps: [], tags: [] @type t :: %__MODULE__{ name: String.t(), steps: [Gherkin.Step.t()], tags: [String.t()] } end defmodule Gherkin.Step do @moduledoc """ Represents a Gherkin step (Given/When/Then/And/But/*). A Step is a single action or assertion in a scenario. It consists of: - keyword: The step type (Given, When, Then, And, But, or *) - text: The step text that matches step definitions - docstring: Optional multi-line text block (triple-quoted) - datatable: Optional table data (pipe-delimited) - line: Line number in the source file """ defstruct keyword: "", text: "", docstring: nil, datatable: nil, line: nil @type t :: %__MODULE__{ keyword: String.t(), text: String.t(), docstring: String.t() | nil, datatable: [[String.t()]] | nil, line: non_neg_integer() | nil } end # Initial parser module scaffold defmodule Gherkin.Parser do @moduledoc """ Minimal Gherkin 6 parser (Feature, Background, Scenario, Step). This module parses Gherkin feature files into Elixir structs, supporting: - Feature with name, description, and tags - Background with steps - Scenarios with steps and tags - Steps with keywords, text, docstrings, and datatables It implements a subset of the Gherkin language focused on core BDD concepts. """ alias Gherkin.{Background, Feature, Scenario, Step} @doc """ Parses a Gherkin feature file from a string into structured data. This function takes a string containing Gherkin syntax and parses it into a structured `Gherkin.Feature` struct with its associated components. ## Parameters * `gherkin_string` - A string containing Gherkin syntax ## Returns Returns a `%Gherkin.Feature{}` struct containing: * `name` - The feature name * `description` - The feature description * `tags` - List of feature-level tags * `background` - Background steps (if present) * `scenarios` - List of scenarios ## Examples # Parse a string containing Gherkin syntax Gherkin.Parser.parse("Feature: Shopping Cart\nScenario: Adding an item") # Returns %Gherkin.Feature{} struct with parsed data """ def parse(gherkin_string) do lines = gherkin_string |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) with {feature_tags, feature_line, rest} <- extract_tags_and_element(lines, "Feature:"), feature_name <- extract_feature_name(feature_line), {background, after_bg} <- extract_background(rest), scenarios <- parse_scenarios(after_bg) do %Feature{ name: feature_name, description: "", background: background, scenarios: scenarios, tags: feature_tags } end end defp extract_feature_name(feature_line) do [_, feature_name] = String.split(feature_line, ":", parts: 2) String.trim(feature_name) end defp extract_background(lines) do {bg_lines, rest_with_scenarios} = Enum.split_while(lines, fn line -> !String.starts_with?(line, "Scenario:") && !String.starts_with?(line, "@") end) background = parse_background(bg_lines) {background, rest_with_scenarios} end defp parse_background(lines) do if Enum.any?(lines, &String.starts_with?(&1, "Background:")) do steps = parse_background_steps(lines) %Background{steps: steps} else nil end end defp parse_background_steps(lines) do lines |> Enum.drop_while(&(&1 == "" or String.starts_with?(&1, "Background:"))) |> parse_steps(lines) end # Parse steps for both background and scenarios defp parse_steps(step_lines, all_lines) do initial_state = {[], nil, false} {steps, _, _} = Enum.reduce(step_lines, initial_state, fn line, state -> process_step_line(line, state, all_lines) end) Enum.reverse(steps) end # Process a single line when parsing steps defp process_step_line(line, {steps, current_step, in_docstring} = state, all_lines) do cond do String.starts_with?(line, ~s(""")) -> handle_docstring_marker(steps, current_step, in_docstring) in_docstring -> handle_docstring_content(line, steps, current_step) String.starts_with?(line, "|") -> handle_table_row(line, steps, current_step, in_docstring) match = Regex.run(~r/^(Given|When|Then|And|But|\*) (.+)$/, line, capture: :all_but_first) -> handle_step(match, steps, all_lines) true -> state end end defp handle_docstring_marker(steps, current_step, in_docstring) do if in_docstring do {steps, nil, false} else {steps, current_step, true} end end defp handle_docstring_content(line, steps, current_step) do updated_step = if is_nil(current_step.docstring) do %{current_step | docstring: line} else %{current_step | docstring: current_step.docstring <> "\n" <> line} end updated_steps = List.replace_at(steps, 0, updated_step) {updated_steps, updated_step, true} end defp handle_table_row(line, steps, current_step, in_docstring) do table_row = line |> String.split("|", trim: true) |> Enum.map(&String.trim/1) if current_step do updated_step = if current_step.datatable do %{current_step | datatable: current_step.datatable ++ [table_row]} else %{current_step | datatable: [table_row]} end updated_steps = List.replace_at(steps, 0, updated_step) {updated_steps, updated_step, in_docstring} else {steps, current_step, in_docstring} end end defp handle_step([keyword, text], steps, all_lines) do line_number = Enum.find_index(all_lines, &(&1 =~ text)) || 0 new_step = %Step{keyword: keyword, text: text, line: line_number} {[new_step | steps], new_step, false} end # Helper function to parse scenarios with their tags defp parse_scenarios(lines) do initial_state = {[], nil, [], [], nil, false} {scenarios, current_scenario, current_tags, steps, _current_step, _in_docstring} = Enum.reduce(lines, initial_state, fn line, state -> process_line(line, state, lines) end) # Add the last scenario if present finalize_scenarios(scenarios, current_scenario, current_tags, steps) end defp finalize_scenarios(scenarios, current_scenario, current_tags, steps) do if current_scenario do scenario = build_scenario(current_scenario, steps, current_tags) scenarios ++ [scenario] else scenarios end end # Extract tags from a line like "@tag1 @tag2 @tag3" defp extract_tags(line) do line |> String.split(~r/\s+/) |> Enum.filter(&String.starts_with?(&1, "@")) |> Enum.map(&String.trim_leading(&1, "@")) end # Extract tags from lines before a Feature/Scenario, returns {tags, element_line, rest} defp extract_tags_and_element(lines, element_prefix) do {tag_lines, rest} = Enum.split_while(lines, &(String.starts_with?(&1, "@") or &1 == "")) # Extract tags from tag lines tags = tag_lines |> Enum.filter(&String.starts_with?(&1, "@")) |> Enum.flat_map(&extract_tags/1) # Find the element line {element_line, new_rest} = case Enum.split_while(rest, &(!String.starts_with?(&1, element_prefix))) do {_, []} -> raise "No #{element_prefix} found after tags" {_pre, [element | post]} -> {element, post} end {tags, element_line, new_rest} end # Helper functions to reduce complexity defp process_line(line, state, lines) do {scenarios, current_scenario, current_tags, steps, current_step, in_docstring} = state cond do String.starts_with?(line, "@") -> handle_tag(line, state) String.starts_with?(line, "Scenario:") -> handle_scenario(line, state) # For step-related lines, delegate to common step processing String.starts_with?(line, ~s(""")) or in_docstring or String.starts_with?(line, "|") or Regex.match?(~r/^(Given|When|Then|And|But|\*) /, line) -> # Process the step-related line {new_steps, new_current_step, new_in_docstring} = process_step_line(line, {steps, current_step, in_docstring}, lines) {scenarios, current_scenario, current_tags, new_steps, new_current_step, new_in_docstring} true -> # Ignore other lines state end end defp handle_tag( line, {scenarios, current_scenario, current_tags, steps, _current_step, _in_docstring} ) do if current_scenario do scenario = build_scenario(current_scenario, steps, current_tags) {scenarios ++ [scenario], nil, extract_tags(line), [], nil, false} else {scenarios, current_scenario, extract_tags(line), steps, nil, false} end end defp handle_scenario( line, {scenarios, current_scenario, current_tags, steps, _current_step, _in_docstring} ) do if current_scenario do scenario = build_scenario(current_scenario, steps, current_tags) {scenarios ++ [scenario], line, [], [], nil, false} else {scenarios, line, current_tags, [], nil, false} end end defp build_scenario(scenario_line, steps, tags) do [_, scenario_name] = String.split(scenario_line, ":", parts: 2) scenario_name = String.trim(scenario_name) %Scenario{ name: scenario_name, steps: Enum.reverse(steps), tags: tags } end end