From 595b40b521df7dd8bca6f881337797b0c8247856 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Sat, 11 Jul 2026 10:40:50 +0300 Subject: [PATCH] - Refactor feature parsing into runnable scenarios --- shellkin | 710 +++++++++++++++--------------------- src/lib/feature/core.sh | 409 ++------------------- src/lib/feature/parse.sh | 234 ++++++++++++ test/lib/feature/core.bats | 2 +- test/lib/feature/parse.bats | 64 ++++ 5 files changed, 635 insertions(+), 784 deletions(-) create mode 100644 src/lib/feature/parse.sh create mode 100644 test/lib/feature/parse.bats diff --git a/shellkin b/shellkin index 4253f33..5683208 100755 --- a/shellkin +++ b/shellkin @@ -415,200 +415,38 @@ trim() { # src/lib/feature/core.sh feature_run() { local feature_file=$1 - local line - local section= - local feature_seen=0 - local scenario_seen=0 + local scenario_index + local step_index + local start + local count local feature_output_started=0 - local in_description=0 - local in_doc_string=0 local failed=0 - local doc_string_indent= - local doc_string_content= - local doc_string_line= - local -a feature_tags=() - local -a pending_tags=() - local -a parsed_tags=() - local -a scenario_tags=() local -a background_steps=() local -a scenario_steps=() - FEATURE_NAME= FEATURE_FILE=$feature_file - FEATURE_SCENARIO_TAGS= - - set +e - - while IFS= read -r line || [[ -n $line ]]; do - if ((in_doc_string != 0)); then - if [[ $(trim "$line") == '"""' ]]; then - feature_doc_string_apply "$doc_string_content" "$section" background_steps scenario_steps || failed=1 - in_doc_string=0 - doc_string_indent= - doc_string_content= - continue - fi - - doc_string_line=$line - if [[ -n $doc_string_indent && $doc_string_line == "$doc_string_indent"* ]]; then - doc_string_line=${doc_string_line#"$doc_string_indent"} - fi - - if [[ -n $doc_string_content ]]; then - doc_string_content+=$'\n' - fi - doc_string_content+=$doc_string_line - continue - fi - - feature_line_parse "$line" - - case $FEATURE_LINE_KIND in - blank | comment) - continue - ;; - feature) - feature_seen=1 - section=feature - in_description=1 - FEATURE_NAME=$FEATURE_LINE_NAME - # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set - feature_tags=("${pending_tags[@]}") - pending_tags=() - continue - ;; - tag) - parsed_tags=() - feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags || { - failed=1 - break - } - if ((feature_seen == 0)); then - pending_tags+=("${parsed_tags[@]}") - else - if ((scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then - : - else - failed=1 - if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then - TEST_ABORT_RUN=1 - scenario_seen=0 - break - fi - fi - scenario_seen=0 - fi - pending_tags+=("${parsed_tags[@]}") - section=tag - in_description=0 - fi - continue - ;; - background) - if ((feature_seen == 0 || scenario_seen != 0 || ${#pending_tags[@]} != 0)); then - failed=1 - break - fi - section=background - in_description=0 - continue - ;; - scenario) - if ((feature_seen == 0)); then - failed=1 - break - fi - if ((scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then - : - else - failed=1 - if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then - TEST_ABORT_RUN=1 - scenario_seen=0 - break - fi - fi - fi - scenario_seen=1 - section=scenario - in_description=0 - FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME - # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set - scenario_tags=("${pending_tags[@]}") - pending_tags=() - scenario_steps=() - continue - ;; - step) - in_description=0 - case $section in - background) - background_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - ;; - scenario) - scenario_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - ;; - *) - failed=1 - ;; - esac - continue - ;; - table_row) - in_description=0 - if feature_table_row_apply "$FEATURE_TABLE_TEXT" "$section" background_steps scenario_steps; then - : - else - failed=1 - fi - continue - ;; - doc_string_fence) - doc_string_indent=${line%%\"\"\"*} - in_doc_string=1 - doc_string_content= - continue - ;; - other) - if [[ $section == feature && $in_description == 1 ]]; then - continue - fi - failed=1 - ;; - esac - done <"$feature_file" - - if ((failed == 0 && ${#pending_tags[@]} != 0)); then - failed=1 - fi + feature_parse "$feature_file" || return 1 + + for scenario_index in "${!FEATURE_PARSED_SCENARIO_NAMES[@]}"; do + start=${FEATURE_PARSED_SCENARIO_STEP_STARTS[$scenario_index]} + count=${FEATURE_PARSED_SCENARIO_STEP_COUNTS[$scenario_index]} + scenario_steps=() + for ((step_index = start; step_index < start + count; step_index++)); do + scenario_steps+=("${FEATURE_PARSED_STEPS[$step_index]}") + done - if ((failed == 0 && scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then - : - else - failed=1 - if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then - TEST_ABORT_RUN=1 - fi - fi - elif ((scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then + FEATURE_SCENARIO_TAGS=${FEATURE_PARSED_SCENARIO_TAGS[$scenario_index]} + if feature_scenario_run "${FEATURE_PARSED_SCENARIO_NAMES[$scenario_index]}" background_steps scenario_steps; then : else failed=1 if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then TEST_ABORT_RUN=1 + break fi fi - fi + done - set -e return "$failed" } @@ -694,240 +532,61 @@ feature_scenario_validate() { for index in "${!background_steps_ref[@]}"; do step=${background_steps_ref[$index]} if ! feature_table_validate "$step"; then - feature__recorded_step_parse "$step" - feature_validation_set_error "${background_lines_ref[$index]}" "data table rows must have the same number of cells for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" - return 1 - fi - if feature_recorded_step_validate "$step" "$FEATURE_PREVIOUS_STEP_TYPE"; then - : - else - feature__recorded_step_parse "$step" - feature_validation_set_error "${background_lines_ref[$index]}" "no matching step definition for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" - return 1 - fi - done - - for index in "${!scenario_steps_ref[@]}"; do - step=${scenario_steps_ref[$index]} - if ! feature_table_validate "$step"; then - feature__recorded_step_parse "$step" - feature_validation_set_error "${scenario_lines_ref[$index]}" "data table rows must have the same number of cells for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" - return 1 - fi - if feature_recorded_step_validate "$step" "$FEATURE_PREVIOUS_STEP_TYPE"; then - : - else - feature__recorded_step_parse "$step" - feature_validation_set_error "${scenario_lines_ref[$index]}" "no matching step definition for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" - return 1 - fi - done -} - -feature_validate() { - local feature_file=$1 - local line - local line_number=0 - local section= - local feature_seen=0 - local scenario_seen=0 - local in_description=0 - local in_doc_string=0 - local failed=0 - local doc_string_indent= - local doc_string_content= - local doc_string_line= - local doc_string_start_line=0 - local pending_tags_line=0 - local pending_tags_context= - local -a pending_tags=() - local -a parsed_tags=() - local -a background_steps=() - local -a background_step_lines=() - local -a scenario_steps=() - local -a scenario_step_lines=() - - FEATURE_NAME= - FEATURE_VALIDATION_LINE= - FEATURE_VALIDATION_MESSAGE= - FEATURE_VALIDATION_CONTEXT= - - set +e - - while IFS= read -r line || [[ -n $line ]]; do - ((line_number += 1)) - - if ((in_doc_string != 0)); then - if [[ $(trim "$line") == '"""' ]]; then - if feature_doc_string_apply "$doc_string_content" "$section" background_steps scenario_steps; then - : - else - feature_validation_set_error "$doc_string_start_line" "doc string must follow a step" '"""' - failed=1 - break - fi - - in_doc_string=0 - doc_string_indent= - doc_string_content= - doc_string_start_line=0 - continue - fi - - doc_string_line=$line - if [[ -n $doc_string_indent && $doc_string_line == "$doc_string_indent"* ]]; then - doc_string_line=${doc_string_line#"$doc_string_indent"} - fi - - if [[ -n $doc_string_content ]]; then - doc_string_content+=$'\n' - fi - doc_string_content+=$doc_string_line - continue - fi - - feature_line_parse "$line" - - case $FEATURE_LINE_KIND in - blank | comment) - continue - ;; - feature) - feature_seen=1 - section=feature - in_description=1 - FEATURE_NAME=$FEATURE_LINE_NAME - pending_tags=() - pending_tags_line=0 - pending_tags_context= - continue - ;; - tag) - parsed_tags=() - if feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags; then - : - else - feature_validation_set_error "$line_number" "invalid tag syntax" "$(trim "$line")" - failed=1 - break - fi - if ((feature_seen == 0)); then - pending_tags+=("${parsed_tags[@]}") - else - if ((scenario_seen != 0)); then - feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 - ((failed == 0)) || break - scenario_seen=0 - fi - pending_tags+=("${parsed_tags[@]}") - section=tag - in_description=0 - fi - if ((pending_tags_line == 0)); then - pending_tags_line=$line_number - pending_tags_context=$(trim "$line") - fi - continue - ;; - background) - if ((feature_seen == 0 || scenario_seen != 0)); then - feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" - failed=1 - break - fi - if ((pending_tags_line != 0)); then - feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" - failed=1 - break - fi - section=background - in_description=0 - continue - ;; - scenario) - if ((feature_seen == 0)); then - feature_validation_set_error "$line_number" "Scenario must appear after Feature" "$(trim "$line")" - failed=1 - break - fi - if ((scenario_seen != 0)); then - feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 - ((failed == 0)) || break - fi - scenario_seen=1 - section=scenario - in_description=0 - FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME - pending_tags=() - pending_tags_line=0 - pending_tags_context= - scenario_steps=() - scenario_step_lines=() - continue - ;; - step) - in_description=0 - case $section in - background) - background_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - background_step_lines+=("$line_number") - ;; - scenario) - scenario_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - scenario_step_lines+=("$line_number") - ;; - *) - feature_validation_set_error "$line_number" "step must appear inside Background or Scenario" "$(trim "$line")" - failed=1 - ;; - esac - continue - ;; - table_row) - in_description=0 - if feature_table_row_apply "$FEATURE_TABLE_TEXT" "$section" background_steps scenario_steps; then - : - else - feature_validation_set_error "$line_number" "data table must follow a step" "$(trim "$line")" - failed=1 - fi - continue - ;; - doc_string_fence) - doc_string_indent=${line%%\"\"\"*} - in_doc_string=1 - doc_string_content= - doc_string_start_line=$line_number - continue - ;; - other) - if [[ $section == feature && $in_description == 1 ]]; then - continue - fi - feature_validation_set_error "$line_number" "invalid feature syntax" "$FEATURE_LINE_NAME" - failed=1 - ;; - esac + feature__recorded_step_parse "$step" + feature_validation_set_error "${background_lines_ref[$index]}" "data table rows must have the same number of cells for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" + return 1 + fi + if feature_recorded_step_validate "$step" "$FEATURE_PREVIOUS_STEP_TYPE"; then + : + else + feature__recorded_step_parse "$step" + feature_validation_set_error "${background_lines_ref[$index]}" "no matching step definition for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" + return 1 + fi + done - ((failed == 0)) || break - done <"$feature_file" + for index in "${!scenario_steps_ref[@]}"; do + step=${scenario_steps_ref[$index]} + if ! feature_table_validate "$step"; then + feature__recorded_step_parse "$step" + feature_validation_set_error "${scenario_lines_ref[$index]}" "data table rows must have the same number of cells for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" + return 1 + fi + if feature_recorded_step_validate "$step" "$FEATURE_PREVIOUS_STEP_TYPE"; then + : + else + feature__recorded_step_parse "$step" + feature_validation_set_error "${scenario_lines_ref[$index]}" "no matching step definition for" "$FEATURE_RECORDED_STEP_KEYWORD $FEATURE_RECORDED_STEP_TEXT" + return 1 + fi + done +} - if ((failed == 0 && in_doc_string != 0)); then - feature_validation_set_error "$doc_string_start_line" "unterminated doc string" '"""' - failed=1 - fi +feature_validate() { + local feature_file=$1 + local scenario_index + local step_index + local start + local count + local -a background_steps=() + local -a background_step_lines=() + local -a scenario_steps=() + local -a scenario_step_lines=() - if ((failed == 0 && pending_tags_line != 0)); then - feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" - failed=1 - fi + feature_parse "$feature_file" || return 1 - if ((failed == 0 && scenario_seen != 0)); then - feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 - fi + for scenario_index in "${!FEATURE_PARSED_SCENARIO_NAMES[@]}"; do + start=${FEATURE_PARSED_SCENARIO_STEP_STARTS[$scenario_index]} + count=${FEATURE_PARSED_SCENARIO_STEP_COUNTS[$scenario_index]} + scenario_steps=() + scenario_step_lines=() + for ((step_index = start; step_index < start + count; step_index++)); do + scenario_steps+=("${FEATURE_PARSED_STEPS[$step_index]}") + scenario_step_lines+=("${FEATURE_PARSED_STEP_LINES[$step_index]}") + done - set -e - return "$failed" + feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || return 1 + done } feature_scenario_run() { @@ -1153,6 +812,241 @@ feature__scenario_tag_match() { ((include_matched != 0)) } +# src/lib/feature/parse.sh +feature_parse() { + local feature_file=$1 + local line + local line_number=0 + local section= + local feature_seen=0 + local scenario_seen=0 + local in_description=0 + local in_doc_string=0 + local failed=0 + local doc_string_indent= + local doc_string_content= + local doc_string_line= + local doc_string_start_line=0 + local pending_tags_line=0 + local pending_tags_context= + local -a feature_tags=() + local -a pending_tags=() + local -a parsed_tags=() + local -a scenario_tags=() + local -a background_steps=() + local -a background_step_lines=() + local -a scenario_steps=() + local -a scenario_step_lines=() + + FEATURE_NAME= + FEATURE_VALIDATION_LINE= + FEATURE_VALIDATION_MESSAGE= + FEATURE_VALIDATION_CONTEXT= + FEATURE_PARSED_SCENARIO_NAMES=() + FEATURE_PARSED_SCENARIO_TAGS=() + FEATURE_PARSED_SCENARIO_STEP_STARTS=() + FEATURE_PARSED_SCENARIO_STEP_COUNTS=() + FEATURE_PARSED_STEPS=() + FEATURE_PARSED_STEP_LINES=() + + while IFS= read -r line || [[ -n $line ]]; do + ((line_number += 1)) + + if ((in_doc_string != 0)); then + if [[ $(trim "$line") == '"""' ]]; then + if feature_doc_string_apply "$doc_string_content" "$section" background_steps scenario_steps; then + : + else + feature_validation_set_error "$doc_string_start_line" "doc string must follow a step" '"""' + failed=1 + break + fi + + in_doc_string=0 + doc_string_indent= + doc_string_content= + doc_string_start_line=0 + continue + fi + + doc_string_line=$line + if [[ -n $doc_string_indent && $doc_string_line == "$doc_string_indent"* ]]; then + doc_string_line=${doc_string_line#"$doc_string_indent"} + fi + + if [[ -n $doc_string_content ]]; then + doc_string_content+=$'\n' + fi + doc_string_content+=$doc_string_line + continue + fi + + feature_line_parse "$line" + + case $FEATURE_LINE_KIND in + blank | comment) + continue + ;; + feature) + feature_seen=1 + section=feature + in_description=1 + FEATURE_NAME=$FEATURE_LINE_NAME + # shellcheck disable=SC2034 # consumed through nameref when scenarios are added + feature_tags=("${pending_tags[@]}") + pending_tags=() + pending_tags_line=0 + pending_tags_context= + continue + ;; + tag) + parsed_tags=() + if feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags; then + : + else + feature_validation_set_error "$line_number" "invalid tag syntax" "$(trim "$line")" + failed=1 + break + fi + if ((feature_seen == 0)); then + pending_tags+=("${parsed_tags[@]}") + else + if ((scenario_seen != 0)); then + feature__parsed_scenario_add "$FEATURE_SCENARIO_NAME" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines + scenario_seen=0 + fi + pending_tags+=("${parsed_tags[@]}") + section=tag + in_description=0 + fi + if ((pending_tags_line == 0)); then + pending_tags_line=$line_number + pending_tags_context=$(trim "$line") + fi + continue + ;; + background) + if ((feature_seen == 0 || scenario_seen != 0)); then + feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" + failed=1 + break + fi + if ((pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + break + fi + section=background + in_description=0 + continue + ;; + scenario) + if ((feature_seen == 0)); then + feature_validation_set_error "$line_number" "Scenario must appear after Feature" "$(trim "$line")" + failed=1 + break + fi + if ((scenario_seen != 0)); then + feature__parsed_scenario_add "$FEATURE_SCENARIO_NAME" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines + fi + scenario_seen=1 + section=scenario + in_description=0 + FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME + scenario_tags=("${pending_tags[@]}") + pending_tags=() + pending_tags_line=0 + pending_tags_context= + scenario_steps=() + scenario_step_lines=() + continue + ;; + step) + in_description=0 + case $section in + background) + background_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") + background_step_lines+=("$line_number") + ;; + scenario) + scenario_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") + scenario_step_lines+=("$line_number") + ;; + *) + feature_validation_set_error "$line_number" "step must appear inside Background or Scenario" "$(trim "$line")" + failed=1 + ;; + esac + continue + ;; + table_row) + in_description=0 + if feature_table_row_apply "$FEATURE_TABLE_TEXT" "$section" background_steps scenario_steps; then + : + else + feature_validation_set_error "$line_number" "data table must follow a step" "$(trim "$line")" + failed=1 + fi + continue + ;; + doc_string_fence) + doc_string_indent=${line%%\"\"\"*} + in_doc_string=1 + doc_string_content= + doc_string_start_line=$line_number + continue + ;; + other) + if [[ $section == feature && $in_description == 1 ]]; then + continue + fi + feature_validation_set_error "$line_number" "invalid feature syntax" "$FEATURE_LINE_NAME" + failed=1 + ;; + esac + + ((failed == 0)) || break + done <"$feature_file" + + if ((failed == 0 && in_doc_string != 0)); then + feature_validation_set_error "$doc_string_start_line" "unterminated doc string" '"""' + failed=1 + fi + + if ((failed == 0 && pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + fi + + if ((failed == 0 && scenario_seen != 0)); then + feature__parsed_scenario_add "$FEATURE_SCENARIO_NAME" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines + fi + + return "$failed" +} + +feature__parsed_scenario_add() { + local scenario_name=$1 + local feature_tags_name=$2 + local scenario_tags_name=$3 + # shellcheck disable=SC2178 # nameref to an array supplied by name + local -n background_steps_ref=$4 + local -n background_lines_ref=$5 + # shellcheck disable=SC2178 # nameref to an array supplied by name + local -n scenario_steps_ref=$6 + local -n scenario_lines_ref=$7 + local start=${#FEATURE_PARSED_STEPS[@]} + local count=$((${#background_steps_ref[@]} + ${#scenario_steps_ref[@]})) + + feature__scenario_tags_set "$feature_tags_name" "$scenario_tags_name" + FEATURE_PARSED_SCENARIO_NAMES+=("$scenario_name") + FEATURE_PARSED_SCENARIO_TAGS+=("$FEATURE_SCENARIO_TAGS") + FEATURE_PARSED_SCENARIO_STEP_STARTS+=("$start") + FEATURE_PARSED_SCENARIO_STEP_COUNTS+=("$count") + FEATURE_PARSED_STEPS+=("${background_steps_ref[@]}" "${scenario_steps_ref[@]}") + FEATURE_PARSED_STEP_LINES+=("${background_lines_ref[@]}" "${scenario_lines_ref[@]}") +} + # src/lib/feature/syntax.sh feature_line_parse() { local line diff --git a/src/lib/feature/core.sh b/src/lib/feature/core.sh index 5b100e0..0c71d51 100644 --- a/src/lib/feature/core.sh +++ b/src/lib/feature/core.sh @@ -1,200 +1,38 @@ ## Executes all scenarios in a feature file. feature_run() { local feature_file=$1 - local line - local section= - local feature_seen=0 - local scenario_seen=0 + local scenario_index + local step_index + local start + local count local feature_output_started=0 - local in_description=0 - local in_doc_string=0 local failed=0 - local doc_string_indent= - local doc_string_content= - local doc_string_line= - local -a feature_tags=() - local -a pending_tags=() - local -a parsed_tags=() - local -a scenario_tags=() local -a background_steps=() local -a scenario_steps=() - FEATURE_NAME= FEATURE_FILE=$feature_file - FEATURE_SCENARIO_TAGS= - - set +e - - while IFS= read -r line || [[ -n $line ]]; do - if ((in_doc_string != 0)); then - if [[ $(trim "$line") == '"""' ]]; then - feature_doc_string_apply "$doc_string_content" "$section" background_steps scenario_steps || failed=1 - in_doc_string=0 - doc_string_indent= - doc_string_content= - continue - fi - - doc_string_line=$line - if [[ -n $doc_string_indent && $doc_string_line == "$doc_string_indent"* ]]; then - doc_string_line=${doc_string_line#"$doc_string_indent"} - fi - - if [[ -n $doc_string_content ]]; then - doc_string_content+=$'\n' - fi - doc_string_content+=$doc_string_line - continue - fi - - feature_line_parse "$line" - - case $FEATURE_LINE_KIND in - blank | comment) - continue - ;; - feature) - feature_seen=1 - section=feature - in_description=1 - FEATURE_NAME=$FEATURE_LINE_NAME - # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set - feature_tags=("${pending_tags[@]}") - pending_tags=() - continue - ;; - tag) - parsed_tags=() - feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags || { - failed=1 - break - } - if ((feature_seen == 0)); then - pending_tags+=("${parsed_tags[@]}") - else - if ((scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then - : - else - failed=1 - if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then - TEST_ABORT_RUN=1 - scenario_seen=0 - break - fi - fi - scenario_seen=0 - fi - pending_tags+=("${parsed_tags[@]}") - section=tag - in_description=0 - fi - continue - ;; - background) - if ((feature_seen == 0 || scenario_seen != 0 || ${#pending_tags[@]} != 0)); then - failed=1 - break - fi - section=background - in_description=0 - continue - ;; - scenario) - if ((feature_seen == 0)); then - failed=1 - break - fi - if ((scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then - : - else - failed=1 - if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then - TEST_ABORT_RUN=1 - scenario_seen=0 - break - fi - fi - fi - scenario_seen=1 - section=scenario - in_description=0 - FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME - # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set - scenario_tags=("${pending_tags[@]}") - pending_tags=() - scenario_steps=() - continue - ;; - step) - in_description=0 - case $section in - background) - background_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - ;; - scenario) - scenario_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - ;; - *) - failed=1 - ;; - esac - continue - ;; - table_row) - in_description=0 - if feature_table_row_apply "$FEATURE_TABLE_TEXT" "$section" background_steps scenario_steps; then - : - else - failed=1 - fi - continue - ;; - doc_string_fence) - doc_string_indent=${line%%\"\"\"*} - in_doc_string=1 - doc_string_content= - continue - ;; - other) - if [[ $section == feature && $in_description == 1 ]]; then - continue - fi - failed=1 - ;; - esac - done <"$feature_file" - - if ((failed == 0 && ${#pending_tags[@]} != 0)); then - failed=1 - fi - - if ((failed == 0 && scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then - : - else - failed=1 - if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then - TEST_ABORT_RUN=1 - fi - fi - elif ((scenario_seen != 0)); then - feature__scenario_tags_set feature_tags scenario_tags - if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then + feature_parse "$feature_file" || return 1 + + for scenario_index in "${!FEATURE_PARSED_SCENARIO_NAMES[@]}"; do + start=${FEATURE_PARSED_SCENARIO_STEP_STARTS[$scenario_index]} + count=${FEATURE_PARSED_SCENARIO_STEP_COUNTS[$scenario_index]} + scenario_steps=() + for ((step_index = start; step_index < start + count; step_index++)); do + scenario_steps+=("${FEATURE_PARSED_STEPS[$step_index]}") + done + + FEATURE_SCENARIO_TAGS=${FEATURE_PARSED_SCENARIO_TAGS[$scenario_index]} + if feature_scenario_run "${FEATURE_PARSED_SCENARIO_NAMES[$scenario_index]}" background_steps scenario_steps; then : else failed=1 if ((TEST_FAIL_FAST != 0 || TEST_ABORT_RUN != 0)); then TEST_ABORT_RUN=1 + break fi fi - fi + done - set -e return "$failed" } @@ -318,208 +156,29 @@ feature_scenario_validate() { ## Validates the structure and step coverage of a feature file. feature_validate() { local feature_file=$1 - local line - local line_number=0 - local section= - local feature_seen=0 - local scenario_seen=0 - local in_description=0 - local in_doc_string=0 - local failed=0 - local doc_string_indent= - local doc_string_content= - local doc_string_line= - local doc_string_start_line=0 - local pending_tags_line=0 - local pending_tags_context= - local -a pending_tags=() - local -a parsed_tags=() + local scenario_index + local step_index + local start + local count local -a background_steps=() local -a background_step_lines=() local -a scenario_steps=() local -a scenario_step_lines=() - FEATURE_NAME= - FEATURE_VALIDATION_LINE= - FEATURE_VALIDATION_MESSAGE= - FEATURE_VALIDATION_CONTEXT= - - set +e - - while IFS= read -r line || [[ -n $line ]]; do - ((line_number += 1)) - - if ((in_doc_string != 0)); then - if [[ $(trim "$line") == '"""' ]]; then - if feature_doc_string_apply "$doc_string_content" "$section" background_steps scenario_steps; then - : - else - feature_validation_set_error "$doc_string_start_line" "doc string must follow a step" '"""' - failed=1 - break - fi - - in_doc_string=0 - doc_string_indent= - doc_string_content= - doc_string_start_line=0 - continue - fi - - doc_string_line=$line - if [[ -n $doc_string_indent && $doc_string_line == "$doc_string_indent"* ]]; then - doc_string_line=${doc_string_line#"$doc_string_indent"} - fi - - if [[ -n $doc_string_content ]]; then - doc_string_content+=$'\n' - fi - doc_string_content+=$doc_string_line - continue - fi + feature_parse "$feature_file" || return 1 - feature_line_parse "$line" - - case $FEATURE_LINE_KIND in - blank | comment) - continue - ;; - feature) - feature_seen=1 - section=feature - in_description=1 - FEATURE_NAME=$FEATURE_LINE_NAME - pending_tags=() - pending_tags_line=0 - pending_tags_context= - continue - ;; - tag) - parsed_tags=() - if feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags; then - : - else - feature_validation_set_error "$line_number" "invalid tag syntax" "$(trim "$line")" - failed=1 - break - fi - if ((feature_seen == 0)); then - pending_tags+=("${parsed_tags[@]}") - else - if ((scenario_seen != 0)); then - feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 - ((failed == 0)) || break - scenario_seen=0 - fi - pending_tags+=("${parsed_tags[@]}") - section=tag - in_description=0 - fi - if ((pending_tags_line == 0)); then - pending_tags_line=$line_number - pending_tags_context=$(trim "$line") - fi - continue - ;; - background) - if ((feature_seen == 0 || scenario_seen != 0)); then - feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" - failed=1 - break - fi - if ((pending_tags_line != 0)); then - feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" - failed=1 - break - fi - section=background - in_description=0 - continue - ;; - scenario) - if ((feature_seen == 0)); then - feature_validation_set_error "$line_number" "Scenario must appear after Feature" "$(trim "$line")" - failed=1 - break - fi - if ((scenario_seen != 0)); then - feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 - ((failed == 0)) || break - fi - scenario_seen=1 - section=scenario - in_description=0 - FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME - pending_tags=() - pending_tags_line=0 - pending_tags_context= - scenario_steps=() - scenario_step_lines=() - continue - ;; - step) - in_description=0 - case $section in - background) - background_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - background_step_lines+=("$line_number") - ;; - scenario) - scenario_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") - scenario_step_lines+=("$line_number") - ;; - *) - feature_validation_set_error "$line_number" "step must appear inside Background or Scenario" "$(trim "$line")" - failed=1 - ;; - esac - continue - ;; - table_row) - in_description=0 - if feature_table_row_apply "$FEATURE_TABLE_TEXT" "$section" background_steps scenario_steps; then - : - else - feature_validation_set_error "$line_number" "data table must follow a step" "$(trim "$line")" - failed=1 - fi - continue - ;; - doc_string_fence) - doc_string_indent=${line%%\"\"\"*} - in_doc_string=1 - doc_string_content= - doc_string_start_line=$line_number - continue - ;; - other) - if [[ $section == feature && $in_description == 1 ]]; then - continue - fi - feature_validation_set_error "$line_number" "invalid feature syntax" "$FEATURE_LINE_NAME" - failed=1 - ;; - esac - - ((failed == 0)) || break - done <"$feature_file" - - if ((failed == 0 && in_doc_string != 0)); then - feature_validation_set_error "$doc_string_start_line" "unterminated doc string" '"""' - failed=1 - fi - - if ((failed == 0 && pending_tags_line != 0)); then - feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" - failed=1 - fi + for scenario_index in "${!FEATURE_PARSED_SCENARIO_NAMES[@]}"; do + start=${FEATURE_PARSED_SCENARIO_STEP_STARTS[$scenario_index]} + count=${FEATURE_PARSED_SCENARIO_STEP_COUNTS[$scenario_index]} + scenario_steps=() + scenario_step_lines=() + for ((step_index = start; step_index < start + count; step_index++)); do + scenario_steps+=("${FEATURE_PARSED_STEPS[$step_index]}") + scenario_step_lines+=("${FEATURE_PARSED_STEP_LINES[$step_index]}") + done - if ((failed == 0 && scenario_seen != 0)); then - feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 - fi - - set -e - return "$failed" + feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || return 1 + done } ## Runs one scenario together with its background steps. diff --git a/src/lib/feature/parse.sh b/src/lib/feature/parse.sh new file mode 100644 index 0000000..c5e0e51 --- /dev/null +++ b/src/lib/feature/parse.sh @@ -0,0 +1,234 @@ +## Parses a feature file into flat runnable scenarios. +feature_parse() { + local feature_file=$1 + local line + local line_number=0 + local section= + local feature_seen=0 + local scenario_seen=0 + local in_description=0 + local in_doc_string=0 + local failed=0 + local doc_string_indent= + local doc_string_content= + local doc_string_line= + local doc_string_start_line=0 + local pending_tags_line=0 + local pending_tags_context= + local -a feature_tags=() + local -a pending_tags=() + local -a parsed_tags=() + local -a scenario_tags=() + local -a background_steps=() + local -a background_step_lines=() + local -a scenario_steps=() + local -a scenario_step_lines=() + + FEATURE_NAME= + FEATURE_VALIDATION_LINE= + FEATURE_VALIDATION_MESSAGE= + FEATURE_VALIDATION_CONTEXT= + FEATURE_PARSED_SCENARIO_NAMES=() + FEATURE_PARSED_SCENARIO_TAGS=() + FEATURE_PARSED_SCENARIO_STEP_STARTS=() + FEATURE_PARSED_SCENARIO_STEP_COUNTS=() + FEATURE_PARSED_STEPS=() + FEATURE_PARSED_STEP_LINES=() + + while IFS= read -r line || [[ -n $line ]]; do + ((line_number += 1)) + + if ((in_doc_string != 0)); then + if [[ $(trim "$line") == '"""' ]]; then + if feature_doc_string_apply "$doc_string_content" "$section" background_steps scenario_steps; then + : + else + feature_validation_set_error "$doc_string_start_line" "doc string must follow a step" '"""' + failed=1 + break + fi + + in_doc_string=0 + doc_string_indent= + doc_string_content= + doc_string_start_line=0 + continue + fi + + doc_string_line=$line + if [[ -n $doc_string_indent && $doc_string_line == "$doc_string_indent"* ]]; then + doc_string_line=${doc_string_line#"$doc_string_indent"} + fi + + if [[ -n $doc_string_content ]]; then + doc_string_content+=$'\n' + fi + doc_string_content+=$doc_string_line + continue + fi + + feature_line_parse "$line" + + case $FEATURE_LINE_KIND in + blank | comment) + continue + ;; + feature) + feature_seen=1 + section=feature + in_description=1 + FEATURE_NAME=$FEATURE_LINE_NAME + # shellcheck disable=SC2034 # consumed through nameref when scenarios are added + feature_tags=("${pending_tags[@]}") + pending_tags=() + pending_tags_line=0 + pending_tags_context= + continue + ;; + tag) + parsed_tags=() + if feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags; then + : + else + feature_validation_set_error "$line_number" "invalid tag syntax" "$(trim "$line")" + failed=1 + break + fi + if ((feature_seen == 0)); then + pending_tags+=("${parsed_tags[@]}") + else + if ((scenario_seen != 0)); then + feature__parsed_scenario_add "$FEATURE_SCENARIO_NAME" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines + scenario_seen=0 + fi + pending_tags+=("${parsed_tags[@]}") + section=tag + in_description=0 + fi + if ((pending_tags_line == 0)); then + pending_tags_line=$line_number + pending_tags_context=$(trim "$line") + fi + continue + ;; + background) + if ((feature_seen == 0 || scenario_seen != 0)); then + feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" + failed=1 + break + fi + if ((pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + break + fi + section=background + in_description=0 + continue + ;; + scenario) + if ((feature_seen == 0)); then + feature_validation_set_error "$line_number" "Scenario must appear after Feature" "$(trim "$line")" + failed=1 + break + fi + if ((scenario_seen != 0)); then + feature__parsed_scenario_add "$FEATURE_SCENARIO_NAME" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines + fi + scenario_seen=1 + section=scenario + in_description=0 + FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME + scenario_tags=("${pending_tags[@]}") + pending_tags=() + pending_tags_line=0 + pending_tags_context= + scenario_steps=() + scenario_step_lines=() + continue + ;; + step) + in_description=0 + case $section in + background) + background_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") + background_step_lines+=("$line_number") + ;; + scenario) + scenario_steps+=("$FEATURE_STEP_TYPE"$'\t'"$FEATURE_STEP_TEXT") + scenario_step_lines+=("$line_number") + ;; + *) + feature_validation_set_error "$line_number" "step must appear inside Background or Scenario" "$(trim "$line")" + failed=1 + ;; + esac + continue + ;; + table_row) + in_description=0 + if feature_table_row_apply "$FEATURE_TABLE_TEXT" "$section" background_steps scenario_steps; then + : + else + feature_validation_set_error "$line_number" "data table must follow a step" "$(trim "$line")" + failed=1 + fi + continue + ;; + doc_string_fence) + doc_string_indent=${line%%\"\"\"*} + in_doc_string=1 + doc_string_content= + doc_string_start_line=$line_number + continue + ;; + other) + if [[ $section == feature && $in_description == 1 ]]; then + continue + fi + feature_validation_set_error "$line_number" "invalid feature syntax" "$FEATURE_LINE_NAME" + failed=1 + ;; + esac + + ((failed == 0)) || break + done <"$feature_file" + + if ((failed == 0 && in_doc_string != 0)); then + feature_validation_set_error "$doc_string_start_line" "unterminated doc string" '"""' + failed=1 + fi + + if ((failed == 0 && pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + fi + + if ((failed == 0 && scenario_seen != 0)); then + feature__parsed_scenario_add "$FEATURE_SCENARIO_NAME" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines + fi + + return "$failed" +} + +feature__parsed_scenario_add() { + local scenario_name=$1 + local feature_tags_name=$2 + local scenario_tags_name=$3 + # shellcheck disable=SC2178 # nameref to an array supplied by name + local -n background_steps_ref=$4 + local -n background_lines_ref=$5 + # shellcheck disable=SC2178 # nameref to an array supplied by name + local -n scenario_steps_ref=$6 + local -n scenario_lines_ref=$7 + local start=${#FEATURE_PARSED_STEPS[@]} + local count=$((${#background_steps_ref[@]} + ${#scenario_steps_ref[@]})) + + feature__scenario_tags_set "$feature_tags_name" "$scenario_tags_name" + FEATURE_PARSED_SCENARIO_NAMES+=("$scenario_name") + FEATURE_PARSED_SCENARIO_TAGS+=("$FEATURE_SCENARIO_TAGS") + FEATURE_PARSED_SCENARIO_STEP_STARTS+=("$start") + FEATURE_PARSED_SCENARIO_STEP_COUNTS+=("$count") + FEATURE_PARSED_STEPS+=("${background_steps_ref[@]}" "${scenario_steps_ref[@]}") + FEATURE_PARSED_STEP_LINES+=("${background_lines_ref[@]}" "${scenario_lines_ref[@]}") +} diff --git a/test/lib/feature/core.bats b/test/lib/feature/core.bats index e863507..d2f1c49 100644 --- a/test/lib/feature/core.bats +++ b/test/lib/feature/core.bats @@ -5,7 +5,7 @@ load ../../test_helper.bash setup() { setup_test_environment eval "$(declare -f run | sed '1s/^run /bats_run /')" - source_libs core/colors core/trim stepdef/pattern stepdef/parse stepdef/hooks feature/syntax feature/table stepdef/files user_helpers/run user_helpers/fail user_helpers/defer step/core output/test feature/core + source_libs core/colors core/trim stepdef/pattern stepdef/parse stepdef/hooks feature/syntax feature/table feature/parse stepdef/files user_helpers/run user_helpers/fail user_helpers/defer step/core output/test feature/core STEPDEF_TYPES=() STEPDEF_PATTERNS=() diff --git a/test/lib/feature/parse.bats b/test/lib/feature/parse.bats new file mode 100644 index 0000000..0f0fec3 --- /dev/null +++ b/test/lib/feature/parse.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats + +setup() { + load '../../test_helper' + setup_test_environment + source_libs core/trim feature/syntax feature/table feature/parse feature/core +} + +teardown() { + teardown_test_environment +} + +@test "feature_parse emits flat runnable scenarios" { + write_file test.feature <<'EOF' +@feature +Feature: Parsed feature + +Background: + Given shared setup + +@first +Scenario: First + When first action + +Scenario: Second + Then second result +EOF + + feature_parse "$TEST_ROOT/test.feature" + + [ "$FEATURE_NAME" = "Parsed feature" ] + [ "${FEATURE_PARSED_SCENARIO_NAMES[*]}" = "First Second" ] + [ "${FEATURE_PARSED_SCENARIO_TAGS[0]}" = "@feature @first" ] + [ "${FEATURE_PARSED_SCENARIO_TAGS[1]}" = "@feature" ] + [ "${FEATURE_PARSED_SCENARIO_STEP_STARTS[*]}" = "0 2" ] + [ "${FEATURE_PARSED_SCENARIO_STEP_COUNTS[*]}" = "2 2" ] + [ "${FEATURE_PARSED_STEPS[0]}" = $'Given\tshared setup' ] + [ "${FEATURE_PARSED_STEPS[1]}" = $'When\tfirst action' ] + [ "${FEATURE_PARSED_STEPS[2]}" = $'Given\tshared setup' ] + [ "${FEATURE_PARSED_STEPS[3]}" = $'Then\tsecond result' ] + [ "${FEATURE_PARSED_STEP_LINES[*]}" = "5 9 5 12" ] +} + +@test "feature_parse preserves attached doc strings and data tables" { + write_file test.feature <<'EOF' +Feature: Step arguments + +Scenario: Arguments + Given these users exist + | name | role | + | Alice | admin | + Then the output should match + """ + hello + world + """ +EOF + + feature_parse "$TEST_ROOT/test.feature" + + [ "${FEATURE_PARSED_SCENARIO_STEP_COUNTS[0]}" -eq 2 ] + [[ ${FEATURE_PARSED_STEPS[0]} == *$'\x1e| name | role |'* ]] + [[ ${FEATURE_PARSED_STEPS[1]} == *$'\thello\nworld' ]] +}