diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3e6f300 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: + - master + - arithmetic + - stack-ptr + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + checks: write + pull-requests: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Ninja + run: sudo apt-get update && sudo apt-get install -y ninja-build + + - name: Configure + run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug + + - name: Build + shell: bash + run: | + set -o pipefail + cmake --build build 2>&1 | tee build.log + + - name: Test + shell: bash + run: | + set -o pipefail + ctest --test-dir build --output-on-failure --output-junit test-results.xml 2>&1 | tee test.log + + - name: Publish Test Results + if: ${{ !cancelled() }} + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: build/test-results.xml + check_name: Test Results + comment_title: Test Results + comment_mode: ${{ github.event_name == 'pull_request' && 'always' || 'off' }} + report_individual_runs: true + check_run_annotations: all tests, skipped tests + + - name: Publish Test Summary + if: always() + shell: bash + run: | + { + echo "## Build Output" + echo '```text' + cat build.log + echo '```' + echo + echo "## Test Output" + echo '```text' + cat test.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-logs + path: | + build.log + test.log + build/test-results.xml diff --git a/CMakeLists.txt b/CMakeLists.txt index 1cf709a..45a4afb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,4 +130,45 @@ add_executable(${PROJECT_NAME} ${TYPES_SOURCE_FILES} ) -define_file_basename_for_sources(${PROJECT_NAME}) \ No newline at end of file +define_file_basename_for_sources(${PROJECT_NAME}) + +enable_testing() + +function(add_script_output_test test_name script_file expected_file) + add_test( + NAME ${test_name} + COMMAND ${CMAKE_COMMAND} + -DSCRIPT_EXECUTABLE=$ + -DSCRIPT_FILE=${script_file} + -DEXPECTED_FILE=${expected_file} + -P ${PROJECT_SOURCE_DIR}/tests/RunScriptOutputTest.cmake + ) +endfunction() + +file(GLOB TEST_SCRIPT_FILES CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/tests/scripts/*.script" +) + +foreach(test_script ${TEST_SCRIPT_FILES}) + get_filename_component(test_name "${test_script}" NAME_WE) + set(expected_file "${PROJECT_SOURCE_DIR}/tests/expected/${test_name}.txt") + + if (NOT EXISTS "${expected_file}") + message(FATAL_ERROR "Missing expected output for ${test_script}: ${expected_file}") + endif() + + add_script_output_test("script_${test_name}" "${test_script}" "${expected_file}") +endforeach() + +file(GLOB EXAMPLE_SCRIPT_FILES CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/examples/*.script" +) + +foreach(example_script ${EXAMPLE_SCRIPT_FILES}) + get_filename_component(example_name "${example_script}" NAME_WE) + set(example_expected_file "${PROJECT_SOURCE_DIR}/tests/expected/examples/${example_name}.txt") + + if (EXISTS "${example_expected_file}") + add_script_output_test("example_${example_name}" "${example_script}" "${example_expected_file}") + endif() +endforeach() diff --git a/examples/echoMultipleArgs.script b/examples/echoMultipleArgs.script new file mode 100644 index 0000000..b00d61e --- /dev/null +++ b/examples/echoMultipleArgs.script @@ -0,0 +1,4 @@ +var greeting Hello +var target World +echo $greeting, $target! +ret 0 diff --git a/examples/nestedFor.script b/examples/nestedFor.script index 08c2581..10a8074 100644 --- a/examples/nestedFor.script +++ b/examples/nestedFor.script @@ -7,14 +7,15 @@ var count 99 for i 0 10 inc for j 10 0 dec - echo . -# sum neg_j_minus_one -1 $j -# sum low 10 $neg_j_minus_one -# mul high $i 10 -# sum value $high $low -# if_lte $value $count -# echo $value -# end_if + mul neg_j -1 $j + sum low 10 $neg_j + mul high $i 10 + sum value $high $low + if_lte $value $count + echo $value + else + ret 0 + end_if end_for endl end_for diff --git a/src/commands/command b/src/commands/command index 9875015..1c7a13e 100644 --- a/src/commands/command +++ b/src/commands/command @@ -87,4 +87,4 @@ #define REMOVE_METADATA(name) ScriptInstance::removeMetadata(name) // Writes a standard command error message with the command name and line number. -#define ERROR(msg) std::cerr << std::endl << "ERROR: '" << COMMAND << "' " << msg << " : " << SOURCE; std::exit(1); +#define ERROR(msg) {std::cerr << std::endl << "ERROR: '" << COMMAND << "' " << msg << " : " << SOURCE; std::exit(1);} diff --git a/src/commands/comments.cpp b/src/commands/comments.cpp index 9c9d15d..d6a67f4 100644 --- a/src/commands/comments.cpp +++ b/src/commands/comments.cpp @@ -1,6 +1,19 @@ #include #include +/* +NAME + comments - register comment tokens as no-op commands + +SYNOPSIS + # + // + /// + +DESCRIPTION + Treats comment-prefixed lines as valid commands that do nothing when + executed. +*/ namespace { diff --git a/src/commands/echo.cpp b/src/commands/echo.cpp index cff01f4..929466c 100644 --- a/src/commands/echo.cpp +++ b/src/commands/echo.cpp @@ -1,7 +1,20 @@ #include +/* +NAME + echo - write text to standard output + +SYNOPSIS + echo + +DESCRIPTION + Expands variables in the provided text and writes the result without + appending a trailing newline. +*/ REGISTER_COMMAND { - std::cout << args; + auto output = args; + DEREFERENCE(output); + std::cout << output; std::cout.flush(); -}; \ No newline at end of file +}; diff --git a/src/commands/end_for.cpp b/src/commands/end_for.cpp index 4b0340f..3ed8b7a 100644 --- a/src/commands/end_for.cpp +++ b/src/commands/end_for.cpp @@ -1,5 +1,16 @@ #include +/* +NAME + end_for - jump back to the matching for loop + +SYNOPSIS + end_for + +DESCRIPTION + Uses loop metadata recorded by for to continue iteration or fall through + when the loop is complete. +*/ REGISTER_COMMAND { try diff --git a/src/commands/endl.cpp b/src/commands/endl.cpp index 125cacf..c4b5f86 100644 --- a/src/commands/endl.cpp +++ b/src/commands/endl.cpp @@ -1,7 +1,17 @@ #include +/* +NAME + endl - write a newline to standard output + +SYNOPSIS + endl + +DESCRIPTION + Flushes a single line ending to standard output. +*/ REGISTER_COMMAND { std::cout << std::endl; std::cout.flush(); -}; \ No newline at end of file +}; diff --git a/src/commands/for.cpp b/src/commands/for.cpp index 8d9288e..29c64ca 100644 --- a/src/commands/for.cpp +++ b/src/commands/for.cpp @@ -1,6 +1,18 @@ #include #include +/* +NAME + for - execute a counted loop + +SYNOPSIS + for + +DESCRIPTION + Iterates from toward the exclusive bound using unary_func, + which must currently be inc or dec. The current value is stored in + for the body of the loop. +*/ REGISTER_COMMAND { auto splitArgs = utils::split(args); diff --git a/src/commands/mul.cpp b/src/commands/mul.cpp new file mode 100644 index 0000000..574f2f2 --- /dev/null +++ b/src/commands/mul.cpp @@ -0,0 +1,33 @@ +#include + +/* +NAME + mul - multiply two integers and store the result + +SYNOPSIS + mul + +DESCRIPTION + Expands variables in the numeric inputs, multiplies the two integer values, + and stores the result in . +*/ +REGISTER_COMMAND +{ + auto vars = args; + DEREFERENCE(vars); + const auto& splitVars = utils::split(vars); + + if (splitVars.size() != 3) + ERROR("requires 3 arguments "); + + try + { + const int a = std::stoi(splitVars[1].c_str()); + const int b = std::stoi(splitVars[2].c_str()); + SET_VARIABLE(splitVars[0], std::to_string(a * b)); + } + catch (std::exception& e) + { + ERROR("Input values must be integers"); + } +}; diff --git a/src/commands/ret.cpp b/src/commands/ret.cpp index 43c1c91..95eb0d7 100644 --- a/src/commands/ret.cpp +++ b/src/commands/ret.cpp @@ -2,17 +2,30 @@ #include #include +/* +NAME + ret - return from the current script frame + +SYNOPSIS + ret + +DESCRIPTION + Returns control to the previous stack frame with the given integer status. + If there is no previous frame, the process exits with that status code. +*/ REGISTER_COMMAND { try { - size_t numArgs = utils::split(args).size(); - if (args.empty()) + auto expandedArgs = args; + DEREFERENCE(expandedArgs); + size_t numArgs = utils::split(expandedArgs).size(); + if (expandedArgs.empty()) throw std::invalid_argument("requires a single integer argument "); if (numArgs > 1) throw std::invalid_argument(std::format("Too many arguments. Expected 1 got {}", numArgs)); - const int value = std::stoi(args); + const int value = std::stoi(expandedArgs); if (!POP_STACK) std::exit(value); diff --git a/src/commands/scope.cpp b/src/commands/scope.cpp index 6978493..ccd4204 100644 --- a/src/commands/scope.cpp +++ b/src/commands/scope.cpp @@ -1,6 +1,17 @@ #include #include +/* +NAME + scope - register brace tokens as no-op commands + +SYNOPSIS + { + } + +DESCRIPTION + Treats brace-only lines as valid commands that do nothing when executed. +*/ namespace { diff --git a/src/commands/sum.cpp b/src/commands/sum.cpp new file mode 100644 index 0000000..a6c8943 --- /dev/null +++ b/src/commands/sum.cpp @@ -0,0 +1,33 @@ +#include + +/* +NAME + sum - add two integers and store the result + +SYNOPSIS + sum + +DESCRIPTION + Expands variables in the numeric inputs, adds the two integer values, and + stores the result in . +*/ +REGISTER_COMMAND +{ + auto vars = args; + DEREFERENCE(vars); + const auto& splitVars = utils::split(vars); + + if (splitVars.size() != 3) + ERROR("requires 3 arguments "); + + try + { + const int a = std::stoi(splitVars[1].c_str()); + const int b = std::stoi(splitVars[2].c_str()); + SET_VARIABLE(splitVars[0], std::to_string(a + b)); + } + catch (std::exception& e) + { + ERROR("Input values must be integers"); + } +}; diff --git a/src/commands/var.cpp b/src/commands/var.cpp index ab8efaa..288e938 100644 --- a/src/commands/var.cpp +++ b/src/commands/var.cpp @@ -1,13 +1,25 @@ #include #include +/* +NAME + var - define or overwrite a variable + +SYNOPSIS + var + +DESCRIPTION + Stores in . The value argument is quote-aware and variables + inside the value are expanded before assignment. +*/ REGISTER_COMMAND { - const auto splitArgs = utils::splitQuoted(args); + auto splitArgs = utils::splitQuoted(args); if (splitArgs.size() != 2) { ERROR("requires arguments"); } + DEREFERENCE(splitArgs[1]); SET_VARIABLE(splitArgs[0], splitArgs[1]); -}; \ No newline at end of file +}; diff --git a/src/commands/wait.cpp b/src/commands/wait.cpp index 26fea2e..1fd36b4 100644 --- a/src/commands/wait.cpp +++ b/src/commands/wait.cpp @@ -1,19 +1,40 @@ #include +#include #include +/* +NAME + wait - pause execution for a number of seconds + +SYNOPSIS + wait + +DESCRIPTION + Expands the argument, parses it as an integer second count, and sleeps for + that duration unless test mode disables real waits. +*/ REGISTER_COMMAND { int value = 0; try { - if (args.size() != 1) + auto expandedArgs = args; + DEREFERENCE(expandedArgs); + if (expandedArgs.size() != 1) throw std::invalid_argument("Too many arguments"); - value = std::stoi(args); + value = std::stoi(expandedArgs); } catch (...) { ERROR("requires a single integer argument "); } + // Tests can disable real sleeps to keep script output checks fast. + if (const char* disableWait = std::getenv("SCRIPT_DISABLE_WAIT")) + { + if (disableWait[0] != '\0' && disableWait[0] != '0') + return; + } + std::this_thread::sleep_for(std::chrono::seconds(value)); -}; \ No newline at end of file +}; diff --git a/tests/RunScriptOutputTest.cmake b/tests/RunScriptOutputTest.cmake new file mode 100644 index 0000000..76e6035 --- /dev/null +++ b/tests/RunScriptOutputTest.cmake @@ -0,0 +1,39 @@ +if (NOT DEFINED SCRIPT_EXECUTABLE) + message(FATAL_ERROR "SCRIPT_EXECUTABLE is required") +endif() + +if (NOT DEFINED SCRIPT_FILE) + message(FATAL_ERROR "SCRIPT_FILE is required") +endif() + +if (NOT DEFINED EXPECTED_FILE) + message(FATAL_ERROR "EXPECTED_FILE is required") +endif() + +execute_process( + COMMAND "${CMAKE_COMMAND}" -E env "SCRIPT_DISABLE_WAIT=1" "${SCRIPT_EXECUTABLE}" "${SCRIPT_FILE}" + RESULT_VARIABLE script_result + OUTPUT_VARIABLE script_stdout + ERROR_VARIABLE script_stderr +) + +if (NOT script_result EQUAL 0) + message(FATAL_ERROR + "Script exited with ${script_result}\n" + "stderr:\n${script_stderr}\n" + "stdout:\n${script_stdout}") +endif() + +file(READ "${EXPECTED_FILE}" expected_stdout) + +string(REPLACE "\r\n" "\n" script_stdout "${script_stdout}") +string(REPLACE "\r\n" "\n" expected_stdout "${expected_stdout}") +string(REGEX REPLACE "\n$" "" script_stdout "${script_stdout}") +string(REGEX REPLACE "\n$" "" expected_stdout "${expected_stdout}") + +if (NOT script_stdout STREQUAL expected_stdout) + message(FATAL_ERROR + "Output mismatch for ${SCRIPT_FILE}\n" + "Expected:\n${expected_stdout}\n" + "Actual:\n${script_stdout}\n") +endif() diff --git a/tests/expected/comments.txt b/tests/expected/comments.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/tests/expected/comments.txt @@ -0,0 +1 @@ +ok diff --git a/tests/expected/comments_scope.txt b/tests/expected/comments_scope.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/tests/expected/comments_scope.txt @@ -0,0 +1 @@ +ok diff --git a/tests/expected/echo.txt b/tests/expected/echo.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/tests/expected/echo.txt @@ -0,0 +1 @@ +hello diff --git a/tests/expected/endl.txt b/tests/expected/endl.txt new file mode 100644 index 0000000..94954ab --- /dev/null +++ b/tests/expected/endl.txt @@ -0,0 +1,2 @@ +hello +world diff --git a/tests/expected/examples/echoMultipleArgs.txt b/tests/expected/examples/echoMultipleArgs.txt new file mode 100644 index 0000000..8ab686e --- /dev/null +++ b/tests/expected/examples/echoMultipleArgs.txt @@ -0,0 +1 @@ +Hello, World! diff --git a/tests/expected/examples/helloWorld.txt b/tests/expected/examples/helloWorld.txt new file mode 100644 index 0000000..ce4ae4d --- /dev/null +++ b/tests/expected/examples/helloWorld.txt @@ -0,0 +1 @@ +Hello...World! diff --git a/tests/expected/for_dec.txt b/tests/expected/for_dec.txt new file mode 100644 index 0000000..3ae0b93 --- /dev/null +++ b/tests/expected/for_dec.txt @@ -0,0 +1 @@ +321 diff --git a/tests/expected/for_inc.txt b/tests/expected/for_inc.txt new file mode 100644 index 0000000..de97a6d --- /dev/null +++ b/tests/expected/for_inc.txt @@ -0,0 +1 @@ +012 diff --git a/tests/expected/mul.txt b/tests/expected/mul.txt new file mode 100644 index 0000000..d81cc07 --- /dev/null +++ b/tests/expected/mul.txt @@ -0,0 +1 @@ +42 diff --git a/tests/expected/ret.txt b/tests/expected/ret.txt new file mode 100644 index 0000000..90be1f3 --- /dev/null +++ b/tests/expected/ret.txt @@ -0,0 +1 @@ +before diff --git a/tests/expected/sum.txt b/tests/expected/sum.txt new file mode 100644 index 0000000..d81cc07 --- /dev/null +++ b/tests/expected/sum.txt @@ -0,0 +1 @@ +42 diff --git a/tests/expected/var.txt b/tests/expected/var.txt new file mode 100644 index 0000000..557db03 --- /dev/null +++ b/tests/expected/var.txt @@ -0,0 +1 @@ +Hello World diff --git a/tests/expected/wait.txt b/tests/expected/wait.txt new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/tests/expected/wait.txt @@ -0,0 +1 @@ +done diff --git a/tests/scripts/comments.script b/tests/scripts/comments.script new file mode 100644 index 0000000..0a0585f --- /dev/null +++ b/tests/scripts/comments.script @@ -0,0 +1,4 @@ +# this should be ignored +// this too +/// and this +echo ok diff --git a/tests/scripts/comments_scope.script b/tests/scripts/comments_scope.script new file mode 100644 index 0000000..b252821 --- /dev/null +++ b/tests/scripts/comments_scope.script @@ -0,0 +1,3 @@ +{ +} +echo ok diff --git a/tests/scripts/echo.script b/tests/scripts/echo.script new file mode 100644 index 0000000..2f08be9 --- /dev/null +++ b/tests/scripts/echo.script @@ -0,0 +1 @@ +echo hello diff --git a/tests/scripts/endl.script b/tests/scripts/endl.script new file mode 100644 index 0000000..de67f7f --- /dev/null +++ b/tests/scripts/endl.script @@ -0,0 +1,3 @@ +echo hello +endl +echo world diff --git a/tests/scripts/for_dec.script b/tests/scripts/for_dec.script new file mode 100644 index 0000000..ce6e35b --- /dev/null +++ b/tests/scripts/for_dec.script @@ -0,0 +1,3 @@ +for i 3 0 dec + echo $i +end_for diff --git a/tests/scripts/for_inc.script b/tests/scripts/for_inc.script new file mode 100644 index 0000000..382f728 --- /dev/null +++ b/tests/scripts/for_inc.script @@ -0,0 +1,3 @@ +for i 0 3 inc + echo $i +end_for diff --git a/tests/scripts/mul.script b/tests/scripts/mul.script new file mode 100644 index 0000000..b688115 --- /dev/null +++ b/tests/scripts/mul.script @@ -0,0 +1,2 @@ +mul product 6 7 +echo $product diff --git a/tests/scripts/ret.script b/tests/scripts/ret.script new file mode 100644 index 0000000..5e4c8e5 --- /dev/null +++ b/tests/scripts/ret.script @@ -0,0 +1,3 @@ +echo before +ret 0 +echo after diff --git a/tests/scripts/sum.script b/tests/scripts/sum.script new file mode 100644 index 0000000..ac54a20 --- /dev/null +++ b/tests/scripts/sum.script @@ -0,0 +1,2 @@ +sum total 19 23 +echo $total diff --git a/tests/scripts/var.script b/tests/scripts/var.script new file mode 100644 index 0000000..4031eac --- /dev/null +++ b/tests/scripts/var.script @@ -0,0 +1,2 @@ +var greeting "Hello World" +echo $greeting diff --git a/tests/scripts/wait.script b/tests/scripts/wait.script new file mode 100644 index 0000000..a7b16b0 --- /dev/null +++ b/tests/scripts/wait.script @@ -0,0 +1,2 @@ +wait 1 +echo done