diff --git a/.github/workflows/_generate-docs.yml b/.github/workflows/_generate-docs.yml new file mode 100644 index 000000000..7c2085857 --- /dev/null +++ b/.github/workflows/_generate-docs.yml @@ -0,0 +1,75 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# ----------------------------------------------------------------------- +# Internal reusable workflow — not intended to be triggered directly. +# Called by ci.yml, prerelease.yml, and release.yml. +# +# Purpose: +# Build the local HTML documentation bundle (MkDocs) for inclusion in +# the Ceedling gem and upload it as the artifact 'gem-docs-site-local'. +# Subsequent jobs in the calling workflow download this artifact to +# make the docs bundle available for testing and gem packaging. +# ----------------------------------------------------------------------- + +--- +name: "Generate Local Docs Bundle" + +on: + workflow_call: + +permissions: + contents: read + +jobs: + generate-docs: + name: "Generate Local Docs Bundle for Gem Inclusion" + runs-on: ubuntu-latest + + steps: + # Use a cache for our tools to speed up builds + # No matrix here; Ruby version is hardcoded to match the cache key format used by test jobs + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-use-ruby-${{ runner.os }}-3.3-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + bundle-use-ruby-${{ runner.os }}-3.3- + + - name: Checkout Latest Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set Up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + # This is overkill, but it's the sanest way to ensure all Rakefile dependencies are available when we run docs:build:local task + - name: Install Gem Dependencies + run: | + bundle install + + # --break-system-packages is required on Ubuntu 24.04+ (PEP 668 prevents pip from + # installing to the system Python environment without explicit opt-in) + - name: Install MkDocs and mkdocs-material + run: | + pip install --break-system-packages mkdocs mkdocs-material + + # Builds local HTML bundle for gem inclusion; invokes MkDocs via the project's + # venv_sh wrapper in the Rakefile (skips venv activation when no .docsenv is present) + - name: Build HTML Documentation Bundle + run: | + rake docs:build:local --trace + + - name: Upload HTML Documentation Bundle + uses: actions/upload-artifact@v4 + with: + name: gem-docs-site-local + path: site-local/ + if-no-files-found: error diff --git a/.github/workflows/_publish-gem.yml b/.github/workflows/_publish-gem.yml new file mode 100644 index 000000000..a2c7652c2 --- /dev/null +++ b/.github/workflows/_publish-gem.yml @@ -0,0 +1,159 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# ----------------------------------------------------------------------- +# Internal reusable workflow — not intended to be triggered directly. +# Called by prerelease.yml (with prerelease: true) and release.yml +# (with prerelease: false). +# +# Purpose: +# Stamp the gem version from the calling workflow's Git tag, build the +# Ceedling gem, and publish a GitHub release or pre-release using +# softprops/action-gh-release@v2. +# +# Version stamping: +# lib/version.rb carries a .dev suffix between releases (e.g. 1.1.1.dev). +# This job derives the actual release version from the Git tag and stamps +# it into lib/version.rb before building — the file is never manually +# edited to match a release tag. +# +# Tag-to-gem-version conversion: +# v1.1.0-pre.1 → 1.1.0.pre.1 (hyphen becomes dot: RubyGems pre-release notation) +# v1.1.0 → 1.1.0 (no suffix; substitution is a no-op) +# +# RubyGems.org publishing: +# The "Push to RubyGems.org" step below is stubbed and commented out. +# To enable automated publishing: uncomment the step and add a +# RUBYGEMS_API_KEY secret to the repository settings. +# ----------------------------------------------------------------------- + +--- +name: "Build and Publish Gem" + +on: + workflow_call: + inputs: + prerelease: + type: boolean + required: true + description: 'true = GitHub pre-release, false = full release' + +# contents: write is required to create and upload GitHub releases +permissions: + contents: write + +jobs: + build-and-publish: + name: "Build and Publish Ceedling Gem" + runs-on: ubuntu-latest + + steps: + # Use a cache for our tools to speed up builds + # No matrix here; Ruby version is hardcoded to match the cache key format used by test jobs + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-use-ruby-${{ runner.os }}-3.3-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + bundle-use-ruby-${{ runner.os }}-3.3- + + - name: Checkout Latest Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set Up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Install Gem Dependencies + run: | + bundle install + + # Derive the gem version from the Git tag and stamp it into lib/version.rb. + # The in-tree version.rb carries a .dev suffix between releases; the release + # version is computed here from the tag and is never manually set. + - name: Stamp Gem Version from Git Tag + shell: bash + run: .github/workflows/stamp_gem_version.sh "${GITHUB_REF_NAME}" lib/version.rb + + # Extract the changelog section matching this release version for use as the + # release body. The script exits 0 (found) or 1 (not found / file missing). + # Shell vars don't persist across steps; version is re-derived from the tag. + - name: Extract Changelog Section for Release Notes + id: changelog + shell: bash + run: | + # Strip the leading 'v' then take only the MAJOR.MINOR.PATCH core, + # discarding any pre-release suffix (e.g. v1.2.3-pre.1 → 1.2.3). + # Changelog entries are keyed on the semver core, not the full tag. + SEMVER="${GITHUB_REF_NAME#v}" + SEMVER="${SEMVER%%-*}" + BODY_FILE="${RUNNER_TEMP}/release_body.md" # $RUNNER_TEMP: Actions-provided temp dir + + echo "found=false" >> "$GITHUB_OUTPUT" + + if .github/workflows/extract_changelog.sh "${SEMVER}" "docs/Changelog.md" "${BODY_FILE}"; then + # Write the extracted markdown content as a multi-line step output. + # A unique timestamp-based delimiter guards against any text in the changelog + # accidentally matching the EOF marker and closing the heredoc early. + EOF_MARKER="RELEASE_BODY_EOF_$(date +%s)" + echo "body<<${EOF_MARKER}" >> "$GITHUB_OUTPUT" + cat "${BODY_FILE}" >> "$GITHUB_OUTPUT" + echo "${EOF_MARKER}" >> "$GITHUB_OUTPUT" + echo "found=true" >> "$GITHUB_OUTPUT" + fi + + # Download HTML docs bundle built by _generate-docs.yml + # Artifacts are shared within the same workflow run by run_id + - name: Download HTML Documentation Bundle + uses: actions/download-artifact@v4 + with: + name: gem-docs-site-local + path: site-local/ + + - name: Build Gem + run: | + gem build ceedling.gemspec + + # softprops/action-gh-release@v2 replaces the archived actions/create-release@v1 + # and actions/upload-release-asset@v1. + # Two conditional steps are used because the action cannot switch between + # body and generate_release_notes within a single step via expressions. + + # Use the extracted changelog section as the release body when the section was found + - name: Log Release Notes Source (changelog body) + if: steps.changelog.outputs.found == 'true' + run: echo "Publishing release with Changelog.md section as release notes body." + + - name: Publish GitHub Release (changelog body) + if: steps.changelog.outputs.found == 'true' + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ inputs.prerelease }} + files: ceedling-*.gem + body: ${{ steps.changelog.outputs.body }} + + # Fall back to GitHub's auto-generated release notes (from last commit) + # when no changelog section exists for this version + - name: Log Release Notes Source (auto-generated) + if: steps.changelog.outputs.found != 'true' + run: echo "No Changelog.md section found for this version; publishing release with GitHub auto-generated release notes." + + - name: Publish GitHub Release (auto-generated notes) + if: steps.changelog.outputs.found != 'true' + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ inputs.prerelease }} + files: ceedling-*.gem + + # Uncomment and add RUBYGEMS_API_KEY secret to automate RubyGems.org publishing + # - name: Push to RubyGems.org + # run: gem push ceedling-*.gem + # env: + # GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..00e24b92c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,334 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# ----------------------------------------------------------------------- +# Continuous Integration Workflow +# +# Purpose: +# Run the full test suite and verify a clean gem build on every push. +# No releases are created here — publishing is handled by prerelease.yml +# and release.yml, which trigger only on intentional Git tags. +# +# Triggers: +# - Push to any branch +# - Pull request targeting master +# - Manual dispatch (workflow_dispatch) +# +# Skip-CI: +# Add any of the following to your commit message to skip this workflow: +# [skip ci] [ci skip] [no ci] [skip actions] [actions skip] +# Note: skip keywords have no effect on workflow_dispatch runs. +# +# Branch exclusions: +# To permanently exclude a branch pattern from CI, add a branches-ignore +# list under the push: trigger below (e.g. docs/**, wip/**). +# ----------------------------------------------------------------------- + +--- +name: CI + +# Triggers the workflow on push to any branch (tag pushes excluded — handled by +# prerelease.yml and release.yml), pull requests targeting master, or manual dispatch +on: + push: + branches: + - '**' # All branches; branches: ['**'] scopes push to refs/heads/ only, + # excluding refs/tags/ pushes — see Skip-CI above + pull_request: + branches: [master] + workflow_dispatch: + + +# Cancel any in-progress run on the same ref when a new push arrives, +# preventing stale run pile-up on active branches +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + + +permissions: + contents: read + + +jobs: + # Job: Build MkDocs HTML documentation bundle for gem inclusion + generate-docs: + name: "Generate Local Docs Bundle for Gem Inclusion" + uses: ./.github/workflows/_generate-docs.yml + + + # Job: Linux unit test suite + tests-linux: + name: "Linux Test Suite" + needs: generate-docs + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ['3.0', '3.1', '3.2', '3.3', '3.4'] + steps: + # Use a cache for our tools to speed up testing + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-use-ruby-${{ runner.os }}-${{ matrix.ruby }}-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + bundle-use-ruby-${{ runner.os }}-${{ matrix.ruby }}- + + # Checks out repository under $GITHUB_WORKSPACE + - name: Checkout Latest Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + # Set up Ruby to run test & build steps on multiple ruby versions + # This action installs Ruby and the Bundler gem using the version captured in Gemfile.lock + - name: Set Up Ruby with Version Matrix + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + + # Workaround: Fix Ruby toolcache Gem directory permissions (Bundler exit 38) + # This is an environmental Ruby/toolcache issue, not a Bundler issue; the fix must + # run after setup-ruby (which populates the toolcache) but before bundle install. + # Issue: https://github.com/rubygems/rubygems/issues/7983 + - name: Apply Workaround for Ruby toolcache Gem Directory Permissions + run: | + sudo chmod -R go-w /opt/hostedtoolcache/Ruby/**/**/lib/ruby/gems/**/gems || true + + # Install Gem Dependencies + - name: Install Gem Dependencies + # Ensure local installation to prevent system directory permission problems + run: | + bundle install + + # Install gdb for backtrace feature testing + - name: Install gdb for Backtrace Feature Testing + run: | + sudo apt-get update -qq + sudo apt-get install --assume-yes --quiet gdb + + # --break-system-packages is required on Ubuntu 24.04+ + # (PEP 668 prevents pip from installing to the system Python environment without explicit opt-in) + - name: "Install GCovr for Tests of Ceedling Plugin: Gcov" + run: | + pip install --break-system-packages gcovr + + # Install ReportGenerator for Gcov plugin + # Fix PATH before tool installation + # https://stackoverflow.com/questions/59010890/github-action-how-to-restart-the-session + - name: "Install ReportGenerator for Tests of Ceedling Plugin: Gcov" + run: | + mkdir --parents $HOME/.dotnet/tools + echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + dotnet tool install --global dotnet-reportgenerator-globaltool + + # Download HTML docs bundle built by generate-docs job + - name: Download HTML Documentation Bundle + uses: actions/download-artifact@v4 + with: + name: gem-docs-site-local + path: site-local/ + + # Run Tests + - name: Run All Self Tests + env: + # Set the RSpec formatter to condensed dots + CI_RSPEC_PROGRESS_FORMAT: true + run: | + rake ci + + # Upload any system test failure logs on test job failure + - name: Upload system test failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: systest-failure-logs-${{ runner.os }}-ruby-${{ matrix.ruby }} + path: systest.*.fail.log + if-no-files-found: ignore + + # Build & Install Ceedling Gem + # Plugin test suites (FFF, module_generator, dependencies) require the installed gem + - name: Build and Install Ceedling Gem + run: | + gem build ceedling.gemspec + gem install --local ceedling-*.gem + + # Run FFF Plugin Tests + - name: "Run Tests on Ceedling Plugin: FFF" + run: | + cd plugins/fff + rake + cd ../.. + + # Run Module Generator Plugin Tests + - name: "Run Tests on Ceedling Plugin: Module Generator" + run: | + cd plugins/module_generator + rake + cd ../.. + + # Run Dependencies Plugin Tests + - name: "Run Tests on Ceedling Plugin: Dependencies" + run: | + cd plugins/dependencies + rake + cd ../.. + + + # Job: Windows unit test suite + tests-windows: + name: "Windows Test Suite" + needs: generate-docs + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + ruby: ['3.0', '3.1', '3.2', '3.3', '3.4'] + steps: + # Use a cache for our tools to speed up testing + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-use-ruby-${{ runner.os }}-${{ matrix.ruby }}-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + bundle-use-ruby-${{ runner.os }}-${{ matrix.ruby }}- + + # Checks out repository under $GITHUB_WORKSPACE + - name: Checkout Latest Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + # Set up Ruby to run test & build steps on multiple ruby versions + # This action installs Ruby and the Bundler gem using the version captured in Gemfile.lock + - name: Set Up Ruby with Version Matrix + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + + # Install Gem Dependencies + - name: Install Gem Dependencies + # Ensure local installation to prevent system directory permission problems + run: | + bundle install + + # Install GCovr for Gcov plugin test + - name: "Install GCovr for Tests of Ceedling Plugin: Gcov" + run: | + pip install gcovr + + # Install ReportGenerator for Gcov plugin test + - name: "Install ReportGenerator for Tests of Ceedling Plugin: Gcov" + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool + + # Download HTML docs bundle built by generate-docs job + - name: Download HTML Documentation Bundle + uses: actions/download-artifact@v4 + with: + name: gem-docs-site-local + path: site-local/ + + # Run Tests + - name: Run All Self Tests + env: + # Set the RSpec formatter to condensed dots + CI_RSPEC_PROGRESS_FORMAT: true + run: | + rake ci + + # Upload any system test failure logs on test job failure + - name: Upload system test failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: systest-failure-logs-${{ runner.os }}-ruby-${{ matrix.ruby }} + path: systest.*.fail.log + if-no-files-found: ignore + + # Build & Install Ceedling Gem + # Plugin test suites (FFF, module_generator, dependencies) require the installed gem + - name: Build and Install Ceedling Gem + run: | + gem build ceedling.gemspec + gem install --local ceedling-*.gem + + # Run FFF Plugin Tests + - name: "Run Tests on Ceedling Plugin: FFF" + run: | + cd plugins/fff + rake + cd ../.. + + # Run Module Generator Plugin Tests + - name: "Run Tests on Ceedling Plugin: Module Generator" + run: | + cd plugins/module_generator + rake + cd ../.. + + # Run Dependencies Plugin Tests + - name: "Run Tests on Ceedling Plugin: Dependencies" + run: | + cd plugins/dependencies + rake + cd ../.. + + + # Job: Build the Ceedling gem once all tests pass + # Verifies a clean gem build is possible; the built gem is uploaded as a + # downloadable workflow artifact. No release is created here. + build-gem: + name: "Build Ceedling Gem" + needs: + - tests-linux + - tests-windows + runs-on: ubuntu-latest + + steps: + # Use a cache for our tools to speed up builds + # No matrix here; Ruby version is hardcoded to match the cache key format used by test jobs + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-use-ruby-${{ runner.os }}-3.3-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + bundle-use-ruby-${{ runner.os }}-3.3- + + - name: Checkout Latest Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set Up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Install Gem Dependencies + run: | + bundle install + + # Download HTML docs bundle built by generate-docs job + - name: Download HTML Documentation Bundle + uses: actions/download-artifact@v4 + with: + name: gem-docs-site-local + path: site-local/ + + - name: Build Ceedling Gem + run: | + gem build ceedling.gemspec + + # Upload the built gem as a workflow artifact (downloadable from the Actions UI) + - name: Upload Built Gem Artifact + uses: actions/upload-artifact@v4 + with: + name: ceedling-gem + path: ceedling-*.gem + if-no-files-found: error diff --git a/.github/workflows/extract_changelog.sh b/.github/workflows/extract_changelog.sh new file mode 100755 index 000000000..6af08645d --- /dev/null +++ b/.github/workflows/extract_changelog.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# Extract a version section from a Keep a Changelog formatted file. +# +# Usage: extract_changelog.sh +# +# Semver core string (e.g. 1.1.0 or 1.2.3) +# Path to the Changelog.md file +# Path to write the extracted section into +# +# Exits 0 and writes content to if the section is found. +# Exits 1 if the changelog file is missing or the version section is absent. +# +# Version section headers are matched by the pattern "^# [VERSION]". +# Any text following the closing "]" on the header line is ignored +# (dates, labels, "Prerelease", em-dashes, hyphens, etc. are all fine). +# The extracted section does NOT include the version header line itself — +# only the body content beneath it is written to . +# +# Local testing examples: +# bash extract_changelog.sh 1.0.1 ../../docs/Changelog.md /tmp/out.md && cat /tmp/out.md +# bash extract_changelog.sh 9.9.9 ../../docs/Changelog.md /tmp/out.md; echo "exit: $?" + +set -euo pipefail + +SEMVER="${1:?version argument required}" +CHANGELOG="${2:?changelog path argument required}" +OUTPUT_FILE="${3:?output file path argument required}" + +if [ ! -f "$CHANGELOG" ]; then + echo "Changelog not found at '${CHANGELOG}'; release notes will use GitHub default." + exit 1 +fi + +# Extract from the matching "# [VERSION]" header to just before the next "# [" header. +# +# Dots in the version string are escaped in the awk BEGIN block so that e.g. +# 1.1.0 matches literally rather than treating "." as a regex wildcard. +awk -v ver="${SEMVER}" ' + BEGIN { gsub(/\./, "\\.", ver); pat = "^# \\[" ver "\\]" } + $0 ~ pat { found=1; next } + found && /^# \[/ { exit } + found { print } +' "$CHANGELOG" > "$OUTPUT_FILE" + +# -s: file exists AND is non-empty +# (handles the edge case where the version header is present but has no content beneath it) +if [ -s "$OUTPUT_FILE" ]; then + echo "Changelog section found for version '${SEMVER}'." + exit 0 +else + echo "Changelog section not found for version '${SEMVER}'." + exit 1 +fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index cff2cae50..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,287 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-24 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - ---- -# Continuous Integration Workflow -name: CI - -# Triggers the workflow on push or pull request events for master & test branches -on: - push: - branches: - - 'master' - - 'test/**' - pull_request: - branches: [ master ] - workflow_dispatch: - - -# Needed by softprops/action-gh-release -permissions: - # Allow built gem file push to Github release - contents: write - - -jobs: - # Job: Linux unit test suite - unit-tests-linux: - name: "Linux Test Suite" - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - ruby: ['3.0', '3.1', '3.2', '3.3', '3.4'] - steps: - # Use a cache for our tools to speed up testing - - uses: actions/cache@v4 - with: - path: vendor/bundle - key: bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby-version }}-${{ hashFiles('**/Gemfile.lock') }} - restore-keys: | - bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby-version }}- - - # Checks out repository under $GITHUB_WORKSPACE - - name: Checkout Latest Repo - uses: actions/checkout@v4 - with: - submodules: recursive - - # Setup Ruby to run test & build steps on multiple ruby versions - - name: Setup Ruby Version Matrix - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - - # Install Gem Depdencies (Bundler version should match the one in Gemfile.lock) - - name: Install Gem Dependencies for Testing and Ceedling Gem Builds - run: | - gem install rubocop -v 0.57.2 - gem install bundler -v "$(grep -A 1 "BUNDLED WITH" Gemfile.lock | tail -n 1)" - bundle update - bundle install - - # Install gdb for backtrace feature testing - - name: Install gdb for Backtrace Feature Testing - run: | - sudo apt-get update -qq - sudo apt-get install --assume-yes --quiet gdb - - # Install GCovr for Gcov plugin - - name: "Install GCovr for Tests of Ceedling Plugin: Gcov" - run: | - sudo pip install gcovr - - # Install ReportGenerator for Gcov plugin - # Fix PATH before tool installation - # https://stackoverflow.com/questions/59010890/github-action-how-to-restart-the-session - - name: "Install ReportGenerator for Tests of Ceedling Plugin: Gcov" - run: | - mkdir --parents $HOME/.dotnet/tools - echo "$HOME/.dotnet/tools" >> $GITHUB_PATH - dotnet tool install --global dotnet-reportgenerator-globaltool - - # Run Tests - - name: Run All Self Tests - run: | - rake ci - - # Build & Install Ceedling Gem - - name: Build and Install Ceedling Gem - run: | - gem build ceedling.gemspec - gem install --local ceedling-*.gem - - # Run temp_sensor - - name: Run Tests on temp_sensor Project - run: | - cd examples/temp_sensor - ceedling test:all - cd ../.. - - # Run FFF Plugin Tests - - name: "Run Tests on Ceedling Plugin: FFF" - run: | - cd plugins/fff - rake - cd ../.. - - # Run Module Generator Plugin Tests - - name: "Run Tests on Ceedling Plugin: Module Generator" - run: | - cd plugins/module_generator - rake - cd ../.. - - # Run Dependencies Plugin Tests - - name: "Run Tests on Ceedling Plugin: Dependencies" - run: | - cd plugins/dependencies - rake - cd ../.. - - # Job: Windows unit test suite - unit-tests-windows: - name: "Windows Test Suite" - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - ruby: ['3.0', '3.1', '3.2', '3.3', '3.4'] - steps: - # Use a cache for our tools to speed up testing - - uses: actions/cache@v4 - with: - path: vendor/bundle - key: bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby-version }}-${{ hashFiles('**/Gemfile.lock') }} - restore-keys: | - bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby-version }}- - - # Checks out repository under $GITHUB_WORKSPACE - - name: Checkout Latest Repo - uses: actions/checkout@v4 - with: - submodules: recursive - - # Setup Ruby to run test & build steps on multiple ruby versions - - name: Setup Ruby Version Matrix - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - - # Install Gem Depdencies (Bundler version should match the one in Gemfile.lock) - - name: Install Gem Dependencies for Testing and Ceedling Gem Builds - shell: bash - run: | - gem install rubocop -v 0.57.2 - gem install bundler -v "$(grep -A 1 "BUNDLED WITH" Gemfile.lock | tail -n 1)" - bundle update - bundle install - - # Install GCovr for Gcov plugin test - - name: "Install GCovr for Tests of Ceedling Plugin: Gcov" - run: | - pip install gcovr - - # Install ReportGenerator for Gcov plugin test - - name: "Install ReportGenerator for Tests of Ceedling Plugin: Gcov" - run: | - dotnet tool install --global dotnet-reportgenerator-globaltool - - # Run Tests - - name: Run All Self Tests - run: | - rake ci - - # Build & Install Gem - - name: Build and Install Ceedling Gem - run: | - gem build ceedling.gemspec - gem install --local ceedling-*.gem - - # Run temp_sensor example project - - name: Run Tests on temp_sensor Project - run: | - cd examples/temp_sensor - ceedling test:all - cd ../.. - - # Run FFF Plugin Tests - - name: "Run Tests on Ceedling Plugin: FFF" - run: | - cd plugins/fff - rake - cd ../.. - - # Run Module Generator Plugin Tests - - name: "Run Tests on Ceedling Plugin: Module Generator" - run: | - cd plugins/module_generator - rake - cd ../.. - - # Run Dependencies Plugin Tests - - name: "Run Tests on Ceedling Plugin: Dependencies" - run: | - cd plugins/dependencies - rake - cd ../.. - - # Job: Automatic Minor Release - auto-release: - name: "Automatic Minor Release" - needs: - - unit-tests-linux - - unit-tests-windows - runs-on: ubuntu-latest - strategy: - matrix: - ruby: [3.3] - - steps: - # Checks out repository under $GITHUB_WORKSPACE - - name: Checkout Latest Repo - uses: actions/checkout@v4 - with: - submodules: recursive - - # Set Up Ruby Tools - - name: Set Up Ruby Tools - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - - # Capture the SHA string - - name: Git commit short SHA as environment variable - shell: bash - run: | - echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - - name: Ceedling tag as environment variable - shell: bash - run: | - echo "ceedling_tag=$(ruby ./lib/version.rb)" >> $GITHUB_ENV - - - name: Ceedling build string as environment variable - shell: bash - run: | - echo "ceedling_build=${{ env.ceedling_tag }}-${{ env.sha_short }}" >> $GITHUB_ENV - - # Create Git Commit SHA file in root of checkout - - name: Git Commit SHA file - shell: bash - run: | - echo "${{ env.sha_short }}" > ${{ github.workspace }}/GIT_COMMIT_SHA - - # Build Gem - - name: Build Gem - run: | - gem build ceedling.gemspec - - # Create Unofficial Release - - name: Create Pre-Release - uses: actions/create-release@v1 - id: create_release - with: - draft: false - prerelease: true - release_name: ${{ env.ceedling_build }} - tag_name: ${{ env.ceedling_build }} - body: "Automatic pre-release for ${{ env.ceedling_build }}" - env: - GITHUB_TOKEN: ${{ github.token }} - - # Post Gem to Unofficial Release - - name: Upload Pre-Release Gem - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./ceedling-${{ env.ceedling_tag }}.gem - asset_name: ceedling-${{ env.ceedling_build }}.gem - asset_content_type: test/x-gemfile - diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 000000000..54a876dee --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,68 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# ----------------------------------------------------------------------- +# Pre-Release Publishing Workflow +# +# Purpose: +# Build the Ceedling gem and publish it as a GitHub pre-release. +# The test suite is NOT re-run here — ci.yml must have passed on the +# commit being tagged before the tag is pushed. +# +# Trigger: +# Push of a tag matching one of these patterns: +# v*.*.*-pre.* (e.g. v1.1.0-pre.1) +# v*.*.*-beta.* (e.g. v1.1.0-beta.1) +# v*.*.*-alpha.* (e.g. v1.1.0-alpha.1) +# +# Tag-to-gem-version convention: +# v1.1.0-pre.1 → ceedling-1.1.0.pre.1.gem +# (hyphens become dots per RubyGems pre-release notation) +# +# Typical workflow: +# 1. Push v1.0.3-pre.1 tag → CI builds ceedling-1.0.3.pre.1.gem, posts GitHub pre-release +# 2. Iterate with v1.0.3-pre.2, etc. as needed +# 3. Push v1.0.3 tag (release.yml) → CI builds ceedling-1.0.3.gem, posts GitHub release +# 4. Bump lib/version.rb to 1.0.4.dev in a commit and continue +# +# Related workflows: +# ci.yml — runs on every push to any branch; no publishing +# release.yml — triggered by full release tags (v*.*.*, no suffix) +# ----------------------------------------------------------------------- + +--- +name: Pre-Release + +# Triggers on pre-release tag patterns only +on: + push: + tags: + - 'v*.*.*-pre.*' + - 'v*.*.*-beta.*' + - 'v*.*.*-alpha.*' + + +# contents: write is required to create and upload GitHub releases +permissions: + contents: write + + +jobs: + # Job: Build MkDocs HTML documentation bundle for gem inclusion + generate-docs: + name: "Generate Local Docs Bundle for Gem Inclusion" + uses: ./.github/workflows/_generate-docs.yml + + # Job: Build gem and publish GitHub pre-release + publish: + name: "Build and Publish Pre-Release Gem" + needs: generate-docs + uses: ./.github/workflows/_publish-gem.yml + with: + prerelease: true + # Pass GITHUB_TOKEN and any other secrets through to the reusable workflow + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..72d1c1e95 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# ----------------------------------------------------------------------- +# Release Publishing Workflow +# +# Purpose: +# Build the Ceedling gem and publish it as a GitHub full release. +# The test suite is NOT re-run here — ci.yml must have passed on the +# commit being tagged before the tag is pushed. +# +# Trigger: +# Push of a tag matching: v*.*.* (e.g. v1.1.0) +# +# Important: the v*.*.* glob also matches pre-release tags (e.g. v1.1.0-pre.1). +# The 'if' guard on the publish job provides a second line of defense: +# any tag containing a hyphen is rejected and the publish job is skipped, +# so only clean version tags (no suffix) produce a full release. +# +# Tag-to-gem-version convention: +# v1.1.0 → ceedling-1.1.0.gem +# +# Post-release steps (manual): +# After pushing a release tag, bump lib/version.rb to the next .dev +# version (e.g. GEM = '1.1.1.dev') and commit to continue development. +# +# RubyGems.org publishing: +# The "Push to RubyGems.org" step in _publish-gem.yml is stubbed and +# commented out. Uncomment it and add a RUBYGEMS_API_KEY repository +# secret to enable automated publishing to rubygems.org. +# +# Related workflows: +# ci.yml — runs on every push to any branch; no publishing +# prerelease.yml — triggered by pre-release tags (v*.*.*-pre.*, etc.) +# ----------------------------------------------------------------------- + +--- +name: Release + +# Triggers on release tag pattern only. +# Primary defense: the negative pattern '!v*.*.*-*' excludes any tag containing +# a hyphen (e.g. v1.0.0-pre.1), which would otherwise match the v*.*.* glob. +# Fallback defense: the 'if' guard on the publish job also rejects hyphenated tags. +on: + push: + tags: + - 'v*.*.*' + - '!v*.*.*-*' # Exclude pre-release tags (e.g. v1.0.0-pre.1, v1.0.0-beta.1) + + +# contents: write is required to create and upload GitHub releases +permissions: + contents: write + + +jobs: + # Job: Build MkDocs HTML documentation bundle for gem inclusion + generate-docs: + name: "Generate Local Docs Bundle for Gem Inclusion" + uses: ./.github/workflows/_generate-docs.yml + + # Job: Build gem and publish GitHub full release + # The 'if' guard rejects tags that contain a hyphen (e.g. v1.1.0-pre.1), + # which would otherwise match the v*.*.* trigger glob and create a spurious + # full release instead of a pre-release + publish: + name: "Build and Publish Release Gem" + needs: generate-docs + if: ${{ !contains(github.ref_name, '-') }} + uses: ./.github/workflows/_publish-gem.yml + with: + prerelease: false + # Pass GITHUB_TOKEN and any other secrets through to the reusable workflow + secrets: inherit diff --git a/.github/workflows/stamp_gem_version.sh b/.github/workflows/stamp_gem_version.sh new file mode 100755 index 000000000..c00f0a9cd --- /dev/null +++ b/.github/workflows/stamp_gem_version.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# Derive a RubyGems version string from a Git tag and stamp it into version.rb. +# +# Usage: stamp_gem_version.sh +# +# Git tag (e.g. v1.1.0-pre.1 or v1.1.0) +# Path to the version.rb file to patch in place +# +# Exits 0 on success. Exits 1 if the version file is missing. +# +# Tag-to-gem-version conversion: +# v1.1.0-pre.1 → 1.1.0.pre.1 (hyphen becomes dot: RubyGems pre-release notation) +# v1.1.0 → 1.1.0 (no suffix; substitution is a no-op) +# +# Local testing examples: +# bash stamp_gem_version.sh v1.1.0 ../../lib/version.rb && grep GEM ../../lib/version.rb +# bash stamp_gem_version.sh v1.1.0-pre.1 ../../lib/version.rb && grep GEM ../../lib/version.rb + +set -euo pipefail + +TAG="${1:?tag argument required}" +VERSION_FILE="${2:?version file path argument required}" + +if [ ! -f "$VERSION_FILE" ]; then + echo "Version file not found at '${VERSION_FILE}'." + exit 1 +fi + +# Strip leading 'v' from the Git tag (e.g. v1.1.0-pre.1 → 1.1.0-pre.1) +GEM_VERSION="${TAG#v}" +# Replace the first hyphen with a dot (RubyGems pre-release notation) +# e.g. 1.1.0-pre.1 → 1.1.0.pre.1 ; 1.1.0 → 1.1.0 (no-op for release tags) +GEM_VERSION="${GEM_VERSION/-/.}" + +sed -i "s/GEM = '.*'/GEM = '${GEM_VERSION}'/" "${VERSION_FILE}" +echo "Stamped gem version: ${GEM_VERSION}" diff --git a/.gitignore b/.gitignore index bd13be2b3..331566bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ .*.swp .swp -Gemfile.lock - -out.fail tags *.taghl @@ -16,11 +13,29 @@ plugins/dependencies/example/boss/build/ plugins/dependencies/example/boss/third_party/ plugins/dependencies/example/supervisor/build/ +# Logs / test output +out.fail +*.fail.log + +# Generated documentation site builds +site-web/ +site-local/ +.docsenv/ + +# IDEs and related ceedling.sublime-project ceedling.sublime-workspace -ceedling-*.gem -.DS_Store -.ruby-version /.idea /.vscode +/.claude + +# Generated commit hash file for `--version` output /GIT_COMMIT_SHA + +# macOS junk +.DS_Store + +# Ruby and Gem related +.ruby-version +ceedling-*.gem +Gemfile.lock diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..93bbf2051 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +-I spec/support +-I spec/support/system diff --git a/Gemfile.lock b/Gemfile.lock index 7e8525acc..650fd882e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,36 +4,33 @@ GEM cgi (0.5.1) constructor (2.0.0) deep_merge (1.2.2) - diff-lcs (1.5.1) + diff-lcs (1.6.2) diy (1.1.2) constructor (>= 1.0.0) - erb (4.0.4) - cgi (>= 0.3.3) - parallel (1.27.0) - rake (13.2.1) require_all (3.0.0) - rr (3.1.1) - rspec (3.13.0) + rr (3.1.2) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.2) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.1) - thor (1.3.2) - unicode-display_width (3.1.2) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + rspec-support (3.13.7) + thor (1.5.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS ruby x64-mingw-ucrt + x64-mingw32 x86_64-darwin-22 x86_64-linux diff --git a/README.md b/README.md index c218cceb1..97f80e13d 100644 --- a/README.md +++ b/README.md @@ -733,6 +733,42 @@ local Ceedling repo, list those task like this: [RSpec]: https://rspec.info +## Working with Documentation + +Ceedling's documentation is built with [MkDocs] + [Material theme] and versioned +with [mike]. All Markdown source lives under `docs/`. The site configuration is +in `mkdocs.yml`. + +**First-time setup** (installs MkDocs, Material, and mike into the container): + +```shell + > rake docs:install +``` + +**Available Rake tasks:** + +| Task | Description | +|---|---| +| `rake docs:install` | Install Python documentation tooling | +| `rake docs:build` | Build site in strict mode — fails on broken links or warnings | +| `rake docs:serve` | Serve plain MkDocs site locally on port 8000 | +| `rake docs:deploy` | Deploy `dev` version to local `gh-pages` branch (no remote push) | +| `rake docs:preview` | Browse mike-versioned site locally on port 8000 | + +**Browser preview in VS Code:** When `mkdocs serve` or `mike serve` binds to +port 8000, VS Code detects it and shows a notification. The **Ports** panel also +provides an **Open in Browser** button. + +**Hosted site:** [https://throwtheswitch.github.io/Ceedling/](https://throwtheswitch.github.io/Ceedling/) + +Versions are deployed automatically via GitHub Actions: `dev` on every push to +`main`, and a per-minor-version alias (e.g. `1.0`) plus `latest` on each +published release. + +[MkDocs]: https://www.mkdocs.org +[Material theme]: https://squidfunk.github.io/mkdocs-material/ +[mike]: https://github.com/jimporter/mike + ## Working in `bin/` vs. `lib/` Most of Ceedling’s functionality is contained in the application code residing diff --git a/Rakefile b/Rakefile index 6dfe1dc83..6363f9839 100644 --- a/Rakefile +++ b/Rakefile @@ -8,19 +8,175 @@ require 'bundler' require 'rspec/core/rake_task' +require 'fileutils' +require 'open3' -desc "Run all rspecs" -RSpec::Core::RakeTask.new(:spec) do |t| - t.pattern = 'spec/**/*_spec.rb' +## +## Testing tasks +## + +# Local developer gets hierarchical documentation output; CI gets compact progress output. +# Most CI systems (GitHub Actions, GitLab CI, CircleCI, etc.) set CI=true automatically. +RSPEC_FORMAT = ENV['CI_RSPEC_PROGRESS_FORMAT'] ? '--format progress' : '--format documentation' + +desc "Run unit specs only" +RSpec::Core::RakeTask.new('specs:units') do |t| + t.pattern = 'spec/units/**/*_spec.rb' + t.rspec_opts = RSPEC_FORMAT +end + +desc "Run system specs only" +RSpec::Core::RakeTask.new('specs:system') do |t| + t.pattern = 'spec/system/**/*_spec.rb' + t.rspec_opts = RSPEC_FORMAT +end + +# Run unit tests first to fail on fast before running slower system tests +desc "Run all specs: unit specs first, then system specs" +task 'specs:all' => ['specs:units', 'specs:system'] + +desc "Run system specs with artifact retention and per-test failure log files" +task 'specs:system:debug' do + ENV['CEEDLING_SYSTEM_TEST_KEEP'] = '1' + Rake::Task['specs:system'].invoke end -Dir['spec/**/*_spec.rb'].each do |p| +# Individual unit specs +Dir['spec/units/**/*_spec.rb'].each do |p| base = File.basename(p,'.*').gsub('_spec','') - desc "rspec #{base}" - RSpec::Core::RakeTask.new("spec:#{base}") do |t| - t.pattern = p + desc "Run unit spec: #{base}" + RSpec::Core::RakeTask.new("spec:unit:#{base}") do |t| + t.pattern = p + t.rspec_opts = '--format documentation' + end +end + +# Individual system specs +Dir['spec/system/**/*_spec.rb'].each do |p| + base = File.basename(p,'.*').gsub('_spec','') + desc "Run system spec: #{base}" + RSpec::Core::RakeTask.new("spec:system:#{base}") do |t| + t.pattern = p + t.rspec_opts = '--format documentation' + end +end + +desc "Run specs by filename matching a substring (e.g., rake \"spec:filter:filename[]\")" +RSpec::Core::RakeTask.new('spec:filter:filename', [:pattern]) do |t, args| + pattern = args[:pattern] || '*' + t.pattern = "spec/{units,system}/**/*#{pattern}*_spec.rb" + t.rspec_opts = '--format documentation' +end + +desc "Run specs matching an example's description (e.g., rake \"spec:filter:example[Version reporting]\")" +RSpec::Core::RakeTask.new('spec:filter:example', [:description]) do |t, args| + description = args[:description] || '' + t.pattern = 'spec/{units,system}/**/*_spec.rb' + t.rspec_opts = "--format documentation --example '#{description}'" +end + +desc "Run specs whose example's description matches a regex pattern (e.g., rake \"spec:filter:match[version|help]\")" +RSpec::Core::RakeTask.new('spec:filter:match', [:regex]) do |t, args| + regex = args[:regex] || '' + t.pattern = 'spec/{units,system}/**/*_spec.rb' + t.rspec_opts = "--format documentation --pattern '#{regex}'" +end + +## +## Default & CI tasks +## + +task :default => ['specs:all'] +task :ci => ['specs:all'] + +## +## Documentation tasks +## + +# Docs tasks Python virtual environment activate / deactivate wrapper +# This wrapper skips venv actions if no venv is in use (such as in CI) +def venv_sh(cmd) + puts "Running: #{cmd}" + script = <<~SHELL + _activated=0 + if [ -z "$VIRTUAL_ENV" ] && [ -f ".docsenv/bin/activate" ]; then + source .docsenv/bin/activate + _activated=1 + fi + #{cmd} + if [ "$_activated" = "1" ]; then deactivate; fi + SHELL + sh('bash', '-c', script, verbose: false) do |ok, res| + raise "ERROR: '#{cmd}' failed (exit #{res.exitstatus})" unless ok end end -task :default => [:spec] -task :ci => [:spec] +namespace :docs do + desc "Install documentation tooling (mkdocs-material, mike) in a Python virtual environment" + task :install do + venv_dir = '.docsenv' + + if File.directory?(venv_dir) + puts "Python virtual environment '#{venv_dir}/' already exists — skipping creation." + else + puts "Creating Python virtual environment '#{venv_dir}/'..." + output, status = Open3.capture2e("python3 -m venv #{venv_dir}") + unless status.success? + $stderr.puts output + raise "Failed to create Python virtual environment '#{venv_dir}/'" + end + puts "Python virtual environment '#{venv_dir}/' created." + end + + puts "Installing documentation packages (mkdocs, mkdocs-material, mike)..." + output, status = Open3.capture2e('bash', '-c', <<~SHELL) + _activated=0 + if [ -z "$VIRTUAL_ENV" ]; then + source #{venv_dir}/bin/activate + _activated=1 + fi + pip install 'mkdocs>=1.6' 'mkdocs-material>=9.5' 'mike>=2.0' + if [ "$_activated" = "1" ]; then deactivate; fi + SHELL + unless status.success? + $stderr.puts output + raise "Failed to install documentation packages" + end + puts "Documentation packages installed." + end + + desc "Snapshot versioned project files into docs/snapshot/ for documentation" + task :snapshot do + snapshot_dir = 'docs/mkdocs/snapshot/' + # Ensure the snapshot directory is empty before writing new files (to clear out anything stale) + FileUtils.rm_rf(snapshot_dir) + ruby "lib/snapshot.rb", "docs/mkdocs/snapshot.yml", snapshot_dir + end + + namespace :build do + desc "Build documentation site for web deployment" + task :web => [:snapshot] do + venv_sh "mkdocs build --strict" + end + + desc "Build documentation site as local HTML files bundle" + task :local => [:snapshot] do + venv_sh "mkdocs build -f mkdocs.local.yml --strict" + end + end + + desc "Serve web deploy docs site locally on port 8000" + task :serve do + venv_sh "mkdocs serve" + end + + desc "Browse versioned docs site locally on port 8000" + task :preview do + venv_sh "mike serve" + end + + desc "Deploy 'dev' version to branch and push to Github Pages" + task :deploy do + venv_sh "mike deploy --push dev" + end +end diff --git a/assets/example_file_with_statics.c b/assets/example_file_with_statics.c new file mode 100644 index 000000000..1e3316d21 --- /dev/null +++ b/assets/example_file_with_statics.c @@ -0,0 +1,10 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +static int calculate_product(int a, int b) { + return a * b; +} diff --git a/assets/project.yml b/assets/project.yml index e4dbd8a30..ce092b3e5 100644 --- a/assets/project.yml +++ b/assets/project.yml @@ -7,6 +7,8 @@ --- :project: + :name: "Example Project" + # how to use ceedling. If you're not sure, leave this as `gem` and `?` :which_ceedling: gem :ceedling_version: '?' @@ -14,9 +16,7 @@ # optional features. If you don't need them, keep them turned off for performance :use_mocks: TRUE :use_test_preprocessor: :none # options are :none, :mocks, :tests, or :all - :use_deep_preprocessor: :none # options are :none, :mocks, :tests, or :all :use_backtrace: :simple # options are :none, :simple, or :gdb - :use_decorators: :auto # decorate Ceedling's output text. options are :auto, :all, or :none # tweak the way ceedling handles automatic tasks :build_root: build @@ -61,7 +61,6 @@ #- command_hooks # write custom actions to be called at different points during the build process #- compile_commands_json_db # generate a compile_commands.json file #- dependencies # automatically fetch 3rd party libraries, etc. - #- subprojects # managing builds and test for static libraries #- fake_function_framework # use FFF instead of CMock # Report options (You'll want to choose one stdout option, but may choose multiple stored options if desired) @@ -91,7 +90,6 @@ :executable: .out #:testpass: .pass #:testfail: .fail - #:subprojects: .a # This is where Ceedling should look for your source and test files. # see documentation for the many options for specifying this. @@ -302,16 +300,6 @@ # :includes: # - include/** -# :subprojects: -# :paths: -# - :name: libprojectA -# :source: -# - ./subprojectA/source -# :include: -# - ./subprojectA/include -# :build_root: ./subprojectA/build -# :defines: [] - # :command_hooks: # :pre_mock_preprocess: # :post_mock_preprocess: diff --git a/assets/test_example_file_source_include.c b/assets/test_example_file_source_include.c new file mode 100644 index 000000000..29c7648fa --- /dev/null +++ b/assets/test_example_file_source_include.c @@ -0,0 +1,17 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "unity.h" +#include "example_file_with_statics.c" + +void setUp(void) {} +void tearDown(void) {} + +void test_calculate_product_via_direct_source_include(void) { + TEST_ASSERT_EQUAL_INT(6, calculate_product(2, 3)); + TEST_ASSERT_EQUAL_INT(0, calculate_product(0, 5)); +} diff --git a/bin/ceedling b/bin/ceedling index 555906872..7648a9a28 100755 --- a/bin/ceedling +++ b/bin/ceedling @@ -21,8 +21,11 @@ CEEDLING_APPCFG = CeedlingAppConfig.new() # Add load paths for `require 'ceedling/*'` statements in bin/ code $LOAD_PATH.unshift( CEEDLING_APPCFG[:ceedling_lib_base_path] ) -require 'constructor' # Assumed installed via Ceedling gem dependencies +# Assumed installed via Ceedling gem dependencies +require 'constructor' +# Commonly needed modules require 'ceedling/constants' +require 'ceedling/array_patches' # intersect?() + overlap?() # Centralized exception handler for: # 1. Bootloader (bin/) @@ -56,8 +59,12 @@ begin # 4. Perform object construction + dependency injection from bin/objects.yml # 5. Remove all paths added to $LOAD_PATH # (Main application will restore certain paths -- possibly updated by :which_ceedling) - $LOAD_PATH.unshift( + $LOAD_PATH.unshift( + # lib/ceedling for base of any require statements CEEDLING_APPCFG[:ceedling_lib_path], + # Add lib/ceedling/config to load configuration handling utils + File.join( CEEDLING_APPCFG[:ceedling_lib_path], 'config' ), + # DIY vendored path diy_vendor_path ) diff --git a/bin/cli.rb b/bin/cli.rb index e0c2b81db..53e6eebeb 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -116,7 +116,6 @@ def start(args, config={}) module CeedlingTasks VERBOSITY_NORMAL = 'normal' - VERBOSITY_DEBUG = 'debug' DOC_LOCAL_FLAG = "Install Ceedling plus supporting tools to vendor/" @@ -201,14 +200,13 @@ def help(command=nil) _options[:mixin] = [] options[:mixin].each {|mixin| _options[:mixin] << mixin.dup() } - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil - + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : Verbosity::ERRORS # Call application help with block to execute Thor's built-in help in the help logic @handler.app_help( ENV, @app_cfg, _options, command ) { |command| super(command) } end - - desc "new NAME [DEST]", "Create a new project structure at optional DEST path" + + desc "new [DEST]", "Create a new project structure at optional DEST path (default is current directory)" method_option :local, :type => :boolean, :default => false, :desc => DOC_LOCAL_FLAG method_option :docs, :type => :boolean, :default => false, :desc => DOC_DOCS_FLAG method_option :configs, :type => :boolean, :default => true, :desc => "Install starter project file in project root" @@ -237,7 +235,7 @@ def new(dest=nil) _options = options.dup() _dest = dest.dup() if !dest.nil? - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : Verbosity::ERRORS @handler.new_project( ENV, @app_cfg, Ceedling::Version::TAG, _options, _dest ) end @@ -281,7 +279,7 @@ def upgrade(path) _options[:project] = options[:project].dup() _path = path.dup() - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : Verbosity::ERRORS @handler.upgrade_project( ENV, @app_cfg, _options, _path ) end @@ -363,7 +361,7 @@ def build(*tasks) _options[:project] = options[:project].dup() if !options[:project].nil? _options[:mixin] = [] options[:mixin].each {|mixin| _options[:mixin] << mixin.dup() } - _options[:verbosity] = VERBOSITY_DEBUG if options[:debug] + _options[:verbosity] = Verbosity::DEBUG if options[:debug] _options[:logfile] = options[:logfile].dup() @handler.build( env:ENV, app_cfg:@app_cfg, options:_options, tasks:tasks ) @@ -411,12 +409,49 @@ def dumpconfig(filepath, *sections) options[:mixin].each {|mixin| _options[:mixin] << mixin.dup() } _filepath = filepath.dup() - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : Verbosity::ERRORS @handler.dumpconfig( ENV, @app_cfg, _options, _filepath, sections ) end + desc "check", "Process project configuration with full logging" + method_option :project, :type => :string, :default => nil, :lazy_default => CLI_MISSING_PARAMETER_DEFAULT, :aliases => ['-p'], :desc => DOC_PROJECT_FLAG + method_option :mixin, :type => :string, :default => [], :repeatable => true, :aliases => ['-m'], :desc => DOC_MIXIN_FLAG + method_option :debug, :type => :boolean, :default => false, :hide => true + long_desc( CEEDLING_HANDOFF_OBJECTS[:loginator].sanitize( + <<-LONGDESC + `ceedling check` loads and processes your project configuration with full + logging — the same loading, merging, manipulation, and validation a real + build would perform — but executes no build tasks and writes no files. + + Use `check` to confirm a configuration is well-formed and to see all startup + logging, including which project file and Mixins were loaded and in what order. + + Notes on Optional Flags: + + • #{LONGDOC_MIXIN_FLAG} + LONGDESC + ) ) + def check() + @handler.validate_string_param( + options[:project], + CLI_MISSING_PARAMETER_DEFAULT, + "--project is missing a required filepath parameter" + ) + + # Get unfrozen copies so we can add / modify + _options = options.dup() + _options[:project] = options[:project].dup() if !options[:project].nil? + _options[:mixin] = [] + options[:mixin].each {|mixin| _options[:mixin] << mixin.dup() } + + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : Verbosity::NORMAL + + @handler.check( ENV, @app_cfg, _options ) + end + + desc "environment", "List all configured environment variable names with values." method_option :project, :type => :string, :default => nil, :lazy_default => CLI_MISSING_PARAMETER_DEFAULT, :aliases => ['-p'], :desc => DOC_PROJECT_FLAG @@ -444,7 +479,7 @@ def environment() _options[:mixin] = [] options[:mixin].each {|mixin| _options[:mixin] << mixin.dup() } - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : Verbosity::ERRORS @handler.environment( ENV, @app_cfg, _options ) end @@ -464,7 +499,7 @@ def examples() # Get unfrozen copies so we can add / modify _options = options.dup() - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : nil @handler.list_examples( ENV, @app_cfg, _options ) end @@ -499,12 +534,27 @@ def example(name, dest=nil) _options = options.dup() _dest = dest.dup() if !dest.nil? - _options[:verbosity] = options[:debug] ? VERBOSITY_DEBUG : nil + _options[:verbosity] = options[:debug] ? Verbosity::DEBUG : nil @handler.create_example( ENV, @app_cfg, _options, name, _dest ) end + desc "docs [DEST]", "Export documentation at optional destination (default is current directory)" + long_desc( CEEDLING_HANDOFF_OBJECTS[:loginator].sanitize( + <<-LONGDESC + `ceedling docs` exports the Ceedling documentation bundle to the filesystem. + + DEST is an optional destination path (e.g. /). + The default is your working directory. A nonexistent path will be created. + LONGDESC + ) ) + def docs(dest=nil) + _dest = dest.dup() if !dest.nil? + @handler.docs( @app_cfg, _dest ) + end + + desc "version", "Display version details of Ceedling components" long_desc( CEEDLING_HANDOFF_OBJECTS[:loginator].sanitize( <<-LONGDESC diff --git a/bin/cli_handler.rb b/bin/cli_handler.rb index 1cd312eb3..1a807b349 100644 --- a/bin/cli_handler.rb +++ b/bin/cli_handler.rb @@ -12,6 +12,8 @@ class CliHandler + DOCS_SUBDIR = 'docs' + constructor :configinator, :projectinator, :cli_helper, :path_validator, :actions_wrapper, :loginator # Override to prevent exception handling from walking & stringifying the object variables. @@ -65,8 +67,8 @@ def app_help(env, app_cfg, options, command, &thor_help) ) else # If no project configuration is available then note why we aren't displaying more - msg = "Run help commands in a directory with a project file to list additional options" - @loginator.log( msg, Verbosity::NORMAL, LogLabels::NOTICE ) + msg = "Run help commands in a directory with a project file to list additional options\n\n" + @loginator.console( msg, LogLabels::NOTICE ) end version = @helper.manufacture_app_version( app_cfg ) @@ -77,7 +79,7 @@ def app_help(env, app_cfg, options, command, &thor_help) # Public to be used by `-T` ARGV hack handling def rake_help(env:, app_cfg:) - @helper.set_verbosity() # Default to normal + @helper.set_verbosity( Verbosity::ERRORS ) list_rake_tasks( env:env, app_cfg:app_cfg ) end @@ -114,11 +116,11 @@ def new_project(env, app_cfg, ceedling_tag, options, dest) # Vendor the tools and install command line helper scripts @helper.vendor_tools( app_cfg[:ceedling_root_path], dest ) if options[:local] - # Copy in documentation - @helper.copy_docs( app_cfg[:ceedling_root_path], dest ) if options[:docs] - # Copy / set up project file @helper.create_project_file( dest, options[:local], ceedling_tag ) if options[:configs] + + # Copy in documentation + @helper.copy_docs( app_cfg[:ceedling_root_path], File.join( dest, DOCS_SUBDIR ) ) if options[:docs] # Copy Git Ignore file if options[:gitsupport] @@ -130,8 +132,7 @@ def new_project(env, app_cfg, ceedling_tag, options, dest) @actions._touch_file( File.join( dest, 'test/support', '.gitkeep') ) end - @loginator.log() # Blank line - @loginator.log( "New project created at #{dest}/\n", Verbosity::NORMAL, LogLabels::TITLE ) + @loginator.console( "\nNew project created at #{File.absolute_path(dest)}/\n", LogLabels::TITLE ) end @@ -149,7 +150,7 @@ def upgrade_project(env, app_cfg, options, path) which, _ = @helper.which_ceedling?( env:env, app_cfg:app_cfg ) if (which == :gem) msg = "Project configuration specifies the Ceedling gem, not vendored Ceedling" - @loginator.log( msg, Verbosity::NORMAL, LogLabels::NOTICE ) + @loginator.console( msg, LogLabels::NOTICE ) end # Thor Actions for project tasks use paths in relation to this path @@ -165,21 +166,23 @@ def upgrade_project(env, app_cfg, options, path) founds_docs = @helper.project_exists?( path, :&, File.join( 'docs', 'CeedlingPacket.md' ) ) if founds_docs @actions.remove_dir( docs_path ) - @helper.copy_docs( app_cfg[:ceedling_root_path], path ) + @helper.copy_docs( app_cfg[:ceedling_root_path], File.join( path, DOCS_SUBDIR ) ) end - @loginator.log() # Blank line - @loginator.log( "Upgraded project at #{path}/\n", Verbosity::NORMAL, LogLabels::TITLE ) + @loginator.console( "\nUpgraded project at #{path}/\n", LogLabels::TITLE ) end def build(env:, app_cfg:, options:{}, tasks:) - @helper.set_verbosity( options[:verbosity] ) + # No override, allow build verbosity to be set by config or command line + @helper.set_verbosity( options[:verbosity], override: false ) @path_validator.standardize_paths( options[:project], options[:logfile], *options[:mixin] ) _, config = @configinator.loadinate( builtin_mixins:BUILTIN_MIXINS, filepath:options[:project], mixins:options[:mixin], env:env ) + @cli_helper.log_project_name( config ) + default_tasks = @configinator.default_tasks( config:config, default_tasks:app_cfg[:default_tasks] ) @helper.process_testcase_filters( @@ -193,7 +196,7 @@ def build(env:, app_cfg:, options:{}, tasks:) logging_path = @helper.process_logging_path( config ) log_filepath = @helper.process_log_filepath( logging_path, options[:log], options[:logfile] ) - @loginator.log( " > Logfile: #{log_filepath}" ) if !log_filepath.empty? + @loginator.console( " > Logfile: #{log_filepath}" ) if !log_filepath.empty? # Save references app_cfg.set_project_config( config ) @@ -254,6 +257,8 @@ def dumpconfig(env, app_cfg, options, filepath, sections) _, config = @configinator.loadinate( builtin_mixins:BUILTIN_MIXINS, filepath:options[:project], mixins:options[:mixin], env:env ) + @cli_helper.log_project_name( config ) + # Exception handling to ensure we dump the configuration regardless of config validation errors begin # If enabled, process the configuration through Ceedling automatic settings, defaults, plugins, etc. @@ -272,13 +277,43 @@ def dumpconfig(env, app_cfg, options, filepath, sections) default_tasks: default_tasks ) else - @loginator.log( " > Skipped loading Ceedling application", Verbosity::OBNOXIOUS ) + @loginator.console( " > Skipped loading Ceedling application" ) end ensure @helper.dump_yaml( config, filepath, sections ) - @loginator.log() # Blank line - @loginator.log( "Dumped project configuration to #{filepath}\n", Verbosity::NORMAL, LogLabels::TITLE ) + @loginator.console( "\nDumped project configuration to #{filepath}\n", LogLabels::TITLE ) + end + end + + + def check(env, app_cfg, options) + # Force obnoxious (or debug) verbosity, overriding any prior verbosity state + @helper.set_verbosity( options[:verbosity] ) + + @path_validator.standardize_paths( options[:project], *options[:mixin] ) + + _, config = @configinator.loadinate( builtin_mixins:BUILTIN_MIXINS, filepath:options[:project], mixins:options[:mixin], env:env ) + + @cli_helper.log_project_name( config ) + + default_tasks = @configinator.default_tasks( config:config, default_tasks:app_cfg[:default_tasks] ) + + # Save references; explicitly disable log file output + app_cfg.set_project_config( config ) + app_cfg.set_logging_path( @helper.process_logging_path( config ) ) + app_cfg.set_log_filepath( '' ) + + _, path = @helper.which_ceedling?( env:env, config:config, app_cfg:app_cfg ) + + begin + @helper.load_ceedling( + config: config, + rakefile_path: path, + default_tasks: default_tasks + ) + ensure + @loginator.console( "\nProject configuration processed.\n\n", LogLabels::TITLE ) end end @@ -290,6 +325,8 @@ def environment(env, app_cfg, options) _, config = @configinator.loadinate( builtin_mixins:BUILTIN_MIXINS, filepath:options[:project], mixins:options[:mixin], env:env ) + @cli_helper.log_project_name( config ) + # Save references app_cfg.set_project_config( config ) app_cfg.set_logging_path( @helper.process_logging_path( config ) ) @@ -318,14 +355,19 @@ def environment(env, app_cfg, options) end end - output = "Environment variables:\n" + output = "Environment variables:" env_list.sort.each do |line| - output << " • #{line}\n" + output << "\n • #{line}" end - @loginator.log() # Blank line - @loginator.log( output + "\n", Verbosity::NORMAL, LogLabels::TITLE ) + if env_list.empty? + output << " \n" + else + output << "\n" + end + + @loginator.console( "#{output}\n", LogLabels::TITLE ) end @@ -343,8 +385,7 @@ def list_examples(env, app_cfg, options) examples.each {|example| output << " • #{example}\n" } - @loginator.log() # Blank line - @loginator.log( output + "\n", Verbosity::NORMAL, LogLabels::TITLE ) + @loginator.console( "#{output}\n", LogLabels::TITLE ) end @@ -385,17 +426,28 @@ def create_example(env, app_cfg, options, name, dest) @helper.vendor_tools( app_cfg[:ceedling_root_path], dest ) if options[:local] # Copy in documentation - @helper.copy_docs( app_cfg[:ceedling_root_path], dest ) if options[:docs] + @helper.copy_docs( app_cfg[:ceedling_root_path], File.join( dest, DOCS_SUBDIR ) ) if options[:docs] + + @loginator.console( "Example project '#{name}' created at #{dest}/\n", LogLabels::TITLE ) + end + + + def docs(app_cfg, dest) + # Thor Actions file operations require an anchored source root + ActionsWrapper.source_root( app_cfg[:ceedling_root_path] ) + + # Default to current working directory when no destination is given + dest ||= '.' - @loginator.log() # Blank line - @loginator.log( "Example project '#{name}' created at #{dest}/\n", Verbosity::NORMAL, LogLabels::TITLE ) + # Written directly to chosen destination path + @helper.copy_docs( app_cfg[:ceedling_root_path], dest ) end def version(env, app_cfg) # Versionator is not needed to persist. So, it's not built in the DIY collection. - @helper.set_verbosity() # Default to normal + @helper.set_verbosity( Verbosity::ERRORS ) # Ceedling bootloader launcher = Versionator.new( app_cfg[:ceedling_root_path] ) @@ -447,7 +499,7 @@ def version(env, app_cfg) # Add a header version = "Welcome to Ceedling!\n\n" + version - @loginator.log( version, Verbosity::NORMAL, LogLabels::TITLE ) + @loginator.console( version, LogLabels::TITLE ) end @@ -478,7 +530,7 @@ def list_rake_tasks(env:, app_cfg:, filepath:nil, mixins:[], silent:false) ) msg = "Ceedling build & plugin tasks:\n(Parameterized tasks tend to need enclosing quotes or escape sequences in most shells)" - @loginator.log( msg, Verbosity::NORMAL, LogLabels::TITLE ) + @loginator.console( msg, LogLabels::TITLE ) @helper.print_rake_tasks() end diff --git a/bin/cli_helper.rb b/bin/cli_helper.rb index 37eab79ff..a1a879102 100644 --- a/bin/cli_helper.rb +++ b/bin/cli_helper.rb @@ -21,6 +21,13 @@ def setup @actions = @actions_wrapper end + def log_project_name(config) + name, _ = @config_walkinator.fetch_value( :project, :name, hash:config ) + + return if name.nil? || name.empty? + + @loginator.console( "#{name.upcase}\n\n", LogLabels::TITLE ) + end def manufacture_app_version(app_cfg) return Versionator.new( @@ -31,27 +38,20 @@ def manufacture_app_version(app_cfg) def help_footer(ceedling_tag='master') - # Blank line - @loginator.log( "" ) + @loginator.console() # Blank line for spacing # Documentation incorporating Ceedling version tag in URL msg = "Ceedling Packet User Manual (v#{ceedling_tag})\n" + - "https://github.com/ThrowTheSwitch/Ceedling/blob/#{ceedling_tag}/docs/CeedlingPacket.md" - @loginator.log( msg, Verbosity::NORMAL, LogLabels::DOCUMENTATION ) - - # Blank line - @loginator.log( "" ) + "https://throwtheswitch.github.io/Ceedling/#{ceedling_tag}/\n\n" + @loginator.console( msg, LogLabels::DOCUMENTATION ) # Ceedling Suite - msg = "Ceedling Suite can help you do more ➡️ https://www.thingamabyte.com/ceedling" - @loginator.log( msg, Verbosity::NORMAL, LogLabels::COMMERCIAL ) + msg = "Ceedling Suite can help you do more ➡️ https://www.thingamabyte.com/ceedling\n\n" + @loginator.console( msg, LogLabels::COMMERCIAL ) # GitHub Sponsors - msg = "Please consider supporting this work ➡️ https://github.com/sponsors/throwtheswitch" - @loginator.log( msg, Verbosity::NORMAL, LogLabels::REQUEST ) - - # Blank line - @loginator.log( "" ) + msg = "Please consider supporting this work ➡️ https://github.com/sponsors/throwtheswitch\n\n" + @loginator.console( msg, LogLabels::REQUEST ) end @@ -108,7 +108,7 @@ def which_ceedling?(env:, config:{}, app_cfg:) # Environment variable if !env['WHICH_CEEDLING'].nil? - @loginator.log( " > Set which Ceedling using environment variable WHICH_CEEDLING", Verbosity::OBNOXIOUS ) + @loginator.console( " > Set which Ceedling using environment variable WHICH_CEEDLING" ) which_ceedling = env['WHICH_CEEDLING'].strip() which_ceedling = :gem if (which_ceedling.casecmp( 'gem' ) == 0) end @@ -139,7 +139,7 @@ def which_ceedling?(env:, config:{}, app_cfg:) # If we're launching from the gem, return :gem and initial Rakefile path if which_ceedling == :gem - @loginator.lazy( Verbosity::OBNOXIOUS ) { " > Launching Ceedling from #{app_cfg[:ceedling_root_path]}/" } + @loginator.log( " > Launching Ceedling from #{app_cfg[:ceedling_root_path]}/", Verbosity::OBNOXIOUS ) return which_ceedling, app_cfg[:ceedling_rakefile_filepath] end @@ -161,7 +161,7 @@ def which_ceedling?(env:, config:{}, app_cfg:) # Update variable to full application start path ceedling_path = app_cfg[:ceedling_rakefile_filepath] - @loginator.lazy( Verbosity::OBNOXIOUS ) { " > Launching Ceedling from #{app_cfg[:ceedling_root_path]}/" } + @loginator.log( " > Launching Ceedling from #{app_cfg[:ceedling_root_path]}/", Verbosity::OBNOXIOUS ) return :path, ceedling_path end @@ -294,8 +294,8 @@ def print_rake_tasks() indentation = ' ' * 2 rake_tasks.gsub!(/^/, indentation) - # Add Rake logging output to our logging handler - @loginator.log( rake_tasks ) + # Print Rake task list directly to the console + @loginator.console( rake_tasks ) end @@ -324,27 +324,48 @@ def run_rake_tasks(tasks) end - # Set global consts for verbosity and debug - def set_verbosity(verbosity=nil) - # If we have already set verbosity, there's nothing to do here - return PROJECT_VERBOSITY if @system_wrapper.constants_include?('PROJECT_VERBOSITY') + # Sets global PROJECT_VERBOSITY and PROJECT_DEBUG constants used throughout + # the Ceedling application. Once set, subsequent calls are no-ops — the method + # returns the already-established verbosity — unless `override:` is true. + # + # `verbosity` accepts: + # - nil → defaults to Verbosity::NORMAL + # - Integer → used directly as a Verbosity level (e.g. Verbosity::OBNOXIOUS) + # - numeric string → parsed as an integer verbosity level (e.g. '4') + # - named string → looked up in VERBOSITY_OPTIONS hash (e.g. 'debug', 'normal') + def set_verbosity(verbosity=nil, override: true) + # Idempotency guard: if verbosity is already established, return it as-is. + # `override: true` bypasses this to allow forced re-configuration (check command). + return PROJECT_VERBOSITY if !override && @system_wrapper.constants_include?('PROJECT_VERBOSITY') + + verbosity = + if verbosity.nil? + Verbosity::NORMAL + + # Integer Verbosity constants (e.g. Verbosity::OBNOXIOUS) pass through directly + elsif verbosity.is_a?( Integer ) + verbosity + + # Numeric string (e.g. '4') — convert to integer + elsif verbosity.to_i.to_s == verbosity + verbosity.to_i + + # Named string (e.g. 'debug', 'normal') — look up integer value + elsif VERBOSITY_OPTIONS.include? verbosity.to_sym + VERBOSITY_OPTIONS[verbosity.to_sym] - verbosity = if verbosity.nil? - Verbosity::NORMAL - elsif verbosity.to_i.to_s == verbosity - verbosity.to_i - elsif VERBOSITY_OPTIONS.include? verbosity.to_sym - VERBOSITY_OPTIONS[verbosity.to_sym] - else - raise "Unkown Verbosity '#{verbosity}' specified" - end + else + raise "Unkown Verbosity '#{verbosity}' specified" + end # Create global constant PROJECT_VERBOSITY + Object.send(:remove_const, 'PROJECT_VERBOSITY') if Object.const_defined?('PROJECT_VERBOSITY') Object.module_eval("PROJECT_VERBOSITY = verbosity") PROJECT_VERBOSITY.freeze() # Create global constant PROJECT_DEBUG debug = (verbosity == Verbosity::DEBUG) + Object.send(:remove_const, 'PROJECT_DEBUG') if Object.const_defined?('PROJECT_DEBUG') Object.module_eval("PROJECT_DEBUG = debug") PROJECT_DEBUG.freeze() @@ -401,7 +422,7 @@ def lookup_example_projects(examples_path) def copy_docs(ceedling_root, dest) - docs_path = File.join( dest, 'docs' ) + docs_path_ceedling = File.join( dest, 'ceedling' ) # Hash that will hold documentation copy paths # - Key: (modified) destination documentation path @@ -410,7 +431,6 @@ def copy_docs(ceedling_root, dest) # Add docs to list from Ceedling (docs/) and supporting projects (docs/) { # Source path => docs/ destination path - 'docs' => '.', 'vendor/unity/docs' => 'unity', 'vendor/cmock/docs' => 'cmock', 'vendor/c_exception/docs' => 'c_exception' @@ -427,17 +447,6 @@ def copy_docs(ceedling_root, dest) end end - # Add docs to list from Ceedling plugins (docs/plugins) - glob = File.join( ceedling_root, 'plugins/**/README.md' ) - listing = @file_wrapper.directory_listing( glob ) # Already case-insensitive - listing.each do |path| - # 'README.md' => '.md' where name extracted from containing path - rename = path.split(/\\|\//)[-2] + '.md' - # For each Ceedling plugin readme, add to hash - dest = File.join( 'plugins', rename ) - doc_files[ dest ] = path - end - # Add licenses from Ceedling (docs/) and supporting projects (docs/) { # Destination path => Source path '.' => '.', # Ceedling @@ -456,10 +465,28 @@ def copy_docs(ceedling_root, dest) doc_files[ dest ] = filepath end - # Copy all documentation - doc_files.each_pair do |dest, src| - @actions._copy_file(src, File.join( docs_path, dest ), :force => true) + # Copy all individual documentation files gathered up + doc_files.each_pair do |_dest, src| + @actions._copy_file(src, File.join( dest, _dest ), :force => true ) + end + + # If present copy internl HTML documentation bundle (site-local/) to docs/ceedling/ + site_local_path = File.join( ceedling_root, DOCS_SITE_LOCAL_PATH ) + if @file_wrapper.directory?( site_local_path ) + @actions._directory( site_local_path, docs_path_ceedling, :force => true ) + else + @loginator.console( "Internal HTML documentation bundle not found", LogLabels::WARNING ) + return end + + ceedling_index_html_filepath = File.absolute_path( File.join( docs_path_ceedling, 'index.html' ) ) + @loginator.console( + "\nCeedling documentation available at #{ceedling_index_html_filepath}", + LogLabels::DOCUMENTATION + ) + + dest_abs = File.absolute_path( dest ) + @loginator.console( " > All other documentation available at #{dest_abs}/\n" ) end diff --git a/bin/versionator.rb b/bin/versionator.rb index 6c3f284ed..67f692a48 100644 --- a/bin/versionator.rb +++ b/bin/versionator.rb @@ -71,7 +71,7 @@ def initialize(ceedling_root_path, ceedling_vendor_path=nil) end end rescue - raise CeedlingException.new( "Could not collect version information for vendor component: #{filename}" ) + raise CeedlingException.new( "Could not collect version information for vendor component ⏩️ #{filename}" ) end # Splat version and evaluate it to create Versionator object accessor diff --git a/ceedling.gemspec b/ceedling.gemspec index 5fb0c1186..f0f18199d 100644 --- a/ceedling.gemspec +++ b/ceedling.gemspec @@ -58,6 +58,7 @@ Ceedling projects are created with a YAML configuration file. A variety of conve s.files += Dir['vendor/unity/src/**/*.[ch]'] s.files += Dir['**/*'] + s.files.reject! { |f| f.start_with?('site-web/') } s.test_files = Dir['test/**/*', 'spec/**/*', 'features/**/*'] s.executables = ['ceedling'] # bin/ceedling diff --git a/docs/CeedlingPacket.md b/docs/CeedlingPacket.md deleted file mode 100644 index 65c97b83d..000000000 --- a/docs/CeedlingPacket.md +++ /dev/null @@ -1,5698 +0,0 @@ - -# Ceedling - -All code is copyright © 2010-2025 Ceedling Project -by Michael Karlesky, Mark VanderVoord, and Greg Williams. - -This Documentation is released under a -[Creative Commons 4.0 Attribution Share-Alike Deed][CC4SA]. - -[CC4SA]: https://creativecommons.org/licenses/by-sa/4.0/deed.en - -# Quick Start - -Ceedling is a fancypants build system that greatly simplifies building -C projects. While it can certainly build release targets, it absolutely -shines at running unit test suites. - -## Steps - -Below is a quick overview of how to get started from Ceedling installation -through running build tasks. Jump down just a teeny bit to see what the Ceedling -command line looks like and navigate to all the documentation for the steps -listed immediately below. - -1. Install Ceedling -1. Create a project - * Use Ceedling to generate an example project, or - * Add a Ceedling project file to the root of an existing project, or - * Create a project from scratch: - 1. Create a project directory - 1. Add source code and optionally test code however you'd like it organized - 1. Create a Ceedling project file in the root of your project directory -1. Run Ceedling tasks from the working directory of your project - -Ceedling requires a command line C toolchain be available in your path. It's -flexible enough to work with most anything on any platform. By default, Ceedling -is ready to work with [GCC] out of the box (we recommend the [MinGW] project -on Windows). - -A common build strategy with tooling other than GCC is to use your target -toolchain for release builds (with or without Ceedling) but rely on Ceedling + -GCC for test builds (more on all this [here][packet-section-2]). - -[GCC]: https://gcc.gnu.org - -## Ceedling Command Line & Build Tasks - -Once you have Ceedling installed, you always have access to `ceedling help`. - -And, once you have Ceedling installed, you have options for project creation -using Ceedling’s application commands: - -* `ceedling new ` -* `ceedling examples` to list available example projects and - `ceedling example ` to create a readymade sample - project whose project file you can copy and modify. - -Once you have a Ceedling project file and a project directory structure for your -code, Ceedling build tasks go like this: - -* `ceedling test:MyCodeModule`, or -* `ceedling test:all`, or -* `ceedling release`, or, if you fancy and have the GCov plugin enabled, -* `ceedling clobber test:all gcov:all release --log --verbosity=obnoxious` - -## Quick Start Documentation - -* [Installation][quick-start-1] -* [Sample test code file + Example Ceedling projects][quick-start-2] -* [Simple Ceedling project file][quick-start-3] -* [Ceedling at the command line][quick-start-4] -* [All your Ceedling project configuration file options][quick-start-5] - -[quick-start-1]: #ceedling-installation--set-up -[quick-start-2]: #commented-sample-test-file -[quick-start-3]: #simple-sample-project-file -[quick-start-4]: #now-what-how-do-i-make-it-go-the-command-line -[quick-start-5]: #the-almighty-project-configuration-file-in-glorious-yaml - -
- ---- - -# Contents - -(Be sure to review **[breaking changes](BreakingChanges.md)** if you are working with -a new release of Ceedling.) - -Building test suites in C requires much more scaffolding than for -a release build. As such, much of Ceedling’s documentation is concerned -with test builds. But, release build documentation is here too. We promise. -It's just all mixed together. - -1. **[Ceedling, a C Build System for All Your Mad Scientisting Needs][packet-section-1]** - - This section provides lots of background, definitions, and links for Ceedling - and its bundled frameworks. It also presents a very simple, example Ceedling - project file. - -1. **[Ceedling, Unity, and CMock’s Testing Abilities][packet-section-2]** - - This section speaks to the philosophy of and practical options for unit testing - code in a variety of scenarios. - -1. **[How Does a Test Case Even Work?][packet-section-3]** - - A brief overview of what a test case is and several simple examples illustrating - how test cases work. - -1. **[Commented Sample Test File][packet-section-4]** - - This sample test file illustrates how to create test cases as well as many of the - conventions that Ceedling relies on to do its work. There's also a brief - discussion of what gets compiled and linked to create an executable test. - -1. **[Anatomy of a Test Suite][packet-section-5]** - - This documentation explains how a unit test grows up to become a test suite. - -1. **[Ceedling Installation & Set Up][packet-section-6]** - - This one is pretty self explanatory. - -1. **[Now What? How Do I Make It _GO_? The Command Line.][packet-section-7]** - - Ceedling’s command line. - -1. **[Important Conventions & Behaviors][packet-section-8]** - - Much of what Ceedling accomplishes — particularly in testing — is by convention. - Code and files structured and named in certain ways trigger sophisticated - Ceedling build features. This section explains all such conventions. - - This section also covers essential high-level behaviors and features including - how to work with search paths, directory structures & file extensions, release - build binary artifacts, build time logging, and Ceedling’s abilities to - preprocess certain code files before they are incorporated into a test build. - -1. **[Using Unity, CMock & CException][packet-section-9]** - - Not only does Ceedling direct the overall build of your code, it also links - together several key tools and frameworks. Those can require configuration of - their own. Ceedling facilitates this. - -1. **[How to Load a Project Configuration. You Have Options, My Friend.][packet-section-10]** - - You can use a command line flag, an environment variable, or rely on a default - file in your working directory to load your base configuration. - - Once your base project configuration is loaded, you have **_Mixins_** for merging - additional configuration for different build scenarios as needed via command line, - environment variable, and/or your project configuration file. - -1. **[The Almighty Ceedling Project Configuration File (in Glorious YAML)][packet-section-11]** - - This is the exhaustive documentation for all of Ceedling’s project file - configuration options — from project paths to command line tools to plugins and - much, much more. - -1. **[Which Ceedling][packet-section-12]** - - Sometimes you may need to point to a different Ceedling to run. - -1. **[Build Directive Macros][packet-section-13]** - - These code macros can help you accomplish your build goals When Ceedling’s - conventions aren’t enough. - -1. **[Ceedling Plugins][packet-section-14]** - - Ceedling is extensible. It includes a number of built-in plugins for code coverage, - test report generation, continuous integration reporting, test file scaffolding - generation, sophisticated release builds, and more. - -1. **[Global Collections][packet-section-15]** - - Ceedling is built in Ruby. Collections are globally available Ruby lists of paths, - files, and more that can be useful for advanced customization of a Ceedling project - file or in creating plugins. - -[packet-section-1]: #ceedling-a-c-build-system-for-all-your-mad-scientisting-needs -[packet-section-2]: #ceedling-unity-and-c-mocks-testing-abilities -[packet-section-3]: #how-does-a-test-case-even-work -[packet-section-4]: #commented-sample-test-file -[packet-section-5]: #anatomy-of-a-test-suite -[packet-section-6]: #ceedling-installation--set-up -[packet-section-7]: #now-what-how-do-i-make-it-go-the-command-line -[packet-section-8]: #important-conventions--behaviors -[packet-section-9]: #using-unity-cmock--cexception -[packet-section-10]: #how-to-load-a-project-configuration-you-have-options-my-friend -[packet-section-11]: #the-almighty-ceedling-project-configuration-file-in-glorious-yaml -[packet-section-12]: #which-ceedling -[packet-section-13]: #build-directive-macros -[packet-section-14]: #ceedling-plugins -[packet-section-15]: #global-collections - ---- - -
- -# Ceedling, a C Build System for All Your Mad Scientisting Needs - -Ceedling allows you to generate an entire test and release build -environment for a C project from a single, short YAML configuration -file. - -Ceedling and its bundled tools, Unity, CMock, and CException, don’t -want to brag, but they’re also quite adept at supporting the tiniest of -embedded processors, the beefiest 64-bit powerhouses available, and -everything in between. - -Assembling build environments for C projects — especially with -automated unit tests — is a pain. No matter the all-purpose build -environment tool you use, configuration is tedious and requires -considerable glue code to pull together the necessary tools and -libraries to run unit tests. The Ceedling bundle handles all this -for you. - -## Simple Sample Project File - -For a project including Unity/CMock unit tests and using the default -toolchain `gcc`, the configuration file could be as simple as this: - -```yaml -:project: - :build_root: project/build/ - :release_build: TRUE - -:paths: - :test: - - tests/** - :source: - - source/** - :include: - - inc/** -``` - -From the command line, to run all your unit tests, you would run -`ceedling test:all`. To build the release version of your project, -you would simply run `ceedling release`. That's it! - -Of course, many more advanced options allow you to configure -your project with a variety of features to meet a variety of needs. -Ceedling can work with practically any command line toolchain -and directory structure – all by way of the configuration file. - -See this [commented project file][example-config-file] -for a much more complete and sophisticated example of a project -configuration. - -See the later [configuration section][project-configuration] for -way more details on your project configuration options. - -A facility for [plugins](#ceedling-plugins) also allows you to -extend Ceedling’s capabilities for needs such as custom code metrics -reporting, build artifact packaging, and much more. A variety of -built-in plugins come with Ceedling. - -[example-config-file]: ../assets/project.yml -[project-configuration]: #the-almighty-ceedling-project-configuration-file-in-glorious-yaml - -## What’s with This Name? - -Glad you asked. Ceedling is tailored for unit tested C projects and is built -upon Rake, a Make replacement implemented in the Ruby scripting language. - -So, we've got C, our Rake, and the fertile soil of a build environment in which -to grow and tend your project and its unit tests. Ta da — _Ceedling_. - -Incidentally, though Rake was the backbone of the earliest versions of -Ceedling, it is now being phased out incrementally in successive releases -of this tool. The name Ceedling is not going away, however! - -## What Do You Mean “Tailored for unit tested C projects”? - -Well, we like to write unit tests for our C code to make it lean and -mean — that whole [Test-Driven Development][tdd] thing. - -Along the way, this style of writing C code spawned two -tools to make the job easier: - -1. A unit test framework for C called _Unity_ -1. A mocking library called _CMock_ - -And, though it's not directly related to testing, a C framework for -exception handling called _CException_ also came along. - -[tdd]: http://en.wikipedia.org/wiki/Test-driven_development - -These tools and frameworks are great, but they require quite -a bit of environment support to pull them all together in a convenient, -usable fashion. We started off with Rakefiles to assemble everything. -These ended up being quite complicated and had to be hand-edited -or created anew for each new project. Ceedling replaces all that -tedium and rework with a configuration file that ties everything -together. - -Though Ceedling is tailored for unit testing, it can also go right -ahead and build your final binary release artifact for you as well. -That said, Ceedling is more powerful as a unit test build environment -than it is a general purpose release build environment. Complicated -projects including separate bootloaders or multiple library builds, -etc. are not necessarily its strong suit (but the -[`subprojects`](../plugins/subprojects/README.md) plugin can -accomplish quite a bit here). - -It's quite common and entirely workable to host Ceedling and your -test suite alongside your existing release build setup. That is, you -can use make, Visual Studio, SCons, Meson, etc. for your release build -and Ceedling for your test build. Your two build systems will simply -“point“ to the same project code. - -## Hold on. Back up. Ruby? Rake? YAML? Unity? CMock? CException? - -Seems overwhelming? It's not bad at all. And, for the benefits testing -bring us, it's all worth it. - -### Ruby - -[Ruby] is a handy scripting language like Perl or Python. It's a modern, -full featured language that happens to be quite handy for accomplishing -tasks like code generation or automating one's workflow while developing -in a compiled language such as C. - -[Ruby]: http://www.ruby-lang.org/en/ - -### Rake - -[Rake] is a utility written in Ruby for accomplishing dependency -tracking and task automation common to building software. It's a modern, -more flexible replacement for [Make]). - -Rakefiles are Ruby files, but they contain build targets similar -in nature to that of Makefiles (but you can also run Ruby code in -your Rakefile). - -[Rake]: http://rubyrake.org/ -[Make]: http://en.wikipedia.org/wiki/Make_(software) - -### YAML - -[YAML] is a "human friendly data serialization standard for all -programming languages." It's kinda like a markup language but don’t -call it that. With a YAML library, you can [serialize] data structures -to and from the file system in a textual, human readable form. Ceedling -uses a serialized data structure as its configuration input. - -YAML has some advanced features that can greatly -[reduce duplication][yaml-anchors-aliases] in a configuration file -needed in complex projects. YAML anchors and aliases are beyond the scope -of this document but may be of use to advanced Ceedling users. Note that -Ceedling does anticipate the use of YAML aliases. It proactively flattens -YAML lists to remove any list nesting that results from the convenience of -aliasing one list inside another. - -[YAML]: http://en.wikipedia.org/wiki/Yaml -[serialize]: http://en.wikipedia.org/wiki/Serialization -[yaml-anchors-aliases]: https://blog.daemonl.com/2016/02/yaml.html - -### Unity - -[Unity] is a [unit test framework][unit-testing] for C. It provides facilities -for test assertions, executing tests, and collecting / reporting test -results. Unity derives its name from its implementation in a single C -source file (plus two C header files) and from the nature of its -implementation - Unity will build in any C toolchain and is configurable -for even the very minimalist of processors. - -[Unity]: http://github.com/ThrowTheSwitch/Unity -[unit-testing]: http://en.wikipedia.org/wiki/Unit_testing - -### CMock - -[CMock] is a tool written in Ruby able to generate [function mocks & stubs][test-doubles] -in C code from a given C header file. Mock functions are invaluable in -[interaction-based unit testing][interaction-based-tests]. -CMock's generated C code uses Unity. - - Through a [plugin][FFF-plugin], Ceedling also supports -[FFF], _Fake Function Framework_, for [fake functions][test-doubles] as an -alternative to CMock’s mocks and stubs. - -[CMock]: http://github.com/ThrowTheSwitch/CMock -[test-doubles]: https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da -[FFF]: https://github.com/meekrosoft/fff -[FFF-plugin]: ../plugins/fff -[interaction-based-tests]: http://martinfowler.com/articles/mocksArentStubs.html - -### CException - -[CException] is a C source and header file that provide a simple -[exception mechanism][exn] for C by way of wrapping up the -[setjmp / longjmp][setjmp] standard library calls. Exceptions are a much -cleaner and preferable alternative to managing and passing error codes -up your return call trace. - -[CException]: http://github.com/ThrowTheSwitch/CException -[exn]: http://en.wikipedia.org/wiki/Exception_handling -[setjmp]: http://en.wikipedia.org/wiki/Setjmp.h - -## Notes on Ceedling Dependencies and Bundled Tools - -* By using the preferred installation option of the Ruby Ceedling gem (see - later installation section), all other Ceedling dependencies will be - installed for you. - -* Regardless of installation method, Unity, CMock, and CException are bundled - with Ceedling. Ceedling is designed to glue them all together for your - project as seamlessly as possible. - -* YAML support is included with Ruby. It requires no special installation - or configuration. If your project file contains properly formatted YAML - with the recognized names and options (see later sections), you are good - to go. - -
- -# Ceedling, Unity, and CMock’s Testing Abilities - -The unit testing Ceedling, Unity, and CMock afford works in practically -any context. - -The simplest sort of test suite is one crafted to run on the same host -system using the same toolchain as the release artifact under development. - -But, Ceedling, Unity, and CMock were developed for use on a wide variety -of systems and include features handy for low-level system development work. -This is especially of interest to embedded systems developers. - -## All your sweet, sweet test suite options - -Ceedling, Unity, and CMock help you create and run test suites using any -of the following approaches. For more on this topic, please see this -[handy dandy article][tts-which-build] and/or follow the links for each -item listed below. - -[tts-which-build]: https://throwtheswitch.org/build/which - -1. **[Native][tts-build-native].** This option builds and runs code on your - host system. - 1. In the simplest case this means you are testing code that is intended - to run on the same sort of system as the test suite. Your test - compiler toolchain is the same as your release compiler toolchain. - 1. However, a native build can also mean your test compiler is different - than your release compiler. With some thought and effort, code for - another platform can be tested on your host system. This is often - the best approach for embedded and other specialized development. -1. **[Emulator][tts-build-cross].** In this option, you build your test code with your target's - toolchain, and then run the test suite using an emulator provided for - that target. This is a good option for embedded and other specialized - development — if an emulator is available. -1. **[On target][tts-build-cross].** The Ceedling bundle of tools can create test suites that - run on a target platform directly. Particularly in embedded development - — believe it or not — this is often the option of last resort. That is, - you should probably go with the other options in this list. - -[tts-build-cross]: https://throwtheswitch.org/build/cross -[tts-build-native]: https://throwtheswitch.org/build/native - -
- -# How Does a Test Case Even Work? - -## Behold assertions - -In its simplest form, a test case is just a C function with no -parameters and no return value that packages up logical assertions. -If no assertions fail, the test case passes. Technically, an empty -test case function is a passing test since there can be no failing -assertions. - -Ceedling relies on the [Unity] project for its unit test framework -(i.e. the thing that provides assertions and counts up passing -and failing tests). - -An assertion is simply a logical comparison of expected and actual -values. Unity provides a wide variety of different assertions to -cover just about any scenario you might encounter. Getting -assertions right is actually a bit tricky. Unity does all that -hard work for you and has been thoroughly tested itself and battle -hardened through use by many, many developers. - -### Super simple passing test case - -```c -#include "unity.h" - -void test_case(void) { - TEST_ASSERT_TRUE( (1 == 1) ); -} -``` - -### Super simple failing test case - -```c -#include "unity.h" - -void test_a_different_case(void) { - TEST_ASSERT_TRUE( (1 == 2) ); -} -``` - -### Realistic simple test case - -In reality, we’re probably not testing the static value of an integer -against itself. Instead, we’re calling functions in our source code -and making assertions against return values. - -```c -#include "unity.h" -#include "my_math.h" - -void test_some_sums(void) { - TEST_ASSERT_EQUALS( 5, mySum( 2, 3) ); - TEST_ASSERT_EQUALS( 6, mySum( 0, 6) ); - TEST_ASSERT_EQUALS( -12, mySum( 20, -32) ); -} -``` - -If an assertion fails, the test case fails. As soon as an assertion -fails, execution within that test case stops. - -Multiple test cases can live in the same test file. When all the -test cases are run, their results are tallied into simple pass -and fail metrics with a bit of metadata for failing test cases such -as line numbers and names of test cases. - -Ceedling and Unity work together to both automatically run your test -cases and tally up all the results. - -### Sample test case output - -Successful test suite run: - -``` --------------------- -OVERALL TEST SUMMARY --------------------- -TESTED: 49 -PASSED: 49 -FAILED: 0 -IGNORED: 0 -``` - -A test suite with a failing test: - -``` -------------------- -FAILED TEST SUMMARY -------------------- -[test/TestModel.c] - Test: testInitShouldCallSchedulerAndTemperatureFilterInit - At line (21): "Function TaskScheduler_Init() called more times than expected." - --------------------- -OVERALL TEST SUMMARY --------------------- -TESTED: 49 -PASSED: 48 -FAILED: 1 -IGNORED: 0 -``` - -### Advanced test cases with mocks - -Often you want to test not just what a function returns but how -it interacts with other functions. - -The simple test cases above work well at the "edges" of a -codebase (libraries, state management, some kinds of I/O, etc.). -But, in the messy middle of your code, code calls other code. -One way to handle testing this is with [mock functions][mocks] and -[interaction-based testing][interaction-based-tests]. - -Mock functions are functions with the same interface as the real -code the mocks replace. A mocked function allows you to control -how it behaves and wrap up assertions within a higher level idea -of expectations. - -What is meant by an expectation? Well… We _expect_ a certain -function is called with certain arguments and that it will return -certain values. With the appropriate code inside a mocked function -all of this can be managed and checked. - -You can write your own mocks, of course. But, it's generally better -to rely on something else to do it for you. Ceedling uses the [CMock] -framework to perform mocking for you. - -Here's some sample code you might want to test: - -```c -#include "other_code.h" - -void doTheThingYo(mode_t input) { - mode_t result = processMode(input); - if (result == MODE_3) { - setOutput(OUTPUT_F); - } - else { - setOutput(OUTPUT_D); - } -} -``` - -And, here's what test cases using mocks for that code could look -like: - -```c -#include "mock_other_code.h" - -void test_doTheThingYo_should_enableOutputF(void) { - // Mocks - processMode_ExpectAndReturn(MODE_1, MODE_3); - setOutput_Expect(OUTPUT_F); - - // Function under test - doTheThingYo(MODE_1); -} - -void test_doTheThingYo_should_enableOutputD(void) { - // Mocks - processMode_ExpectAndReturn(MODE_2, MODE_4); - setOutput_Expect(OUTPUT_D); - - // Function under test - doTheThingYo(MODE_2); -} -``` - -Remember, the generated mock code you can’t see here has a whole bunch -of smarts and Unity assertions inside it. CMock scans header files and -then generates mocks (C code) from the function signatures it finds in -those header files. It's kinda magical. - -### That was the basics, but you’ll need more - -For more on the assertions and mocking shown above, consult the -documentation for [Unity] and [CMock] or the resources in -Ceedling’s [README](/README.md). - -Ceedling, Unity, and CMock rely on a variety of -[conventions to make your life easier][conventions-and-behaviors]. -Read up on these to understand how to build up test cases -and test suites. - -Also take a look at the very next sections for more examples -and details on how everything fits together. - -[conventions-and-behaviors]: #important-conventions--behaviors - -
- -# Commented Sample Test File - -**Here is a beautiful test file to help get you started…** - -## Core concepts in code - -After absorbing this sample code, you’ll have context for much -of the documentation that follows. - -The sample test file below demonstrates the following: - -1. Making use of the Unity & CMock test frameworks. -1. Adding the source under test (`foo.c`) to the final test - executable by convention (`#include "foo.h"`). -1. Adding two mocks to the final test executable by convention - (`#include "mock_bar.h` and `#include "mock_baz.h`). -1. Adding a source file with no matching header file to the test - executable with a test build directive macro - `TEST_SOURCE_FILE("more.c")`. -1. Creating two test cases with mock expectations and Unity - assertions. - -All other conventions and features are documented in the sections -that follow. - -```c -// test_foo.c ----------------------------------------------- -#include "unity.h" // Compile/link in Unity test framework -#include "types.h" // Header file with no *.c file -- no compilation/linking -#include "foo.h" // Corresponding source file, foo.c, under test will be compiled and linked -#include "mock_bar.h" // bar.h will be found and mocked as mock_bar.c + compiled/linked in; -#include "mock_baz.h" // baz.h will be found and mocked as mock_baz.c + compiled/linked in - -TEST_SOURCE_FILE("more.c") // foo.c depends on symbols from more.c, but more.c has no matching more.h - -void setUp(void) {} // Every test file requires this function; - // setUp() is called by the generated runner before each test case function - -void tearDown(void) {} // Every test file requires this function; - // tearDown() is called by the generated runner after each test case function - -// A test case function -void test_Foo_Function1_should_Call_Bar_AndGrill(void) -{ - Bar_AndGrill_Expect(); // Function from mock_bar.c that instructs our mocking - // framework to expect Bar_AndGrill() to be called once - TEST_ASSERT_EQUAL(0xFF, Foo_Function1()); // Foo_Function1() is under test (Unity assertion): - // (a) Calls Bar_AndGrill() from bar.h - // (b) Returns a byte compared to 0xFF -} - -// Another test case function -void test_Foo_Function2_should_Call_Baz_Tec(void) -{ - Baz_Tec_ExpectAnd_Return(1); // Function from mock_baz.c that instructs our mocking - // framework to expect Baz_Tec() to be called once and return 1 - TEST_ASSERT_TRUE(Foo_Function2()); // Foo_Function2() is under test (Unity assertion) - // (a) Calls Baz_Tec() in baz.h - // (b) Returns a value that can be compared to boolean true -} - -// end of test_foo.c ---------------------------------------- -``` - -## Ceedling actions from the sample test code - -From the test file specified above Ceedling will generate -`test_foo_runner.c`. This runner file will contain `main()` and will call -both of the example test case functions. - -The final test executable will be `test_foo.exe` (Windows) or `test_foo.out` -for Unix-based systems (extensions are configurable. Based on the `#include` -list and test directive macro above, the test executable will be the output -of the linker having processed `unity.o`, `foo.o`, `mock_bar.o`, `mock_baz.o`, -`more.o`, `test_foo.o`, and `test_foo_runner.o`. - -Ceedling finds the needed code files, generates mocks, generates a runner, -compiles all the code files, and links everything into the test executable. -Ceedling will then run the test executable and collect test results from it -to be reported to the developer at the command line. - -## Incidentally, Ceedling comes with example projects - -Ceedling comes with entire example projects you can extract. - -1. Execute `ceedling examples` in your terminal to list available example - projects. -1. Execute `ceedling example [destination]` to extract the - named example project. - -You can inspect the _project.yml_ file and source & test code. Run -`ceedling help` from the root of the example projects to see what you can -do, or just go nuts with `ceedling test:all`. - -
- -# Anatomy of a Test Suite - -A Ceedling test suite is composed of one or more individual test executables. - -The [Unity] project provides the actual framework for test case assertions -and unit test sucess/failure accounting. If mocks are enabled, [CMock] builds -on Unity to generate mock functions from source header files with expectation -test accounting. Ceedling is the glue that combines these frameworks, your -project’s toolchain, and your source code into a collection of test -executables you can run as a singular suite. - -## What is a test executable? - -Put simply, in a Ceedling test suite, each test file becomes a test executable. -Your test code file becomes a single test executable. - -`test_foo.c` ➡️ `test_foo.out` (or `test_foo.exe` on Windows) - -A single test executable generally comprises the following. Each item in this -list is a C file compiled into an object file. The entire list is linked into -a final test executable. - -* One or more release C code files under test (`foo.c`) -* `unity.c`. -* A test C code file (`test_foo.c`). -* A generated test runner C code file (`test_foo_runner.c`). `main()` is located - in the runner. -* If using mocks: - * `cmock.c` - * One more mock C code files generated from source header files (`mock_bar.c`) - -## Why multiple individual test executables in a suite? - -For several reasons: - -* This greatly simplifies the building of your tests. -* C lacks any concept of namespaces or reflection abilities able to segment and - distinguish test cases. -* This allows the same release code to be built differently under different - testing scenarios. Think of how different `#define`s, compiler flags, and - linked libraries might come in handy for different tests of the same - release C code. One source file can be built and tested in different ways - with multiple test files. - -## Ceedling’s role in your test suite - -A test executable is not all that hard to create by hand, but it can be tedious, -repetitive, and error-prone. - -What Ceedling provides is an ability to perform the process repeatedly and simply -at the push of a button, alleviating the tedium and any forgetfulness. Just as -importantly, Ceedling also does all the work of running each of those test -executables and tallying all the test results. - -
- -# Ceedling Installation & Set Up - -**How Exactly Do I Get Started?** - -You have two good options for installing and running Ceedling: - -1. The Ceedling Ruby Gem -1. Prepackaged _MadScienceLab_ Docker images - -The simplest way to get started with a local installation is to install -Ceedling as a Ruby gem. Gems are simply prepackaged Ruby-based software. -Other options exist, but the Ceedling Gem is the best option for a local -installation. However, you will also need a compiler toolchain (e.g. GNU -Compiler Collection) plus any supporting tools used by any plugins you -enabled. - -If you are familiar with the virtualization technology Docker, our premade -Docker images will get you started with Ceedling and all the accompanying -tools lickety split. Install Docker, pull down one of the _MadScienceLab_ -images and go. - -## Local Installation As a [Ruby Gem][ruby-gem]: - -1. [Download and install Ruby][ruby-install]. Ruby 3 is required. - -1. Use Ruby’s command line gem package manager to install Ceedling from - the [RubyGems repository][rubygems-repo]: `gem install ceedling`. - * Unity, CMock, and CException come along with Ceedling at no extra - charge. - * Installing from the RubyGems repo will also install Ceedling’s - dependencies. -1. Execute Ceedling at command line to create example project - or an empty Ceedling project in your filesystem (executing - `ceedling help` first is, well, helpful). - -[ruby-gem]: http://docs.rubygems.org/read/chapter/1 -[ruby-install]: http://www.ruby-lang.org/en/downloads/ -[rubygems-repo]: http://rubygems.org - -### Gem install notes - -Steps 1–2 above are a one-time affair for your local environment. -When steps 1-2 are completed once, only step 3 is needed for each new -code projects. - -If you are working with prerelease versions of Ceedling or some other -off-the-beaten-path installation scenario, you may want to directly -install the Ceedling .gem file attached to any of the Github releases. -No problem. - -The steps are similar to the preceding with two changes: - -1. `gem install --local ` -1. Any missing dependencies must be manually installed before -installation of the local Ceedling gem will succeed. A local -installation attempt will complain about any missing dependencies. -Simply `gem install` them by name. - -## _MadScienceLab_ Docker Images - -As an alternative to local installation, fully packaged Docker images containing Ruby, Ceedling, the GCC toolchain, and more are also available. [Docker][docker-overview] is a virtualization technology that provides self-contained software bundles that are a portable, well-managed alternative to local installation of tools like Ceedling. - -Four Docker image variants containing Ceedling and supporting tools exist. These four images are available for both Intel and ARM host platforms (Docker does the right thing based on your host environment). The latter includes ARM Linux and Apple’s M-series macOS devices. - -1. **_[MadScienceLab][docker-image-base]_**. This image contains Ruby, Ceedling, CMock, Unity, CException, the GNU Compiler Collection (gcc), and a handful of essential C libraries and command line utilities. -1. **_[MadScienceLab Plugins][docker-image-plugins]_**. This image contains all of the above plus the command line tools that Ceedling’s built-in plugins rely on. Naturally, it is quite a bit larger than option (1) because of the additional tools and dependencies. -1. **_[MadScienceLab ARM][docker-image-arm]_**. This image mirrors (1) with the compiler toolchain replaced with the GNU `arm-none-eabi` variant. -1. **_[MadScienceLab ARM + Plugins][docker-image-arm-plugins]_**. This image is (3) with the addition of all the complementary plugin tooling just like (2) provides. - -See the Docker Hub pages linked above for more documentation on these images. - -Just to be clear here, most users of the _MadScienceLab_ Docker images will probably care about the ability to run unit tests on your own host. If you are one of those users, no matter what host platform you are on — Intel or ARM — you’ll want to go with (1) or (2) above. The tools within the image will automatically do the right thing within your environment. Options (3) and (4) are most useful for specialized cross-compilation scenarios. - -### _MadScienceLab_ Docker Image usage basics - -To use a _MadScienceLab_ image from your local terminal: - -1. [Install Docker][docker-install] -1. Determine: - 1. The local path of your Ceedling project - 1. The variant and revision of the Docker image you’ll be using -1. Run the container with: - 1. The Docker `run` command and `-it --rm` command line options - 1. A Docker volume mapping from the root of your project to the default project path inside the container (_/home/dev/project_) - -See the command line examples in the following two sections. - -Note that all of these somewhat lengthy command lines lend themselves well to being wrapped up in simple helper scripts specific to your project and directory structure. - -### Run a _MadScienceLab_ Docker Image as an interactive terminal - -When the container launches as shown below, it will drop you into a Z-shell command line that has access to all the tools and utilities available within the container. In this usage, the Docker container becomes just another terminal, including ending its execution with `exit`. - -```shell - > docker run -it --rm -v /my/local/project/path:/home/dev/project throwtheswitch/madsciencelab-plugins:1.0.0 -``` - -Once the _MadScienceLab_ container’s command line is available, to run Ceedling, execute it just as you would after installing Ceedling locally: - -```shell - ~/project > ceedling help -``` - -```shell - ~/project > ceedling new ... -``` - -```shell - ~/project > ceedling test:all -``` - -### Run a _MadScienceLab_ Docker Image as a command line utility - -Alternatively, you can run Ceedling through the _MadScienceLab_ Docker container directly from the command line as a command line utility. The general pattern is immediately below. - -```shell - > docker run --rm -v /my/local/project/path:/home/dev/project throwtheswitch/madsciencelab-plugins:1.0.0 -``` - -As a specific example, to run all tests in a suite, the command line would be this: - -```shell - > docker run --rm -v /my/local/project/path:/home/dev/project throwtheswitch/madsciencelab-plugins:1.0.0 ceedling test:all -``` - -In this usage, the container starts, executes Ceedling, and then ends. - -[docker-overview]: https://www.ibm.com/topics/docker -[docker-install]: https://www.docker.com/products/docker-desktop/ - -[docker-image-base]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab -[docker-image-plugins]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab-plugins -[docker-image-arm]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab-arm-none-eabi -[docker-image-arm-plugins]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab-arm-none-eabi-plugins - -## Getting Started after Ceedling is Installed - -1. Once Ceedling is installed, you’ll want to start to integrate it with new - and old projects alike. If you wanted to start to work on a new project - named `foo`, Ceedling can create the skeleton of the project using `ceedling - new foo `. Likewise if you already have a project named `bar` - and you want to “inject” Ceedling into it, you would run `ceedling new bar - `, and Ceedling will create any files and directories it needs. - -1. Now that you have Ceedling integrated with a project, you can start using it. - A good starting point is to enable the [plugin](#ceedling-plugins) - `module_generator` in your project configuration file and create a source + - test code module to get accustomed to Ceedling by issuing the command - `ceedling 'module:create[name]'`. - -## Grab Bag of Ceedling Notes - -1. Certain advanced features of Ceedling rely on `gcc` and `cpp` as - preprocessing tools. In most Linux systems, these tools are already available. - For Windows environments, we recommend the - [MinGW project](http://www.mingw.org/) (Minimalist GNU for Windows). This - represents an optional, additional setup / installation step to complement - the list above. Upon installing MinGW ensure your system path is updated or - set `:environment` ↳ `:path` in your project configuration (see `:environment` - section). - -1. When using Ceedling in Windows environments, a test filename should not - include the sequences “patch” or “setup”. After a test build these test - filenames will become test executables. Windows Installer Detection Technology - (part of UAC) requires administrator privileges to execute filenames including - these strings. - -
- -# Now What? How Do I Make It _GO_? The Command Line. - -We’re getting a little ahead of ourselves here, but it's good -context on how to drive this bus. Everything is done via the command -line. We'll cover project conventions and how to actually configure -your project in later sections. - -For now, let's talk about the command line. - -To run tests, build your release artifact, etc., you will be using the -trusty command line. Ceedling is transitioning away from being built -around Rake. As such, right now, interacting with Ceedling at the -command line involves two different conventions: - -1. **Application Commands.** Application commands tell Ceedling what to - to do with your project. These create projects, load project files, - begin builds, output version information, etc. These include rich - help and operate similarly to popular command line tools like `git`. -1. **Build & Plugin Tasks.** Build tasks actually execute test suites, - run release builds, etc. These tasks are created from your project - file. These are generated through Ceedling’s Rake-based code and - conform to its conventions — simplistic help, no option flags, but - bracketed arguments. - -In the case of running builds, both come into play at the command line. - -The two classes of command line arguments are clearly labelled in the -summary of all commands provided by `ceedling help`. - -## Quick command line example to get you started - -To exercise the Ceedling command line quickly, follow these steps after -[installing Ceedling](#ceedling-installation--set-up): - -1. Open a terminal and chnage directories to a location suitable for - an example project. -1. Execute `ceedling example temp_sensor` in your terminal. The `example` - argument is an application command. -1. Change directories into the new _temp_sensor/_ directory. -1. Execute `ceedling test:all` in your terminal. The `test:all` is a - build task executed by the default (and omitted) `build` application - command. -1. Take a look at the build and test suite console output as well as - the _project.yml_ file in the root of the example project. - -## Ceedling application commands - -Ceedling provides robust command line help for application commands. -Execute `ceedling help` for a summary view of all application commands. -Execute `ceedling help ` for detailed help. - -_NOTE:_ Because the built-in command line help is thorough, we will only -briefly list and explain the available application commands. - -* `ceedling [no arguments]`: - - Runs the default build tasks. Unless set in the project file, Ceedling - uses a default task of `test:all`. To override this behavior, set your - own default tasks in the project file (see later section). - -* `ceedling build ` or `ceedling `: - - Runs the named build tasks. `build` is optional (i.e. `ceedling test:all` - is equivalent to `ceedling build test:all`). Various option flags - exist to control project configuration loading, verbosity levels, - logging, test task filters, etc. - - See next section to understand the build & plugin tasks this application - command is able to execute. Run `ceedling help build` to understand all - the command line flags that work with build & plugin tasks. - -* `ceedling dumpconfig`: - - Process project configuration and write final result to a YAML file. - Various option flags exist to control project configuration loading, - configuration manipulation, and configuration sub-section extraction. - -* `ceedling environment`: - - Lists project related environment variables: - - * All environment variable names and string values added to your - environment from within Ceedling and through the `:environment` - section of your configuration. This is especially helpful in - verifying the evaluation of any string replacement expressions in - your `:environment` config entries. - * All existing Ceedling-related environment variables set before you - ran Ceedling from the command line. - -* `ceedling example`: - - Extracts an example project from within Ceedling to your local - filesystem. The available examples are listed with - `ceedling examples`. Various option flags control whether the example - contains vendored Ceedling and/or a documentation bundle. - -* `ceedling examples`: - - Lists the available examples within Ceedling. To extract an example, - use `ceedling example`. - -* `ceedling help`: - - Displays summary help for all application commands and detailed help - for each command. `ceedling help` also loads your project - configuration (if available) and lists all build tasks from it. - Various option flags control what project configuration is loaded. - -* `ceedling new`: - - Creates a new project structure. Various option flags control whether - the new project contains vendored Ceedling, a documentation bundle, - and/or a starter project configuration file. - -* `ceedling upgrade`: - - Upgrade vendored installation of Ceedling for an existing project - along with any locally installed documentation bundles. - -* `ceedling version`: - - Displays version information for Ceedling and its components. Version output for Ceedling includes the Git Commit short SHA in Ceedling’s build identifier and Ceedling’s path of origin. - - ``` - 🌱 Welcome to Ceedling! - - Ceedling => #.#.#- - ---------------------- - - - Build Frameworks - ---------------------- - CMock => #.#.# - Unity => #.#.# - CException => #.#.# - ``` - - If the short SHA information is unavailable such as in local development, the SHA is omitted. The source for this string is generated and captured in the Gem at the time of Ceedling’s automated build in CI. - -## Ceedling build & plugin tasks - -Build task are loaded from your project configuration. Unlike -application commands that are fixed, build tasks vary depending on your -project configuration and the files within your project structure. - -Ultimately, build & plugin tasks are executed by the `build` application -command (but the `build` keyword can be omitted — see above). - -* `ceedling paths:*`: - - List all paths collected from `:paths` entries in your YAML config - file where `*` is the name of any section contained in `:paths`. This - task is helpful in verifying the expansion of path wildcards / globs - specified in the `:paths` section of your config file. - -* `ceedling files:assembly` -* `ceedling files:header` -* `ceedling files:source` -* `ceedling files:support` -* `ceedling files:test` - - List all files and file counts collected from the relevant search - paths specified by the `:paths` entries of your YAML config file. The - `files:assembly` task will only be available if assembly support is - enabled in the `:release_build` or `:test_build` sections of your - configuration file. - -* `ceedling test:all`: - - Run all unit tests. - -* `ceedling test:*`: - - Execute the named test file or the named source file that has an - accompanying test. No path. Examples: `ceedling test:foo`, `ceedling - test:foo.c` or `ceedling test:test_foo.c` - -* `ceedling test:* --test-case= ` - Execute individual test cases which match `test_case_name`. - - For instance, if you have a test file _test_gpio.c_ containing the following - test cases (test cases are simply `void test_name(void)`: - - - `test_gpio_start` - - `test_gpio_configure_proper` - - `test_gpio_configure_fail_pin_not_allowed` - - … and you want to run only _configure_ tests, you can call: - - `ceedling test:gpio --test-case=configure` - - **Test case matching notes** - - * Test case matching is on sub-strings. `--test_case=configure` matches on - the test cases including the word _configure_, naturally. - `--test-case=gpio` would match all three test cases. - -* `ceedling test:* --exclude_test_case= ` - Execute test cases which do not match `test_case_name`. - - For instance, if you have file test_gpio.c with defined 3 tests: - - - `test_gpio_start` - - `test_gpio_configure_proper` - - `test_gpio_configure_fail_pin_not_allowed` - - … and you want to run only start tests, you can call: - - `ceedling test:gpio --exclude_test_case=configure` - - **Test case exclusion matching notes** - - * Exclude matching follows the same sub-string logic as discussed in the - preceding section. - -* `ceedling test:pattern[*]`: - - Execute any tests whose name and/or path match the regular expression - pattern (case sensitive). Example: `ceedling "test:pattern[(I|i)nit]"` - will execute all tests named for initialization testing. - - _NOTE:_ Quotes are likely necessary around the regex characters or - entire task to distinguish characters from shell command line operators. - -* `ceedling test:path[*]`: - - Execute any tests whose path contains the given string (case - sensitive). Example: `ceedling test:path[foo/bar]` will execute all tests - whose path contains foo/bar. _Notes:_ - - 1. Both directory separator characters `/` and `\` are valid. - 1. Quotes may be necessary around the task to distinguish the parameter's - characters from shell command line operators. - -* `ceedling release`: - - Build all source into a release artifact (if the release build option - is configured). - -* `ceedling release:compile:*`: - - Sometimes you just need to compile a single file dagnabit. Example: - `ceedling release:compile:foo.c` - -* `ceedling release:assemble:*`: - - Sometimes you just need to assemble a single file doggonit. Example: - `ceedling release:assemble:foo.s` - -* `ceedling summary`: - - If plugins are enabled, this task will execute the summary method of - any plugins supporting it. This task is intended to provide a quick - roundup of build artifact metrics without re-running any part of the - build. - -* `ceedling clean`: - - Deletes all toolchain binary artifacts (object files, executables), - test results, and any temporary files. Clean produces no output at the - command line unless verbosity has been set to an appreciable level. - -* `ceedling clobber`: - - Extends clean task's behavior to also remove generated files: test - runners, mocks, preprocessor output. Clobber produces no output at the - command line unless verbosity has been set to an appreciable level. - -## Ceedling Command Line Tasks, Extra Credit - -### Combining Tasks At the Command Line - -Multiple build tasks can be executed at the command line. - -For example, `ceedling clobber test:all release` will remove all generated -files; build and run all tests; and then build all source — in that order. If -any task fails along the way, execution halts before the next task. - -Task order is executed as provided and can be important! Running `clobber` after -a `test:` or `release:` task will not accomplish much. - -### Build Directory and Revision Control - -The `clobber` task removes certain build directories in the -course of deleting generated files. In general, it's best not -to add to source control any Ceedling generated directories -below the root of your top-level build directory. That is, leave -anything Ceedling & its accompanying tools generate out of source -control (but go ahead and add the top-level build directory that -holds all that stuff if you want). - -### Logging decorators - -Ceedling attempts to bring more joy to your console logging. This may include -fancy Unicode characters, emoji, or color. - -Example: -``` ------------------------ -❌ OVERALL TEST SUMMARY ------------------------ -TESTED: 6 -PASSED: 5 -FAILED: 1 -IGNORED: 0 -``` - -By default, Ceedling makes an educated guess as to which platforms can best -support this. Some platforms (we’re looking at you, Windows) do not typically -have default font support in their terminals for these features. So, by default -this feature is disabled on problematic platforms while enabled on others. - -An environment variable `CEEDLING_DECORATORS` forces decorators on or off with a -`true` (`1`) or `false` (`0`) string value. - -If you find a monospaced font that provides emojis, etc. and works with Windows’ -command prompt, you can (1) Install the font (2) change your command prompt’s -font (3) set `CEEDLING_DECORATORS` to `true`. - -
- -# Important Conventions & Behaviors - -**How to get things done and understand what’s happening during builds** - -## Directory Structure, Filenames & Extensions - -Much of Ceedling’s functionality is driven by collecting files -matching certain patterns inside the paths it's configured -to search. See the documentation for the `:extension` section -of your configuration file (found later in this document) to -configure the file extensions Ceedling uses to match and collect -files. Test file naming is covered later in this section. - -Test files and source files must be segregated by directories. -Any directory structure will do. Tests can be held in subdirectories -within source directories, or tests and source directories -can be wholly separated at the top of your project’s directory -tree. - -## Search Paths for Test Builds - -Test builds in C are fairly complex. Each test file becomes a test -executable. Each test executable needs generated runner code and -optionally generated mocks. Slicing and dicing what files are -compiled and linked and how search paths are assembled is tricky -business. That’s why Ceedling exists in the first place. Because -of these issues, search paths, in particular, require quite a bit -of special handling. - -Unless your project is relying exclusively on `extern` statements and -uses no mocks for testing, Ceedling _**must**_ be told where to find -header files. Without search path knowledge, mocks cannot be generated, -and test file compilation will fail for lack of symbol definitions -and function declarations. - -Ceedling provides two mechanisms for configuring search paths: - -1. The [`:paths` ↳ `:include`](#paths--include) section within your - project file (or mixin files). -1. The [`TEST_INCLUDE_PATH(...)`](#test_include_path) build directive - macro. This is only available within test files. - -In testing contexts, you have three options for assembling the core of -the search path list used by Ceedling for test builds: - -1. List all search paths within the `:paths` ↳ `:include` subsection - of your project file. This is the simplest and most common approach. -1. Create the search paths for each test file using calls to the - `TEST_INCLUDE_PATH(...)` build directive macro within each test file. -1. Blending the preceding options. In this approach the subsection - within your project file acts as a common, base list of search - paths while the build directive macro allows the list to be - expanded upon for each test file. This method is especially helpful - for large and/or complex projects in trimming down - problematically long compiler command lines. - -As for the complete search path list for test builds created by Ceedling, -it is assembled from a variety of sources. In order: - -1. Mock generation build path (if mocking is enabled) -1. Paths provided via `TEST_INCLUDE_PATH(...)` build directive macro -1. Any paths within `:paths` ↳ `:test` list containing header files -1. `:paths` ↳ `:support` list from your project configuration -1. `:paths` ↳ `:include` list from your project configuration -1. `:paths` ↳ `:libraries` list from your project configuration -1. Internal path for Unity’s unit test framework C code -1. Internal paths for CMock and CException’s C code (if respective - features enabled) -1. `:paths` ↳ `:test_toolchain_include` list from your project - configuration - -The paths lists above are documented in detail in the discussion of -project configuration. - -_**Notes:**_ - -* The order of your `:paths` entries directly translates to the ordering - of search paths. -* The logic of the ordering above is essentially that: - * Everything above (5) should have precedence to allow test-specific - symbols, function signatures, etc. to be found before that of your - source code under test. This is the necessary pattern for effective - testing and test builds. - * Everything below (5) is supporting symbols and function signatures - for your source code. Your source code should be processed before - these for effective builds generally. -* (3) is a balancing act. It is entirely possible that test developers - will choose to create common files of symbols and supporting code - necessary for unit tests and choose to organize it alongside their - test files. A test build must be able to find these references. At the - same time it is highly unlikely every test directory path in a project - is necessary for a test build — particularly in large and sophisticated - projects. To reduce overall search path length and problematic command - lines, this convention tailors the search path. This is low risk - tailoring but could cause gotchas in edge cases or when Ceedling is - combined with other tools. Any other such tailoring is avoided as it - could too easily cause maddening build problems. -* Remember that the ordering of search paths is impacted by the merge - order of any Mixins. Paths specified with Mixins will be added to - path lists in your project configuration in the order of merging. - -## Search Paths for Release Builds - -Unlike test builds, release builds are relatively straightforward. Each -source file is compiled into an object file. All object files are linked. -A Ceedling release build may optionally compile and link in CException -and can handle linking in libraries as well. - -Search paths for release builds are configured with `:paths` ↳ `:include` -in your project configuration. That’s about all there is to it. - -## Conventions for Source Files & Binary Release Artifacts - -Your binary release artifact results from the compilation and -linking of all source files Ceedling finds in the specified source -directories. At present only source files with a single (configurable) -extension are recognized. That is, `*.c` and `*.cc` files will not -both be recognized - only one or the other. See the configuration -options and defaults in the documentation for the `:extension` -sections of your configuration file (found later in this document). - -## Conventions for Test Files & Executable Test Fixtures - -Ceedling builds each individual test file with its accompanying -source file(s) into a single, monolithic test fixture executable. - -### Test File Naming Convention - -Ceedling recognizes test files by a naming convention — a (configurable) -prefix such as "`test_`" at the beginning of the file name with the same -file extension as used by your C source files. See the configuration options -and defaults in the documentation for the `:project` and `:extension` -sections of your configuration file (elsewhere in this document). - -Depending on your configuration options, Ceedling can recognize -a variety of test file naming patterns in your test search paths. -For example, `test_some_super_functionality.c`, `TestYourSourceFile.cc`, -or `testing_MyAwesomeCode.C` could each be valid test file -names. Note, however, that Ceedling can recognize only one test -file naming convention per project. - -### Conventions for Source and Mock Files to Be Compiled & Linked - -Ceedling knows what files to compile and link into each individual -test executable by way of the `#include` list contained in each -test file and optional test directive macros. - -The `#include` list directs Ceedling in two ways: - -1. Any C source files in the configured project directories - corresponding to `#include`d header files will be compiled and - linked into the resulting test fixture executable. -1. If you are using mocks, header files with the appropriate - mocking prefix (e.g. `mock_foo.h`) direct Ceedling to find the - source header file (e.g. `foo.h`), generate a mock from it, and - compile & link that generated code into into the test executable - as well. - -Sometimes the source file you need to add to your test executable has -no corresponding header file — e.g. `file_abc.h` contains symbols -present in `file_xyz.c`. In these cases, you can use the test -directive macro `TEST_SOURCE_FILE(...)` to tell Ceedling to compile -and link the desired source file into the test executable (see -macro documentation elsewhere in this doc). - -That was a lot of information and many clauses in a very few -sentences; the commented example test file code that follows in a -bit will make it clearer. - -### Convention for Test Case Functions + Test Runner Generation - -By naming your test functions according to convention, Ceedling -will extract and collect into a generated test runner C file the -appropriate calls to all your test case functions. This runner -file handles all the execution minutiae so that your test file -can be quite simple. As a bonus, you’ll never forget to wire up -a test function to be executed. - -In this generated runner lives the `main()` entry point for the -resulting test executable. There are no configurable options for -the naming convention of your test case functions. - -A test case function signature must have these elements: - -1. `void` return -1. `void` parameter list -1. A function name prepended with lowercase "`test`". - -In other words, a test function signature should look like this: -`void test(void)`. - -## Ceedling preprocessing behavior for your tests - -### Preprocessing feature background and overview - -Ceedling and CMock are advanced tools that both perform fairly sophisticated -parsing. - -However, neither of these tools fully understands the entire C language, -especially C’s preprocessing statements. - -If your test files rely on macros and `#ifdef` conditionals used in certain -ways (see examples below), there’s a chance that Ceedling will break on trying -to process your test files, or, alternatively, your test suite will build but -not execute as expected. - -Similarly, generating mocks of header files with macros and `#ifdef` -conditionals around or in function signatures can get weird. Of course, it’s -often in sophisticated projects with complex header files that mocking is most -desired in the first place. - -Ceedling includes an optional ability to preprocess the following files before -then extracting test cases and functions to be mocked with text parsing. - -1. Your test files, or -1. Mockable header files, or -1. Both of the above - -See the [`:project` ↳ `:use_test_preprocessor`][project-settings] project -configuration setting. - -This Ceedling feature uses `gcc`’s preprocessing mode and the `cpp` preprocessor -tool to strip down / expand test files and headers to their raw code content -that can then be parsed as text by Ceedling and CMock. These tools must be in -your search path if Ceedling’s preprocessing is enabled. - -**Ceedling’s test preprocessing abilities are directly tied to the features and -output of `gcc` and `cpp`. The default Ceedling tool definitions for these should -not be redefined for other toolchains. It is highly unlikely to work for you. -Future Ceedling improvements will allow for a plugin-style ability to use your -own tools in this highly specialized capacity.** - -[project-settings]: #project-global-project-settings - -### Ceedling preprocessing limitations and gotchas - -#### Preprocessing limitations cheatsheet - -Ceedling’s preprocessing abilities are generally quite useful — especially in -projects with multiple build configurations for different feature sets or -multiple targets, legacy code that cannot be refactored, and complex header -files provided by vendors. - -However, best applying Ceedling’s preprocessing abilities requires understanding -how the feature works, when to use it, and its limitations. - -At a high level, Ceedling’s preprocessing is applicable for cases where macros -or conditional compilation preprocessing statements (e.g. `#ifdef`): - -* Generate or hide/reveal your test files’ `#include` statements. -* Generate or hide/reveal your test files’ test case function signatures - (e.g. `void test_foo()`. -* Generate or hide/reveal mockable header files’ `#include` statements. -* Generate or hide/reveal header files’ mockable function signatures. - -**_NOTE:_ You do not necessarily need to enable Ceedling’s preprocessing only -because you have preprocessing statements in your test files or mockable header -files. The feature is only truly needed if your project meets the conditions -above.** - -The sections that follow flesh out the details of the bulleted list above. - -#### Preprocessing gotchas - -**_IMPORTANT:_ As of Ceedling 1.0.0, Ceedling’s test preprocessing feature -has a limitation that affects Unity features triggered by the following macros.** - -* `TEST_CASE()` -* `TEST_RANGE()` - -`TEST_CASE()` and `TEST_RANGE()` are Unity macros that are positional in a file -in relation to the test case functions they modify. While Ceedling's test file -preprocessing can preserve these macro calls, their position cannot be preserved. - -That is, Ceedling’s preprocessing and these Unity features are not presently -compatible. Note that it _is_ possible to enable preprocessing for mockable -header files apart from enabling it for test files. See the documentation for -`:project` ↳ `:use_test_preprocessing`. This can allow test preprocessing in the -common cases of sophtisticate mockable headers while Unity’s `TEST_CASE()` and -`TEST_RANGE()` are utilized in a test file untouched by preprocessing. - -**_IMPORTANT:_ The following new build directive macro `TEST_INCLUDE_PATH()` -available in Ceedling 1.0.0 is incompatible with enclosing conditional -compilation C preprocessing statements:** - -Wrapping `TEST_INCLUDE_PATH()` in conditional compilation statements -(e.g. `#ifdef`) will not behave as you expect. This macro is used as a marker -for advanced abilities discovered by Ceedling parsing a test file as plain text. -Whether or not Ceedling preprocessing is enabled, Ceedling will always discover -this marker macro in the plain text of a test file. - -Why is `TEST_INCLUDE_PATH()` incompatible with `#ifdef`? Well, it’s because of -a cyclical dependency that cannot be resolved. In order to perform test -preprocessing, we need a full complement of `#include` search paths. These -could be provided, in part, by `TEST_INCLUDE_PATH()`. But, if we allow -`TEST_INCLUDE_PATH()` to be placed within conditional compilation C -preprocessing statements, our search paths may be different after test -preprocessing! The only solution is to disallow this and scan a test file as -plain text looking for this macro at the beginning of a test build. - -**_Notes:_** - -* `TEST_SOURCE_FILE()` _can_ be placed within conditional compilation - C preprocessing statements. -* `TEST_INCLUDE_PATH()` & `TEST_SOURCE_FILE()` can be “hidden” from Ceedling’s - text scanning with traditional C comments. - -### Preprocessing of your test files - -When preprocessing is enabled for test files, Ceedling will expand preprocessor -statements in test files before extracting `#include` conventions and test case -signatures. That is, preprocessing output is used to generate test runners -and assemble the components of a test executable build. - -**_NOTE:_** Conditional directives _inside_ test case functions generally do -not require Ceedling’s test preprocessing ability. Assuming your code is correct, -the C preprocessor within your toolchain will do the right thing for you -in your test build. Read on for more details and the other cases of interest. - -Test file preprocessing by Ceedling is applicable primarily when conditional -preprocessor directives generate the `#include` statements for your test file -and/or generate or enclose full test case functions. Ceedling will not be able -to properly discover your `#include` statements or test case functions unless -they are plainly available in an expanded, raw code version of your test file. -Ceedling’s preprocessing abilities provide that expansion. - -#### Examples of when Ceedling preprocessing **_is_** needed for test files - -Generally, Ceedling preprocessing is needed when: - -1. `#include` statements are generated by macros -1. `#include` statements are conditionally present due to `#ifdef` statements -1. Test case function signatures are generated by macros -1. Test case function signatures are conditionaly present due to `#ifdef` statements - -```c -// #include conventions are not recognized for anything except #include "..." statements -INCLUDE_STATEMENT_MAGIC("header_file") -``` -```c -// Test file scanning will always see this #include statement -#ifdef BUILD_VARIANT_A -#include "mock_FooBar.h" -#endif -``` -```c -// Test runner generation scanning will see the test case function signature and think this test case exists in every build variation -#ifdef MY_SUITE_BUILD -void test_some_test_case(void) { - TEST_ASSERT_EQUALS(...); -} -#endif -``` -```c -// Test runner generation will not recognize this as a test case when scanning the file -void TEST_CASE_MAGIC("foo_bar_case") { - TEST_ASSERT_EQUALS(...); -} -``` - -#### Examples of when test preprocessing is **_not_** needed for test files - -```c -// Code inside a test case is simply code that your toolchain will expand and build as you desire -// You can manage your compile time symbols with the :defines section of your project configuration file -void test_some_test_case(void) { -#ifdef BUILD_VARIANT_A - TEST_ASSERT_EQUALS(...); -#endif - -#ifdef BUILD_VARIANT_B - TEST_ASSERT_EQUALS(...); -#endif -} -``` - -### Preprocessing of mockable header files - -When preprocessing is enabled for mocking, Ceedling will expand preprocessor -statements in header files before generating mocks from them. CMock requires -a clear look at function definitions and types in order to do its work. - -Header files with preprocessor directives and conditional macros can easily -obscure details from CMock’s limited C parser. Advanced C projects tend -to rely on preprocessing directives and macros to accomplish everything from -build variants to OS calls to register access to managing proprietary language -extensions. - -Mocking is often most useful in complicated codebases. As such Ceedling’s -preprocessing abilities tend to be quite necessary to properly expand header -files so CMock can parse them. - -#### Examples of when Ceedling preprocessing **_is_** needed for mockable headers - -Generally, Ceedling preprocessing is needed when: - -1. Function signatures are formed by macros -1. Function signatures are conditionaly present due to surrounding `#ifdef` - statements -1. Macros expand to become function decorators, return types, or parameters - -**_Important Notes:_** - -* Sometimes CMock’s parsing features can be configured to handle scenarios - that fall within (3) above. CMock can match and remove most text strings, - match and replace certain text strings, map custom types to mockable - alternatives, and be extended with a Unity helper to handle complex and - compound types. See [CMock]’s documentation for more. - -* Test preprocessing causes any macros or symbols in a mockable header to - “disappear” in the generated mock. It’s quite common to have needed symbols - or macros in a header file that do not directly impact the function - signatures to be mocked. This can break compilation of your test suite. - - Possible solutions to this problem include: - - 1. Move symbols and macros in your header file that do not impact function - signatures to another source header file that will not be filtered - by Ceedling’s header file preprocessing. - 1. If (1) is not possible, you may duplicate the needed symbols and macros - in a header file that is only available in your test build search paths - and include it in your test file. - -```c -// Header file scanning will see this function signature but mistakenly mock the name of the macro -void FUNCTION_SIGNATURE_MAGIC(...); -``` - -```c -// Header file scanning will always see this function signature -#ifdef BUILD_VARIANT_A -unsigned int someFunction(void); -#endif -``` - -```c -// Header file scanning will either fail for this function signature or extract erroneous type names -INLINE_MAGIC RETURN_TYPE_MAGIC someFunction(PARAMETER_MAGIC); -``` - -## Execution time (duration) reporting in Ceedling operations & test suites - -### Ceedling’s logged run times - -Ceedling logs two execution times for every project run. - -It first logs the set up time necessary to process your project file, parse code -files, build an internal representation of your project, etc. This duration does -not capture the time necessary to load the Ruby runtime itself. - -``` -Ceedling set up completed in 223 milliseconds -``` - -Secondly, each Ceedling run also logs the time necessary to run all the tasks -you specify at the command line. - -``` -Ceedling operations completed in 1.03 seconds -``` - -### Ceedling test suite and Unity test executable run durations - -A test suite comprises one or more Unity test executables (see -[Anatomy of a Test Suite][anatomy-test-suite]). Ceedling times indvidual Unity -test executable run durations. It also sums these into a total test suite -execution time. These duration values are typically used in generating test -reports via plugins. - -Not all test report formats utilize duration values. For those that do, some -effort is usually required to map Ceedling duration values to a relevant test -suite abstraction within a given test report format. - -Because Ceedling can execute builds with multiple threads, care must be taken -to interpret test suite duration values — particularly in relation to -Ceedling’s logged run times. - -In a multi-threaded build it's quite common for the logged Ceedling project run -time to be less than the total suite time in a test report. In multi-threaded -builds on multi-core machines, test executables are run on different processors -simultaneously. As such, the total on-processor time in a test report can -exceed the operation time Ceedling itself logs to the console. Further, because -multi-threading tends to introduce context switching and processor scheduling -overhead, the run duration of a test executable may be reported as longer than -a in a comparable single-threaded build. - -[anatomy-test-suite]: #anatomy-of-a-test-suite - -### Unity test case run times - -Individual test case exection time tracking is specifically a [Unity] feature -(see its documentation for more details). If enabled and if your platform -supports the time mechanism Unity relies on, Ceedling will automatically -collect test case time values — generally made use of by test report plugins. - -To enable test case duration measurements, they must be enabled as a Unity -compilation option. Add `UNITY_INCLUDE_EXEC_TIME` to Unity's compilation -symbols (`:unity` ↳ `:defines`) in your Ceedling project file (see example -below). Unity test case durations as reported by Ceedling default to 0 if the -compilation option is not set. - -```yaml -:unity: - :defines: - - UNITY_INCLUDE_EXEC_TIME -``` - -_NOTE:_ Most test cases are quite short, and most computers are quite fast. As - such, Unity test case execution time is often reported as 0 milliseconds as - the CPU execution time for a test case typically remains in the microseconds - range. Unity would require special rigging that is inconsistently available - across platforms to measure test case durations at a finer resolution. - -## The Magic of Dependency Tracking - -Previous versions of Ceedling used features of Rake to offer -various kinds of smart rebuilds--that is, only regenerating files, -recompiling code files, or relinking executables when changes within -the project had occurred since the last build. Optional Ceedling -features discovered “deep dependencies” such that, for example, a -change in a header file several nested layers deep in `#include` -statements would cause all the correct test executables to be -updated and run. - -These features have been temporarily disabled and/or removed for -test suites and remain in limited form for release build while -Ceedling undergoes a major overhaul. - -Please see the [Release Notes](ReleaseNotes.md). - -### Notes on (Not So) Smart Rebuids - -* New features that are a part of the Ceedling overhaul can - significantly speed up test suite execution and release builds - despite the present behavior of brute force running all build - steps. See the discussion of enabling multi-threaded builds in - later sections. - -* When smart rebuilds return, they will further speed up builds as - will other planned optimizations. - -## Ceedling’s Build Output (Files, That Is) - -Ceedling requires a top-level build directory for all the stuff -that it, the accompanying test tools, and your toolchain generate. -That build directory's location is configured in the top-level -`:project` section of your configuration file (discussed later). There -can be a ton of generated files. By and large, you can live a full -and meaningful life knowing absolutely nothing at all about -the files and directories generated below the root build directory. - -As noted already, it's good practice to add your top-level build -directory to source control but nothing generated beneath it. -you’ll spare yourself headache if you let Ceedling delete and -regenerate files and directories in a non-versioned corner -of your project’s filesystem beneath the top-level build directory. - -The `artifacts/` directory is the one and only directory you may -want to know about beneath the top-level build directory. The -subdirectories beneath `artifacts` will hold your binary release -target output (if your project is configured for release builds) -and will serve as the conventional location for plugin output. -This directory structure was chosen specifically because it -tends to work nicely with Continuous Integration setups that -recognize and list build artifacts for retrieval / download. - -## Build _Errors_ vs. Test _Failures_. Oh, and Exit Codes. - -### Errors vs. Failures - -Ceedling will run a specified build until an **_error_**. An error -refers to a build step encountering an unrecoverable problem. Files -not found, nonexistent paths, compilation errors, missing symbols, -plugin exceptions, etc. are all errors that will cause Ceedling -to immediately end a build. - -A **_failure_** refers to a test failure. That is, an assertion of -an expected versus actual value failed within a unit test case. -A test failure will not stop a build. Instead, the suite will run -to completion with test failures collected and reported along with -all test case statistics. - -### Ceedling Exit Codes - -In its default configuration, Ceedling terminates with an exit code -of `1`: - - * On any build error and immediately terminates upon that build - error. - * On any test case failure but runs the build to completion and - shuts down normally. - -This behavior can be especially handy in Continuous Integration -environments where you typically want an automated CI build to break -upon either build errors or test failures. - -If this exit code convention for test failures does not work for you, -no problem-o. You may be of the mind that running a test suite to -completion should yield a successful exit code (even if tests failed). -Add the following to your project file to force Ceedling to finish a -build with an exit code of 0 even upon test case failures. - -```yaml -# Ceedling terminates with happy `exit(0)` even if test cases fail -:test_build: - :graceful_fail: true -``` - -If you use the option for graceful failures in CI, you’ll want to -rig up some kind of logging monitor that scans Ceedling’s test -summary report sent to `$stdout` and/or a log file. Otherwise, you -could have a successful build but failing tests. - -### Notes on Unity Test Executable Exit Codes - -Ceedling works by collecting multiple Unity test executables together -into a test suite ([more here](#anatomy-of-a-test-suite). - -A Unity test executable's exit code is the number of failed tests. An -exit code of `0` means all tests passed while anything larger than zero -is the number of test failures. - -Because of platform limitations on how big an exit code number can be -and because of the logical complexities of distinguishing test failure -counts from build errors or plugin problems, Ceedling conforms to a -much simpler exit code convention than Unity: `0` = 🙂 while `1` = ☹️. - -
- -# Using Unity, CMock & CException - -If you jumped ahead to this section but do not follow some of the -lingo here, please jump back to an [earlier section for definitions -and helpful links][helpful-definitions]. - -[helpful-definitions]: #hold-on-back-up-ruby-rake-yaml-unity-cmock-cexception - -## An overview of how Ceedling supports, well, its supporting frameworks - -If you are using Ceedling for unit testing, this means you are using Unity, -the C testing framework. Unity is fully built-in and enabled for test builds. -It cannot be disabled. - -If you want to use mocks in your test cases, you’ll need to enable mocking -and configure CMock with `:project` ↳ `:use_mocks` and the `:cmock` section -of your project configuration respectively. CMock is fully supported by -Ceedling but generally requires some set up for your project’s needs. - -If you are incorporating CException into your release artifact, you’ll need -to enable exceptions and configure CException with `:project` ↳ -`:use_exceptions` and the `:cexception` section of your project -configuration respectively. Enabling CException makes it available in both -release builds and test builds. - -This section provides a high-level view of how the various tools become -part of your builds and fit into Ceedling’s configuration file. Ceedling’s -configuration file is discussed in detail in the next section. - -See [Unity], [CMock], and [CException]’s project documentation for all -your configuration options. Ceedling offers facilities for providing these -frameworks their compilation and configuration settings. Discussing -these tools and all their options in detail is beyond the scope of Ceedling -documentation. - -## Unity Configuration - -Unity is wholly compiled C code. As such, its configuration is entirely -controlled by a variety of compilation symbols. These can be configured -in Ceedling’s `:unity` project settings. - -### Example Unity configurations - -#### Itty bitty processor & toolchain with limited test execution options - -```yaml -:unity: - :defines: - - UNITY_INT_WIDTH=16 # 16 bit processor without support for 32 bit instructions - - UNITY_EXCLUDE_FLOAT # No floating point unit -``` - -#### Great big gorilla processor that grunts and scratches - -```yaml -:unity: - :defines: - - UNITY_SUPPORT_64 # Big memory, big counters, big registers - - UNITY_LINE_TYPE=\"unsigned int\" # Apparently, we’re writing lengthy test files, - - UNITY_COUNTER_TYPE=\"unsigned int\" # and we've got a ton of test cases in those test files - - UNITY_FLOAT_TYPE=\"double\" # You betcha -``` - -#### Example Unity configuration header file - -Sometimes, you may want to funnel all Unity configuration options into a -header file rather than organize a lengthy `:unity` ↳ `:defines` list. Perhaps your -symbol definitions include characters needing escape sequences in YAML that are -driving you bonkers. - -```yaml -:unity: - :defines: - - UNITY_INCLUDE_CONFIG_H -``` - -```c -// unity_config.h -#ifndef UNITY_CONFIG_H -#define UNITY_CONFIG_H - -#include "uart_output.h" // Helper library for your custom environment - -#define UNITY_INT_WIDTH 16 -#define UNITY_OUTPUT_START() uart_init(F_CPU, BAUD) // Helper function to init UART -#define UNITY_OUTPUT_CHAR(a) uart_putchar(a) // Helper function to forward char via UART -#define UNITY_OUTPUT_COMPLETE() uart_complete() // Helper function to inform that test has ended - -#endif -``` - -### Routing Unity’s report output - -Unity defaults to using `putchar()` from C's standard library to -display test results. - -For more exotic environments than a desktop with a terminal — e.g. -running tests directly on a non-PC target — you have options. - -For instance, you could create a routine that transmits a character via -RS232 or USB. Once you have that routine, you can replace `putchar()` -calls in Unity by overriding the function-like macro `UNITY_OUTPUT_CHAR`. - -Even though this override can also be defined in Ceedling YAML, most -shell environments do not handle parentheses as command line arguments -very well. Consult your toolchain and shell documentation. - -If redefining the function and macros breaks your command line -compilation, all necessary options and functionality can be defined in -`unity_config.h`. Unity will need the `UNITY_INCLUDE_CONFIG_H` symbol in the -`:unity` ↳ `:defines` list of your Ceedling project file (see example above). - -## CMock Configuration - -CMock is enabled in Ceedling by default. However, no part of it enters a -test build unless mock generation is triggered in your test files. -Triggering mock generation is done by an `#include` convention. See the -section on [Ceedling conventions and behaviors][conventions] for more. - -You are welcome to disable CMock in the `:project` block of your Ceedling -configuration file. This is typically only useful in special debugging -scenarios or for Ceedling development itself. - -[conventions]: #important-conventions--behaviors - -CMock is a mixture of Ruby and C code. CMock's Ruby components generate -C code for your unit tests. CMock's base C code is compiled and linked into -a test executable in the same way that any C file is — including Unity, -CException, and generated mock C code, for that matter. - -CMock's code generation can be configured using YAML similar to Ceedling -itself. Ceedling’s project file is something of a container for CMock's -YAML configuration (Ceedling also uses CMock's configuration, though). - -See the documentation for the top-level [`:cmock`][cmock-yaml-config] -section within Ceedling’s project file. - -[cmock-yaml-config]: #cmock-configure-cmocks-code-generation--compilation - -Like Unity and CException, CMock's C components are configured at -compilation with symbols managed in your Ceedling project file's -`:cmock` ↳ `:defines` section. - -### Example CMock configurations - -```yaml -:project: - # Shown for completeness -- CMock enabled by default in Ceedling - :use_mocks: TRUE - -:cmock: - :when_no_prototypes: :warn - :enforce_strict_ordering: TRUE - :defines: - # Memory alignment (packing) on 16 bit boundaries - - CMOCK_MEM_ALIGN=1 - :plugins: - - :ignore - :treat_as: - uint8: HEX8 - uint16: HEX16 - uint32: UINT32 - int8: INT8 - bool: UINT8 -``` - -## CException Configuration - -Like Unity, CException is wholly compiled C code. As such, its -configuration is entirely controlled by a variety of `#define` symbols. -These can be configured in Ceedling’s `:cexception` ↳ `:defines` project -settings. - -Unlike Unity which is always available in test builds and CMock that -defaults to available in test builds, CException must be enabled -if you wish to use it in your project. - -### Example CException configurations - -```yaml -:project: - # Enable CException for both test and release builds - :use_exceptions: TRUE - -:cexception: - :defines: - # Possible exception codes of -127 to +127 - - CEXCEPTION_T='signed char' - -``` - -
- -# How to Load a Project Configuration. You Have Options, My Friend. - -Ceedling needs a project configuration to accomplish anything for you. -Ceedling's project configuration is a large in-memory data structure. -That data structure is loaded from a human-readable file format called -[YAML]. - -The next section details Ceedling’s project configuration options in -available through YAML. This section explains all your options for -loading and modifying the project configuration itself. - -## Overview of Project Configuration Loading & Smooshing - -Ceedling has a certain pipeline for loading and manipulating the -configuration it uses to build your projects. It goes something like -this: - -1. Load the base project configuration from a YAML file. -1. Merge the base configuration with zero or more Mixins from YAML files. -1. Load zero or more plugins that provide default configuration values - or alter the base project configuration. -1. Populate the configuration with default values if anything was left - unset to ensure all configuration needed to run is present. - -Ceedling provides reasonably verbose logging at startup telling you which -configuration file and Mixins were used and in what order they were merged. -Similarly, it provides fairly robust validation and warning messages to -help you catch a broken configuration and problematic combinations of -settings. - -For nitty-gritty details on plugin configuration behavior, see the -_[Plugin Development Guide](PluginDevelopmentGuide.md)_ - -## Options for Loading Your Base Project Configuration - -You have three options for telling Ceedling what single base project -configuration to load. These options are ordered below according to their -precedence. If an option higher in the list is present, it is used. - -1. Command line option flags -1. Environment variable -1. Default file in working directory - -### `--project` command line flags - -Many of Ceedling's [application commands][packet-section-7] include an -optional `--project` flag. When provided, Ceedling will load as its base -configuration the YAML filepath provided. - -Example: `ceedling --project=my/path/build.yml test:all` - -_NOTE:_ Ceedling loads any relative paths within your configuration in -relation to your working directory. This can cause a disconnect between -configuration paths, working directory, and the path to your project -file. - -If the filepath does not exist, Ceedling terminates with an error. - -### Environment variable `CEEDLING_PROJECT_FILE` - -If a `--project` flag is not used at the command line, but the -environment variable `CEEDLING_PROJECT_FILE` is set, Ceedling will use -the path it contains to load your project configuration. The path can -be absolute or relative (to your working directory). - -If the filepath does not exist, Ceedling terminates with an error. - -### Default _project.yml_ in your working directory - -If neither a `--project` command line flag nor the environment variable -`CEEDLING_PROJECT_FILE` are set, then Ceedling tries to load a file -named _project.yml_ in your working directory. - -If this file does not exist, Ceedling terminates with an error. - -## Applying Mixins to Your Base Project Configuration - -Once you have a base configuation loaded, you may want to modify it for -any number of reasons. Some example scenarios: - -* A single project actually contains mutiple build variations. You would - like to maintain a common configuration that is shared among build - variations with each build variation’s differences maintained separately. -* Your repository contains the configuration needed by your Continuous - Integration server setup, but this is not fun to run locally. You would - like to modify the configuration locally with configuration details - maintained by you external to your locally cloned repository. -* Ceedling's default `gcc` tools do not work for your project needs. You - would like the complex tooling configurations you most often need to - be maintained separately and shared among projects. - -Mixins allow you to merge configuration with your project configuration -just after the base project file is loaded. The merge is so low-level -and generic that you can, in fact, load an empty base configuration -and merge in entire project configurations through mixins. - -## Desgning for Mixins Plus Merging Rules - -Merging of any sort tends to be hard to do well. It’s tricky at a -code-level, yes, but, just as importantly, merging can be hard to grasp in -your head. - -The brief sections that follow provide an overview of our recommended -design approach and the merge rules at play. - -_**Note:**_ `ceedling dumpconfig` can be invaluable in developing and -troubleshooting your mixins. The `dumpconfig` application command will -load your mixins just as a build would but produce the resulting merged -configuration for inspection in a YAML file you specify. - -### Design for additive Mixin merges - -Generally speaking, the simplest way to conceive of managing your project -configuration with mixins is to design for an additive merge. This means -each mixin is successively adding something to your base configuration. -In certain scenarios it is possible to overwrite configuration values with -mixin values (see the rules that follow), but an additive merge is -probably easier to understand and create. - -At a high level, additive merges can be constructed like this: - -1. Plan to add to lists with mixins. Path collections, plugins, and - tool flags & compilation symbols are all examples of lists that - can be added to. Other lists exist in a project configuration too — - many within containing configuration entires. Add paths, plugins, and - flags & symbols to your base configuration that are in common to all - your buid variations and then customize the lists by adding to them - with mixins. -1. Leave entire sections in your base configuration blank and fill them - out by merging mixins. With this strategy, you might configure all - the basics in a base project configuration but merge into it the - path collections, tool configurations, and compilation symbols you need - for a specific project. - -### Mixins deep merge rules - -Mixins are merged in a specific order. See the documentation sections -following the examples for details. - -Smooshing of mixin configurations into the base project configuration -follows a few basic rules: - -* If a configuration key/value pair does not already exist at the time - of merging, the mixin key/value pair is added to the configuration. -* If a container — e.g. list or hash — already exists at the time of a - merge, the contents are _combined_. - * In the case of lists, merged list values are added to the end of - the existing list. - * If the configuration contains a list but the mixin value is a - different type, it is added to the list. The typical case is a - list of strings growing with an additional single string. Note - that the reverse case is not true. A configuration containing a - single value and a mixin containing a list, will trigger the - following rule. -* If a simple value — e.g. boolean, string, numeric — already exists - in the configuration at the time of merging, that value is replaced - by the mixin value being merged. That merge is accompanied with a - warning log entry to highlight what has happened. - -_**Note:**_ That second bullet can have a significant impact on how your -various project configuration paths — including those used for header -search paths — are ordered. In brief, the contents of your `:paths` -from your base configuration will come first followed by any additions -from your mixins. See the section [Search Paths for Test Builds][test-search-paths] -for more. - -[test-search-paths]: #search-paths-for-test-builds - -## Mixins Example: Our Example Scenario - -Let’s start with an example that helps explain how mixins are merged. -Then, the documentation sections that follow will discuss everything -in detail. - -In this example, we will load a base project configuration and then -apply three mixins using each of the available means — command line, -envionment variable, and `:mixins` section in the base project -configuration file. - -### Example environment variable - -`CEEDLING_MIXIN_1` = `./env.yml` - -### Example command line - -`ceedling --project=base.yml --mixin=support/mixins/cmdline.yml ` - -_NOTE:_ The `--mixin` flag supports more than filepaths and can be used -multiple times in the same command line for multiple mixins (see later -documentation section). - -The example command line above will produce the following logging output -when verbosity is increased beyond the default. - -``` -🚧 Loaded project configuration from command line argument using base.yml - + Merging command line mixin using support/mixins/cmdline.yml - + Merging CEEDLING_MIXIN_1 mixin using ./env.yml - + Merging project configuration mixin using ./enabled.yml -``` - -_Notes_ - -* The logging output above referencing _enabled.yml_ comes from the - `:mixins` section within the base project configuration file provided below. -* The resulting configuration in this example is missing settings required - by Ceedling. This will cause a validation build error that is not shown - here. - -### Mixins Example: Configuration files - -#### _base.yml_ — Our base project configuration file - -Our base project configuration file: - -1. Sets up a configuration file-based mixin. Ceedling will look for a mixin - named _enabled_ in the specified load paths. In this simple configuration - that means Ceedling looks for and merges _support/mixins/enabled.yml_. -1. Creates a `:project` section in our configuration. -1. Creates a `:plugins` section in our configuration and enables the standard - console test report output plugin. - -```yaml -:mixins: # `:mixins` section only recognized in base project configuration - :enabled: # `:enabled` list supports names and filepaths - - enabled # Ceedling looks for name as enabled.yml in load paths and merges if found - :load_paths: - - support/mixins - -:project: - :build_root: build/ - -:plugins: - :enabled: - - report_tests_pretty_stdout -``` - -#### _support/mixins/cmdline.yml_ — Mixin via command line filepath flag - -This mixin will merge a `:project` section with the existing `:project` -section from the base project file per the deep merge rules above. - -```yaml -:project: - :use_test_preprocessor: :all - :test_file_prefix: Test -``` - -#### _env.yml_ — Mixin via environment variable filepath - -This mixin will merge a `:plugins` section with the existing `:plugins` -section from the base project file per the deep merge rules (noted -after the examples). - -```yaml -:plugins: - :enabled: - - compile_commands_json_db -``` - -#### _support/mixins/enabled.yml_ — Mixin via base project configuration file `:mixins` section - -This mixin listed in the base configuration project file will merge -`:project` and `:plugins` sections with those that already exist from -the base configuration plus earlier mixin merges per the deep merge -rules (noted after the examples). - -```yaml -:project: - :use_test_preprocessor: :none - -:plugins: - :enabled: - - gcov -``` - -### Mixins Example: Resulting project configuration - -Behold the project configuration following mixin merges: - -```yaml -:project: - :build_root: build/ # From base.yml - :use_test_preprocessor: :all # Value in support/mixins/cmdline.yml overwrote value from support/mixins/enabled.yml - :test_file_prefix: Test # Added to :project from support/mixins/cmdline.yml - -:plugins: - :enabled: # :plugins ↳ :enabled from two mixins merged with oringal list in base.yml - - report_tests_pretty_stdout # From base.yml - - compile_commands_json_db # From env.yml - - gcov # From support/mixins/enabled.yml - -# NOTE: Original :mixins section is removed from resulting config -``` - -## Options for Loading Mixins - -You have three options for telling Ceedling what mixins to load. These -options are ordered below according to their precedence. A Mixin higher -in the list is merged earlier. In addition, options higher in the list -force duplicate mixin filepaths to be ignored lower in the list. - -Unlike base project file loading that resolves to a single filepath, -multiple mixins can be specified using any or all of these options. - -1. Command line option flags -1. Environment variables -1. Base project configuration file entries - -### `--mixin` command line flags - -As already discussed above, many of Ceedling's application commands -include an optional `--project` flag. Most of these same commands -also recognize optional `--mixin` flags. Note that `--mixin` can be -used multiple times in a single command line. - -When provided, Ceedling will load the specified YAML file and merge -it with the base project configuration. - -A Mixin flag can contain one of two types of values: - -1. A filename or filepath to a mixin yaml file. A filename contains - a file extension. A filepath includes a leading directory path. -1. A simple name (no file extension and no path). This name is used - as a lookup in Ceedling's mixin load paths. - -Example: `ceedling --project=build.yml --mixin=foo --mixin=bar/mixin.yaml test:all` - -Simple mixin names (#2 above) require mixin load paths to search. -A default mixin load path is always in the list and points to within -Ceedling itself (in order to host eventual built-in mixins like -built-in plugins). User-specified load paths must be added through -the `:mixins` section of the base configuration project file. See -the [documentation for the `:mixins` section of your project -configuration][mixins-config-section] for more details. - -Order of precedence is set by the command line mixin order -left-to-right. - -Filepaths may be relative (in relation to the working directory) or -absolute. - -If the `--mixin` filename or filepath does not exist, Ceedling -terminates with an error. If Ceedling cannot find a mixin name in -any load paths, it terminates with an error. - -[mixins-config-section]: #base-project-configuration-file-mixins-section-entries - -### Mixin environment variables - -Mixins can also be loaded through environment variables. Ceedling -recognizes environment variables with a naming scheme of -`CEEDLING_MIXIN_#`, where `#` is any number greater than 0. - -Precedence among the environment variables is a simple ascending -sort of the trailing numeric value in the environment variable name. -For example, `CEEDLING_MIXIN_5` will be merged before -`CEEDLING_MIXIN_99`. - -Mixin environment variables only hold filepaths. Filepaths may be -relative (in relation to the working directory) or absolute. - -If the filepath specified by an environment variable does not exist, -Ceedling terminates with an error. - -### Base project configuration file `:mixins` section entries - -Ceedling only recognizes a `:mixins` section in your base project -configuration file. A `:mixins` section in a mixin is ignored. In addition, -the `:mixins` section of a base project configuration file is filtered -out of the resulting configuration. - -The `:mixins` configuration section can contain up to two subsections. -Each subsection is optional. - -* `:enabled` - - An optional array comprising (A) mixin filenames/filepaths and/or - (B) simple mixin names. - - 1. A filename contains a file extension. A filepath includes a - directory path. The file content is YAML. - 1. A simple name (no file extension and no path) is used - as a file lookup among any configured load paths (see next - section) and as a lookup name among Ceedling’s built-in mixins - (currently none). - - Enabled entries support [inline Ruby string expansion][inline-ruby-string-expansion]. - - **Default**: `[]` - -* `:load_paths` - - Paths containing mixin files to be searched via mixin names. A mixin - filename in a load path has the form _.yml_ by default. If - an alternate filename extension has been specified in your project - configuration (`:extension` ↳ `:yaml`) it will be used for file - lookups in the mixin load paths instead of _.yml_. - - Searches start in the path at the top of the list. - - Both mixin names in the `:enabled` list (above) and on the command - line via `--mixin` flag use this list of load paths for searches. - - Load paths entries support [inline Ruby string expansion][inline-ruby-string-expansion]. - - **Default**: `[]` - -Example `:mixins` YAML blurb: - -```yaml -:mixins: - :enabled: - - foo # Search for foo.yml in proj/mixins & support/ and 'foo' among built-in mixins - - path/bar.yaml # Merge this file with base project conig - :load_paths: - - proj/mixins - - support -``` - -Relating the above example to command line `--mixin` flag handling: - -* A command line flag of `--mixin=foo` is equivalent to the `foo` - entry in the `:enabled` mixin configuration. -* A command line flag of `--mixin=path/bar.yaml` is equivalent to the - `path/bar.yaml` entry in the `:enabled` mixin configuration. -* Note that while command line `--mixin` flags work identically to - entries in `:mixins` ↳ `:enabled`, they are merged first instead of - last in the mixin precedence. - -
- -# The Almighty Ceedling Project Configuration File (in Glorious YAML) - -See this [commented project file][example-config-file] for a nice -example of a complete project configuration. - -## Some YAML Learnin’ - -Please consult YAML documentation for the finer points of format -and to understand details of our YAML-based configuration file. - -We recommend [Wikipedia's entry on YAML](http://en.wikipedia.org/wiki/Yaml) -for this. A few highlights from that reference page: - -* YAML streams are encoded using the set of printable Unicode - characters, either in UTF-8 or UTF-16. - -* White space indentation is used to denote structure; however, - tab characters are never allowed as indentation. - -* Comments begin with the number sign (`#`), can start anywhere - on a line, and continue until the end of the line unless enclosed - by quotes. - -* List members are denoted by a leading hyphen (`-`) with one member - per line, or enclosed in square brackets (`[...]`) and separated - by comma space (`, `). - -* Hashes are represented using colon space (`: `) in the form - `key: value`, either one per line or enclosed in curly braces - (`{...}`) and separated by comma space (`, `). - -* Strings (scalars) are ordinarily unquoted, but may be enclosed - in double-quotes (`"`), or single-quotes (`'`). - -* YAML requires that colons and commas used as list separators - be followed by a space so that scalar values containing embedded - punctuation can generally be represented without needing - to be enclosed in quotes. - -* Repeated nodes are initially denoted by an ampersand (`&`) and - thereafter referenced with an asterisk (`*`). These are known as - anchors and aliases in YAML speak. - -## Notes on Project File Structure and Documentation That Follows - -* Each of the following sections represent top-level entries - in the YAML configuration file. Top-level means the named entries - are furthest to the left in the hierarchical configuration file - (not at the literal top of the file). - -* Unless explicitly specified in the configuration file by you, - Ceedling uses default values for settings. - -* At minimum, these settings must be specified for a test suite: - * `:project` ↳ `:build_root` - * `:paths` ↳ `:source` - * `:paths` ↳ `:test` - * `:paths` ↳ `:include` and/or use of `TEST_INCLUDE_PATH(...)` - build directive macro within your test files - -* At minimum, these settings must be specified for a release build: - * `:project` ↳ `:build_root` - * `:paths` ↳ `:source` - -* As much as is possible, Ceedling validates your settings in - properly formed YAML. - -* Improperly formed YAML will cause a Ruby error when the YAML - is parsed. This is usually accompanied by a complaint with - line and column number pointing into the project file. - -* Certain advanced features rely on `gcc` and `cpp` as preprocessing - tools. In most Linux systems, these tools are already available. - For Windows environments, we recommend the [MinGW] project - (Minimalist GNU for Windows). - -* Ceedling is primarily meant as a build tool to support automated - unit testing. All the heavy lifting is involved there. Creating - a simple binary release build artifact is quite trivial in - comparison. Consequently, most default options and the construction - of Ceedling itself is skewed towards supporting testing, though - Ceedling can, of course, build your binary release artifact - as well. Note that some complex binary release builds are beyond - Ceedling’s abilities. See the Ceedling plugin [subprojects] for - extending release build abilities. - -[MinGW]: http://www.mingw.org/ - -## Ceedling-specific YAML Handling & Conventions - -### Inline Ruby string expansion - -Ceedling is able to execute inline Ruby string substitution code within the -entries of certain project file configuration elements. - -In some cases, this evaluation may occurs when elements of the project -configuration are loaded and processed into a data structure for use by the -Ceedling application (e.g. path handling). In other cases, this evaluation -occurs each time a project configuration element is referenced (e.g. tools). - -_Notes:_ -* One good option for validating and troubleshooting inline Ruby string - exapnsion is use of `ceedling dumpconfig` at the command line. This application - command causes your project configuration to be processed and written to a - YAML file with any inline Ruby string expansions, well, expanded along with - defaults set, plugin actions applied, etc. -* A commonly needed expansion is that of referencing an environment variable. - Inline Ruby string expansion supports this. See the example below. - -#### Ruby string expansion syntax - -To exapnd the string result of Ruby code within a configuration value string, -wrap the Ruby code in the substitution pattern `#{…}`. - -Inline Ruby string expansion may constitute the entirety of a configuration -value string, may be embedded within a string, or may be used multiple times -within a string. - -Because of the `#` it’s a good idea to wrap any string values in your YAML that -rely on this feature with quotation marks. Quotation marks for YAML strings are -optional. However, the `#` can cause a YAML parser to see a comment. As such, -explicitly indicating a string to the YAML parser with enclosing quotation -marks alleviates this problem. - -#### Ruby string expansion example - -```yaml -:some_config_section: - :some_key: - - "My env string #{ENV['VAR1']}" - - "My utility result string #{`util --arg`.strip()}" -``` - -In the example above, the two YAML strings will include the strings returned by -the Ruby code within `#{…}`: - -1. The first string uses Ruby’s environment variable lookup `ENV[…]` to fetch -the value assigned to variable `VAR1`. -1. The second string uses Ruby’s backtick shell execution ``…`` to insert the -string generated by a command line utility. - -#### Project file sections that offer inline Ruby string expansion - -* `:mixins` -* `:environment` -* `:paths` plus any second tier configuration key name ending in `_path` or - `_paths` -* `:flags` -* `:defines` -* `:tools` -* `:release_build` ↳ `:artifacts` - -See each section’s documentation for details. - -[inline-ruby-string-expansion]: #inline-ruby-string-expansion - -### Path handling - -Any second tier setting keys anywhere in YAML whose names end in `_path` or -`_paths` are automagically processed like all Ceedling-specific paths in the -YAML to have consistent directory separators (i.e. `/`) and to take advantage -of inline Ruby string expansion (see preceding section for details). - -## Let’s Be Careful Out There - -Ceedling performs validation of the values you set in your -configuration file (this assumes your YAML is correct and will -not fail format parsing, of course). - -That said, validation is limited to only those settings Ceedling -uses and those that can be reasonably validated. Ceedling does -not limit what can exist within your configuration file. In this -way, you can take full advantage of YAML as well as add sections -and values for use in your own custom plugins (documented later). - -The consequence of this is simple but important. A misspelled -configuration section or value name is unlikely to cause Ceedling -any trouble. Ceedling will happily process that section -or value and simply use the properly spelled default maintained -internally — thus leading to unexpected behavior without warning. - -## `:project`: Global project settings - -**_NOTE:_** In future versions of Ceedling, test-specific and release-specific -build settings presently organized beneath `:project` will likely be renamed -and migrated to the `:test_build` and `:release_build` sections. - -* `:build_root` - - Top level directory into which generated path structure and files are - placed. NOTE: this is one of the handful of configuration values that - must be set. The specified path can be absolute or relative to your - working directory. - - **Default**: (none) - -* `:default_tasks` - - A list of default build / plugin tasks Ceedling should execute if - none are provided at the command line. - - _NOTE:_ These are build & plugin tasks (e.g. `test:all` and `clobber`). - These are not application commands (e.g. `dumpconfig`) or command - line flags (e.g. `--verbosity`). See the documentation - [on using the command line][command-line] to understand the distinction - between application commands and build & plugin tasks. - - Example YAML: - ```yaml - :project: - :default_tasks: - - clobber - - test:all - - release - ``` - **Default**: `['test:all']` - - [command-line]: #now-what-how-do-i-make-it-go-the-command-line - -* `:use_mocks` - - Configures the build environment to make use of CMock. Note that if - you do not use mocks, there's no harm in leaving this setting as its - default value. - - **Default**: TRUE - -* `:use_test_preprocessor` - - This option allows Ceedling to work with test files that contain - tricky conditional compilation statements (e.g. `#ifdef`) as well as mockable - header files containing conditional preprocessor directives and/or macros. - - See the [documentation on test preprocessing][test-preprocessing] for more. - - With any preprocessing enabled, the `gcc` & `cpp` tools must exist in an - accessible system search path. - - * `:none` disables preprocessing. - * `:all` enables preprocessing for all mockable header files and test C files. - * `:mocks` enables only preprocessing of header files that are to be mocked. - * `:tests` enables only preprocessing of your test files. - - See also the complementary setting `:use_deep_preprocessor`. - - [test-preprocessing]: #preprocessing-behavior-for-tests - - **Default**: `:none` - -* `:use_deep_preprocessor` - - This option is an addon to `:use_test_preprocessor`. It is **_only_** - appropriate to enable this setting if you are also using `:use_test_preprocessor` - and _only_ if Ceedling’s test preprocessing configuration includes mock generation. - - This setting allows Ceedling to better support limited amd specific situations - where definitions required for test builds might be buried in your source files’ - `#include` chain and not ending up injected into generated mocks. - - At present, when enabled, this setting only injects a far lengthier list of `#include` - directives in your generated mocks. The most common need for this is in - projects with complex source code where CMock’s sophisticated `inline` mocking feature - is enabled. - - Except in rare cases, **_you probably do not need this feature_**. It will add some - overhead to your build and you risk oddball problems like doubly-defined symbols - and other such problems from using more `#include` directives than are probably needed - in your generated mocks. - - If compilation of your mocks is failing for lack of symbols (because of incomplete - `#include` lists in said mocks), you have other options to try besides this setting: - - 1. Using `:cmock` ↳ `:includes` (or its variations) to manually inject needed header - files into generated mocks. - 1. Go spelunking through your project to find any `#define`s your source code relies - on in a release build that should be duplicated in a test build. If `#include` - statements are surrounded by conditional compilation preprocessing statements - without the corresponding triggering conditions in your test build, the - GCC preproccessor Ceedling relies on will not discover all the `#include` - statements in the dependencies chain and thus will not be able to inject them - into generated mocks. See Ceeling’s extensive support for adding symbols to your - test build in the `:defines` project configuration section. - - Available options: - - * `:none` disables deep preprocessing, leaving normal shallow mode. - * `:mocks` enables deep preprocessing of header files that are to be mocked. - - **Default**: `:none` - -* `:test_file_prefix` - - Ceedling collects test files by convention from within the test file - search paths. The convention includes a unique name prefix and a file - extension matching that of source files. - - Why not simply recognize all files in test directories as test files? - By using the given convention, we have greater flexibility in what we - do with C files in the test directories. - - **Default**: "test_" - -* `:release_build` - - When enabled, a release Rake task is exposed. This configuration - option requires a corresponding release compiler and linker to be - defined (`gcc` is used as the default). - - Ceedling is primarily concerned with facilitating the complicated - mechanics of automating unit tests. The same mechanisms are easily - capable of building a final release binary artifact (i.e. non test - code — the thing that is your final working software that you execute - on target hardware). That said, if you have complicated release - builds, you should consider a traditional build tool for these. - Ceedling shines at executing test suites. - - More release configuration options are available in the `:release_build` - section. - - **Default**: FALSE - -* `:compile_threads` - - A value greater than one enables parallelized build steps. Ceedling - creates a number of threads up to `:compile_threads` for build steps. - These build steps execute batched operations including but not - limited to mock generation, code compilation, and running test - executables. - - Particularly if your build system includes multiple cores, overall - build time will drop considerably as compared to running a build with - a single thread. - - Tuning the number of threads for peak performance is an art more - than a science. A special value of `:auto` instructs Ceedling to - query the host system's number of virtual cores. To this value it - adds a constant of 4. This is often a good value sufficient to "max - out" available resources without overloading available resources. - - `:compile_threads` is used for all release build steps and all test - suite build steps except for running the test executables that make - up a test suite. See next section for more. - - **Default**: 1 - -* `:test_threads` - - The behavior of and values for `:test_threads` are identical to - `:compile_threads` with one exception. - - `test_threads:` specifically controls the number of threads used to - run the test executables comprising a test suite. - - Why the distinction from `:compile_threads`? Some test suite builds - rely not on native executables but simulators running cross-compiled - code. Some simulators are limited to running only a single instance at - a time. Thus, with this and the previous setting, it becomes possible - to parallelize nearly all of a test suite build while still respecting - the limits of certain simulators depended upon by test executables. - - **Default**: 1 - -* `:which_ceedling` - - This is an advanced project option primarily meant for development work - on Ceedling itself. This setting tells the code that launches the - Ceedling application where to find the code to launch. - - This entry can be either a directory path or `gem`. - - See the section [Which Ceedling](#which_ceedling) for full details. - - **Default**: `gem` - -* `:use_backtrace` - - When a test executable encounters a ☠️ **Segmentation Fault** or other crash - condition, the executable immediately terminates and no further details for - test suite reporting are collected. - - But, fear not. You can bring your dead unit tests back to life. - - By default, in the case of a crash, Ceedling reruns the test executable for - each test case using a special mode to isolate that test case. In this way - Ceedling can iteratively identify which test cases are causing the crash or - exercising release code that is causing the crash. Ceedling then assembles - the final test reporting results from these individual test case runs. - - You have three options for this setting, `:none`, `:simple` or `:gdb`: - - 1. `:none` will simply cause a test report to list each test case as failed - due to a test executable crash. - - Sample Ceedling run output with backtrace `:none`: - - ``` - 👟 Executing - ------------ - Running TestUsartModel.out... - ☠️ ERROR: Test executable `TestUsartModel.out` seems to have crashed - - ------------------- - FAILED TEST SUMMARY - ------------------- - [test/TestUsartModel.c] - Test: testGetBaudRateRegisterSettingShouldReturnAppropriateBaudRateRegisterSetting - At line (24): "Test executable crashed" - - Test: testCrash - At line (37): "Test executable crashed" - - Test: testGetFormattedTemperatureFormatsTemperatureFromCalculatorAppropriately - At line (44): "Test executable crashed" - - Test: testShouldReturnErrorMessageUponInvalidTemperatureValue - At line (50): "Test executable crashed" - - Test: testShouldReturnWakeupMessage - At line (56): "Test executable crashed" - - ----------------------- - ❌ OVERALL TEST SUMMARY - ----------------------- - TESTED: 5 - PASSED: 0 - FAILED: 5 - IGNORED: 0 - ``` - - 1. `:simple` causes Ceedling to re-run each test case in the - test executable individually to identify and report the problematic - test case(s). This is the default option and is described above. - - Sample Ceedling run output with backtrace `:simple`: - - ``` - 👟 Executing - ------------ - Running TestUsartModel.out... - ☠️ ERROR: Test executable `TestUsartModel.out` seems to have crashed - - ------------------- - FAILED TEST SUMMARY - ------------------- - [test/TestUsartModel.c] - Test: testCrash - At line (37): "Test case crashed" - - ----------------------- - ❌ OVERALL TEST SUMMARY - ----------------------- - TESTED: 5 - PASSED: 4 - FAILED: 1 - IGNORED: 0 - ``` - - 1. `:gdb` uses the [`gdb`][gdb] debugger to identify and report the - troublesome line of code triggering the crash. If this option is enabled, - but `gdb` is not available to Ceedling, project configuration validation - will terminate with an error at startup. - - Sample Ceedling run output with backtrace `:gdb`: - - ``` - 👟 Executing - ------------ - Running TestUsartModel.out... - ☠️ ERROR: Test executable `TestUsartModel.out` seems to have crashed - - ------------------- - FAILED TEST SUMMARY - ------------------- - [test/TestUsartModel.c] - Test: testCrash - At line (40): "Test case crashed >> Program received signal SIGSEGV, Segmentation fault. - 0x00005618066ea1fb in testCrash () at test/TestUsartModel.c:40 - 40 uint32_t i = *nullptr;" - - ----------------------- - ❌ OVERALL TEST SUMMARY - ----------------------- - TESTED: 5 - PASSED: 4 - FAILED: 1 - IGNORED: 0 - ``` - - **_Notes:_** - - 1. The default of `:simple` only works in an environment capable of - using command line arguments (passed to the test executable). If you are - targeting a simulator with your test executable binaries, `:simple` is - unlikely to work for you. In the simplest case, you may simply fall back - to `:none`. With some work and using Ceedling’s various features, much - more sophisticated options are possible. - 1. The `:gdb` option currently only supports the native build platform. - That is, the `:gdb` backtrace option cannot handle backtrace for - cross-compiled code or any sort of simulator-based test fixture. - - **Default**: `:simple` - - [gdb]: https://www.sourceware.org/gdb/ - -### Example `:project` YAML blurb - -```yaml -:project: - :build_root: project_awesome/build - :use_exceptions: FALSE - :use_test_preprocessor: :all - :release_build: TRUE - :compile_threads: :auto -``` - -## `:mixins` Configuring mixins to merge - -This section of a project configuration file is documented in the -[discussion of project files and mixins][mixins-config-section]. - -**_Notes:_** - -* A `:mixins` section is only recognized within a base project configuration - file. Any `:mixins` sections within mixin files are ignored. -* A `:mixins` section in a Ceedling configuration is entirely filtered out of - the resulting configuration. That is, it is unavailable for use by plugins - and will not be present in any output from `ceedling dumpconfig`. -* A `:mixins` section supports [inline Ruby string expansion][inline-ruby-string-expansion]. - See the full documetation on Mixins for details. - -## `:test_build` Configuring a test build - -**_NOTE:_** In future versions of Ceedling, test-related settings presently -organized beneath `:project` will be renamed and migrated to this section. - -* `:use_assembly` - - This option causes Ceedling to enable an assembler tool and collect a - list of assembly file sources for use in a test suite build. - - The default assembler is the GNU tool `as`; like all other tools, it - may be overridden in the `:tools` section. - - After enabliing this feature, two conditions must be true in order to - inject assembly code into the build of a test executable: - - 1. The assembly files must be visible to Ceedling by way of `:paths` and - `:extension` settings for assembly files. Here, assembly files would be - equivalent to C code files handled in the same ways. - 1. Ceedling must be told into which test executable build to insert a - given assembly file. The easiest way to do so is with the - `TEST_SOURCE_FILE()` build directive macro (documented in a later section). - - **Default**: FALSE - -### Example `:test_build` YAML blurb - -```yaml -:test_build: - :use_assembly: TRUE -``` - -## `:release_build` Configuring a release build - -**_NOTE:_** In future versions of Ceedling, release build-related settings -presently organized beneath `:sproject` will be renamed and migrated to -this section. - -* `:output` - - The name of your release build binary artifact to be found in /artifacts/release. Ceedling sets the default artifact file - extension to that as is explicitly specified in the `:extension` - section or as is system specific otherwise. - - **Default**: `project.exe` or `project.out` - -* `:use_assembly` - - This option causes Ceedling to enable an assembler tool and add any - assembly code present in the project to the release artifact's build. - - The default assembler is the GNU tool `as`; it may be overridden - in the `:tools` section. - - The assembly files must be visible to Ceedling by way of `:paths` and - `:extension` settings for assembly files. - - **Default**: FALSE - -* `:artifacts` - - By default, Ceedling copies to the _/artifacts/release_ - directory the output of the release linker and (optionally) a map - file. Many toolchains produce other important output files as well. - Adding a file path to this list will cause Ceedling to copy that file - to the artifacts directory. - - The artifacts directory is helpful for organizing important build - output files and provides a central place for tools such as Continuous - Integration servers to point to build output. Selectively copying - files prevents incidental build cruft from needlessly appearing in the - artifacts directory. - - Note that [inline Ruby string expansion][inline-ruby-string-expansion] - is available in artifact paths. - - **Default**: `[]` (empty) - -### Example `:release_build` YAML blurb - -```yaml -:release_build: - :output: top_secret.bin - :use_assembly: TRUE - :artifacts: - - build/release/out/c/top_secret.s19 -``` - -## Project `:paths` configuration - -**Paths for build tools and building file collections** - -Ceedling relies on various path and file collections to do its work. File -collections are automagically assembled from paths, matching globs / wildcards, -and file extensions (see project configuration `:extension`). - -Entries in `:paths` help create directory-based bulk file collections. The -`:files` configuration section is available for filepath-oriented tailoring of -these buk file collections. - -Entries in `:paths` ↳ `:include` also specify search paths for header files. - -All of the configuration subsections that follow default to empty lists. In -YAML, list items can be comma separated within brackets or organized per line -with a dash. An empty list can only be denoted as `[]`. Typically, you will see -Ceedling project files use lists broken up per line. - -```yaml -:paths: - :support: [] # Empty list (internal default) - :source: - - files/code # Typical list format - -``` - -Examples that illustrate the many `:paths` entry features follow all -the various path-related documentation sections. - -_**Note:**_ If you use Mixins to build up path lists in your project -configuration, the merge order of those Mixins will dictate the ordering of -your path lists. Particularly given that the search path list built with -`:paths` ↳ `:include` you will want to pay attention to ordering issues -involved in specifying path lists in Mixins. - -*

:paths:test

- - All C files containing unit test code. NOTE: this is one of the - handful of configuration values that must be set for a test suite. - - **Default**: `[]` (empty) - -*

:paths:source

- - All C files containing release code (code to be tested) - - NOTE: this is one of the handful of configuration values that must - be set for either a release build or test suite. - - **Default**: `[]` (empty) - -*

:paths:support

- - Any C files you might need to aid your unit testing. For example, on - occasion, you may need to create a header file containing a subset of - function signatures matching those elsewhere in your code (e.g. a - subset of your OS functions, a portion of a library API, etc.). Why? - To provide finer grained control over mock function substitution or - limiting the size of the generated mocks. - - **Default**: `[]` (empty) - -*

:paths:include

- - See these two important discussions to fully understand your options - for header file search paths: - - * [Configuring Your Header File Search Paths][header-file-search-paths] - * [`TEST_INCLUDE_PATH(...)` build directive macro][test-include-path-macro] - - [header-file-search-paths]: #configuring-your-header-file-search-paths - [test-include-path-macro]: #test_include_path - - This set of paths specifies the locations of your header files. If - your header files are intermixed with source files, you must duplicate - some or all of your `:paths` ↳ `:source` entries here. - - In its simplest use, your include paths list can be exhaustive. - That is, you list all path locations where your project’s header files - reside in this configuration list. - - However, if you have a complex project or many, many include paths that - create problematically long search paths at the compilation command - line, you may treat your `:paths` ↳ `:include` list as a base, common - list. Having established that base list, you can then extend it on a - test-by-test basis with use of the `TEST_INCLUDE_PATH(...)` build - directive macro in your test files. - - **Default**: `[]` (empty) - -*

:paths:test_toolchain_include

- - System header files needed by the test toolchain - should your - compiler be unable to find them, finds the wrong system include search - path, or you need a creative solution to a tricky technical problem. - - Note that if you configure your own toolchain in the `:tools` section, - this search path is largely meaningless to you. However, this is a - convenient way to control the system include path should you rely on - the default [GCC] tools. - - **Default**: `[]` (empty) - -*

:paths:release_toolchain_include

- - Same as preceding albeit related to the release toolchain. - - **Default**: `[]` (empty) - -*

:paths:libraries

- - Library search paths. [See `:libraries` section][libraries]. - - **Default**: `[]` (empty) - - [libraries]: #libraries - -*

:paths:<custom>

- - Any paths you specify for custom list. List is available to tool - configurations and/or plugins. Note a distinction – the preceding names - are recognized internally to Ceedling and the path lists are used to - build collections of files contained in those paths. A custom list is - just that - a custom list of paths. - -### `:paths` configuration options & notes - -1. A path can be absolute (fully qualified) or relative. -1. A path can include a glob matcher (more on this below). -1. A path can use [inline Ruby string expansion][inline-ruby-string-expansion]. -1. Subtractive paths are possible and useful. See the documentation below. -1. Path order beneath a subsection (e.g. `:paths` ↳ `:include`) is preserved - when the list is iterated internally or passed to a tool. - -### `:paths` Globs - -Globs are effectively fancy wildcards. They are not as capable as full regular -expressions but are easier to use. Various OSs and programming languages -implement them differently. - -For a quick overview, see this [tutorial][globs-tutorial]. - -Ceedling supports globs so you can specify patterns of directories without the -need to list each and every required path. - -Ceedling `:paths` globs operate similarlry to [Ruby globs][ruby-globs] except -that they are limited to matching directories within `:paths` entries and not -also files. In addition, Ceedling adds a useful convention with certain uses of -the `*` and `**` operators. - -Glob operators include the following: `*`, `**`, `?`, `[-]`, `{,}`. - -* `*` - * When used within a character string, `*` is simply a standard wildcard. - * When used after a path separator, `/*` matches all subdirectories of depth 1 - below the parent path, not including the parent path. -* `**`: All subdirectories recursively discovered below the parent path, not - including the parent path. This pattern only makes sense after a path - separator `/**`. -* `?`: Single alphanumeric character wildcard. -* `[x-y]`: Single alphanumeric character as found in the specified range. -* `{x, y, ...}`: Matching any of the comma-separated patterns. Two or more - patterns may be listed within the brackets. Patterns may be specific - character sequences or other glob operators. - -Special conventions: - -* If a globified path ends with `/*` or `/**`, the resulting list of directories - also includes the parent directory. - -See the example `:paths` YAML blurb section. - -[globs-tutotrial]: http://ruby.about.com/od/beginningruby/a/dir2.htm -[ruby-globs]: https://ruby-doc.org/core-3.0.0/Dir.html#method-c-glob - -### Subtractive `:paths` entries - -Globs are super duper helpful when you have many paths to list. But, what if a -single glob gets you 20 nested paths, but you actually want to exclude 2 of -those paths? - -Must you revert to listing all 18 paths individually? No, my friend, we've got -you. Behold, subtractive paths. - -Put simply, with an optional preceding decorator `-:`, you can instruct Ceedling -to remove certain directory paths from a collection after it builds that -collection. - -By default, paths are additive. For pretty alignment in your YAML, you may also -use `+:`, but strictly speaking, it's not necessary. - -Subtractive paths may be simple paths or globs just like any other path entry. - -See examples below. - -_**Note:**_ The resolution of subtractive paths happens after your full paths -lists are assembled. So, if you use `:paths` entries in Mixins to build up your -project configuration, subtractive paths will only be processed after the final -mixin is merged. That is, you can merge in additive and subtractive paths with -Mixins to your heart’s content. The subtractive paths are not removed until all -Mixins have been merged. - -### Example `:paths` YAML blurbs - -_NOTE:_ Ceedling standardizes paths for you. Internally, all paths use forward - slash `/` path separators (including on Windows), and Ceedling cleans up - trailing path separators to be consistent internally. - -#### Simple `:paths` entries - -```yaml -:paths: - # All /*. => test/release compilation input - :source: - - project/src/ # Resulting source list has just two relative directory paths - - project/aux # (Traversal goes no deeper than these simple paths) - - # All => compilation search paths + mock search paths - :include: # All => compilation input - - project/src/inc # Include paths are subdirectory of src/ - - /usr/local/include/foo # Header files for a prebuilt library at fully qualified path - - # All /*. => test compilation input + test suite executables - :test: - - ../tests # Tests have parent directory above working directory -``` - -#### Common `:paths` globs with subtractive path entries - -```yaml -:paths: - :source: - - +:project/src/** # Recursive glob yields all subdirectories of any depth plus src/ - - -:project/src/exp # Exclude experimental code in exp/ from release or test builds - # `+:` is decoration for pretty alignment; only `-:` changes a list - - :include: - - +:project/src/**/inc # Include every subdirectory inc/ beneath src/ - - -:project/src/exp/inc # Remove header files subdirectory for experimental code -``` - -#### Advanced `:paths` entries with globs and string expansion - -```yaml -:paths: - :test: - - test/**/f??? # Every 4 character “f-series" subdirectory beneath test/ - - :my_things: # Custom path list - - "#{PROJECT_ROOT}/other" # Inline Ruby string expansion using Ceedling global constant -``` - -```yaml -:paths: - :test: - - test/{foo,b*,xyz} # Path list will include test/foo/, test/xyz/, and any subdirectories - # beneath test/ beginning with 'b', including just test/b/ -``` - -Globs and inline Ruby string expansion can require trial and error to arrive at -your intended results. Ceedling provides as much validation of paths as is -practical. - -Use the `ceedling paths:*` and `ceedling files:*` command line tasks — -documented in a preceding section — to verify your settings. (Here `*` is -shorthand for `test`, `source`, `include`, etc. Confusing? Sorry.) - -The command line option `ceedling dumpconfig` can also help your troubleshoot -your configuration file. This application command causes Ceedling to process -your configuration file and write the result to another YAML file for your -inspection. - -## `:files` Modify file collections - -**File listings for tailoring file collections** - -Ceedling relies on file collections to do its work. These file collections are -automagically assembled from paths, matching globs / wildcards, and file -extensions (see project configuration `:extension`). - -Entries in `:files` accomplish filepath-oriented tailoring of the bulk file -collections created from `:paths` directory listings and filename pattern -matching. - -On occasion you may need to remove from or add individual files to Ceedling’s -file collections. - -The path grammar documented in the `:paths` configuration section largely -applies to `:files` path entries - albeit with regard to filepaths and not -directory paths. The `:files` grammar and YAML examples are documented below. - -*

:files:test

- - Modify the collection of unit test C files. - - **Default**: `[]` (empty) - -*

:files:source

- - Modify the collection of all source files used in unit test builds and release builds. - - **Default**: `[]` (empty) - -*

:files:assembly

- - Modify the (optional) collection of assembly files used in release builds. - - **Default**: `[]` (empty) - -*

:files:include

- - Modify the collection of all source header files used in unit test builds (e.g. for mocking) and release builds. - - **Default**: `[]` (empty) - -*

:files:support

- - Modify the collection of supporting C files available to unit tests builds. - - **Default**: `[]` (empty) - -*

:files:libraries

- - Add a collection of library paths to be included when linking. - - **Default**: `[]` (empty) - -### `:files` configuration options & notes - -1. A path can be absolute (fully qualified) or relative. -1. A path can include a glob matcher (more on this below). -1. A path can use [inline Ruby string expansion][inline-ruby-string-expansion]. -1. Subtractive paths prepended with a `-:` decorator are possible and useful. - See the documentation below. - -### `:files` Globs - -Globs are effectively fancy wildcards. They are not as capable as full regular -expressions but are easier to use. Various OSs and programming languages -implement them differently. - -For a quick overview, see this [tutorial][globs-tutorial]. - -Ceedling supports globs so you can specify patterns of files as well as simple, -ordinary filepaths. - -Ceedling `:files` globs operate identically to [Ruby globs][ruby-globs] except -that they ignore directory paths. Only filepaths are recognized. - -Glob operators include the following: `*`, `**`, `?`, `[-]`, `{,}`. - -* `*` - * When used within a character string, `*` is simply a standard wildcard. - * When used after a path separator, `/*` matches all subdirectories of depth - 1 below the parent path, not including the parent path. -* `**`: All subdirectories recursively discovered below the parent path, not - including the parent path. This pattern only makes sense after a path - separator `/**`. -* `?`: Single alphanumeric character wildcard. -* `[x-y]`: Single alphanumeric character as found in the specified range. -* `{x, y, ...}`: Matching any of the comma-separated patterns. Two or more - patterns may be listed within the brackets. Patterns may be specific - character sequences or other glob operators. - -### Subtractive `:files` entries - -Tailoring a file collection includes adding to it but also subtracting from it. - -Put simply, with an optional preceding decorator `-:`, you can instruct Ceedling -to remove certain file paths from a collection after it builds that -collection. - -By default, paths are additive. For pretty alignment in your YAML, you may also -use `+:`, but strictly speaking, it's not necessary. - -Subtractive paths may be simple paths or globs just like any other path entry. - -See examples below. - -### Example `:files` YAML blurbs - -#### Simple `:files` tailoring - -```yaml -:paths: - # All /*. => test/release compilation input - :source: - - src/** - -:files: - :source: - - +:callbacks/serial_comm.c # Add source code outside src/ - - -:src/board/atm134.c # Remove board code -``` - -#### Advanced `:files` tailoring - -```yaml -:paths: - # All /*. => test compilation input + test suite executables - :test: - - test/** - -:files: - :test: - # Remove every test file anywhere beneath test/ whose name ends with 'Model'. - # String replacement inserts a global constant that is the file extension for - # a C file. This is an anchor for the end of the filename and automaticlly - # uses file extension settings. - - "-:test/**/*Model#{EXTENSION_SOURCE}" - - # Remove test files at depth 1 beneath test/ with 'analog' anywhere in their names. - - -:test/*{A,a}nalog* - - # Remove test files at depth 1 beneath test/ that are of an “F series” - # test collection FAxxxx, FBxxxx, and FCxxxx where 'x' is any character. - - -:test/F[A-C]???? -``` - -## `:environment:` Insert environment variables into shells running tools - -Ceedling creates environment variables from any key / value pairs in the -environment section. Keys become an environment variable name in uppercase. The -values are strings assigned to those environment variables. These value strings -are either simple string values in YAML or the concatenation of a YAML array -of strings. - -`:environment` is a list of single key / value pair entries processed in the -configured list order. - -`:environment` variable value strings can include -[inline Ruby string expansion][inline-ruby-string-expansion]. Thus, later -entries can reference earlier entries. - -### Special case: `PATH` handling - -In the specific case of specifying an environment key named `:path`, an array -of string values will be concatenated with the appropriate platform-specific -path separation character (i.e. `:` on Unix-variants, `;` on Windows). - -All other instances of environment keys assigned a value of a YAML array use -simple concatenation. - -### Example `:environment` YAML blurb - -Note that `:environment` is a list of key / value pairs. Only one key per entry -is allowed, and that key must be a `:`__. - -```yaml -:environment: - - :license_server: gizmo.intranet # LICENSE_SERVER set with value "gizmo.intranet" - - :license: "#{`license.exe`}" # LICENSE set to string generated from shelling out to - # execute license.exe; note use of enclosing quotes to - # prevent a YAML comment. - - - :logfile: system/logs/thingamabob.log # LOGFILE set with path for a log file - - - :path: # Concatenated with path separator (see special case above) - - Tools/gizmo/bin # Prepend existing PATH with gizmo path - - "#{ENV['PATH']}" # Pattern #{…} triggers ruby evaluation string expansion - # NOTE: value string must be quoted because of '#' to - # prevent a YAML comment. -``` - -## `:extension` Filename extensions used to collect lists of files searched in `:paths` - -Ceedling uses path lists and wildcard matching against filename extensions to collect file lists. - -* `:header`: - - C header files - - **Default**: .h - -* `:source`: - - C code files (whether source or test files) - - **Default**: .c - -* `:assembly`: - - Assembly files (contents wholly assembler instructions) - - **Default**: .s - -* `:object`: - - Resulting binary output of C code compiler (and assembler) - - **Default**: .o - -* `:executable`: - - Binary executable to be loaded and executed upon target hardware - - **Default**: .exe or .out (Win or Linux) - -* `:testpass`: - - Test results file (not likely to ever need a redefined value) - - **Default**: .pass - -* `:testfail`: - - Test results file (not likely to ever need a redefined value) - - **Default**: .fail - -* `:dependencies`: - - File containing make-style dependency rules created by the `gcc` preprocessor - - **Default**: .d - -### Example `:extension` YAML blurb - -```yaml -:extension: - :source: .cc - :executable: .bin -``` - -## `:defines` Command line symbols used in compilation - -Ceedling’s internal, default compiler tool configurations (see later `:tools` section) -execute compilation of test and source C files. - -These default tool configurations are a one-size-fits-all approach. If you need to add to -the command line symbols for individual tests or a release build, the `:defines` section -allows you to easily do so. - -Particularly in testing, symbol definitions in the compilation command line are often needed: - -1. You may wish to control aspects of your test suite. Conditional compilation statements - can control which test cases execute in which circumstances. (Preprocessing must be - enabled, `:project` ↳ `:use_test_preprocessor`.) - -1. Testing means isolating the source code under test. This can leave certain symbols - unset when source files are compiled in isolation. Adding symbol definitions in your - Ceedling project file for such cases is one way to meet this need. - -Entries in `:defines` modify the command lines for compilers used at build time. In the -default case, symbols listed beneath `:defines` become `-D` arguments. - -### `:defines` verification (Ceedling does none) - -Ceedling does no verification of your configured `:define` symbols. - -Unity, CMock, and CException conditional compilation statements, your toolchain's -preprocessor, and/or your toolchain's compiler will complain appropriately if your -specified symbols are incorrect, incomplete, or incompatible. - -Ceedling _does_ validate your `:defines` block in your project configuration. - -### `:defines` organization: Contexts and Matchers - -The basic layout of `:defines` involves the concept of contexts. - -General case: -```yaml -:defines: - :: # :test, :release, etc. - - # Simple list of symbols added to all compilation - - ... -``` - -Advanced matching for **_test_** or **_preprocess_** build handling only: -```yaml -:defines: - :test: - : # Matches a subset of test executables - - # List of symbols added to that subset's compilation - - ... - :preprocess: # Only applicable if :project ↳ :use_test_preprocessor enabled - : # Matches a subset of test executables - - # List of symbols added to that subset's compilation - - ... -``` - -A context is the build context you want to modify — `:release`, `:preprocess`, or `:test`. -Plugins can also hook into `:defines` with their own context. - -You specify the symbols you want to add to a build step beneath a `:`. In many -cases this is a simple YAML list of strings that will become symbols defined in a -compiler's command line. - -Specifically in the `:test` and `:preprocess` contexts you also have the option to -create test file matchers that create symbol definitions for some subset of your build. - -*

:defines:release

- - This project configuration entry adds the items of a simple YAML list as symbols to - the compilation of every C file in a release build. - - **Default**: `[]` (empty) - -*

:defines:test

- - This project configuration entry adds the specified items as symbols to compilation of C - components in a test executable’s build. - - Symbols may be represented in a simple YAML list or with a more sophisticated file matcher - YAML key plus symbol list. Both are documented below. - - Every C file that comprises a test executable build will be compiled with the symbols - configured that match the test filename itself. - - **Default**: `[]` (empty) - -*

:defines:preprocess

- - This project configuration entry adds the specified items as symbols to any needed - preprocessing of components in a test executable’s build. Preprocessing must be enabled - for this matching to have any effect. (See `:project` ↳ `:use_test_preprocessor`.) - - Preprocessing here refers to handling macros, conditional includes, etc. in header files - that are mocked and in complex test files before runners are generated from them. - (See more about the [Ceedling preprocessing](#ceedling-preprocessing-behavior-for-your-tests) - feature.) - - Like the `:test` context, compilation symbols may be represented in a simple YAML list - or with a more sophisticated file matcher YAML key plus symbol list. Both are documented - below. - - _NOTE:_ Left unspecified, `:preprocess` symbols default to be identical to `:test` - symbols. Override this behavior by adding `:defines` ↳ `:preprocess` symbols. If you want - no additional symbols for preprocessing regardless of `test` symbols, specify an - empty list `[]` in your `:preprocess` matcher. - - **Default**: Identical to `:test` context unless specified - -*

:defines:<plugin context>

- - Some advanced plugins make use of build contexts as well. For instance, the Ceedling - Gcov plugin uses a context of `:gcov`, surprisingly enough. For any plugins with tools - that take advantage of Ceedling’s internal mechanisms, you can add to those tools' - compilation symbols in the same manner as the built-in contexts. - -### `:defines` options - -* `:use_test_definition`: - - If enabled, add a symbol to test compilation derived from the test file name. The - resulting symbol is a sanitized, uppercase, ASCII version of the test file name. - Any non ASCII characters (e.g. Unicode) are replaced by underscores as are any - non-alphanumeric characters. Underscores and dashes are preserved. The symbol name - is wrapped in underscores unless they already exist in the leading and trailing - positions. Example: _test_123abc-xyz😵.c_ ➡️ `_TEST_123ABC-XYZ_`. - - **Default**: False - -### Simple `:defines` configuration - -A simple and common need is configuring conditionally compiled features in a code base. -The following example illustrates using simple YAML lists for symbol definitions at -compile time. - -```yaml -:defines: - :test: # All compilation of all C files for all test executables - - FEATURE_X=ON - - PRODUCT_CONFIG_C - :release: # All compilation of all C files in a release artifact - - FEATURE_X=ON - - PRODUCT_CONFIG_C -``` - -Given the YAML blurb above, the two symbols will be defined in the compilation command -lines for all C files in all test executables within a test suite build and for all C -files in a release build. - -### Advanced `:defines` per-test matchers - -Ceedling treats each test executable as a mini project. As a reminder, each test file, -together with all C sources and frameworks, becomes an individual test executable of -the same name. - -**_In the `:test` and `:preprocess` contexts only_**, symbols may be defined for only -those test executable builds that match filename criteria. Matchers match on test -filenames only, and the specified symbols are added to the build step for all files -that are components of matched test executables. - -In short, for instance, this means your compilation of _TestA_ can have different -symbols than compilation of _TestB_. Those symbols will be applied to every C file -that is compiled as part those individual test executable builds. Thus, in fact, with -separate test files unit testing the same source C file, you may exercise different -conditional compilations of the same source. See the example in the section below. - -#### `:defines` per-test matcher examples with YAML - -Before detailing matcher capabilities and limits, here are examples to illustrate the -basic ideas of test file name matching. - -This first example builds on the previous simple symbol list example. The imagined scenario -is that of unit testing the same single source C file with different product features -enabled. The per-test matchers shown here use test filename substring matchers. - -```yaml -# Imagine three test files all testing aspects of a single source file Comms.c with -# different features enabled via conditional compilation. -:defines: - :test: - # Tests for FeatureX configuration - :CommsFeatureX: # Matches a test executable name including 'CommsFeatureX' - - FEATURE_X=ON - - FEATURE_Z=OFF - - PRODUCT_CONFIG_C - # Tests for FeatureZ configuration - :CommsFeatureZ: # Matches a test executable name including 'CommsFeatureZ' - - FEATURE_X=OFF - - FEATURE_Z=ON - - PRODUCT_CONFIG_C - # Tests of base functionality - :CommsBase: # Matches a test executable name including 'CommsBase' - - FEATURE_X=OFF - - FEATURE_Z=OFF - - PRODUCT_BASE -``` - -This example illustrates each of the test file name matcher types. - -```yaml -:defines: - :test: - :*: # Wildcard: Add '-DA' for compilation all files for all test executables - - A - :Model: # Substring: Add '-DCHOO' for compilation of all files of any test executable with 'Model' in its name - - CHOO - :/M(ain|odel)/: # Regex: Add '-DBLESS_YOU' for all files of any test executable with 'Main' or 'Model' in its name - - BLESS_YOU - :Comms*Model: # Wildcard: Add '-DTHANKS' for all files of any test executables that have zero or more characters - - THANKS # between 'Comms' and 'Model' -``` - -#### Using `:defines` per-test matchers - -These matchers are available: - -1. Wildcard (`*`) - 1. If specified in isolation, matches all tests. - 1. If specified within a string, matches any test filename with that - wildcard expansion. -1. Substring — Matches on part of a test filename (up to all of it, including - full path). -1. Regex (`/.../`) — Matches test file names against a regular expression. - -Notes: -* Substring filename matching is case sensitive. -* Wildcard matching is effectively a simplified form of regex. That is, multiple - approaches to matching can match the same filename. - -Symbols by matcher are cumulative. This means the symbols from multiple -matchers can be applied to all compilation for any single test executable. - -Referencing the example above, here are the extra compilation symbols for a -handful of test executables: - -* _test_Something_: `-DA` -* _test_Main_: `-DA -DBLESS_YOU` -* _test_Model_: `-DA -DCHOO -DBLESS_YOU` -* _test_CommsSerialModel_: `-DA -DCHOO -DBLESS_YOU -DTHANKS` - -The simple `:defines` list format remains available for the `:test` and `:preprocess` -contexts. Of course, this format is limited in that it applies symbols to the -compilation of all C files for all test executables. - -This simple list format for `:test` and `:preprocess` contexts… - -```yaml -:defines: - :test: - - A -``` - -…is equivalent to this matcher version: - -```yaml -:defines: - :test: - :*: - - A -``` - -#### Distinguishing similar or identical filenames with `:defines` per-test matchers - -You may find yourself needing to distinguish test files with the same name or test -files with names whose base naming is identical. - -Of course, identical test filenames have a natural distinguishing feature in their -containing directory paths. Files of the same name can only exist in different -directories. As such, your matching must include the path. - -```yaml -:defines: - :test: - :hardware/test_startup: # Match any test names beginning with 'test_startup' in hardware/ directory - - A - :network/test_startup: # Match any test names beginning with 'test_startup' in network/ directory - - B -``` - -It's common in C file naming to use the same base name for multiple files. Given the -following example list, care must be given to matcher construction to single out -test_comm_startup.c. - -* tests/test_comm_hw.c -* tests/test_comm_startup.c -* tests/test_comm_startup_timers.c - -```yaml -:defines: - :test: - :test_comm_startup.c: # Full filename with extension distinguishes this file test_comm_startup_timers.c - - FOO -``` - -The preceding examples use substring matching, but, regular expression matching -could also be appropriate. - -#### Using YAML anchors & aliases for complex testing scenarios with `:defines` - -See the short but helpful article on [YAML anchors & aliases][yaml-anchors-aliases] to -understand these features of YAML. - -Particularly in testing complex projects, per-test file matching may only get you so -far in meeting your symbol definition needs. For instance, you may need to use the -same symbols across many test files, but no convenient name matching scheme works. -Advanced YAML features can help you copy the same symbols into multiple `:defines` -test file matchers. - -The following advanced example illustrates how to create a set of compilation symbols -for test preprocessing that are identical to test compilation with one addition. - -In brief, this example uses YAML features to copy the `:test` matcher configuration -that matches all test executables into the `:preprocess` context and then add an -additional compilation symbol to the list. - -```yaml -:defines: - :test: &config-test-defines # YAML anchor - :*: &match-all-tests # YAML anchor - - PRODUCT_FEATURE_X - - ASSERT_LEVEL=2 - - USES_RTOS=1 - :test_foo: - - DRIVER_FOO=1u - :test_bar: - - DRIVER_BAR=5u - :preprocess: - <<: *config-test-defines # Insert all :test defines file matchers via YAML alias - :*: # Override wildcard matching key in copy of *config-test-defines - - *match-all-tests # Copy test defines for all files via YAML alias - - RTOS_SPECIAL_THING # Add single additional symbol to all test executable preprocessing - # test_foo, test_bar, and any other matchers are present because of <<: above -``` - -## `:libraries` - -Ceedling allows you to pull in specific libraries for release and test builds with a -few levels of support. - -*

:libraries:test

- - Libraries that should be injected into your test builds when linking occurs. - - These can be specified as naked library names or with relative paths if search paths - are specified with `:paths` ↳ `:libraries`. Otherwise, absolute paths may be used - here. - - These library files **must** exist when tests build. - - **Default**: `[]` (empty) - -*

:libraries:release

- - Libraries that should be injected into your release build when linking occurs. - - These can be specified as naked library names or with relative paths if search paths - are specified with `:paths` ↳ `:libraries`. Otherwise, absolute paths may be used - here. - - These library files **must** exist when the release build occurs **unless** you - are using the _subprojects_ plugin. In that case, the plugin will attempt to build - the needed library for you as a dependency. - - **Default**: `[]` (empty) - -*

:libraries:system

- - Libraries listed here will be injected into releases and tests. - - These libraries are assumed to be findable by the configured linker tool, should need - no path help, and can be specified by common linker shorthand for libraries. - - For example, specifying `m` will include the math library per the GCC convention. The - file itself on a Unix-like system will be `libm` and the `gcc` command line argument - will be `-lm`. - - **Default**: `[]` (empty) - -### `:libraries` options - -* `:flag`: - - Command line argument format for specifying a library. - - **Default**: `-l${1}` (GCC format) - -* `:path_flag`: - - Command line argument format for adding a library search path. - - Library search paths may be added to your project with `:paths` ↳ `:libraries`. - - **Default**: `-L "${1}”` (GCC format) - -### `:libraries` example with YAML blurb - -```yaml -:paths: - :libraries: - - proj/libs # Linker library search paths - -:libraries: - :test: - - test/commsstub.lib # Imagined communication library that logs to console without traffic - :release: - - release/comms.lib # Imagined production communication library - :system: - - math # Add system math library to test & release builds - :flag: -Lib=${1} # This linker does not follow the gcc convention -``` - -### `:libraries` notes - -* If you've specified your own link step, you are going to want to add `${4}` to your - argument list in the position where library files should be added to the command line. - For `gcc`, this is often at the very end. Other tools may vary. See the `:tools` - section for more. - -## `:flags` Configure preprocessing, compilation & linking command line flags - -Ceedling’s internal, default tool configurations execute compilation and linking of test -and source files among a variety of other tooling needs. (See later `:tools` section.) - -These default tool configurations are a one-size-fits-all approach. If you need to add -flags to the command line for individual tests or a release build, the `:flags` section -allows you to easily do so. - -Entries in `:flags` modify the command lines for tools used at build time. - -### Flags organization: Contexts, Operations, and Matchers - -The basic layout of `:flags` involves the concepts of contexts and operations. - -General case: -```yaml -:flags: - :: # :test or :release - :: # :preprocess, :compile, :assemble, or :link - - - - ... -``` - -Advanced matching for **_test_** build handling only: -```yaml -:flags: - :test: - :: # :preprocess, :compile, :assemble, or :link - :: # Matches a subset of test executables - - # List of flags added to that subset's build operation command line - - ... -``` - -A context is the build context you want to modify — `:test` or `:release`. Plugins can -also hook into `:flags` with their own context. - -An operation is the build step you wish to modify — `:preprocess`, `:compile`, `:assemble`, -or `:link`. - -* The `:preprocess` operation is only used from within the `:test` context. -* The `:assemble` operation is only of use within the `:test` or `:release` contexts if - assembly support has been enabled in `:test_build` or `:release_build`, respectively, and - assembly files are a part of the project. - -You specify the flags you want to add to a build step beneath `:` ↳ `:`. -In many cases this is a simple YAML list of strings that will become flags in a tool's -command line. - -**_Specifically and only in the `:test` context_** you also have the option to create test -file matchers that apply flags to some subset of your test build. Note that file matchers -and the simpler flags list format cannot be mixed for `:flags` ↳ `:test`. - -*

:flags:release:compile

- - This project configuration entry adds the items of a simple YAML list as flags to - compilation of every C file in a release build. - - **Default**: `[]` (empty) - -*

:flags:release:link

- - This project configuration entry adds the items of a simple YAML list as flags to - the link step of a release build artifact. - - **Default**: `[]` (empty) - -*

:flags:test:compile

- - This project configuration entry adds the specified items as flags to compilation of C - components in a test executable's build. - - Flags may be represented in a simple YAML list or with a more sophisticated file matcher - YAML key plus flag list. Both are documented below. - - **Default**: `[]` (empty) - -*

:flags:test:preprocess

- - This project configuration entry adds the specified items as flags to any needed - preprocessing of components in a test executable’s build. Preprocessing must be enabled - for this matching to have any effect. (See `:project` ↳ `:use_test_preprocessor`.) - - Preprocessing here refers to handling macros, conditional includes, etc. in header files - that are mocked and in complex test files before runners are generated from them. - (See more about the [Ceedling preprocessing](#ceedling-preprocessing-behavior-for-your-tests) - feature.) - - Flags may be represented in a simple YAML list or with a more sophisticated file matcher - YAML key plus flag list. Both are documented below. - - _NOTE:_ Left unspecified, `:preprocess` flags default to behaving identically to `:compile` - flags. Override this behavior by adding `:test` ↳ `:preprocess` flags. If you want no - additional flags for preprocessing regardless of test compilation flags, simply specify - an empty list `[]`. - - **Default**: Same flags as specified for test compilation - -*

:flags:test:link

- - This project configuration entry adds the specified items as flags to the link step of - test executables. - - Flags may be represented in a simple YAML list or with a more sophisticated file matcher - YAML key plus flag list. Both are documented below. - - **Default**: `[]` (empty) - -*

:flags:<plugin context>

- - Some advanced plugins make use of build contexts as well. For instance, the Ceedling - Gcov plugin uses a context of `:gcov`, surprisingly enough. For any plugins with tools - that take advantage of Ceedling’s internal mechanisms, you can add to those tools' - flags in the same manner as the built-in contexts and operations. - -### Simple `:flags` configuration - -A simple and common need is enforcing a particular C standard. The following example -illustrates simple YAML lists for flags. - -```yaml -:flags: - :release: - :compile: - - -std=c99 # Add `-std=c99` to compilation of all C files in the release build - :test: - :compile: - - -std=c99 # Add `-std=c99` to the compilation of all C files in all test executables -``` - -Given the YAML blurb above, when test or release compilation occurs, the flag specifying -the C standard will be in the command line for compilation of all C files. - -### Advanced `:flags` per-test matchers - -Ceedling treats each test executable as a mini project. As a reminder, each test file, -together with all C sources and frameworks, becomes an individual test executable of -the same name. - -_In the `:test` context only_, flags can be applied to build step operations — -preprocessing, compilation, and linking — for only those test executables that match -file name criteria. Matchers match on test filenames only, and the specified flags -are added to the build step for all files that are components of matched test -executables. - -In short, for instance, this means your compilation of _TestA_ can have different flags -than compilation of _TestB_. And, in fact, those flags will be applied to every C file -that is compiled as part those individual test executable builds. - -#### `:flags` per-test matcher examples with YAML - -Before detailing matcher capabilities and limits, here are examples to illustrate the -basic ideas of test file name matching. - -```yaml -:flags: - :test: - :compile: - :*: # Wildcard: Add '-foo' for all files compiled for all test executables - - -foo - :Model: # Substring: Add '-Wall' for all files compiled for any test executable with 'Model' in its filename - - -Wall - :/M(ain|odel)/: # Regex: Add 🏴‍☠️ flag for all files compiled for any test executable with 'Main' or 'Model' in its filename - - -🏴‍☠️ - :Comms*Model: - - --freak # Wildcard: Add your `--freak` flag for all files compiled for any test executable with zero or more - # characters between 'Comms' and 'Model' - :link: - :tests/comm/TestUsart.c: # Substring: Add '--bar --baz' to the link step of the TestUsart executable - - --bar - - --baz -``` - -#### Using `:flags` per-test matchers - -These matchers are available: - -1. Wildcard (`*`) - 1. If specified in isolation, matches all tests. - 1. If specified within a string, matches any test filename with that - wildcard expansion. -1. Substring — Matches on part of a test filename (up to all of it, including - full path). -1. Regex (`/.../`) — Matches test file names against a regular expression. - -Notes: -* Substring filename matching is case sensitive. -* Wildcard matching is effectively a simplified form of regex. That is, - multiple approaches to matching can match the same filename. - -Flags by matcher are cumulative. This means the flags from multiple matchers can be -applied to all files processed by the named build operation for any single test executable. - -Referencing the example above, here are the extra compilation flags for a handful of -test executables: - -* _test_Something_: `-foo` -* _test_Main_: `-foo -🏴‍☠️` -* _test_Model_: `-foo -Wall -🏴‍☠️` -* _test_CommsSerialModel_: `-foo -Wall -🏴‍☠️ --freak` - -The simple `:flags` list format remains available for the `:test` context. Of course, -this format is limited in that it applies flags to all C files processed by the named -build operation for all test executables. - -This simple list format for the `:test` context… - -```yaml -:flags: - :test: - :compile: - - -foo -``` - -…is equivalent to this matcher version: - -```yaml -:flags: - :test: - :compile: - :*: - - -foo -``` - -#### Distinguishing similar or identical filenames with `:flags` per-test matchers - -You may find yourself needing to distinguish test files with the same name or test -files with names whose base naming is identical. - -Of course, identical test filenames have a natural distinguishing feature in their -containing directory paths. Files of the same name can only exist in different -directories. As such, your matching must include the path. - -```yaml -:flags: - :test: - :compile: - :hardware/test_startup: # Match any test names beginning with 'test_startup' in hardware/ directory - - A - :network/test_startup: # Match any test names beginning with 'test_startup' in network/ directory - - B -``` - -It's common in C file naming to use the same base name for multiple files. Given the -following example list, care must be given to matcher construction to single out -test_comm_startup.c. - -* tests/test_comm_hw.c -* tests/test_comm_startup.c -* tests/test_comm_startup_timers.c - -```yaml -:flags: - :test: - :compile: - :test_comm_startup.c: # Full filename with extension distinguishes this file test_comm_startup_timers.c - - FOO -``` - -The preceding examples use substring matching, but, regular expression matching -could also be appropriate. - -#### Using YAML anchors & aliases for complex testing scenarios with `:flags` - -See the short but helpful article on [YAML anchors & aliases][yaml-anchors-aliases] to -understand these features of YAML. - -Particularly in testing complex projects, per-test file matching may only get you so -far in meeting your build step flag needs. For instance, you may need to set various -flags for operations across many test files, but no convenient name matching scheme -works. Advanced YAML features can help you copy the same flags into multiple `:flags` -test file matchers. - -Please see the discussion in `:defines` for a complete example. - -## `:cexception` Configure CException’s features - -* `:defines`: - - List of symbols used to configure CException's features in its source and header files - at compile time. - - See [Using Unity, CMock & CException](#using-unity-cmock--cexception) for much more on - configuring and making use of these frameworks in your build. - - To manage overall command line length, these symbols are only added to compilation when - a CException C source file is compiled. - - No symbols must be set unless CException's defaults are inappropriate for your - environment and needs. - - Note CException must be enabled for it to be added to a release or test build and for - these symbols to be added to a build of CException (see link referenced earlier for more). - - **Default**: `[]` (empty) - -## `:cmock` Configure CMock’s code generation & compilation - -Ceedling sets values for a subset of CMock settings. All CMock options are -available to be set, but only those options set by Ceedling in an automated -fashion are documented below. See CMock documentation. - -Ceedling sets values for a subset of CMock settings. All CMock options are -available to be set, but only those options set by Ceedling in an automated -fashion are documented below. See [CMock] documentation. - -* `:enforce_strict_ordering`: - - Tests fail if expected call order is not same as source order - - **Default**: TRUE - -* `:verbosity`: - - If not set, defaults to Ceedling’s verbosity level - -* `:defines`: - - Adds list of symbols used to configure CMock’s C code features in its source and header - files at compile time. - - See [Using Unity, CMock & CException](#using-unity-cmock--cexception) for much more on - configuring and making use of these frameworks in your build. - - To manage overall command line length, these symbols are only added to compilation when - a CMock C source file is compiled. - - No symbols must be set unless CMock’s defaults are inappropriate for your environment - and needs. - - **Default**: `[]` (empty) - -* `:plugins`: - - To enable CMock’s optional and advanced features available via CMock plugin, simply add - `:cmock` ↳ `:plugins` to your configuration and specify your desired additional CMock - plugins as a simple list of the plugin names. - - See [CMock's documentation][cmock-docs] to understand plugin options. - - [cmock-docs]: https://github.com/ThrowTheSwitch/CMock/blob/master/docs/CMock_Summary.md - - **Default**: `[]` (empty) - -* `:unity_helper_path`: - - A Unity helper is a simple header file used by convention to support your specialized - test case needs. For example, perhaps you want a Unity assertion macro for the - contents of a struct used throughout your project. Write the macro you need in a Unity - helper header file and `#include` that header file in your test file. - - When a Unity helper is provided to CMock, it takes on more significance, and more - magic happens. CMock parses Unity helper header files and uses macros of a certain - naming convention to extend CMock’s handling of mocked parameters. - - See the [Unity] and [CMock] documentation for more details. - - `:unity_helper_path` may be a single string or a list. Each value must be a relative - path from your Ceedling working directory to a Unity helper header file (these are - typically organized within containing Ceedling `:paths` ↳ `:support` directories). - - **Default**: `[]` (empty) - -* `:includes`: - - In certain advanced testing scenarios, you may need to inject additional header files - into generated mocks. The filenames in this list will be transformed into `#include` - directives created at the top of every generated mock. - - If `:unity_helper_path` is in use (see preceding), the filenames at the end of any - Unity helper file paths will be automatically injected into this list provided to - CMock. - - **Default**: `[]` (empty) - -### Notes on Ceedling’s nudges for CMock strict ordering - -The preceding settings are tied to other Ceedling settings; hence, why they are -documented here. - -The first setting above, `:enforce_strict_ordering`, defaults to `FALSE` within -CMock. However, it is set to `TRUE` by default in Ceedling as our way of -encouraging you to use strict ordering. - -Strict ordering is teeny bit more expensive in terms of code generated, test -execution time, and complication in deciphering test failures. However, it’s -good practice. And, of course, you can always disable it by overriding the -value in the Ceedling project configuration file. - -## `:unity` Configure Unity’s features - -* `:defines`: - - Adds list of symbols used to configure Unity's features in its source and header files - at compile time. - - See [Using Unity, CMock & CException](#using-unity-cmock--cexception) for much more on - configuring and making use of these frameworks in your build. - - To manage overall command line length, these symbols are only added to compilation when - a Unity C source file is compiled. - - **_Note_**: No symbols must be set unless Unity's defaults are inappropriate for your - environment and needs. - - **Default**: `[]` (empty) - -* `:use_param_tests`: - - Configures Unity test runner generation and `#define`s for test compilation to support - Unity’s parameterized test cases. - - Example parameterized test case: - - ```C - TEST_RANGE([5, 100, 5]) - void test_should_handle_divisible_by_5_for_parameterized_test_range(int num) { - TEST_ASSERT_EQUAL(0, (num % 5)); - } - ``` - - See [Unity] documentation for more on parameterized test cases. - - _**Note:**_ Unity’s parameterized tests are incompatible with Ceedling’s preprocessing - features enabled for test files. See more in [Ceedling’s preprocessing documentation](#preprocessing-gotchas) . - - **Default**: false - -## `:test_runner` Configure test runner generation - -The format of Ceedling test files — the C files that contain unit test cases — -is intentionally simple. It’s pure code and all legit, simple C with `#include` -statements, test case functions, and optional `setUp()` and `tearDown()` -functions. - -To create test executables, we need a `main()` and a variety of calls to the -Unity framework to “hook up” all your test cases into a test suite. You can do -this by hand, of course, but it's tedious and needed updates as code evolves -are easily forgotten. - -So, Unity provides a script able to generate a test runner in C for you. It -relies on [ceedling-conventions] used in your test files. Ceedling takes this -a step further by calling this script for you with all the needed parameters. - -Test runner generation is configurable. The `:test_runner` section of your -Ceedling project file allows you to pass options to Unity’s runner generation -script. Based on other Ceedling options, Ceedling also sets certain test runner -generation configuration values for you. - -[Test runner configuration options are documented in the Unity project][unity-runner-options]. - -**_Notes:_** - -* **Unless you have advanced or unique needs, Unity test runner generation - configuration in Ceedling is generally not needed.** -* In previous versions of Ceedling, the test runner option - `:cmdline_args` was needed for certain advanced test suite features. This - option is still needed, but Ceedling automatically sets it for you in the - scenarios requiring it. Be aware that this option works well in desktop, - native testing but is generally unsupported by emulators running test - executables (the idea of command line arguments passed to an executable is - generally only possible with desktop command line terminals.) - -Example configuration: - -```yaml -:test_runner: - # Insert additional #include statements in a generated runner - :includes: - - Foo.h - - Bar.h -``` - -[ceedling-conventions]: #important-conventions--behaviors -[unity-runner-options]: https://github.com/ThrowTheSwitch/Unity/blob/master/docs/UnityHelperScriptsGuide.md#options-accepted-by-generate_test_runnerrb - -## `:tools` Configuring command line tools used for build steps - -Ceedling requires a variety of tools to work its magic. By default, the GNU -toolchain (`gcc`, `cpp`, `as` — and `gcov` via plugin) are configured and ready -for use with no additions to your project configuration YAML file. - -A few items before we dive in: - -1. Sometimes Ceedling’s built-in tools are _nearly_ what you need but not - quite. If you only need to add some arguments to all uses of tool's command - line, Ceedling offers a shortcut to do so. See the - [final section of the `:tools`][tool-definition-shortcuts] documentation for - details. -1. If you need fine-grained control of the arguments Ceedling uses in the build - steps for test executables, see the documentation for [`:flags`][flags]. - Ceedling allows you to control the command line arguments for each test - executable build — with a variety of pattern matching options. -1. If you need to link libraries — your own or standard options — please see - the [top-level `:libraries` section][libraries] available for your - configuration file. Ceedling supports a number of useful options for working - with pre-compiled libraries. If your library linking needs are super simple, - the shortcut in (1) might be the simplest option. - -[flags]: #flags-configure-preprocessing-compilation--linking-command-line-flags -[tool-definition-shortcuts]: #ceedling-tool-modification-shortcuts - -### Ceedling tools for test suite builds - -Our recommended approach to writing and executing test suites relies on the GNU -toolchain. _*Yes, even for embedded system work on platforms with their own, -proprietary C toolchain.*_ Please see -[this section of documentation][sweet-suite] to understand this recommendation -among all your options. - -You can and sometimes must run a Ceedling test suite in an emulator or on -target, and Ceedling allows you to do this through tool definitions documented -here. Generally, you’ll likely want to rely on the default definitions. - -[sweet-suite]: #all-your-sweet-sweet-test-suite-options - -### Ceedling tools for release builds - -More often than not, release builds require custom tool definitions. The GNU -toolchain is configured for Ceeding release builds by default just as with test -builds. you’ll likely need your own definitions for `:release_compiler`, -`:release_linker`, and possibly `:release_assembler`. - -### Ceedling plugin tools - -Ceedling plugins are free to define their own tools that are loaded into your -project configuration at startup. Plugin tools are defined using the same -mechanisns as Ceedling’s built-in tools and are called the same way. That is, -all features available to you for working with tools as an end users are -generally available for working with plugin-based tools. This presumes a -plugin author followed guidance and convention in creating any command line -actions. - -### Ceedling tool definitions - -Contained in this section are details on Ceedling’s default tool definitions. -For sake of space, the entirety of a given definition is not shown. If you need -to get in the weeds or want a full example, see the file `defaults.rb` in -Ceedling’s lib/ directory. - -#### Tool definition overview - -Listed below are the built-in tool names, corresponding to build steps along -with the numbered parameters that Ceedling uses to fill out a full command line -for the named tool. The full list of fundamental elements for a tool definition -are documented in the sections that follow along with examples. - -Not every numbered parameter listed immediately below must be referenced in a -Ceedling tool definition. If `${4}` isn’t referenced by your custom tool, -Ceedling simply skips it while expanding a tool definition into a command line. - -The numbered parameters below are references that expand / are replaced with -actual values when the corresponding command line is constructed. If the values -behind these parameters are lists, Ceedling expands the containing reference -multiple times with the contents of the value. A conceptual example is -instructive… - -#### Simplified tool definition / expansion example - -A partial tool definition: - -```yaml -:tools: - :power_drill: - :executable: dewalt.exe - :arguments: - - "--X${3}" -``` - -Let's say that `${3}` is a list inside Ceedling, `[2, 3, 7]`. The expanded tool -command line for `:tools` ↳ `:power_drill` would look like this: - -```shell - > dewalt.exe --X2 --X3 --X7 -``` - -#### Ceedling’s default build step tool definitions - -**_NOTE:_** Ceedling’s tool definitions for its preprocessing and backtrace -features are not documented here. Ceedling’s use of tools for these features -are tightly coupled to the options and output of those tools. Drop-in -replacements using other tools are not practically possible. Eventually, an -improved plugin system will provide options for integrating alternative tools. - -* `:test_compiler`: - - Compiler for test & source-under-test code - - - `${1}`: Input source - - `${2}`: Output object - - `${3}`: Optional output list - - `${4}`: Optional output dependencies file - - `${5}`: Header file search paths - - `${6}`: Command line #defines - - **Default**: `gcc` - -* `:test_assembler`: - - Assembler for test assembly code - - - `${1}`: input assembly source file - - `${2}`: output object file - - `${3}`: search paths - - `${4}`: #define symbols (accepted but ignored by GNU assembler) - - **Default**: `as` - -* `:test_linker`: - - Linker to generate test fixture executables - - - `${1}`: input objects - - `${2}`: output binary - - `${3}`: optional output map - - `${4}`: optional library list - - `${5}`: optional library path list - - **Default**: `gcc` - -* `:test_fixture`: - - Executable test fixture - - - `${1}`: simulator as executable with`${1}` as input binary file argument or native test executable - - **Default**: `${1}` - -* `:release_compiler`: - - Compiler for release source code - - - `${1}`: input source - - `${2}`: output object - - `${3}`: optional output list - - `${4}`: optional output dependencies file - - **Default**: `gcc` - -* `:release_assembler`: - - Assembler for release assembly code - - - `${1}`: input assembly source file - - `${2}`: output object file - - `${3}`: search paths - - `${4}`: #define symbols (accepted but ignored by GNU assembler) - - **Default**: `as` - -* `:release_linker`: - - Linker for release source code - - - `${1}`: input objects - - `${2}`: output binary - - `${3}`: optional output map - - `${4}`: optional library list - - `${5}`: optional library path list - - **Default**: `gcc` - -#### Tool defintion configurable elements - -1. `:executable` - Command line executable (required). - - NOTE: If an executable contains a space (e.g. `Code Cruncher`), and the - shell executing the command line generated from the tool definition needs - the name quoted, add escaped quotes in the YAML: - - ```yaml - :tools: - :test_compiler: - :executable: \"Code Cruncher\" - ``` - -1. `:arguments` - List (array of strings) of command line arguments and - substitutions (required). - -1. `:name` - Simple name (i.e. "nickname") of tool beyond its - executable name. This is optional. If not explicitly set - then Ceedling will form a name from the tool's YAML entry key. - -1. `:stderr_redirect` - Control of capturing `$stderr` messages - {`:none`, `:auto`, `:win`, `:unix`, `:tcsh`}. - Defaults to `:none` if unspecified. You may create a custom entry by - specifying a simple string instead of any of the recognized - symbols. As an example, the `:unix` symbol maps to the string `2>&1` - that is automatically inserted at the end of a command line. - - This option is rarely necessary. `$stderr` redirection was originally - often needed in early versions of Ceedling. Shell output stream handling - is now automatically handled. This option is preserved for possible edge - cases. - -1. `:optional` - By default a tool you define is required for operation. This - means a build will be aborted if Ceedling cannot find your tool’s executable - in your environment. However, setting `:optional` to `true` causes this - check to be skipped. This is most often needed in plugin scenarios where a - tool is only needed if an accompanying configuration option requires it. In - such cases, a programmatic option available in plugin Ruby code using the - Ceedling class `ToolValidator` exists to process tool definitions as needed. - -#### Tool element runtime substitution - -To accomplish useful work on multiple files, a configured tool will most often -require that some number of its arguments or even the executable itself change -for each run. Consequently, every tool’s argument list and executable field -possess two means for substitution at runtime. - -Ceedling provides inline Ruby string expansion and a notation for populating -tool elements with dynamically gathered values within the build environment. - -##### Tool element runtime substitution: Inline Ruby string expansion - -`"#{...}"`: This notation is that of the beloved -[inline Ruby string expansion][inline-ruby-string-expansion] available in a -variety of configuration file sections. This string expansion occurs each -time a tool configuration is executed during a build. - -##### Tool element runtime substitution: Notational substitution - -A Ceedling tool's other form of dynamic substitution relies on a `$` notation. -These `$` operators can exist anywhere in a string and can be decorated in any -way needed. To use a literal `$`, escape it as `\\$`. - -* `$`: Simple substitution for value(s) globally available within the runtime - (most often a string or an array). - -* `${#}`: When a Ceedling tool's command line is expanded from its configured - representation, runs of that tool will be made with a parameter list of - substitution values. Each numbered substitution corresponds to a position in - a parameter list. - - * In the case of a compiler `${1}` will be a C code file path, and `$ - {2}` will be the file path of the resulting object file. - - * For a linker `${1}` will be an array of object files to link, and `$ - {2}` will be the resulting binary executable. - - * For an executable test fixture `${1}` is either the binary executable - itself (when using a local toolchain such as GCC) or a binary input file - given to a simulator in its arguments. - -### Example `:tools` YAML blurb - -```yaml -:tools: - :test_compiler: - :executable: compiler # Exists in system search path - :name: 'acme test compiler' - :arguments: - - -I"${5}" # Expands to -I search paths from `:paths` section + build directive path macros - - -D"${6}" # Expands to all -D defined symbols from `:defines` section - - --network-license # Simple command line argument - - -optimize-level 4 # Simple command line argument - - "#{`args.exe -m acme.prj`}" # In-line Ruby call to shell out & build string of arguments - - -c ${1} # Source code input file - - -o ${2} # Object file output - - :test_linker: - :executable: /programs/acme/bin/linker.exe # Full file path - :name: 'acme test linker' - :arguments: - - ${1} # List of object files to link - - -l$-lib: # In-line YAML array substitution to link in foo-lib and bar-lib - - foo - - bar - - -o ${2} # Binary output artifact - - :test_fixture: - :executable: tools/bin/acme_simulator.exe # Relative file path to command line simulator - :name: 'acme test fixture' - :stderr_redirect: :win # Inform Ceedling what model of $stderr capture to use - :arguments: - - -mem large # Simple command line argument - - -f "${1}" # Binary executable input file for simulator -``` - -#### `:tools` example blurb notes - -* `${#}` is a replacement operator expanded by Ceedling with various - strings, lists, etc. assembled internally. The meaning of each - number is specific to each predefined default tool (see - documentation above). - -* See [search path order][##-search-path-order] to understand how - the `-I"${5}"` term is expanded. - -* At present, `$stderr` redirection is primarily used to capture - errors from test fixtures so that they can be displayed at the - conclusion of a test run. For instance, if a simulator detects - a memory access violation or a divide by zero error, this notice - might go unseen in all the output scrolling past in a terminal. - -* The built-in preprocessing tools _can_ be overridden with - non-GCC equivalents. However, this is highly impractical to do - as preprocessing features are quite dependent on the - idiosyncrasies and features of the GCC toolchain. - -#### Example Test Compiler Tooling - -Resulting compiler command line construction from preceding example -`:tools` YAML blurb… - -```shell -> compiler -I"/usr/include” -I”project/tests” - -I"project/tests/support” -I”project/source” -I”project/include” - -DTEST -DLONG_NAMES -network-license -optimize-level 4 arg-foo - arg-bar arg-baz -c project/source/source.c -o - build/tests/out/source.o -``` - -Notes on compiler tooling example: - -- `arg-foo arg-bar arg-baz` is a fabricated example string collected from - `$stdout` as a result of shell execution of `args.exe`. -- The `-c` and `-o` arguments are fabricated examples simulating a single - compilation step for a test; `${1}` & `${2}` are single files. - -#### Example Test Linker Tooling - -Resulting linker command line construction from preceding example -`:tools` YAML blurb… - -```shell -> \programs\acme\bin\linker.exe thing.o unity.o - test_thing_runner.o test_thing.o mock_foo.o mock_bar.o -lfoo-lib - -lbar-lib -o build\tests\out\test_thing.exe -``` - -Notes on linker tooling example: - -- In this scenario `${1}` is an array of all the object files needed to - link a test fixture executable. - -#### Example Test Fixture Tooling - -Resulting test fixture command line construction from preceding example -`:tools` YAML blurb… - -```shell -> tools\bin\acme_simulator.exe -mem large -f "build\tests\out\test_thing.bin 2>&1” -``` - -Notes on test fixture tooling example: - -1. `:executable` could have simply been `${1}` if we were compiling - and running native executables instead of cross compiling. That is, - if the output of the linker runs on the host system, then the test - fixture _is_ `${1}`. -1. We’re using `$stderr` redirection to allow us to capture simulator error - messages to `$stdout` for display at the run's conclusion. - -### Ceedling tool modification shortcuts - -Sometimes Ceedling’s default tool defininitions are _this close_ to being just -what you need. But, darn, you need one extra argument on the command line, or -you just need to hack the tool executable. You’d love to get away without -overriding an entire tool definition just in order to tweak it. - -We got you. - -#### Ceedling tool executable replacement - -Sometimes you need to do some sneaky stuff. We get it. This feature lets you -replace the executable of a tool definition — including an internal default — -with your own. - -To use this shortcut, simply add a configuration section to your project file at -the top-level, `:tools_` ↳ `:executable`. Of course, you can -combine this with the following modification option in a single block for the -tool. Executable replacement can make use of -[inline Ruby string expansion][inline-ruby-string-expansion]. - -See the list of tool names at the beginning of the `:tools` documentation to -identify the named options. Plugins can also include their own tool definitions -that can be modified with this same option. - -This example YAML... - -```yaml -:tools_test_compiler: - :executable: foo -``` - -... will produce the following: - -```shell - > foo -``` - -#### Ceedling tool arguments addition shortcut - -Now, this little feature only allows you to add arguments to the end of a tool -command line. Not the beginning. And, you can’t remove arguments with this -option. - -Further, this little feature is a blanket application across all uses of a tool. -If you need fine-grained control of command line flags in build steps per test -executable, please see the [`:flags` configuration documentation][flags]. - -To use this shortcut, simply add a configuration section to your project file at -the top-level, `:tools_` ↳ `:arguments`. Of course, you can -combine this with the preceding modification option in a single block for the -tool. - -See the list of tool names at the beginning of the `:tools` documentation to -identify the named options. Plugins can also include their own tool definitions -that can be modified with this same hack. - -This example YAML... - -```yaml -:tools_test_compiler: - :arguments: - - --flag # Add `--flag` to the end of all test C file compilation -``` - -... will produce the following (for the default executable): - -```shell - > gcc --flag -``` - -## `:plugins` Ceedling extensions - -See the section below dedicated to plugins for more information. This section -pertains to enabling plugins in your project configuration. - -Ceedling includes a number of built-in plugins. See the collection within -the project at [plugins/][ceedling-plugins] or the [documentation section below](#ceedling-plugins) -dedicated to Ceedling’s plugins. Each built-in plugin subdirectory includes -thorough documentation covering its capabilities and configuration options. - -_Note_: Many users find that the handy-dandy [Command Hooks plugin][command-hooks] -is often enough to meet their needs. This plugin allows you to connect your own -scripts and command line tools to Ceedling build steps. - -[custom-plugins]: PluginDevelopmentGuide.md -[ceedling-plugins]: ../plugins/ -[command-hooks]: ../plugins/command_hooks/ - -* `:load_paths`: - - Base paths to search for plugin subdirectories or extra Ruby functionality. - - Ceedling maintains the Ruby load path for its built-in plugins. This list of - paths allows you to add your own directories for custom plugins or simpler - Ruby files referenced by your Ceedling configuration options elsewhere. - - **Default**: `[]` (empty) - -* `:enabled`: - - List of plugins to be used - a plugin's name is identical to the - subdirectory that contains it. - - **Default**: `[]` (empty) - -Plugins can provide a variety of added functionality to Ceedling. In -general use, it's assumed that at least one reporting plugin will be -used to format test results (usually `report_tests_pretty_stdout`). - -If no reporting plugins are specified, Ceedling will print to `$stdout` the -(quite readable) raw test results from all test fixtures executed. - -### Example `:plugins` YAML blurb - -```yaml -:plugins: - :load_paths: - - project/tools/ceedling/plugins # Home to your collection of plugin directories. - - project/support # Home to some ruby code your custom plugins share. - :enabled: - - report_tests_pretty_stdout # Nice test results at your command line. - - our_custom_code_metrics_report # You created a plugin to scan all code to collect - # line counts and complexity metrics. Its name is a - # subdirectory beneath the first `:load_path` entry. - -``` - -
- -# Which Ceedling - -In certain scenarios you may need to run a different version of Ceedling. -Typically, Ceedling developers need this ability. But, it could come in -handy in certain advanced Continuous Integration build scenarios or some -sort of version behavior comparison. - -It’s not uncommon in Ceedling development work to have the last production -gem installed while modifying the application code in a locally cloned -repository. Or, you may be bouncing between local versions of Ceedling to -troubleshoot changes. - -Which Ceedling handling gives you options on what gets run. - -## Which Ceedling background - -Ceedling is usually packaged and installed as a Ruby Gem. This gem ends -up installed in an appropriate place by the `gem` package installer. -Inside the gem installation is the entire Ceedling project. The `ceedling` -command line launcher lives in `bin/` while the Ceedling application lives -in `lib/`. The code in `/bin` manages lots of startup details and base -configuration. Ultimately, it then launches the main application code from -`lib/`. - -The features and conventions controlling _which ceedling_ dictate which -application code the `ceedling` command line handler launches. - -_NOTE:_ If you are a developer working on the code in Ceedling’s `bin/` -and want to run it while a gem is installed, you must take the additional -step of specifying the path to the `ceedling` launcher in your file system. - -In Unix-like systems, this will look like: -`> my/ceedling/changes/bin/ceedling `. - -On Windows systems, you may need to run: -`> ruby my\ceedling\changes\bin\ceedling `. - -## Which Ceedling options and precedence - -When Ceedling starts up, it evaluates a handful of conditions to determine -which Ceedling location to launch. - -The following are evaluated in order: - -1. Environment variable `WHICH_CEEDLING`. If this environment variable is - set, its value is used. -1. Configuration entry `:project` ↳ `:which_ceedling`. If this is set, - its value is used. -1. The path `vendor/ceedling`. If this path exists in your working - directory — typically because of a `--local` vendored installation at - project creation — its contents are used to launch Ceedling. -1. If none of the above exist, the `ceedling` launcher defaults to using - the `lib/` directory next to the `bin/` directory from which the - `ceedling` launcher is running. In the typical case this is the default - gem installation. - -_NOTE:_ Configuration entry (2) does not make sense in some scenarios. -When running `ceedling new`, `ceedling examples`, or `ceedling example` -there is no project file to read. Similarly, `ceedling upgrade` does not -load a project file; it merely works with the directory structure and -contets of a project. In these cases, the environment variable is your -only option to set which Ceedling to launch. - -## Which Ceedling settings - -The environment variable and configuration entry for _Which Ceedling_ can -contain two values: - -1. The value `gem` indicates that the command line `ceedling` launcher - should run the application packaged alongside it in `lib/` (these - paths are typically found in the gem installation location). -1. A relative or absolute path in your file system. Such a path should - point to the top-level directory that contains Ceedling’s `bin/` and - `lib/` sub-directories. - -
- -# Build Directive Macros - -## Overview of Build Directive Macros - -Ceedling supports a small number of build directive macros. At present, -these macros are only for use in test files. - -By placing these macros in your test files, you may control aspects of an -individual test executable's build from within the test file itself. - -These macros are actually defined in Unity, but they evaluate to empty -strings. That is, the macros do nothing and only serve as text markers for -Ceedling to parse. But, by placing them in your test files they -communicate instructions to Ceedling when scanned at the beginning of a -test build. - -**_Notes:_** - -- Since these macros are defined in _unity.h_, it’s essential to - `#include "unity.h"` before making use of them in your test file. - Typically, _unity.h_ is referenced at or near the top of a test file - anyhow, but this is an important detail to call out. -- **`TEST_SOURCE_FILE()` and `TEST_INCLUDE_PATH()`, new in Ceedling - 1.0.0, are incompatible with enclosing conditional compilation C - preprocessing statements.** See - [Ceedling’s preprocessing documentation](#preprocessing-gotchas) - for more details. - -## `TEST_SOURCE_FILE()` - -### `TEST_SOURCE_FILE()` Purpose - -The `TEST_SOURCE_FILE()` build directive allows the simple injection of -a specific source file into a test executable’s build. - -The Ceedling [convention][ceedling-conventions] of compiling and linking -any C file that corresponds in name to an `#include`d header file does -not always work. A given source file may not have a header file that -corresponds directly to its name. In some specialized cases, a source -file may not rely on a header file at all. - -Attempting to `#include` a needed C source file directly is both ugly and -can cause various build problems with duplicated symbols, etc. - -`TEST_SOURCE_FILE()` is the way to cleanly and simply add a given C file -to the executable built from a test file. `TEST_SOURCE_FILE()` is also one -of the best methods for adding an assembly file to the build of a given -test executable—if assembly support is enabled for test builds. - -### `TEST_SOURCE_FILE()` Usage - -The argument for the `TEST_SOURCE_FILE()` build directive macro is a -single filename or filepath as a string enclosed in quotation marks. Use -forward slashes for path separators. The filename or filepath must be -present within Ceedling’s source file collection. - -To understand your source file collection: - -- See the documentation for project file configuration section - [`:paths`](#project-paths-configuration). -- Dump a listing your project’s source files with the command line task - `ceedling files:source`. - -Multiple uses of `TEST_SOURCE_FILE()` are perfectly fine. You’ll likely -want one per line within your test file. - -### `TEST_SOURCE_FILE()` Example - -```c -/* - * Test file test_mycode.c to exercise functions in mycode.c. - */ - -#include "unity.h" // Contains TEST_SOURCE_FILE() definition -#include "support.h" // Needed symbols and macros -//#include "mycode.h" // Header file corresponding to mycode.c by convention does not exist - -// Tell Ceedling to compile and link mycode.c as part of the test_mycode executable -TEST_SOURCE_FILE("foo/bar/mycode.c") - -// --- Unit test framework calls --- - -void setUp(void) { - ... -} - -void test_MyCode_FooBar(void) { - ... -} -``` - -## `TEST_INCLUDE_PATH()` - -### `TEST_INCLUDE_PATH()` Purpose - -The `TEST_INCLUDE_PATH()` build directive allows a header search path to -be injected into the build of an individual test executable. - -Unless you have a pretty funky C project, generally at least one search path entry -is necessary for every test executable build. That path can come from a `:paths` -↳ `:include` entry in your project configuration or by using `TEST_INCLUDE_PATH()` -in a test file. - -Please see [Configuring Your Header File Search Paths][header-file-search-paths] -for an overview of Ceedling’s options and conventions for header file search paths. - -### `TEST_INCLUDE_PATH()` Usage - -`TEST_INCLUDE_PATH()` entries in your test file are only an additive customization. -The path will be added to the base / common path list specified by -`:paths` ↳ `:include` in the project file. If no list is specified in your project -configuration, `TEST_INCLUDE_PATH()` entries will comprise the entire header search -path list. - -The argument for the `TEST_INCLUDE_PATH()` build directive macro is a single -filepath as a string enclosed in quotation marks. Use forward slashes for -path separators. - -**_Note_**: At present, a limitation of the `TEST_INCLUDE_PATH()` build directive -macro is that paths are relative to the working directory from which you are -executing `ceedling`. A change to your working directory could require updates to -the path arguments of dall instances of `TEST_INCLUDE_PATH()`. - -Multiple uses of `TEST_INCLUDE_PATH()` are perfectly fine. You’ll likely want one -per line within your test file. - -[header-file-search-paths]: #configuring-your-header-file-search-paths - -### `TEST_INCLUDE_PATH()` Example - -```c -/* - * Test file test_mycode.c to exercise functions in mycode.c. - */ - -#include "unity.h" // Contains TEST_INCLUDE_PATH() definition -#include "somefile.h" // Needed symbols and macros - -// Add the following to the compiler's -I search paths used to -// compile all components comprising the test_mycode executable. -TEST_INCLUDE_PATH("foo/bar/") -TEST_INCLUDE_PATH("/usr/local/include/baz/") - -// --- Unit test framework calls --- - -void setUp(void) { - ... -} - -void test_MyCode_FooBar(void) { - ... -} -``` - -
- -# Ceedling Plugins - -Ceedling includes a number of plugins. See the collection of built-in [plugins/][ceedling-plugins] -or consult the list with summaries and links to documentation in the subsection -that follows. Each plugin subdirectory includes full documentation of its -capabilities and configuration options. - -To enable built-in plugins or your own custom plugins, see the documentation for -the `:plugins` section in Ceedling project configuation options. - -Many users find that the handy-dandy [Command Hooks plugin][command-hooks] -is often enough to meet their needs. This plugin allows you to connect your own -scripts and tools to Ceedling build steps. - -As mentioned, you can create your own plugins. See the [guide][custom-plugins] -for how to create custom plugins. - -[//]: # (Links in this section already defined above) - -## Ceedling’s built-in plugins, a directory - -### Ceedling plugin `report_tests_pretty_stdout` - -[This plugin][report_tests_pretty_stdout] is meant to be the default for -printing test results to the console. Without it, readable test results are -still produced but are not nicely formatted and summarized. - -Plugin output includes a well-formatted list of summary statistics, ignored and -failed tests, and any extraneous output (e.g. `printf()` statements or -simulator memory errors) collected from executing the test fixtures. - -Alternatives to this plugin are: - - * `report_tests_ide_stdout` - * `report_tests_gtestlike_stdout` - -Both of the above write to the console test results with a format that is useful -to IDEs generally in the case of the former, and GTest-aware reporting tools in -the case of the latter. - -[report_tests_pretty_stdout]: ../plugins/report_tests_pretty_stdout - -### Ceedling plugin `report_tests_ide_stdout` - -[This plugin][report_tests_ide_stdout] prints to the console test results -formatted similarly to `report_tests_pretty_stdout` with one key difference. -This plugin's output is formatted such that an IDE executing Ceedling tasks can -recognize file paths and line numbers in test failures, etc. - -This plugin's formatting is often recognized in an IDE's build window and -automatically linked for file navigation. With such output, you can select a -test result in your IDE's execution window and jump to the failure (or ignored -test) in your test file (more on using [IDEs] with Ceedling, Unity, and -CMock). - -If enabled, this plugin should be used in place of -`report_tests_pretty_stdout`. - -[report_tests_ide_stdout]: ../plugins/report_tests_ide_stdout - -[IDEs]: https://www.throwtheswitch.org/ide - -### Ceedling plugin `report_tests_teamcity_stdout` - -[TeamCity] is one of the original Continuous Integration server products. - -[This plugin][report_tests_teamcity_stdout] processes test results into TeamCity -service messages printed to the console. TeamCity's service messages are unique -to the product and allow the CI server to extract build steps, test results, -and more from software builds if present. - -The output of this plugin is useful in actual CI builds but is unhelpful in -local developer builds. See the plugin's documentation for options to enable -this plugin only in CI builds and not in local builds. - -[TeamCity]: https://jetbrains.com/teamcity -[report_tests_teamcity_stdout]: ../plugins/report_tests_teamcity_stdout - -### Ceedling plugin `report_tests_gtestlike_stdout` - -[This plugin][report_tests_gtestlike_stdout] collects test results and prints -them to the console in a format that mimics [Google Test's output][gtest-sample-output]. -Google Test output is both human readable and recognized -by a variety of reporting tools, IDEs, and Continuous Integration servers. - -If enabled, this plugin should be used in place of -`report_tests_pretty_stdout`. - -[gtest-sample-output]: -https://subscription.packtpub.com/book/programming/9781800208988/11/ch11lvl1sec31/controlling-output-with-google-test -[report_tests_gtestlike_stdout]: ../plugins/report_tests_gtestlike_stdout - -### Ceedling plugin `command_hooks` - -[This plugin][command-hooks] provides a simple means for connecting Ceedling’s build events to -Ceedling tool entries you define in your project configuration (see `:tools` -documentation). In this way you can easily connect your own scripts or command -line utilities to build steps without creating an entire custom plugin. - -[//]: # (Links defined in a previous section) - -### Ceedling plugin `module_generator` - -A pattern emerges in day-to-day unit testing, especially in the practice of -Test- Driven Development. Again and again, one needs a triplet of a source -file, header file, and test file — scaffolded in such a way that they refer to -one another. - -[This plugin][module_generator] allows you to save precious minutes by creating -these templated files for you with convenient command line tasks. - -[module_generator]: ../plugins/module_generator - -### Ceedling plugin `fff` - -The Fake Function Framework, [FFF], is an alternative approach to [test doubles][test-doubles] -than that used by CMock. - -[This plugin][FFF-plugin] replaces Ceedling generation of CMock-based mocks and -stubs in your tests with FFF-generated fake functions instead. - -[//]: # (FFF links are defined up in an introductory section explaining CMock) - -### Ceedling plugin `beep` - -[This plugin][beep] provides a simple audio notice when a test build completes suite -execution or fails due to a build error. It is intended to support developers -running time-consuming test suites locally (i.e. in the background). - -The plugin provides a variety of options for emitting audio notificiations on -various desktop platforms. - -[beep]: ../plugins/beep - -### Ceedling plugin `bullseye` - -[This plugin][bullseye-plugin] adds additional Ceedling tasks to execute tests -with code coverage instrumentation provided by the commercial code coverage -tool provided by [Bullseye]. The Bullseye tool provides visualization and report -generation from the coverage results produced by an instrumented test suite. - -[bullseye]: http://www.bullseye.com -[bullseye-plugin]: ../plugins/bullseye - -### Ceedling plugin `gcov` - -[This plugin][gcov-plugin] adds additional Ceedling tasks to execute tests with GNU code -coverage instrumentation. Coverage reports of various sorts can be generated -from the coverage results produced by an instrumented test suite. - -This plugin manages the use of up to three coverage reporting tools. The GNU -[gcov] tool provides simple coverage statitics to the console as well as to the -other supported reporting tools. Optional Python-based [GCovr] and .Net-based -[ReportGenerator] produce fancy coverage reports in XML, JSON, HTML, etc. -formats. - -[gcov-plugin]: ../plugins/gcov -[gcov]: http://gcc.gnu.org/onlinedocs/gcc/Gcov.html -[GCovr]: https://www.gcovr.com/ -[ReportGenerator]: https://reportgenerator.io - -### Ceedling plugin `report_tests_log_factory` - -[This plugin][report_tests_log_factory] produces any or all of three useful test -suite reports in JSON, JUnit, or CppUnit format. It further provides a -mechanism for users to create their own custom reports with a small amount of -custom Ruby rather than a full plugin. - -[report_tests_log_factory]: ../plugins/report_tests_log_factory - -### Ceedling plugin `report_build_warnings_log` - -[This plugin][report_build_warnings_log] scans the output of build tools for console -warning notices and produces a simple text file that collects all such warning -messages. - -[report_build_warnings_log]: ../plugins/report_build_warnings_log - -### Ceedling plugin `report_tests_raw_output_log` - -[This plugin][report_tests_raw_output_log] captures extraneous console output -generated by test executables — typically for debugging — to log files named -after the test executables. - -[report_tests_raw_output_log]: ../plugins/report_tests_raw_output_log - -### Ceedling plugin `subprojects` - -[This plugin][subprojects] supports subproject release builds of static -libraries. It manages differing sets of compiler flags and linker flags that -fit the needs of different library builds. - -[subprojects]: ../plugins/subprojects - -### Ceedling plugin `dependencies` - -[This plugin][dependencies] manages release build dependencies including -fetching those dependencies and calling a given dependenc's build process. -Ultimately, this plugin generates the components needed by your Ceedling -release build target. - -[dependencies]: ../plugins/dependencies - -### Ceedling plugin `compile_commands_json_db` - -[This plugin][compile_commands_json_db] create a [JSON Compilation Database][json-compilation-database]. -This file is useful to [any code editor or IDE][lsp-tools] that implements -syntax highlighting, etc. by way of the LLVM project’s [`clangd`][clangd] -Language Server Protocol conformant language server. - -[compile_commands_json_db]: ../plugins/compile_commands_json_db -[lsp-tools]: https://microsoft.github.io/language-server-protocol/implementors/tools/ -[clangd]: https://clangd.llvm.org -[json-compilation-database]: https://clang.llvm.org/docs/JSONCompilationDatabase.html - -
- -# Global Collections - -Collections are Ruby arrays and Rake FileLists (that act like -arrays). Ceedling did work to populate and assemble these by -processing the project file, using internal knowledge, -expanding path globs, etc. at startup. - -Collections are globally available Ruby constants. These -constants are documented below. Collections are also available -via accessors on the `Configurator` object (same names but all -lower case methods). - -Global collections are typically used in Rakefiles, plugins, -and Ruby scripts where the contents tend to be especially -handy for crafting custom functionality. - -Once upon a time collections were a core component of Ceedling. -As the tool has grown in sophistication and as many of its -features now operate per test executable, the utility of and -number of collections has dwindled. Previously, nearly all -Ceedling actions happened in bulk and with the same -collections used for all tasks. This is no longer true. - -* `COLLECTION_PROJECT_OPTIONS`: - - All project option files with path found in the configured - options paths having the configured YAML file extension. - -* `COLLECTION_ALL_TESTS`: - - All files with path found in the configured test paths - having the configured source file extension. - -* `COLLECTION_ALL_ASSEMBLY`: - - All files with path found in the configured source and - test support paths having the configured assembly file - extension. - -* `COLLECTION_ALL_SOURCE`: - - All files with path found in the configured source paths - having the configured source file extension. - -* `COLLECTION_ALL_HEADERS`: - - All files with path found in the configured include, - support, and test paths having the configured header file - extension. - -* `COLLECTION_ALL_SUPPORT`: - - All files with path found in the configured test support - paths having the configured source file extension. - -* `COLLECTION_PATHS_INCLUDE`: - - All configured include paths. - -* `COLLECTION_PATHS_SOURCE`: - - All configured source paths. - -* `COLLECTION_PATHS_SUPPORT`: - - All configured support paths. - -* `COLLECTION_PATHS_TEST`: - - All configured test paths. - -* `COLLECTION_PATHS_SOURCE_AND_INCLUDE`: - - All configured source and include paths. - -* `COLLECTION_PATHS_SOURCE_INCLUDE_VENDOR`: - - All configured source and include paths plus applicable - vendor paths (Unity's source path plus CMock and - CException's source paths if mocks and exceptions are - enabled). - -* `COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE`: - - All configured test, support, source, and include paths. - -* `COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE_VENDOR`: - - All test, support, source, include, and applicable - vendor paths (Unity's source path plus CMock and - CException's source paths if mocks and exceptions are - enabled). - -* `COLLECTION_PATHS_RELEASE_TOOLCHAIN_INCLUDE`: - - All configured release toolchain include paths. - -* `COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE`: - - All configured test toolchain include paths. - -* `COLLECTION_PATHS_VENDOR`: - - Unity's source path plus CMock and CException's source - paths if mocks and exceptions are enabled. - -* `COLLECTION_VENDOR_FRAMEWORK_SOURCES`: - - Unity plus CMock, and CException's .c filenames (without - paths) if mocks and exceptions are enabled. - -* `COLLECTION_RELEASE_BUILD_INPUT`: - - * All files with path found in the configured source - paths having the configured source file extension. - * If exceptions are enabled, the source files for - CException. - * If assembly support is enabled, all assembly files - found in the configured paths having the configured - assembly file extension. - -* `COLLECTION_EXISTING_TEST_BUILD_INPUT`: - - * All files with path found in the configured source - paths having the configured source file extension. - * All files with path found in the configured test - paths having the configured source file extension. - * Unity's source files. - * If exceptions are enabled, the source files for - CException. - * If mocks are enabled, the C source files for CMock. - * If assembly support is enabled, all assembly files - found in the configured paths having the configured - assembly file extension. - - This collection does not include .c files generated by - Ceedling and its supporting frameworks at build time - (e.g. test runners and mocks). Further, this collection - does not include source files added to a test - executable's build list with the `TEST_SOURCE_FILE()` - build directive macro. - -* `COLLECTION_RELEASE_ARTIFACT_EXTRA_LINK_OBJECTS`: - - If exceptions are enabled, CException's .c filenames - (without paths) remapped to configured object file - extension. - -* `COLLECTION_TEST_FIXTURE_EXTRA_LINK_OBJECTS`: - - All test support source filenames (without paths) - remapped to configured object file extension. - -
diff --git a/docs/Changelog.md b/docs/Changelog.md index fc0e2fed1..41622874d 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -9,7 +9,43 @@ This changelog is complemented by two other documents: --- -# [1.0.1] - 2025-01-30 +# [1.1.0] — Prerelease + +## 🌟 Added + +- **Partials.** [A Partial](https://throwtheswitch.github.io/Ceedling/dev/testing-guide/partials/) is a new feature that allows a test author to work with portions of the same C module under test differently from within the same test file. For example, a test can now cause some functions in the source module under test to be mocked while other source functions are executed against assertions (see #936). +- CLI additions: + - `ceedling help` output provides links for further support and Github sponsorship. + - `ceedling check` validates your configuration and produces logs from processing it without executing a build. + - `ceedling docs` exports new HTML-based documentation site to your filesystem. +- Preprocessing support for distinguishing and handling system includes (`#include `) and user includes (`#include "user.h"`). + +## 💪 Fixed + +- #1011 Performance Improvements. +- #1014 Line Continuations not working in test name. +- #1015 directive-only issue. +- #1024 Fixed bug in options-handling for warnings log report. +- #358 Mocks with relative path in include . +- `:gcov` section of `:flags` is able to use filename matchers again (like `:test` section). +- Now properly reports timing for single-batch builds (i.e. non-parallel builds). +- Fixes to `#include`s handling and encoding. +- PR #1126 fix for race condition in cache handling of `#include` listings in YAML files. +- PR #1056 fix for extracting `#include` directive filenames that contain dashes. +- Type handling in example `temp_sensor` project compatible with C23 (and previous C standards). + +## ⚠️ Changed + +- The monolothic _CeedlingPacket.md_ user manual has been replaced by a full web-based documentation site. + - A verion that is navigable from your filesystem is included within Ceedling and exportable through CLI functions. + - The online version is available at: https://throwtheswitch.github.io/Ceedling/ +- PR #1003 improvements for Mixin merges — clearer logging and edge case handling. +- Significant refactoring and improvements to logging and parallel processing. +- Streamlined preprocessing, eliminating redundant steps and reducing memory usage. +- Resolved ambiguity in updated `ceedling new` handling from 0.31.1 to 1.0.0. +- Fixes for typos and grammar in documentation and logging. + +# [1.0.1] — 2025-01-30 ## 💪 Fixed diff --git a/docs/mkdocs/assets/images/ceedling-logo-circle.png b/docs/mkdocs/assets/images/ceedling-logo-circle.png new file mode 100644 index 000000000..3eb6d73ba Binary files /dev/null and b/docs/mkdocs/assets/images/ceedling-logo-circle.png differ diff --git a/docs/mkdocs/assets/images/ceedling.svg b/docs/mkdocs/assets/images/ceedling.svg new file mode 100644 index 000000000..3ab41a3ab --- /dev/null +++ b/docs/mkdocs/assets/images/ceedling.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + eedling + + + + + + diff --git a/docs/mkdocs/assets/images/favicon.png b/docs/mkdocs/assets/images/favicon.png new file mode 100644 index 000000000..9c144deb3 Binary files /dev/null and b/docs/mkdocs/assets/images/favicon.png differ diff --git a/docs/mkdocs/assets/stylesheets/admonitions.css b/docs/mkdocs/assets/stylesheets/admonitions.css new file mode 100644 index 000000000..ba9af203e --- /dev/null +++ b/docs/mkdocs/assets/stylesheets/admonitions.css @@ -0,0 +1,152 @@ +/* Restore older style of Admonitions (nicer) */ + +.md-typeset .admonition, +.md-typeset details { + border-width: 0; + border-left-width: 4px; +} + +/* + Styling Built-In Admonition Types + ================================= + */ + +/* ── Note ────────────────────────────────────────────────────────────────── + Usage: !!! note "This does not imply if the swallow is African or European" + Body text here. + ────────────────────────────────────────────────────────────────────────── */ + +/* Overrides default note style. + */ +.md-typeset .admonition.note, +.md-typeset details.note { + border-color: rgb(58, 0, 64); +} + +.md-typeset .note > .admonition-title, +.md-typeset .note > summary { + background-color: rgba(58, 0, 64, .15); +} + +.md-typeset .note > .admonition-title::before, +.md-typeset .note > summary::before { + background-color: rgb(58, 0, 64); +} + +/* ── Tip ────────────────────────────────────────────────────────────────── + Usage: !!! tip "You pronounce it 'potato'" + Body text here. + ────────────────────────────────────────────────────────────────────────── */ + +/* Overrides default tip style. + */ +.md-typeset .admonition.tip, +.md-typeset details.tip { + border-color: rgb(58, 0, 64); +} + +.md-typeset .tip > .admonition-title, +.md-typeset .tip > summary { + background-color: rgba(58, 0, 64, .15); +} + +.md-typeset .tip > .admonition-title::before, +.md-typeset .tip > summary::before { + background-color: rgb(58, 0, 64); +} + +/* ── Info ────────────────────────────────────────────────────────────────── + Usage: !!! info "The answer to Life, The Universe, and Everything is 42." + Body text here. + ────────────────────────────────────────────────────────────────────────── */ + +/* Overrides default info style. + */ +.md-typeset .admonition.info, +.md-typeset details.info { + border-color: rgb(58, 0, 64); +} + +.md-typeset .info > .admonition-title, +.md-typeset .info > summary { + background-color: rgba(58, 0, 64, .15); +} + +.md-typeset .info > .admonition-title::before, +.md-typeset .info > summary::before { + background-color: rgb(58, 0, 64); +} + +/* + Custom Admonition Types + ======================= + */ + +/* ── Sponsorship ──────────────────────────────────────────────────────────── + Usage: !!! sponsorship "ThingamaByte is a Great Sponsor" + Body text here. + ────────────────────────────────────────────────────────────────────────── */ + +/* Icon: Material Design Icons 'currency-usd' + Source: material/templates/.icons/material/currency-usd.svg + (Only available at time of site-generation in a container) + + CSS mask-image requires an SVG data URL but the :material-currency-usd: shortcode + is Markdown-only and is not available to CSS. + + The SVG path recreates the 'currency-usd' icon from Material Design Icons as + the file is unavailable outside the site-generation container. + */ +:root { + --md-admonition-icon--sponsorship: url("data:image/svg+xml;charset=utf-8,"); +} + +.md-typeset .admonition.sponsorship, +.md-typeset details.sponsorship { + border-color: rgb(63, 176, 78); +} + +.md-typeset .sponsorship > .admonition-title, +.md-typeset .sponsorship > summary { + background-color: rgb(91,255,113); +} + +.md-typeset .sponsorship > .admonition-title::before, +.md-typeset .sponsorship > summary::before { + background-color: rgb(63, 176, 78); + -webkit-mask-image: var(--md-admonition-icon--sponsorship); + mask-image: var(--md-admonition-icon--sponsorship); +} + +/* ── Feature ──────────────────────────────────────────────────────────────── + Usage: !!! feature "New in 1.0" + Body text here. + ────────────────────────────────────────────────────────────────────────── */ + +/* Icon: Custom 16-point starburst badge (no MDI equivalent) + A circular "new and improved" badge seal with 16 short ridged points. + Constructed by alternating outer radius 11 and inner radius 9 at 11.25° steps. + + CSS mask-image requires an SVG data URL; Markdown icon shortcodes are + not available to CSS. + */ +:root { + --md-admonition-icon--feature: url("data:image/svg+xml;charset=utf-8,"); +} + +.md-typeset .admonition.feature, +.md-typeset details.feature { + border-color: rgb(63, 176, 78); +} + +.md-typeset .feature > .admonition-title, +.md-typeset .feature > summary { + background-color: rgb(91,255,113); +} + +.md-typeset .feature > .admonition-title::before, +.md-typeset .feature > summary::before { + background-color: rgb(63, 176, 78); + -webkit-mask-image: var(--md-admonition-icon--feature); + mask-image: var(--md-admonition-icon--feature); +} diff --git a/docs/mkdocs/assets/stylesheets/fixes-local.css b/docs/mkdocs/assets/stylesheets/fixes-local.css new file mode 100644 index 000000000..f384d95db --- /dev/null +++ b/docs/mkdocs/assets/stylesheets/fixes-local.css @@ -0,0 +1,15 @@ +/* sidebar logo and style */ +/* Fix sidebar scrollwrap overflow from bad JS height calculation. + We can't set site_url for the local site, and this causes the header height to be miscalculated. + */ +.md-sidebar { + top: 2.4rem !important; +} +.md-sidebar__scrollwrap { + height: calc(100vh - 2.4rem) !important; /* 100vh minus header height */ + overflow-y: auto; +} +.md-nav--primary .md-nav__title { + background-color: #EEEEEE !important; + font-size: 0; +} diff --git a/docs/mkdocs/assets/stylesheets/styling.css b/docs/mkdocs/assets/stylesheets/styling.css new file mode 100644 index 000000000..46ae044aa --- /dev/null +++ b/docs/mkdocs/assets/stylesheets/styling.css @@ -0,0 +1,92 @@ +/*------------------------------------------------- + * Header logo and style + *------------------------------------------------*/ + .md-header { + background-color: #EEEEEE; +} +.md-header__title { + display: none; +} +.md-header__button.md-logo { + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; +} +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 4.0rem; +} +@media screen and (max-width: 76.1875em) { + .md-header__button.md-logo { + display: flex; /* force it to stay visible */ + } +} + +/*------------------------------------------------- + * Search bar + *------------------------------------------------*/ + .md-search { + margin-left: auto; +} +.md-search__form { + background-color: #DDDDDD; + border-radius: 4px; +} +.md-search__input { + color: #3A0040; /* Replace with your desired color */ +} +.md-search__input::placeholder { + color: #3A0040; +} +.md-search__icon { + color: #3A0040; +} + +/*------------------------------------------------- + * Repository display + *------------------------------------------------*/ +.md-header__inner { + justify-content: space-between; +} +.md-header__source { + background-color: #3A0040; + border-radius: 4px; + padding: 0 0.5rem; +} +.md-source__repository { + color: #3fb04e; + font-weight: bold; +} +.md--source__facts { + color: #3fb04e; + font-size: 0.7rem; +} + +/*------------------------------------------------- + * Navigation section separators + *------------------------------------------------*/ + + /* Separator around nested subsections only */ +.md-nav__item--section .md-nav__item--section { + border-top: 1px solid var(--md-default-fg-color--lightest); + margin-top: 0.6rem; + padding-top: 0.6rem; +} + +/* No bottom border on last subsection */ +.md-nav__item--section .md-nav__item--section:last-child { + border-bottom: none; +} + +/* Bottom border only on subsections that aren't last */ +.md-nav__item--section .md-nav__item--section:not(:last-child) { + border-bottom: 1px solid var(--md-default-fg-color--lightest); + margin-bottom: 0.6rem; + padding-bottom: 0.6rem; +} + +/* Add breathing room inside subsection before the bottom border */ +.md-nav__item--section .md-nav__item--section:not(:last-child) > .md-nav { + padding-bottom: 0.6rem; +} diff --git a/docs/mkdocs/configuration/environment-vars.md b/docs/mkdocs/configuration/environment-vars.md new file mode 100644 index 000000000..11a0b108b --- /dev/null +++ b/docs/mkdocs/configuration/environment-vars.md @@ -0,0 +1,14 @@ +# Environment Variables + +Ceedling recognizes several environment variables that control its behavior +independently of your project configuration file. + +These handful of environment variables fall into two categories: + +1. Options for loading your project configuration. +1. Controlling logging output styling. + +See the [Environment Variables Reference](../reference/environment-vars.md) +for the full listing of all recognized variables and their accepted values. + +

diff --git a/docs/mkdocs/configuration/global-collections.md b/docs/mkdocs/configuration/global-collections.md new file mode 100644 index 000000000..1f2993bac --- /dev/null +++ b/docs/mkdocs/configuration/global-collections.md @@ -0,0 +1,156 @@ +# Global Collections + +Collections are Ruby arrays and Rake FileLists (that act like +arrays). Ceedling did work to populate and assemble these by +processing the project file, using internal knowledge, +expanding path globs, etc. at startup. + +Collections are globally available Ruby constants. These +constants are documented below. Collections are also available +via accessors on the `Configurator` object (same names but all +lower case methods). + +Global collections are typically used in Rakefiles, plugins, +and Ruby scripts where the contents tend to be especially +handy for crafting custom functionality. + +!!! note "Collections are no longer a core component of Ceedling" + As Ceedling has grown in sophistication and as many of its + features now operate per test executable, the utility of and + number of collections has dwindled. + + Once upon a time, nearly all Ceedling actions happened in + bulk and with the same collections used for all tasks. This + is no longer true. + +* `COLLECTION_PROJECT_OPTIONS`: + + All project option files with path found in the configured + options paths having the configured YAML file extension. + +* `COLLECTION_ALL_TESTS`: + + All files with path found in the configured test paths + having the configured source file extension. + +* `COLLECTION_ALL_ASSEMBLY`: + + All files with path found in the configured source and + test support paths having the configured assembly file + extension. + +* `COLLECTION_ALL_SOURCE`: + + All files with path found in the configured source paths + having the configured source file extension. + +* `COLLECTION_ALL_HEADERS`: + + All files with path found in the configured include, + support, and test paths having the configured header file + extension. + +* `COLLECTION_ALL_SUPPORT`: + + All files with path found in the configured test support + paths having the configured source file extension. + +* `COLLECTION_PATHS_INCLUDE`: + + All configured include paths. + +* `COLLECTION_PATHS_SOURCE`: + + All configured source paths. + +* `COLLECTION_PATHS_SUPPORT`: + + All configured support paths. + +* `COLLECTION_PATHS_TEST`: + + All configured test paths. + +* `COLLECTION_PATHS_SOURCE_AND_INCLUDE`: + + All configured source and include paths. + +* `COLLECTION_PATHS_SOURCE_INCLUDE_VENDOR`: + + All configured source and include paths plus applicable + vendor paths (Unity’s source path plus CMock and + CException’s source paths if mocks and exceptions are + enabled). + +* `COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE`: + + All configured test, support, source, and include paths. + +* `COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE_VENDOR`: + + All test, support, source, include, and applicable + vendor paths (Unity’s source path plus CMock and + CException’s source paths if mocks and exceptions are + enabled). + +* `COLLECTION_PATHS_RELEASE_TOOLCHAIN_INCLUDE`: + + All configured release toolchain include paths. + +* `COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE`: + + All configured test toolchain include paths. + +* `COLLECTION_PATHS_VENDOR`: + + Unity’s source path plus CMock and CException’s source + paths if mocks and exceptions are enabled. + +* `COLLECTION_VENDOR_FRAMEWORK_SOURCES`: + + Unity plus CMock, and CException’s .c filenames (without + paths) if mocks and exceptions are enabled. + +* `COLLECTION_RELEASE_BUILD_INPUT`: + + * All files with path found in the configured source + paths having the configured source file extension. + * If exceptions are enabled, the source files for + CException. + * If assembly support is enabled, all assembly files + found in the configured paths having the configured + assembly file extension. + +* `COLLECTION_EXISTING_TEST_BUILD_INPUT`: + + * All files with path found in the configured source + paths having the configured source file extension. + * All files with path found in the configured test + paths having the configured source file extension. + * Unity’s source files. + * If exceptions are enabled, the source files for + CException. + * If mocks are enabled, the C source files for CMock. + * If assembly support is enabled, all assembly files + found in the configured paths having the configured + assembly file extension. + + This collection does not include .c files generated by + Ceedling and its supporting frameworks at build time + (e.g. test runners and mocks). Further, this collection + does not include source files added to a test + executable’s build list with the `TEST_SOURCE_FILE()` + build directive macro. + +* `COLLECTION_RELEASE_ARTIFACT_EXTRA_LINK_OBJECTS`: + + If exceptions are enabled, CException’s .c filenames + (without paths) remapped to configured object file + extension. + +* `COLLECTION_TEST_FIXTURE_EXTRA_LINK_OBJECTS`: + + All test support source filenames (without paths) + remapped to configured object file extension. + +

diff --git a/docs/mkdocs/configuration/index.md b/docs/mkdocs/configuration/index.md new file mode 100644 index 000000000..42b88cd29 --- /dev/null +++ b/docs/mkdocs/configuration/index.md @@ -0,0 +1,68 @@ +# Configuration + +!!! tip "Annotated Sample Configuration" + See the [annotated sample project configuration file](../snapshot/assets/project.yml) + for a commented example of available settings. + +## Project File + +
+ +- :material-file-cog: **[Project File Basics][project-file]** + + --- + + YAML conventions, project file structure, and special Ceedling-specific YAML + handling. + +- :material-book-open-variant: **[Configuration Reference][configuration-reference]** + + --- + + Exhaustive documentation for all project configuration options — + organized by section. + +- :material-file-import: **[How to Load a Project Configuration][configuration-loading]** + + --- + + Load your base configuration via command line flag, environment variable, or + default file. Add Mixins to merge additional configuration for different + build scenarios. + +
+ +## Advanced Topics + +
+ +- :material-clipboard-play-multiple-outline: **[Parallel Builds][parallel-builds]** + + --- + + Configure Ceedling to take advantage of multiple CPU cores for faster build + steps and test suite execution. + +- :material-directions-fork: **[Which Ceedling?][which-ceedling]** + + --- + + Sometimes you may need to point to a different Ceedling to run. + +- :material-database: **[Global Collections][global-collections]** + + --- + + Globally available Ruby lists of paths, files, and more — useful for advanced + project customization and plugin development. + +
+ +[configuration-loading]: loading.md +[project-file]: project-file.md +[configuration-reference]: reference/index.md +[which-ceedling]: which-ceedling.md +[global-collections]: global-collections.md +[parallel-builds]: parallel-builds.md + +

diff --git a/docs/mkdocs/configuration/loading.md b/docs/mkdocs/configuration/loading.md new file mode 100644 index 000000000..fda057227 --- /dev/null +++ b/docs/mkdocs/configuration/loading.md @@ -0,0 +1,436 @@ +# How to Load a Project Configuration + +**You have options, my friend** + +Ceedling needs a project configuration to accomplish anything for you. +Ceedling's project configuration is a large in-memory data structure. +That data structure is loaded from a human-readable file format called +[YAML]. + +The next section details Ceedling's project configuration options in +available through YAML. This section explains all your options for +loading and modifying the project configuration itself. + +## Loading & Smooshing Overview + +Ceedling has a certain pipeline for loading and manipulating the +configuration it uses to build your projects. It goes something like +this: + +1. Load the base project configuration from a YAML file. +1. Merge the base configuration with zero or more Mixins from YAML files. +1. Load zero or more plugins that provide default configuration values + or alter the base project configuration. +1. Populate the configuration with default values if anything was left + unset to ensure all configuration needed to run is present. + +Ceedling provides reasonably verbose logging at startup telling you which +configuration file and Mixins were used and in what order they were merged. +Similarly, it provides fairly robust validation and warning messages to +help you catch a broken configuration and problematic combinations of +settings. + +For nitty-gritty details on plugin configuration behavior, see the +_[Plugin Development Guide](../development/plugins/index.md)_ + +## Base Configuration Loading Options + +You have three options for telling Ceedling what single base project +configuration to load. These options are ordered below according to their +precedence. If an option higher in the list is present, it is used. + +1. Command line option flags +1. Environment variable +1. Default file in working directory + +### `--project` command line flags + +Many of Ceedling's [application commands](../getting-started/command-line.md) include an +optional `--project` flag. When provided, Ceedling will load as its base +configuration the YAML filepath provided. + +Example: `ceedling --project=my/path/build.yml test:all` + +!!! warning "Path relationships" + Ceedling loads any relative paths within your configuration in + relation to your working directory. This can cause a disconnect between + configuration paths, working directory, and the path to your project + file. + +If the filepath does not exist, Ceedling terminates with an error. + +### Environment variable `CEEDLING_PROJECT_FILE` + +If a `--project` flag is not used at the command line, but the +environment variable `CEEDLING_PROJECT_FILE` is set, Ceedling will use +the path it contains to load your project configuration. The path can +be absolute or relative (to your working directory). + +If the filepath does not exist, Ceedling terminates with an error. + +### Default _project.yml_ in your working directory + +If neither a `--project` command line flag nor the environment variable +`CEEDLING_PROJECT_FILE` are set, then Ceedling tries to load a file +named _project.yml_ in your working directory. + +If this file does not exist, Ceedling terminates with an error. + +## Applying Mixins to Base Configuration + +Once you have a base configuation loaded, you may want to modify it for +any number of reasons. Some example scenarios: + +* A single project actually contains mutiple build variations. You would + like to maintain a common configuration that is shared among build + variations with each build variation's differences maintained separately. +* Your repository contains the configuration needed by your Continuous + Integration server setup, but this is not fun to run locally. You would + like to modify the configuration locally with configuration details + maintained by you external to your locally cloned repository. +* Ceedling's default `gcc` tools do not work for your project needs. You + would like the complex tooling configurations you most often need to + be maintained separately and shared among projects. + +Mixins allow you to merge configuration with your project configuration +just after the base project file is loaded. The merge is so low-level +and generic that you can, in fact, load an empty base configuration +and merge in entire project configurations through mixins. + +## Designing for Mixins merge rules + +Merging of any sort tends to be hard to do well. It's tricky at a +code-level, yes, but, just as importantly, merging can be hard to grasp in +your head. + +The brief sections that follow provide an overview of our recommended +design approach and the merge rules at play. + +!!! tip "Use `dumpconfig` to debug mixins" + `ceedling dumpconfig` can be invaluable in developing and troubleshooting + your mixins. The `dumpconfig` application command will load your mixins + just as a build would but produce the resulting merged configuration for + inspection in a YAML file you specify. + +### Additive Mixin merges + +Generally speaking, the simplest way to conceive of managing your project +configuration with mixins is to design for an additive merge. This means +each mixin is successively adding something to your base configuration. +In certain scenarios it is possible to overwrite configuration values with +mixin values (see the rules that follow), but an additive merge is +probably easier to understand and create. + +At a high level, additive merges can be constructed like this: + +1. Plan to add to lists with mixins. Path collections, plugins, and + tool flags & compilation symbols are all examples of lists that + can be added to. Other lists exist in a project configuration too — + many within containing configuration entires. Add paths, plugins, and + flags & symbols to your base configuration that are in common to all + your buid variations and then customize the lists by adding to them + with mixins. +1. Leave entire sections in your base configuration blank and fill them + out by merging mixins. With this strategy, you might configure all + the basics in a base project configuration but merge into it the + path collections, tool configurations, and compilation symbols you need + for a specific project. + +### Mixins deep merge rules + +Mixins are merged in a specific order. See the documentation sections +following the examples for details. + +Smooshing of mixin configurations into the base project configuration +follows a few basic rules: + +* If a configuration key/value pair does not already exist at the time + of merging, the mixin key/value pair is added to the configuration. +* If a container — e.g. list or hash — already exists at the time of a + merge, the contents are _combined_. + * In the case of lists, merged list values are added to the end of + the existing list. + * If the configuration contains a list but the mixin value is a + different type, it is added to the list. The typical case is a + list of strings growing with an additional single string. Note + that the reverse case is not true. A configuration containing a + single value and a mixin containing a list, will trigger the + following rule. +* If a simple value — e.g. boolean, string, numeric — already exists + in the configuration at the time of merging, that value is replaced + by the mixin value being merged. That merge is accompanied with a + warning log entry to highlight what has happened. + +!!! warning "Mixin merge order affects path ordering" + That second bullet can have a significant impact on how your various + project configuration paths — including those used for header search + paths — are ordered. In brief, the contents of your `:paths` from your + base configuration will come first followed by any additions from your + mixins. See the section [Search Paths for Test Builds](../testing-guide/conventions.md#search-paths-for-test-builds) + for more. + +## Mixins Example +### Our Example Scenario +Let's start with an example that helps explain how mixins are merged. +Then, the documentation sections that follow will discuss everything +in detail. + +In this example, we will load a base project configuration and then +apply three mixins using each of the available means — command line, +envionment variable, and `:mixins` section in the base project +configuration file. + +### Example environment variable + +`CEEDLING_MIXIN_1` = `./env.yml` + +### Example command line + +`ceedling --project=base.yml --mixin=support/mixins/cmdline.yml ` + +!!! info "The `--mixin` flag supports more than filepaths" + The [`--mixin` flag](#-mixin-command-line-flags) can be used multiple times + in the same command line to smoosh together multiple mixins. + +The example command line above will produce the following logging output +when verbosity is increased beyond the default. + +``` +🚧 Loaded project configuration from command line argument using base.yml + + Merging command line mixin using support/mixins/cmdline.yml + + Merging CEEDLING_MIXIN_1 mixin using ./env.yml + + Merging project configuration mixin using ./enabled.yml +``` + +_Notes:_ + +* The logging output above referencing _enabled.yml_ comes from the + `:mixins` section within the base project configuration file provided below. +* The resulting configuration in this example is missing settings required + by Ceedling. This will cause a validation build error that is not shown + here. + +### Example Configuration files + +#### _base.yml_ — Our base project configuration file + +Our base project configuration file: + +1. Sets up a configuration file-based mixin. Ceedling will look for a mixin + named _enabled_ in the specified load paths. In this simple configuration + that means Ceedling looks for and merges _support/mixins/enabled.yml_. +1. Creates a `:project` section in our configuration. +1. Creates a `:plugins` section in our configuration and enables the standard + console test report output plugin. + +```yaml +:mixins: # `:mixins` section only recognized in base project configuration + :enabled: # `:enabled` list supports names and filepaths + - enabled # Ceedling looks for name as enabled.yml in load paths and merges if found + :load_paths: + - support/mixins + +:project: + :build_root: build/ + +:plugins: + :enabled: + - report_tests_pretty_stdout +``` + +#### _support/mixins/cmdline.yml_ — Mixin via command line filepath flag + +This mixin will merge a `:project` section with the existing `:project` +section from the base project file per the deep merge rules above. + +```yaml +:project: + :use_test_preprocessor: :all + :test_file_prefix: Test +``` + +#### _env.yml_ — Mixin via environment variable filepath + +This mixin will merge a `:plugins` section with the existing `:plugins` +section from the base project file per the deep merge rules (noted +after the examples). + +```yaml +:plugins: + :enabled: + - compile_commands_json_db +``` + +#### _support/mixins/enabled.yml_ — Mixin via base project configuration file `:mixins` section + +This mixin listed in the base configuration project file will merge +`:project` and `:plugins` sections with those that already exist from +the base configuration plus earlier mixin merges per the deep merge +rules (noted after the examples). + +```yaml +:project: + :use_test_preprocessor: :none + +:plugins: + :enabled: + - gcov +``` + +### Example resulting configuration + +Behold the project configuration following mixin merges: + +```yaml +:project: + :build_root: build/ # From base.yml + :use_test_preprocessor: :all # Value in support/mixins/cmdline.yml overwrote value from support/mixins/enabled.yml + :test_file_prefix: Test # Added to :project from support/mixins/cmdline.yml + +:plugins: + :enabled: # :plugins ↳ :enabled from two mixins merged with oringal list in base.yml + - report_tests_pretty_stdout # From base.yml + - compile_commands_json_db # From env.yml + - gcov # From support/mixins/enabled.yml +``` +!!! note "Original `:mixins` section is removed from resulting config" + +## Options for loading Mixins + +You have three options for telling Ceedling what mixins to load. These +options are ordered below according to their precedence. A Mixin higher +in the list is merged earlier. In addition, options higher in the list +force duplicate mixin filepaths to be ignored lower in the list. + +Unlike base project file loading that resolves to a single filepath, +multiple mixins can be specified using any or all of these options. + +1. Command line option flags +1. Environment variables +1. Base project configuration file entries + +### `--mixin` command line flags + +As already discussed above, many of Ceedling's application commands +include an optional `--project` flag. Most of these same commands +also recognize optional `--mixin` flags. Note that `--mixin` can be +used multiple times in a single command line. + +When provided, Ceedling will load the specified YAML file and merge +it with the base project configuration. + +A Mixin flag can contain one of two types of values: + +1. A filename or filepath to a mixin yaml file. A filename contains + a file extension. A filepath includes a leading directory path. +1. A simple name (no file extension and no path). This name is used + as a lookup in Ceedling's mixin load paths. + +Example: `ceedling --project=build.yml --mixin=foo --mixin=bar/mixin.yaml test:all` + +Simple mixin names (#2 above) require mixin load paths to search. +A default mixin load path is always in the list and points to within +Ceedling itself (in order to host eventual built-in mixins like +built-in plugins). User-specified load paths must be added through +the `:mixins` section of the base configuration project file. See +the [documentation for the `:mixins` section of your project +configuration][mixins-config-section] for more details. + +Order of precedence is set by the command line mixin order +left-to-right. + +Filepaths may be relative (in relation to the working directory) or +absolute. + +If the `--mixin` filename or filepath does not exist, Ceedling +terminates with an error. If Ceedling cannot find a mixin name in +any load paths, it terminates with an error. + +[mixins-config-section]: #base-configuration-file-mixins-entries + +### Mixin environment variables + +Mixins can also be loaded through environment variables. Ceedling +recognizes environment variables with a naming scheme of +`CEEDLING_MIXIN_#`, where `#` is any number greater than 0. + +Precedence among the environment variables is a simple ascending +sort of the trailing numeric value in the environment variable name. +For example, `CEEDLING_MIXIN_5` will be merged before +`CEEDLING_MIXIN_99`. + +Mixin environment variables only hold filepaths. Filepaths may be +relative (in relation to the working directory) or absolute. + +If the filepath specified by an environment variable does not exist, +Ceedling terminates with an error. + +### Base configuration file `:mixins` entries + +Ceedling only recognizes a `:mixins` section in your base project +configuration file. A `:mixins` section in a mixin is ignored. In addition, +the `:mixins` section of a base project configuration file is filtered +out of the resulting configuration. + +The `:mixins` configuration section can contain up to two subsections. +Each subsection is optional. + +* `:enabled` + + An optional array comprising (A) mixin filenames/filepaths and/or + (B) simple mixin names. + + 1. A filename contains a file extension. A filepath includes a + directory path. The file content is YAML. + 1. A simple name (no file extension and no path) is used + as a file lookup among any configured load paths (see next + section) and as a lookup name among Ceedling's built-in mixins + (currently none). + + Enabled entries support [inline Ruby string expansion][inline-ruby-string-expansion]. + + **Default**: `[]` + +* `:load_paths` + + Paths containing mixin files to be searched via mixin names. A mixin + filename in a load path has the form _.yml_ by default. If + an alternate filename extension has been specified in your project + configuration (`:extension` ↳ `:yaml`) it will be used for file + lookups in the mixin load paths instead of _.yml_. + + Searches start in the path at the top of the list. + + Both mixin names in the `:enabled` list (above) and on the command + line via `--mixin` flag use this list of load paths for searches. + + Load paths entries support [inline Ruby string expansion][inline-ruby-string-expansion]. + + **Default**: `[]` + +Example `:mixins` YAML blurb: + +```yaml +:mixins: + :enabled: + - foo # Search for foo.yml in proj/mixins & support/ and 'foo' among built-in mixins + - path/bar.yaml # Merge this file with base project conig + :load_paths: + - proj/mixins + - support +``` + +Relating the above example to command line `--mixin` flag handling: + +* A command line flag of `--mixin=foo` is equivalent to the `foo` + entry in the `:enabled` mixin configuration. +* A command line flag of `--mixin=path/bar.yaml` is equivalent to the + `path/bar.yaml` entry in the `:enabled` mixin configuration. +* Note that while command line `--mixin` flags work identically to + entries in `:mixins` ↳ `:enabled`, they are merged first instead of + last in the mixin precedence. + +[YAML]: http://en.wikipedia.org/wiki/Yaml +[inline-ruby-string-expansion]: project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/parallel-builds.md b/docs/mkdocs/configuration/parallel-builds.md new file mode 100644 index 000000000..db257668b --- /dev/null +++ b/docs/mkdocs/configuration/parallel-builds.md @@ -0,0 +1,97 @@ +# Parallel Build Steps + +Beginning with version 1.0.0, Ceedling supports parallelization of tasks. + +## Configuration + +To enable parallel builds, simply configure either or both of the following in +your project configuration: + +* [`:project` ↳ `:compile_threads`](reference/project.md#compile_threads) +* [`:project` ↳ `:test_threads`](reference/project.md#test_threads) + +These two settings allow you to separate build step parallelism from test suite +execution parallelism. Why? A core reason is because some emulator setups +necessary for test suite execution do not allow multiple instances running +simultaneously. + +## Operating System restrictions + +!!! warning "Operating Systems security protections can limit parallelism" + +Modern versions of macOS and Windows both include security gatekeeping that can +limit Ceedling’s ability to achieve speedups and true parallelism. These +products can: + +1. Execute first-run security checks on newly built, unsgined executables +(such as components of a test suite) that greatly slow down execution. +1. Force one-at-a-time execution for any unsigned software attempting to +spawn multiple processes simultaneously. + +Signing the executables Ceedling generates is wildly complex and maybe even +practically impossible. + +A current limitation of Ceedling since 1.0.0 also contributes to these +bottlenecks. Namely, with the release of 1.0.0 Ceedling lost “delta” builds — +the ability to only regenerate components of a test suite that have changed. +This means Ceedling’s test suites are fully rebuilt each time with OS first-run +security checks to fire at run time. Future versions of Ceedling will restore +this ability. + +!!! tip "Configuring OS security restrictions" + Read up on your Operating System’s options for configuring the restrictions + noted above. For instance, you may be able to configure certain exceptions + for your terminal application that cascade to benefit Ceedling’s parallelism. + +## Parallel execution in Ruby + +You may have heard that Ruby is actually only single-threaded or may know of its +Global Interpreter Lock (GIL) that prevents parallel execution +[^python-note]. This is true but not the whole story. + +To oversimplify a complicated subject, the Ruby implementations typically used +to run Ceedling do afford concurrency and true parallelism speedups but only in +certain circumstances. It so happens that these circumstances are precisely the +workload that Ceedling manages. + +“Mainstream” Ruby implementations — not JRuby[^jruby-note], for example — offer + the following exceptions and abilities of which Ceedling takes advantage. + +### I/O operations thread context switching + +Since version 1.9, Ruby supports native threads and not only green threads. +However, native threads are limited by the GIL to executing one at a time +regardless of the number of cores in your processor. But, the key exception +here is the GIL is “relaxed” for I/O operations. + +When a native thread blocks for I/O, Ruby allows the OS scheduler to context +switch to a thread ready to execute. This is the original benefit of threads +when they were first developed back when CPUs contained a single core and +multi-processor systems were rare and special. + +Ceedling does a fair amount of file and standard stream I/O in its pure Ruby +code. Thus, when multiple threads are enabled in the project configuration, +execution can speed up for these operations because Ruby’s GIL allows those I/O +operations to execute with true parallelism. + +### Process spawning + +Ruby’s process spawning abilities have always mapped directly to OS +capabilities. When a processor has multiple cores available, the OS tends to +spread multiple child processes across those cores in true parallel execution. + +Much of Ceedling’s workload is executing a tool — such as a compiler — in a +child process. With multiple threads enabled, each thread can spawn a child +process for a build tool used by a build step. These child processes can be +spread across multiple cores in true parallel execution. + +[^python-note]: Python and most other scripting-style languages rely on the +Global Interpreter Lock concept for managing parallelism. A class of languages +that includes Go, Rust, and Java fully support true parallelism. + +[^jruby-note]: JRuby implements the Ruby language by way of an underlying Java +implementation. This allows Ruby to run in certain contexts it might not +otherwise be able to — particularly in Enterprise systems. Ruby parallelism in +JRuby is built atop Java’s true, native parallelism. Taking advantage of JRuby +requires certain customization of the Ruby language, and Ceedling has never +been tested for JRuby support. \ No newline at end of file diff --git a/docs/mkdocs/configuration/project-file.md b/docs/mkdocs/configuration/project-file.md new file mode 100644 index 000000000..85c49837e --- /dev/null +++ b/docs/mkdocs/configuration/project-file.md @@ -0,0 +1,187 @@ +# The Mighty Ceedling Project Configuration File + +**In glorious YAML** + +!!! tip "Complete project file example" + See this [commented project file][example-config-file] for a nice + example of a complete project configuration. + +## Some YAML Learnin’ + +Please consult YAML documentation for the finer points of format +and to understand details of our YAML-based configuration file. + +We recommend [Wikipedia’s entry on YAML](http://en.wikipedia.org/wiki/Yaml) +for this. A few highlights from that reference page: + +* YAML streams are encoded using the set of printable Unicode + characters, either in UTF-8 or UTF-16. + +* White space indentation is used to denote structure; however, + tab characters are never allowed as indentation. + +* Comments begin with the number sign (`#`), can start anywhere + on a line, and continue until the end of the line unless enclosed + by quotes. + +* List members are denoted by a leading hyphen (`-`) with one member + per line, or enclosed in square brackets (`[...]`) and separated + by comma space (`, `). + +* Hashes are represented using colon space (`: `) in the form + `key: value`, either one per line or enclosed in curly braces + (`{...}`) and separated by comma space (`, `). + +* Strings (scalars) are ordinarily unquoted, but may be enclosed + in double-quotes (`"`), or single-quotes (`'`). + +* YAML requires that colons and commas used as list separators + be followed by a space so that scalar values containing embedded + punctuation can generally be represented without needing + to be enclosed in quotes. + +* Repeated nodes are initially denoted by an ampersand (`&`) and + thereafter referenced with an asterisk (`*`). These are known as + anchors and aliases in YAML speak. + +## Notes on Project File Structure + +* Each of the following sections represent top-level entries + in the YAML configuration file. Top-level means the named entries + are furthest to the left in the hierarchical configuration file + (not at the literal top of the file). + +* Unless explicitly specified in the configuration file by you, + Ceedling uses default values for settings. + +* At minimum, these settings must be specified for a test suite: + * `:project` ↳ `:build_root` + * `:paths` ↳ `:source` + * `:paths` ↳ `:test` + * `:paths` ↳ `:include` and/or use of `TEST_INCLUDE_PATH(...)` + build directive macro within your test files + +* At minimum, these settings must be specified for a release build: + * `:project` ↳ `:build_root` + * `:paths` ↳ `:source` + +* As much as is possible, Ceedling validates your settings in + properly formed YAML. + +* Improperly formed YAML will cause a Ruby error when the YAML + is parsed. This is usually accompanied by a complaint with + line and column number pointing into the project file. + +* Certain advanced features rely on `gcc` and `cpp` as preprocessing + tools. In most Linux systems, these tools are already available. + For Windows environments, we recommend the [MinGW] project + (Minimalist GNU for Windows). + +* Ceedling is primarily meant as a build tool to support automated + unit testing. All the heavy lifting is involved there. Creating + a simple binary release build artifact is quite trivial in + comparison. Consequently, most default options and the construction + of Ceedling itself is skewed towards supporting testing, though + Ceedling can, of course, build your binary release artifact + as well. Note that some complex binary release builds are beyond + Ceedling's abilities. See the [`dependencies`](../plugins/dependencies.md) plugin for + extending release build abilities. + +[MinGW]: http://www.mingw.org/ + +## Ceedling YAML Handling & Conventions + +### Inline Ruby string expansion + +Ceedling is able to execute inline Ruby string substitution code within the +entries of certain project file configuration elements. + +In some cases, this evaluation may occurs when elements of the project +configuration are loaded and processed into a data structure for use by the +Ceedling application (e.g. path handling). In other cases, this evaluation +occurs each time a project configuration element is referenced (e.g. tools). + +_Notes:_ +* One good option for validating and troubleshooting inline Ruby string + exapnsion is use of `ceedling dumpconfig` at the command line. This application + command causes your project configuration to be processed and written to a + YAML file with any inline Ruby string expansions, well, expanded along with + defaults set, plugin actions applied, etc. +* A commonly needed expansion is that of referencing an environment variable. + Inline Ruby string expansion supports this. See the example below. + +#### Ruby string expansion syntax + +To exapnd the string result of Ruby code within a configuration value string, +wrap the Ruby code in the substitution pattern `#{…}`. + +Inline Ruby string expansion may constitute the entirety of a configuration +value string, may be embedded within a string, or may be used multiple times +within a string. + +Because of the `#` it's a good idea to wrap any string values in your YAML that +rely on this feature with quotation marks. Quotation marks for YAML strings are +optional. However, the `#` can cause a YAML parser to see a comment. As such, +explicitly indicating a string to the YAML parser with enclosing quotation +marks alleviates this problem. + +#### Ruby string expansion example + +```yaml +:some_config_section: + :some_key: + - "My env string #{ENV['VAR1']}" + - "My utility result string #{`util --arg`.strip()}" +``` + +In the example above, the two YAML strings will include the strings returned by +the Ruby code within `#{…}`: + +1. The first string uses Ruby's environment variable lookup `ENV[…]` to fetch +the value assigned to variable `VAR1`. +1. The second string uses Ruby's backtick shell execution ``…`` to insert the +string generated by a command line utility. + +#### Project file sections that offer inline Ruby string expansion + +* `:mixins` +* `:environment` +* `:paths` plus any second tier configuration key name ending in `_path` or + `_paths` +* `:flags` +* `:defines` +* `:tools` +* `:release_build` ↳ `:artifacts` + +See each section's documentation for details. + +[inline-ruby-string-expansion]: #inline-ruby-string-expansion + +### Path handling + +Any second tier setting keys anywhere in YAML whose names end in `_path` or +`_paths` are automagically processed like all Ceedling-specific paths in the +YAML to have consistent directory separators (i.e. `/`) and to take advantage +of inline Ruby string expansion (see preceding section for details). + +## Let’s Be Careful Out There + +Ceedling performs validation of the values you set in your +configuration file (this assumes your YAML is correct and will +not fail format parsing, of course). + +That said, validation is limited to only those settings Ceedling +uses and those that can be reasonably validated. Ceedling does +not limit what can exist within your configuration file. In this +way, you can take full advantage of YAML as well as add sections +and values for use in your own custom plugins (documented later). + +The consequence of this is simple but important. A misspelled +configuration section or value name is unlikely to cause Ceedling +any trouble. Ceedling will happily process that section +or value and simply use the properly spelled default maintained +internally — thus leading to unexpected behavior without warning. + +[example-config-file]: ../snapshot/assets/project.yml + +

diff --git a/docs/mkdocs/configuration/reference/cexception.md b/docs/mkdocs/configuration/reference/cexception.md new file mode 100644 index 000000000..ab58ce190 --- /dev/null +++ b/docs/mkdocs/configuration/reference/cexception.md @@ -0,0 +1,35 @@ +# `:cexception` + +**Configure CException’s features** + +## Exmaple `:cmock` YAML + +```yaml +:cexception: + :defines: + # Enables CException `#include`ing CExceptionConfig.h + - CEXCEPTION_USE_CONFIG_FILE=1 +``` + +## `:defines` + +List of symbols used to configure CException’s features in its source and +header files at compile time. + +See [Using Unity, CMock & CException](../../testing-guide/frameworks.md) for +much more on configuring and making use of these frameworks in your build. + +To manage overall command line length, these symbols are only added to +compilation when a CException C source file is compiled. + +!!! note + No symbols must be set unless CException’s defaults are inappropriate for your + environment and needs. + +Note CException must be enabled for it to be added to a release or test build +and for these symbols to be added to a build of CException (see link referenced +earlier for more). + +**Default**: `[]` (empty) + +

diff --git a/docs/mkdocs/configuration/reference/cmock.md b/docs/mkdocs/configuration/reference/cmock.md new file mode 100644 index 000000000..6ca47fa34 --- /dev/null +++ b/docs/mkdocs/configuration/reference/cmock.md @@ -0,0 +1,106 @@ +# `:cmock` + +**Configure CMock’s code generation & compilation** + +Ceedling sets values for a subset of CMock settings. All CMock options are +available to be set, but only those options set by Ceedling in an automated +fashion are documented below. See [CMock documentation][cmock-docs]. + +## Exmaple `:cmock` YAML + +```yaml +:cmock: + :when_no_prototypes: :warn + :enforce_strict_ordering: TRUE + :defines: + # Memory alignment (packing) on 16 bit boundaries + - CMOCK_MEM_ALIGN=1 +``` + +## `:enforce_strict_ordering` + +Tests fail if expected call order is not same as source order + +**Default**: TRUE + +## `:verbosity` + +If not set, defaults to Ceedling’s verbosity level + +## `:defines` + +Adds list of symbols used to configure CMock’s C code features in its source +and header files at compile time. + +See [Using Unity, CMock & CException](../../testing-guide/frameworks.md) for +much more on configuring and making use of these frameworks in your build. + +To manage overall command line length, these symbols are only added to +compilation when a CMock C source file is compiled. + +No symbols must be set unless CMock’s defaults are inappropriate for your +environment and needs. + +**Default**: `[]` (empty) + +## `:plugins` + +To enable CMock’s optional and advanced features available via CMock plugin, +simply add `:cmock` ↳ `:plugins` to your configuration and specify your desired +additional CMock plugins as a simple list of the plugin names. + +See [CMock’s documentation][cmock-docs] to understand plugin options. + +**Default**: `[]` (empty) + +## `:unity_helper_path` + +A Unity helper is a simple header file used by convention to support your +specialized test case needs. For example, perhaps you want a Unity assertion +macro for the contents of a struct used throughout your project. Write the macro +you need in a Unity helper header file and `#include` that header file in your +test file. + +When a Unity helper is provided to CMock, it takes on more significance, and +more magic happens. CMock parses Unity helper header files and uses macros of a +certain naming convention to extend CMock’s handling of mocked parameters. + +See the [Unity and CMock documentation](../../testing-guide/frameworks.md) +for more details. + +`:unity_helper_path` may be a single string or a list. Each value must be a +relative path from your Ceedling working directory to a Unity helper header file +(these are typically organized within containing Ceedling `:paths` ↳ `:support` +directories). + +**Default**: `[]` (empty) + +## `:includes` + +In certain advanced testing scenarios, you may need to inject additional header +files into generated mocks. The filenames in this list will be transformed into +`#include` directives created at the top of every generated mock. + +If `:unity_helper_path` is in use (see preceding), the filenames at the end of +any Unity helper file paths will be automatically injected into this list +provided to CMock. + +**Default**: `[]` (empty) + +## Notes on Ceedling’s nudges for CMock strict ordering + +The preceding settings are tied to other Ceedling settings; hence, why they are +documented here. + +The first setting above, `:enforce_strict_ordering`, defaults to `FALSE` within +CMock. However, it is set to `TRUE` by default in Ceedling as our way of +encouraging you to use strict ordering. + +Strict ordering is teeny bit more expensive in terms of code generated, test +execution time, and complication in deciphering test failures. However, it’s +good practice. And, of course, you can always disable it by overriding the +value in the Ceedling project configuration file. + +[cmock-docs]: https://github.com/ThrowTheSwitch/CMock/blob/master/docs/CMock_Summary.md + +

diff --git a/docs/mkdocs/configuration/reference/defines.md b/docs/mkdocs/configuration/reference/defines.md new file mode 100644 index 000000000..c5d8c2317 --- /dev/null +++ b/docs/mkdocs/configuration/reference/defines.md @@ -0,0 +1,351 @@ +# `:defines` + +**Command line symbols used in compilation** + +Ceedling’s internal, default compiler tool configurations (see later `:tools` +section) execute compilation of test and source C files. + +These default tool configurations are a one-size-fits-all approach. If you need +to add to the command line symbols for individual tests or a release build, the +`:defines` section allows you to easily do so. + +Particularly in testing, symbol definitions in the compilation command line are +often needed: + +1. You may wish to control aspects of your test suite. Conditional compilation + statements can control which test cases execute in which circumstances. + (Preprocessing must be enabled, `:project` ↳ `:use_test_preprocessor`.) + +1. Testing means isolating the source code under test. This can leave certain + symbols unset when source files are compiled in isolation. Adding symbol + definitions in your Ceedling project file for such cases is one way to meet + this need. + +Entries in `:defines` modify the command lines for compilers used at build time. +In the default case, symbols listed beneath `:defines` become `-D` +arguments. + +## `:defines` verification (none) + +Ceedling does no verification of your configured `:define` symbols. + +Unity, CMock, and CException conditional compilation statements, your +toolchain’s preprocessor, and/or your toolchain’s compiler will complain +appropriately if your specified symbols are incorrect, incomplete, or +incompatible. + +Ceedling _does_ validate your `:defines` block in your project configuration. + +## Contexts and Matchers + +The basic layout of `:defines` involves the concept of contexts. + +General case: +```yaml +:defines: + :: # :test, :release, etc. + - # Simple list of symbols added to all compilation + - ... +``` + +Advanced matching for **_test_** or **_preprocess_** build handling only: +```yaml +:defines: + :test: + : # Matches a subset of test executables + - # List of symbols added to that subset’s compilation + - ... + :preprocess: # Only applicable if :project ↳ :use_test_preprocessor enabled + : # Matches a subset of test executables + - # List of symbols added to that subset’s compilation + - ... +``` + +A context is the build context you want to modify — `:release`, `:preprocess`, +or `:test`. Plugins can also hook into `:defines` with their own context. + +You specify the symbols you want to add to a build step beneath a `:`. +In many cases this is a simple YAML list of strings that will become symbols +defined in a compiler’s command line. + +Specifically in the `:test` and `:preprocess` contexts you also have the option +to create test file matchers that create symbol definitions for some subset of +your build. + +## `:defines` ↳ `:release` + +This project configuration entry adds the items of a simple YAML list as +symbols to the compilation of every C file in a release build. + +**Default**: `[]` (empty) + +## `:defines` ↳ `:test` + +This project configuration entry adds the specified items as symbols to +compilation of C components in a test executable’s build. + +Symbols may be represented in a simple YAML list or with a more sophisticated +file matcher YAML key plus symbol list. Both are documented below. + +Every C file that comprises a test executable build will be compiled with the +symbols configured that match the test filename itself. + +**Default**: `[]` (empty) + +## `:defines` ↳ `:preprocess` + +This project configuration entry adds the specified items as symbols to any +needed preprocessing of components in a test executable’s build. Preprocessing +must be enabled for this matching to have any effect. (See `:project` ↳ +`:use_test_preprocessor`.) + +Preprocessing here refers to handling macros, conditional includes, etc. in +header files that are mocked and in complex test files before runners are +generated from them. (See more about the +[Ceedling preprocessing](../../testing-guide/conventions.md#ceedling-preprocessing-behavior-for-your-tests) +feature.) + +Like the `:test` context, compilation symbols may be represented in a simple +YAML list or with a more sophisticated file matcher YAML key plus symbol list. +Both are documented below. + +!!! note "Default `:preprocess` symbols" + Left unspecified, `:preprocess` symbols default to be identical to + `:test` symbols. Override this behavior by adding `:defines` ↳ `:preprocess` + symbols. If you want no additional symbols for preprocessing regardless of + `test` symbols, specify an empty list `[]` in your `:preprocess` matcher. + +**Default**: Identical to `:test` context unless specified + +## `:defines` ↳ `:` + +Some advanced plugins make use of build contexts as well. For instance, the +Ceedling Gcov plugin uses a context of `:gcov`, surprisingly enough. For any +plugins with tools that take advantage of Ceedling’s internal mechanisms, you +can add to those tools' compilation symbols in the same manner as the built-in +contexts. + +## `:defines` options + +* `:use_test_definition`: + + If enabled, add a symbol to test compilation derived from the test file name. + The resulting symbol is a sanitized, uppercase, ASCII version of the test file + name. Any non ASCII characters (e.g. Unicode) are replaced by underscores as + are any non-alphanumeric characters. Underscores and dashes are preserved. The + symbol name is wrapped in underscores unless they already exist in the leading + and trailing positions. Example: _test_123abc-xyz😵.c_ ➡️ `_TEST_123ABC-XYZ_`. + + **Default**: False + +## Simple `:defines` configuration + +A simple and common need is configuring conditionally compiled features in a +code base. The following example illustrates using simple YAML lists for symbol +definitions at compile time. + +```yaml +:defines: + :test: # All compilation of all C files for all test executables + - FEATURE_X=ON + - PRODUCT_CONFIG_C + :release: # All compilation of all C files in a release artifact + - FEATURE_X=ON + - PRODUCT_CONFIG_C +``` + +Given the YAML blurb above, the two symbols will be defined in the compilation +command lines for all C files in all test executables within a test suite build +and for all C files in a release build. + +## Advanced `:defines` per-test matchers + +Ceedling treats each test executable as a mini project. As a reminder, each +test file, together with all C sources and frameworks, becomes an individual +test executable of the same name. + +**_In the `:test` and `:preprocess` contexts only_**, symbols may be defined +for only those test executable builds that match filename criteria. Matchers +match on test filenames only, and the specified symbols are added to the build +step for all files that are components of matched test executables. + +In short, for instance, this means your compilation of _TestA_ can have +different symbols than compilation of _TestB_. Those symbols will be applied to +every C file that is compiled as part those individual test executable builds. +Thus, in fact, with separate test files unit testing the same source C file, you +may exercise different conditional compilations of the same source. See the +example in the section below. + +### Per-test matcher examples + +Before detailing matcher capabilities and limits, here are examples to +illustrate the basic ideas of test file name matching. + +This first example builds on the previous simple symbol list example. The +imagined scenario is that of unit testing the same single source C file with +different product features enabled. The per-test matchers shown here use test +filename substring matchers. + +```yaml +# Imagine three test files all testing aspects of a single source file Comms.c with +# different features enabled via conditional compilation. +:defines: + :test: + # Tests for FeatureX configuration + :CommsFeatureX: # Matches a test executable name including 'CommsFeatureX' + - FEATURE_X=ON + - FEATURE_Z=OFF + - PRODUCT_CONFIG_C + # Tests for FeatureZ configuration + :CommsFeatureZ: # Matches a test executable name including 'CommsFeatureZ' + - FEATURE_X=OFF + - FEATURE_Z=ON + - PRODUCT_CONFIG_C + # Tests of base functionality + :CommsBase: # Matches a test executable name including 'CommsBase' + - FEATURE_X=OFF + - FEATURE_Z=OFF + - PRODUCT_BASE +``` + +This example illustrates each of the test file name matcher types. + +```yaml +:defines: + :test: + :*: # Wildcard: Add '-DA' for compilation all files for all test executables + - A + :Model: # Substring: Add '-DCHOO' for compilation of all files of any test executable with 'Model' in its name + - CHOO + :/M(ain|odel)/: # Regex: Add '-DBLESS_YOU' for all files of any test executable with 'Main' or 'Model' in its name + - BLESS_YOU + :Comms*Model: # Wildcard: Add '-DTHANKS' for all files of any test executables that have zero or more characters + - THANKS # between 'Comms' and 'Model' +``` + +### Per-test matchers types + +These matchers are available: + +1. Wildcard (`*`) + 1. If specified in isolation, matches all tests. + 1. If specified within a string, matches any test filename with that + wildcard expansion. +1. Substring — Matches on part of a test filename (up to all of it, including + full path). +1. Regex (`/.../`) — Matches test file names against a regular expression. + +Notes: +* Substring filename matching is case sensitive. +* Wildcard matching is effectively a simplified form of regex. That is, multiple + approaches to matching can match the same filename. + +Symbols by matcher are cumulative. This means the symbols from multiple +matchers can be applied to all compilation for any single test executable. + +Referencing the example above, here are the extra compilation symbols for a +handful of test executables: + +* _test_Something_: `-DA` +* _test_Main_: `-DA -DBLESS_YOU` +* _test_Model_: `-DA -DCHOO -DBLESS_YOU` +* _test_CommsSerialModel_: `-DA -DCHOO -DBLESS_YOU -DTHANKS` + +The simple `:defines` list format remains available for the `:test` and +`:preprocess` contexts. Of course, this format is limited in that it applies +symbols to the compilation of all C files for all test executables. + +This simple list format for `:test` and `:preprocess` contexts… + +```yaml +:defines: + :test: + - A +``` + +…is equivalent to this matcher version: + +```yaml +:defines: + :test: + :*: + - A +``` + +### Distinguishing similar / identical filenames + +You may find yourself needing to distinguish test files with the same name or +test files with names whose base naming is identical. + +Of course, identical test filenames have a natural distinguishing feature in +their containing directory paths. Files of the same name can only exist in +different directories. As such, your matching must include the path. + +```yaml +:defines: + :test: + :hardware/test_startup: # Match any test names beginning with 'test_startup' in hardware/ directory + - A + :network/test_startup: # Match any test names beginning with 'test_startup' in network/ directory + - B +``` + +It’s common in C file naming to use the same base name for multiple files. +Given the following example list, care must be given to matcher construction to +single out test_comm_startup.c. + +* tests/test_comm_hw.c +* tests/test_comm_startup.c +* tests/test_comm_startup_timers.c + +```yaml +:defines: + :test: + :test_comm_startup.c: # Full filename with extension distinguishes this file test_comm_startup_timers.c + - FOO +``` + +The preceding examples use substring matching, but, regular expression matching +could also be appropriate. + +### YAML anchors & aliases + +See the short but helpful article on [YAML anchors & aliases][yaml-anchors-aliases] +to understand these features of YAML. + +Particularly in testing complex projects, per-test file matching may only get +you so far in meeting your symbol definition needs. For instance, you may need +to use the same symbols across many test files, but no convenient name matching +scheme works. Advanced YAML features can help you copy the same symbols into +multiple `:defines` test file matchers. + +The following advanced example illustrates how to create a set of compilation +symbols for test preprocessing that are identical to test compilation with one +addition. + +In brief, this example uses YAML features to copy the `:test` matcher +configuration that matches all test executables into the `:preprocess` context +and then add an additional compilation symbol to the list. + +```yaml +:defines: + :test: &config-test-defines # YAML anchor + :*: &match-all-tests # YAML anchor + - PRODUCT_FEATURE_X + - ASSERT_LEVEL=2 + - USES_RTOS=1 + :test_foo: + - DRIVER_FOO=1u + :test_bar: + - DRIVER_BAR=5u + :preprocess: + <<: *config-test-defines # Insert all :test defines file matchers via YAML alias + :*: # Override wildcard matching key in copy of *config-test-defines + - *match-all-tests # Copy test defines for all files via YAML alias + - RTOS_SPECIAL_THING # Add single additional symbol to all test executable preprocessing + # test_foo, test_bar, and any other matchers are present because of <<: above +``` + +[yaml-anchors-aliases]: https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/ + +

diff --git a/docs/mkdocs/configuration/reference/environment.md b/docs/mkdocs/configuration/reference/environment.md new file mode 100644 index 000000000..d96452afe --- /dev/null +++ b/docs/mkdocs/configuration/reference/environment.md @@ -0,0 +1,55 @@ +# `:environment:` + +**Insert environment variables into the shells running tools** + +Ceedling creates environment variables from any key / value pairs in the +`:environment` section of project configuration. + +Keys become an environment variable name in uppercase. Values are strings +assigned to those environment variables. These value strings are either simple +string values as provided in the YAML or they are a concatenation of a YAML +array of strings. + +`:environment` is a list of single key / value pair entries processed in the +configured list order. + +!!! note "`:environment` is a key / value YAML hash" + `:environment` is a list of key / value pairs. Only one key per entry + is allowed, and that key must be a `:`__. `:environment` is + not unusual YAML, but it is a use of YAML unlike other sections of + project configuration. + +`:environment` variable value strings can include +[inline Ruby string expansion][inline-ruby-string-expansion]. Thus, later +entries can reference earlier entries. + +## Special case: `PATH` handling + +In the specific case of specifying an environment key named `:path`, an array +of string values will be concatenated with the appropriate platform-specific +path separation character (i.e. `:` on Unix-variants, `;` on Windows). + +All other instances of environment keys assigned a value of a YAML array use +simple concatenation. + +## Example `:environment` YAML + +```yaml +:environment: + - :license_server: gizmo.intranet # LICENSE_SERVER set with value "gizmo.intranet" + - :license: "#{`license.exe`}" # LICENSE set to string generated from shelling out to + # execute license.exe; note use of enclosing quotes to + # prevent a YAML comment. + + - :logfile: system/logs/thingamabob.log # LOGFILE set with path for a log file + + - :path: # Concatenated with path separator (see special case above) + - Tools/gizmo/bin # Prepend existing PATH with gizmo path + - "#{ENV['PATH']}" # Pattern #{…} triggers ruby evaluation string expansion + # NOTE: value string must be quoted because of '#' to + # prevent a YAML comment. +``` + +[inline-ruby-string-expansion]: ../project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/reference/extension.md b/docs/mkdocs/configuration/reference/extension.md new file mode 100644 index 000000000..43a3d5c37 --- /dev/null +++ b/docs/mkdocs/configuration/reference/extension.md @@ -0,0 +1,63 @@ +# `:extension` + +**Filename extensions used to collect lists of files searched in [`:paths`](paths.md)** + +Ceedling uses path lists and wildcard matching against filename extensions to collect file lists. + +## Example `:extension` YAML + +```yaml +:extension: + :source: .cc + :executable: .bin +``` + +## `:header` + +C header files + +**Default**: .h + +## `:source` + +C code files (whether source or test files) + +**Default**: .c + +## `:assembly` + +Assembly files (contents wholly assembler instructions) + +**Default**: .s + +## `:object` + +Resulting binary output of C code compiler (and assembler) + +**Default**: .o + +## `:executable` + +Binary executable to be loaded and executed upon target hardware + +**Default**: .exe or .out (Win or Linux) + +## `:testpass` + +Test results file (not likely to ever need a redefined value) + +**Default**: .pass + +## `:testfail` + +Test results file (not likely to ever need a redefined value) + +**Default**: .fail + +## `:dependencies` + +File containing make-style dependency rules created by the `gcc` preprocessor + +**Default**: .d + +

diff --git a/docs/mkdocs/configuration/reference/files.md b/docs/mkdocs/configuration/reference/files.md new file mode 100644 index 000000000..cec59d37c --- /dev/null +++ b/docs/mkdocs/configuration/reference/files.md @@ -0,0 +1,152 @@ +# `:files` + +**Tailoring file collections** + +Ceedling relies on file collections to do its work. These file collections are +automagically assembled from paths, matching globs / wildcards, and file +extensions. See [project configuration `:extension`](extension.md). + +Entries in `:files` accomplish filepath-oriented tailoring of the bulk file +collections created from `:paths` directory listings and filename pattern +matching. + +On occasion you may need to remove from or add individual files to Ceedling’s +file collections. + +The path grammar documented in the `:paths` configuration section largely +applies to `:files` path entries — albeit with regard to filepaths and not +directory paths. The `:files` grammar and YAML examples are documented below. + +## Example `:files` YAML + +### Simple tailoring + +```yaml +:paths: + # All /*. => test/release compilation input + :source: + - src/** + +:files: + :source: + - +:callbacks/serial_comm.c # Add source code outside src/ + - -:src/board/atm134.c # Remove board code +``` + +### Advanced tailoring + +```yaml +:paths: + # All /*. => test compilation input + test suite executables + :test: + - test/** + +:files: + :test: + # Remove every test file anywhere beneath test/ whose name ends with 'Model'. + # String replacement inserts a global constant that is the file extension for + # a C file. This is an anchor for the end of the filename and automaticlly + # uses file extension settings. + - "-:test/**/*Model#{EXTENSION_SOURCE}" + + # Remove test files at depth 1 beneath test/ with 'analog' anywhere in their names. + - -:test/*{A,a}nalog* + + # Remove test files at depth 1 beneath test/ that are of an "F series" + # test collection FAxxxx, FBxxxx, and FCxxxx where 'x' is any character. + - -:test/F[A-C]???? +``` + +## `:files` ↳ `:test` + +Modify the collection of unit test C files. + +**Default**: `[]` (empty) + +## `:files` ↳ `:source` + +Modify the collection of all source files used in unit test builds and release +builds. + +**Default**: `[]` (empty) + +## `:files` ↳ `:assembly` + +Modify the (optional) collection of assembly files used in release builds. + +**Default**: `[]` (empty) + +## `:files` ↳ `:include` + +Modify the collection of all source header files used in unit test builds +(e.g. for mocking) and release builds. + +**Default**: `[]` (empty) + +## `:files` ↳ `:support` + +Modify the collection of supporting C files available to unit tests builds. + +**Default**: `[]` (empty) + +## `:files` ↳ `:libraries` + +Add a collection of library paths to be included when linking. + +**Default**: `[]` (empty) + +## `:files` options & notes + +1. A path can be absolute (fully qualified) or relative. +1. A path can include a glob matcher (more on this below). +1. A path can use [inline Ruby string expansion][inline-ruby-string-expansion]. +1. Subtractive paths prepended with a `-:` decorator are possible and useful. + See the documentation below. + +## `:files` Globs + +Globs are effectively fancy wildcards. They are not as capable as full regular +expressions but are easier to use. Various OSs and programming languages +implement them differently. + +For a quick overview, see this [tutorial][globs-tutorial]. + +Ceedling supports globs so you can specify patterns of files as well as simple, +ordinary filepaths. + +Ceedling `:files` globs operate identically to [Ruby globs][ruby-globs] except +that they ignore directory paths. Only filepaths are recognized. + +Glob operators include the following: `*`, `**`, `?`, `[-]`, `{,}`. + +* `*` + * When used within a character string, `*` is simply a standard wildcard. + * When used after a path separator, `/*` matches all subdirectories of depth + 1 below the parent path, not including the parent path. +* `**`: All subdirectories recursively discovered below the parent path, not + including the parent path. This pattern only makes sense after a path + separator `/**`. +* `?`: Single alphanumeric character wildcard. +* `[x-y]`: Single alphanumeric character as found in the specified range. +* `{x, y, ...}`: Matching any of the comma-separated patterns. Two or more + patterns may be listed within the brackets. Patterns may be specific + character sequences or other glob operators. + +## Subtractive `:files` entries + +Tailoring a file collection includes adding to it but also subtracting from it. + +Put simply, with an optional preceding decorator `-:`, you can instruct Ceedling +to remove certain file paths from a collection after it builds that collection. + +By default, paths are additive. For pretty alignment in your YAML, you may also +use `+:`, but strictly speaking, it’s not necessary. + +Subtractive paths may be simple paths or globs just like any other path entry. + + +[globs-tutorial]: http://ruby.about.com/od/beginningruby/a/dir2.htm +[ruby-globs]: https://ruby-doc.org/core-3.0.0/Dir.html#method-c-glob +[inline-ruby-string-expansion]: ../project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/reference/flags.md b/docs/mkdocs/configuration/reference/flags.md new file mode 100644 index 000000000..839ce3e96 --- /dev/null +++ b/docs/mkdocs/configuration/reference/flags.md @@ -0,0 +1,295 @@ +# `:flags` + +**Configure preprocessing, compilation, and linking command line flags** + +Ceedling’s internal, default tool configurations execute compilation and linking +of test and source files among a variety of other tooling needs. (See later +`:tools` section.) + +These default tool configurations are a one-size-fits-all approach. If you need +to add flags to the command line for individual tests or a release build, the +`:flags` section allows you to easily do so. + +Entries in `:flags` modify the command lines for tools used at build time. + +## Contexts, Operations, and Matchers + +The basic layout of `:flags` involves the concepts of contexts and operations. + +General case: +```yaml +:flags: + :: # :test or :release + :: # :preprocess, :compile, :assemble, or :link + - + - ... +``` + +Advanced matching for **_test_** build handling only: +```yaml +:flags: + :test: + :: # :preprocess, :compile, :assemble, or :link + :: # Matches a subset of test executables + - # List of flags added to that subset’s build operation command line + - ... +``` + +A context is the build context you want to modify — `:test` or `:release`. +Plugins can also hook into `:flags` with their own context. + +An operation is the build step you wish to modify — `:preprocess`, `:compile`, +`:assemble`, or `:link`. + +* The `:preprocess` operation is only used from within the `:test` context. +* The `:assemble` operation is only of use within the `:test` or `:release` + contexts if assembly support has been enabled in `:test_build` or + `:release_build`, respectively, and assembly files are a part of the project. + +You specify the flags you want to add to a build step beneath `:` ↳ +`:`. In many cases this is a simple YAML list of strings that will +become flags in a tool’s command line. + +**_Specifically and only in the `:test` context_** you also have the option to +create test file matchers that apply flags to some subset of your test build. +Note that file matchers and the simpler flags list format cannot be mixed for +`:flags` ↳ `:test`. + +## `:flags` ↳ `:release` ↳ `:compile` + +This project configuration entry adds the items of a simple YAML list as flags +to compilation of every C file in a release build. + +**Default**: `[]` (empty) + +## `:flags` ↳ `:release` ↳ `:link` + +This project configuration entry adds the items of a simple YAML list as flags +to the link step of a release build artifact. + +**Default**: `[]` (empty) + +## `:flags` ↳ `:test` ↳ `:compile` + +This project configuration entry adds the specified items as flags to +compilation of C components in a test executable’s build. + +Flags may be represented in a simple YAML list or with a more sophisticated +file matcher YAML key plus flag list. Both are documented below. + +**Default**: `[]` (empty) + +## `:flags` ↳ `:test` ↳ `:preprocess` + +This project configuration entry adds the specified items as flags to any +needed preprocessing of components in a test executable’s build. Preprocessing +must be enabled for this matching to have any effect. (See `:project` ↳ +`:use_test_preprocessor`.) + +Preprocessing here refers to handling macros, conditional includes, etc. in +header files that are mocked and in complex test files before runners are +generated from them. (See more about the +[Ceedling preprocessing](../../testing-guide/conventions.md#ceedling-preprocessing-behavior-for-your-tests) +feature.) + +Flags may be represented in a simple YAML list or with a more sophisticated +file matcher YAML key plus flag list. Both are documented below. + +!!! note "`:preprocess` flags default behavior" + Left unspecified, `:preprocess` flags default to behaving identically + to `:compile` flags. Override this behavior by adding `:test` ↳ `:preprocess` + flags. If you want no additional flags for preprocessing regardless of test + compilation flags, simply specify an empty list `[]`. + +**Default**: Same flags as specified for test compilation + +## `:flags` ↳ `:test` ↳ `:link` + +This project configuration entry adds the specified items as flags to the link +step of test executables. + +Flags may be represented in a simple YAML list or with a more sophisticated +file matcher YAML key plus flag list. Both are documented below. + +**Default**: `[]` (empty) + +## `:flags` ↳ `:` + +Some advanced plugins make use of build contexts as well. For instance, the +Ceedling Gcov plugin uses a context of `:gcov`, surprisingly enough. For any +plugins with tools that take advantage of Ceedling’s internal mechanisms, you +can add to those tools' flags in the same manner as the built-in contexts and +operations. + +## Simple `:flags` configuration + +A simple and common need is enforcing a particular C standard. The following +example illustrates simple YAML lists for flags. + +```yaml +:flags: + :release: + :compile: + # Add `-std=c99` to compilation of all C files in the release build + - -std=c99 + :test: + :compile: + # Add `-std=c99` to the compilation of all C files in all test executables + - -std=c99 +``` + +Given the YAML above, when test or release compilation occurs, the flag +specifying the C standard will be in the command line for compilation of all C +files. + +## Advanced `:flags` per-test matchers + +Ceedling treats each test executable as a mini project. As a reminder, each +test file, together with all C sources and frameworks, becomes an individual +test executable of the same name. + +_In the `:test` context only_, flags can be applied to build step operations — +preprocessing, compilation, and linking — for only those test executables that +match file name criteria. Matchers match on test filenames only, and the +specified flags are added to the build step for all files that are components +of matched test executables. + +In short, for instance, this means your compilation of _TestA_ can have +different flags than compilation of _TestB_. And, in fact, those flags will be +applied to every C file that is compiled as part those individual test +executable builds. + +### `:flags` per-test matcher examples with YAML + +Before detailing matcher capabilities and limits, here are examples to +illustrate the basic ideas of test file name matching. + +```yaml +:flags: + :test: + :compile: + :*: # Wildcard: Add '-foo' for all files compiled for all test executables + - -foo + :Model: # Substring: Add '-Wall' for all files compiled for any test executable with 'Model' in its filename + - -Wall + :/M(ain|odel)/: # Regex: Add 🏴‍☠️ flag for all files compiled for any test executable with 'Main' or 'Model' in its filename + - -🏴‍☠️ + :Comms*Model: + - --freak # Wildcard: Add your `--freak` flag for all files compiled for any test executable with zero or more + # characters between 'Comms' and 'Model' + :link: + :tests/comm/TestUsart.c: # Substring: Add '--bar --baz' to the link step of the TestUsart executable + - --bar + - --baz +``` + +### Using `:flags` per-test matchers + +These matchers are available: + +1. Wildcard (`*`) + 1. If specified in isolation, matches all tests. + 1. If specified within a string, matches any test filename with that + wildcard expansion. +1. Substring — Matches on part of a test filename (up to all of it, including + full path). +1. Regex (`/.../`) — Matches test file names against a regular expression. + +Notes: +* Substring filename matching is case sensitive. +* Wildcard matching is effectively a simplified form of regex. That is, + multiple approaches to matching can match the same filename. + +Flags by matcher are cumulative. This means the flags from multiple matchers +can be applied to all files processed by the named build operation for any +single test executable. + +Referencing the example above, here are the extra compilation flags for a +handful of test executables: + +* _test_Something_: `-foo` +* _test_Main_: `-foo -🏴‍☠️` +* _test_Model_: `-foo -Wall -🏴‍☠️` +* _test_CommsSerialModel_: `-foo -Wall -🏴‍☠️ --freak` + +The simple `:flags` list format remains available for the `:test` context. Of +course, this format is limited in that it applies flags to all C files processed +by the named build operation for all test executables. + +This simple list format for the `:test` context… + +```yaml +:flags: + :test: + :compile: + - -foo +``` + +…is equivalent to this matcher version: + +```yaml +:flags: + :test: + :compile: + :*: + - -foo +``` + +### Distinguishing similar / identical filenames + +You may find yourself needing to distinguish test files with the same name or +test files with names whose base naming is identical. + +Of course, identical test filenames have a natural distinguishing feature in +their containing directory paths. Files of the same name can only exist in +different directories. As such, your matching must include the path. + +```yaml +:flags: + :test: + :compile: + # Match any test names beginning with 'test_startup' in hardware/ directory + :hardware/test_startup: + - A + # Match any test names beginning with 'test_startup' in network/ directory + :network/test_startup: + - B +``` + +It’s common in C file naming to use the same base name for multiple files. +Given the following example list, care must be given to matcher construction to +single out test_comm_startup.c. + +* tests/test_comm_hw.c +* tests/test_comm_startup.c +* tests/test_comm_startup_timers.c + +```yaml +:flags: + :test: + :compile: + # Full filename with extension distinguishes this file test_comm_startup_timers.c + :test_comm_startup.c: + - FOO +``` + +The preceding examples use substring matching, but, regular expression matching +could also be appropriate. + +### YAML anchors & aliases + +See the short but helpful article on [YAML anchors & aliases][yaml-anchors-aliases] +to understand these features of YAML. + +Particularly in testing complex projects, per-test file matching may only get +you so far in meeting your build step flag needs. For instance, you may need to +set various flags for operations across many test files, but no convenient name +matching scheme works. Advanced YAML features can help you copy the same flags +into multiple `:flags` test file matchers. + +Please see the discussion in [`:defines`][defines] for a complete example. + +[yaml-anchors-aliases]: https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/ +[defines]: defines.md + +

diff --git a/docs/mkdocs/configuration/reference/index.md b/docs/mkdocs/configuration/reference/index.md new file mode 100644 index 000000000..5f858a0da --- /dev/null +++ b/docs/mkdocs/configuration/reference/index.md @@ -0,0 +1,168 @@ +# Configuration Reference + +All top-level keys of the Ceedling project configuration file, each on its own page. + +## Project & Build Structure + +
+ +- :material-book-open-variant: **[`:project` — Global Project Settings][ref-project]** + + --- + + Build root, default tasks, parallelism, test preprocessor, release build + toggle, crash backtrace, and more. + +- :material-book-open-variant: **[`:mixins` — Mixins][ref-mixins]** + + --- + + Load and merge additional YAML configuration files into your base project + configuration for flexible, composable builds. + +- :material-book-open-variant: **[`:test_build` — Test Build Settings][ref-test-build]** + + --- + + Assembly file support for test suite builds. + +- :material-book-open-variant: **[`:release_build` — Release Build Settings][ref-release-build]** + + --- + + Output artifact name, assembly support, and additional artifact file copying. + +- :material-book-open-variant: **[`:environment` — Environment Variables][ref-environment]** + + --- + + Define shell environment variables that are set before tools are invoked, + with support for inline Ruby string expansion. + +
+ +## Files, Paths & Extensions + +
+ +- :material-book-open-variant: **[`:paths` — Search Paths][ref-paths]** + + --- + + Directory path lists for source, tests, headers, support files, and libraries. + Supports globs, subtractive entries, and inline Ruby string expansion. + +- :material-book-open-variant: **[`:files` — File Collections][ref-files]** + + --- + + Fine-grained tailoring of the file collections assembled from `:paths` — + add or subtract individual files with globs and subtractive entries. + +- :material-book-open-variant: **[`:extension` — File Extensions][ref-extension]** + + --- + + Override the default filename extensions for source, header, object, assembly, + executable, and other file types. + +
+ +## Compilation & Linking + +
+ +- :material-book-open-variant: **[`:defines` — Compilation Symbols][ref-defines]** + + --- + + Add `-D` symbols to compiler command lines for release builds, all tests, or + individual test executables matched by name, substring, or regex. + +- :material-book-open-variant: **[`:flags` — Compilation & Link Flags][ref-flags]** + + --- + + Add flags to preprocessor, compiler, assembler, and linker command lines for + release builds, all tests, or individual test executables via matchers. + +- :material-book-open-variant: **[`:libraries` — Libraries][ref-libraries]** + + --- + + Specify test, release, and system libraries to include at link time, with + configurable flag formats and library search paths. + +
+ +## Frameworks + +
+ +- :material-book-open-variant: **[`:unity` — Unity][ref-unity]** + + --- + + Compile-time symbol definitions to configure Unity's behavior, plus + parameterized test case support. + +- :material-book-open-variant: **[`:cmock` — CMock][ref-cmock]** + + --- + + CMock code generation options, strict ordering, Unity helper paths, and + compile-time symbol definitions. + +- :material-book-open-variant: **[`:cexception` — CException][ref-cexception]** + + --- + + Compile-time symbol definitions to configure CException's behavior. + +- :material-book-open-variant: **[`:test_runner` — Test Runner Generation][ref-test-runner]** + + --- + + Options passed to Unity's test runner generation script, including additional + header includes. + +
+ +## Tools & Extensions + +
+ +- :material-book-open-variant: **[`:tools` — Command Line Tools][ref-tools]** + + --- + + Full tool definitions for every build step: compiler, assembler, linker, and + test fixture. Includes shortcuts for modifying built-in tools. + +- :material-book-open-variant: **[`:plugins` — Ceedling Extensions][ref-plugins]** + + --- + + Enable built-in and custom plugins, and specify additional plugin load paths. + +
+ +[ref-project]: project.md +[ref-mixins]: mixins.md +[ref-test-build]: test-build.md +[ref-release-build]: release-build.md +[ref-paths]: paths.md +[ref-files]: files.md +[ref-environment]: environment.md +[ref-extension]: extension.md +[ref-defines]: defines.md +[ref-libraries]: libraries.md +[ref-flags]: flags.md +[ref-cexception]: cexception.md +[ref-cmock]: cmock.md +[ref-unity]: unity.md +[ref-test-runner]: test-runner.md +[ref-tools]: tools.md +[ref-plugins]: plugins.md + +

diff --git a/docs/mkdocs/configuration/reference/libraries.md b/docs/mkdocs/configuration/reference/libraries.md new file mode 100644 index 000000000..034928f4b --- /dev/null +++ b/docs/mkdocs/configuration/reference/libraries.md @@ -0,0 +1,82 @@ +# `:libraries` + +**Pull in specific libraries for release and test builds** + +## `:libraries` example YAML + +```yaml +:paths: + :libraries: + - proj/libs # Linker library search paths + +:libraries: + :test: + - test/commsstub.lib # Imagined communication library that logs to console without traffic + :release: + - release/comms.lib # Imagined production communication library + :system: + - math # Add system math library to test & release builds + :flag: -Lib=${1} # This linker does not follow the gcc convention +``` + +## `:libraries` ↳ `:test` + +Libraries that should be injected into your test builds when linking occurs. + +These can be specified as naked library names or with relative paths if search +paths are specified with `:paths` ↳ `:libraries`. Otherwise, absolute paths +may be used here. + +These library files **must** exist when tests build. + +**Default**: `[]` (empty) + +## `:libraries` ↳ `:release` + +Libraries that should be injected into your release build when linking occurs. + +These can be specified as naked library names or with relative paths if search +paths are specified with `:paths` ↳ `:libraries`. Otherwise, absolute paths +may be used here. + +These library files **must** exist when the release build occurs **unless** you +are using the [`dependencies`](../../plugins/dependencies.md) plugin. In that case, the plugin will attempt to +build the needed library for you as a dependency. + +**Default**: `[]` (empty) + +## `:libraries` ↳ `:system` + +Libraries listed here will be injected into releases and tests. + +These libraries are assumed to be findable by the configured linker tool, should +need no path help, and can be specified by common linker shorthand for libraries. + +For example, specifying `m` will include the math library per the GCC +convention. The file itself on a Unix-like system will be `libm` and the `gcc` +command line argument will be `-lm`. + +**Default**: `[]` (empty) + +## `:flag` + +Command line argument format for specifying a library. + +**Default**: `-l${1}` (GCC format) + +## `:path_flag` + +Command line argument format for adding a library search path. + +Library search paths may be added to your project with `:paths` ↳ `:libraries`. + +**Default**: `-L "${1}"` (GCC format) + +## `:libraries` notes + +* If you've specified your own link step, you are going to want to add `${4}` to + your argument list in the position where library files should be added to the + command line. For `gcc`, this is often at the very end. Other tools may vary. + See the `:tools` section for more. + +

diff --git a/docs/mkdocs/configuration/reference/mixins.md b/docs/mkdocs/configuration/reference/mixins.md new file mode 100644 index 000000000..24ec8fd2d --- /dev/null +++ b/docs/mkdocs/configuration/reference/mixins.md @@ -0,0 +1,24 @@ +# `:mixins` + +**Configuring your project by merging more configuration** + +Mixins allow you to merge configuration with your project configuration +just after the base project file is loaded. + +This project configuration section is documented extensively +in the [discussion of project files and mixins][mixins-config-section]. + +## Notes + +* A `:mixins` section is only recognized within a base project configuration + file. Any `:mixins` sections within mixin files are ignored. +* A `:mixins` section in a Ceedling configuration is entirely filtered out of + the resulting configuration. That is, it is unavailable for use by plugins + and will not be present in any output from `ceedling dumpconfig`. +* A `:mixins` section supports [inline Ruby string expansion][inline-ruby-string-expansion]. + See the full documetation on Mixins for details. + +[mixins-config-section]: ../loading.md#applying-mixins-to-base-configuration +[inline-ruby-string-expansion]: ../project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/reference/paths.md b/docs/mkdocs/configuration/reference/paths.md new file mode 100644 index 000000000..0ac5a1601 --- /dev/null +++ b/docs/mkdocs/configuration/reference/paths.md @@ -0,0 +1,276 @@ +# `:paths` + +**Paths for build tools and building file collections** + +Ceedling relies on various path and file collections to do its work. File +collections are automagically assembled from paths, matching globs / wildcards, +and file extensions. See [project configuration `:extension`](extension.md). + +Entries in `:paths` help create directory-based bulk file collections. The +`:files` configuration section is available for filepath-oriented tailoring of +these bulk file collections. + +Entries in `:paths` ↳ `:include` also specify search paths for header files. + +All of the configuration subsections that follow default to empty lists. In +YAML, list items can be comma separated within brackets or organized per line +with a dash. An empty list can only be denoted as `[]`. Typically, you will see +Ceedling project files use lists broken up per line. + +```yaml +:paths: + :support: [] # Empty list (internal default) + :source: + - files/code # Typical list format + +``` + +!!! warning "Mixin order controls path list order" + If you use Mixins to build up path lists in your project configuration, + the merge order of those Mixins will dictate the ordering of your path + lists. Particularly given that the search path list built with + `:paths` ↳ `:include` you will want to pay attention to ordering issues + involved in specifying path lists in Mixins. + +## Example `:paths` YAML + +!!! note "Path standardization" + Ceedling standardizes paths for you. Internally, all paths use forward + slash `/` path separators (including on Windows) and trailing path + separators are cleaned up. This should have no impact on your project + regardless of platform, but you will see the effects in logging. + +### Simple entries + +```yaml +:paths: + # All /*. => test/release compilation input + :source: + - project/src/ # Resulting source list has just two relative directory paths + - project/aux # (Traversal goes no deeper than these simple paths) + + # All => compilation search paths + mock search paths + :include: # All => compilation input + - project/src/inc # Include paths are subdirectory of src/ + - /usr/local/include/foo # Header files for a prebuilt library at fully qualified path + + # All /*. => test compilation input + test suite executables + :test: + - ../tests # Tests have parent directory above working directory +``` + +### Common globs with subtractive entries + +```yaml +:paths: + :source: + - +:project/src/** # Recursive glob yields all subdirectories of any depth plus src/ + - -:project/src/exp # Exclude experimental code in exp/ from release or test builds + # `+:` is decoration for pretty alignment; only `-:` changes a list + + :include: + - +:project/src/**/inc # Include every subdirectory inc/ beneath src/ + - -:project/src/exp/inc # Remove header files subdirectory for experimental code +``` + +### Advanced entries with globs and string expansion + +```yaml +:paths: + :test: + - test/**/f??? # Every 4 character "f-series" subdirectory beneath test/ + + :my_things: # Custom path list + - "#{PROJECT_ROOT}/other" # Inline Ruby string expansion using Ceedling global constant +``` + +```yaml +:paths: + :test: + - test/{foo,b*,xyz} # Path list will include test/foo/, test/xyz/, and any subdirectories + # beneath test/ beginning with 'b', including just test/b/ +``` + +Globs and inline Ruby string expansion can require trial and error to arrive at +your intended results. Ceedling provides as much validation of paths as is +practical. + +!!! tip "Troubleshooting paths" + * Use the `ceedling paths:*` and `ceedling files:*` command line tasks to + verify your settings. (Here `*` is shorthand for `test`, `source`, `include`, + etc. Confusing? Sorry.) + * `ceedling dumpconfig` can also help your troubleshoot your configuration. + This application command causes Ceedling to fully process your configuration + (e.g. Mixins, any string expansion, etc.) and write the result to another + YAML file for your inspection. + +## `:paths` ↳ `:test` + +All C files containing unit test code. + +!!! note "Test suite configuration requirement" + `:paths` ↳ `:test` is one of the handful of configuration values that must + be set for a test suite. + +**Default**: `[]` (empty) + +## `:paths` ↳ `:source` + +All C files containing release code (code to be tested). + +!!! note "Build configuration requirement" + `:paths` ↳ `:source` must be set for any build — release build or test suite — + to run. + +**Default**: `[]` (empty) + +## `:paths` ↳ `:support` + +Any C files you might need to aid your unit testing. For example, on occasion, +you may need to create a header file containing a subset of function signatures +matching those elsewhere in your code (e.g. a subset of your OS functions, a +portion of a library API, etc.). Why? To provide finer grained control over +mock function substitution or limiting the size of the generated mocks. + +**Default**: `[]` (empty) + +## `:paths` ↳ `:include` + +See these two important discussions to fully understand your options for header +file search paths: + + * [Configuring Your Header File Search Paths][header-file-search-paths] + * [`TEST_INCLUDE_PATH(...)` build directive macro][test-include-path-macro] + +[header-file-search-paths]: ../../testing-guide/conventions.md#search-paths-for-test-builds +[test-include-path-macro]: ../../testing-guide/build-directives.md#test_include_path + +This set of paths specifies the locations of your header files. If your header +files are intermixed with source files, you must duplicate some or all of your +`:paths` ↳ `:source` entries here. + +In its simplest use, your include paths list can be exhaustive. That is, you +list all path locations where your project’s header files reside in this +configuration list. + +However, if you have a complex project or many, many include paths that create +problematically long search paths at the compilation command line, you may treat +your `:paths` ↳ `:include` list as a base, common list. Having established that +base list, you can then extend it on a test-by-test basis with use of the +`TEST_INCLUDE_PATH(...)` build directive macro in your test files. + +**Default**: `[]` (empty) + +## `:paths` ↳ `:test_toolchain_include` + +System header files needed by the test toolchain — should your compiler be +unable to find them, finds the wrong system include search path, or you need a +creative solution to a tricky technical problem. + +Note that if you configure your own toolchain in the `:tools` section, this +search path is largely meaningless to you. However, this is a convenient way +to control the system include path should you rely on the default [GCC][GCC] +tools. + +**Default**: `[]` (empty) + +## `:paths` ↳ `:release_toolchain_include` + +Same as preceding albeit related to the release toolchain. + +**Default**: `[]` (empty) + +## `:paths` ↳ `:libraries` + +Library search paths. [See `:libraries` section][libraries]. + +[libraries]: libraries.md + +**Default**: `[]` (empty) + +## `:paths` ↳ `:` + +Any paths you specify for custom list. List is available to tool configurations +and/or plugins. Note a distinction — the preceding names are recognized +internally to Ceedling and the path lists are used to build collections of +files contained in those paths. A custom list is just that — a custom list of +paths. + +## `:paths` configuration options & notes + +1. A path can be absolute (fully qualified) or relative. +1. A path can include a glob matcher (more on this below). +1. A path can use [inline Ruby string expansion][inline-ruby-string-expansion]. +1. Subtractive paths are possible and useful. See the documentation below. +1. Path order beneath a subsection (e.g. `:paths` ↳ `:include`) is preserved + when the list is iterated internally or passed to a tool. + +## `:paths` Globs + +Globs are effectively fancy wildcards. They are not as capable as full regular +expressions but are easier to use. Various OSs and programming languages +implement them differently. + +For a quick overview, see this [tutorial][globs-tutotrial]. + +Ceedling supports globs so you can specify patterns of directories without the +need to list each and every required path. + +Ceedling `:paths` globs operate similarly to [Ruby globs][ruby-globs] except +that they are limited to matching directories within `:paths` entries and not +also files. In addition, Ceedling adds a useful convention with certain uses of +the `*` and `**` operators. + +Glob operators include the following: `*`, `**`, `?`, `[-]`, `{,}`. + +* `*` + * When used within a character string, `*` is simply a standard wildcard. + * When used after a path separator, `/*` matches all subdirectories of depth + 1 below the parent path, not including the parent path. +* `**`: All subdirectories recursively discovered below the parent path, not + including the parent path. This pattern only makes sense after a path + separator `/**`. +* `?`: Single alphanumeric character wildcard. +* `[x-y]`: Single alphanumeric character as found in the specified range. +* `{x, y, ...}`: Matching any of the comma-separated patterns. Two or more + patterns may be listed within the brackets. Patterns may be specific + character sequences or other glob operators. + +Special conventions: + +* If a globified path ends with `/*` or `/**`, the resulting list of + directories also includes the parent directory. + +[globs-tutotrial]: http://ruby.about.com/od/beginningruby/a/dir2.htm +[ruby-globs]: https://ruby-doc.org/core-3.0.0/Dir.html#method-c-glob + +## Subtractive entries + +Globs are super duper helpful when you have many paths to list. But, what if a +single glob gets you 20 nested paths, but you actually want to exclude 2 of +those paths? + +Must you revert to listing all 18 paths individually? No, my friend, we've got +you. Behold, subtractive paths. + +Put simply, with an optional preceding decorator `-:`, you can instruct Ceedling +to remove certain directory paths from a collection after it builds that +collection. + +By default, paths are additive. For pretty alignment in your YAML, you may also +use `+:`, but strictly speaking, it’s not necessary. + +Subtractive paths may be simple paths or globs just like any other path entry. + +!!! note "Subtractive paths resolve after all mixins merge" + The resolution of subtractive paths happens after your full paths lists are + assembled. So, if you use `:paths` entries in Mixins to build up your + project configuration, subtractive paths will only be processed after the + final mixin is merged. That is, you can merge in additive and subtractive + paths with Mixins to your heart’s content. The subtractive paths are not + removed until all Mixins have been merged. + +[GCC]: https://gcc.gnu.org +[inline-ruby-string-expansion]: ../project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/reference/plugins.md b/docs/mkdocs/configuration/reference/plugins.md new file mode 100644 index 000000000..70a21cbe3 --- /dev/null +++ b/docs/mkdocs/configuration/reference/plugins.md @@ -0,0 +1,60 @@ +# `:plugins` + +**Ceedling extensions** + +See the section below dedicated to plugins for more information. This section +pertains to enabling plugins in your project configuration. + +Ceedling includes a number of [built-in plugins][ceedling-plugins]. + +!!! tip "Handy-dandy Command Hooks plugin for custom needs" + Many users find the handy-dandy [Command Hooks plugin][command-hooks] + is often enough to meet their custom needs. This plugin allows you to + connect your own scripts and command line tools to Ceedling build steps. + +For documentation on creating your own custom plugins, see the +[Plugin Development Guide][custom-plugins]. + +## Example `:plugins` YAML + +```yaml +:plugins: + :load_paths: + - project/tools/ceedling/plugins # Home to your collection of plugin directories. + - project/support # Home to some ruby code your custom plugins share. + :enabled: + - report_tests_pretty_stdout # Nice test results at your command line. + - our_custom_code_metrics_report # You created a plugin to scan all code to collect + # line counts and complexity metrics. Its name is a + # subdirectory beneath the first `:load_path` entry. +``` + +## `:load_paths` + +Base paths to search for plugin subdirectories or extra Ruby functionality. + +Ceedling maintains the Ruby load path for its built-in plugins. This list of +paths allows you to add your own directories for custom plugins or simpler +Ruby files referenced by your Ceedling configuration options elsewhere. + +**Default**: `[]` (empty) + +## `:enabled` + +List of plugins to be used — a plugin's name is identical to the subdirectory +that contains it. + +**Default**: `[]` (empty) + +Plugins can provide a variety of added functionality to Ceedling. In general +use, it's assumed that at least one reporting plugin will be used to format +test results (usually `report_tests_pretty_stdout`). + +If no reporting plugins are specified, Ceedling will print to `$stdout` the +(quite readable) raw test results from all test fixtures executed. + +[custom-plugins]: ../../development/plugins/index.md +[ceedling-plugins]: ../../plugins/index.md +[command-hooks]: ../../plugins/command-hooks.md + +

diff --git a/docs/mkdocs/configuration/reference/project.md b/docs/mkdocs/configuration/reference/project.md new file mode 100644 index 000000000..88bd82b35 --- /dev/null +++ b/docs/mkdocs/configuration/reference/project.md @@ -0,0 +1,313 @@ +# `:project`: Global project settings + +!!! warning "`:project` settings will be reorganized" + In future versions of Ceedling, test-specific and release-specific build + settings presently organized beneath `:project` will likely be renamed and + migrated to the `:test_build` and `:release_build` sections. + +## Example `:project` YAML + +```yaml +:project: + :nane: "Acme Smartomatic" + :build_root: project_awesome/build + :use_exceptions: FALSE + :use_test_preprocessor: :all + :release_build: TRUE + :compile_threads: :auto +``` + +## `:name` + +Optional project name that, if present, adds a new first line to logging output +with the project name. + +## `:build_root` + +Top level directory into which generated path structure and files are placed. + +!!! note "Build configuration requirement" + This is one of the handful of configuration values that must be set for + any project. `:project` ↳ `:build_root` must be set for any build — release + build or test suite — to run. + +**Default**: (none) + +## `:default_tasks` + +A list of default build / plugin tasks Ceedling should execute if none are +provided at the command line. + +!!! note "Build & plugin tasks only" + Build & plugin tasks include tasks such as `test:all` and `clobber`. + `:default_tasks` does not support application commands (e.g. `dumpconfig`) + or command line flags (e.g. `--verbosity`). + + See the documentation [on using the command line][command-line] + to understand the distinction between application commands and build & + plugin tasks. + +[command-line]: ../../getting-started/command-line.md + +Example YAML: +```yaml +:project: + :default_tasks: + - clobber + - test:all + - release +``` +**Default**: `['test:all']` + +## `:use_mocks` + +Configures the build environment to make use of CMock. Note that if you do not +use mocks, there's no harm in leaving this setting as its default value. + +**Default**: TRUE + +## `:use_partials` + +Enables Ceedling Partials. [Partials][partials] allow you to test and mock +inaccessible functions and variables in the C code under test without rewriting +your source code. + +[partials]: ../../testing-guide/partials/index.md + +**Default**: FALSE + +## `:use_test_preprocessor` + +This option allows Ceedling to work with test files that contain tricky +conditional compilation statements (e.g. `#ifdef`) as well as mockable header +files containing conditional preprocessor directives and/or macros. + +See the [documentation on test preprocessing][test-preprocessing] for more. + +!!! note + With any preprocessing enabled, the `gcc` & `cpp` tools must exist in an + accessible system search path. + +[test-preprocessing]: ../../testing-guide/conventions.md#ceedling-preprocessing-behavior-for-your-tests + +* `:none` disables preprocessing. +* `:all` enables preprocessing for all mockable header files and test C files. +* `:mocks` enables only preprocessing of header files that are to be mocked. +* `:tests` enables only preprocessing of your test files. + +**Default**: `:none` + +## `:test_file_prefix` + +Ceedling collects test files by convention from within the test file search +paths. The convention includes a unique name prefix and a file extension +matching that of source files. + +Why not simply recognize all files in test directories as test files? By using +the given convention, we have greater flexibility in what we do with C files in +the test directories. + +**Default**: "test_" + +## `:release_build` + +When enabled, a release Rake task is exposed. This configuration option requires +a corresponding release compiler and linker to be defined (`gcc` is used as the +default). + +Ceedling is primarily concerned with facilitating the complicated mechanics of +automating unit tests. The same mechanisms are easily capable of building a +final release binary artifact (i.e. non test code — the thing that is your +final working software that you execute on target hardware). That said, if you +have complicated release builds, you should consider a traditional build tool +for these. Ceedling shines at executing test suites. + +More release configuration options are available in the `:release_build` section. + +**Default**: FALSE + +## `:compile_threads` + +A value greater than one enables parallelized build steps. Ceedling creates a +number of threads up to `:compile_threads` for build steps. These build steps +execute batched operations including but not limited to mock generation, code +compilation, and running test executables. + +Particularly if your build system includes multiple cores, overall build time +will drop considerably as compared to running a build with a single thread. + +Tuning the number of threads for peak performance is an art more than a +science. A special value of `:auto` instructs Ceedling to query the host +system's number of virtual cores. To this value it adds a constant of 4. This +is often a good value sufficient to "max out" available resources without +overloading available resources. + +`:compile_threads` is used for all release build steps and all test suite build +steps except for running the test executables that make up a test suite. See +next section for more. + +**Default**: 1 + +## `:test_threads` + +The behavior of and values for `:test_threads` are identical to +`:compile_threads` with one exception. + +`test_threads:` specifically controls the number of threads used to run the +test executables comprising a test suite. + +Why the distinction from `:compile_threads`? Some test suite builds rely not on +native executables but simulators running cross-compiled code. Some simulators +are limited to running only a single instance at a time. Thus, with this and +the previous setting, it becomes possible to parallelize nearly all of a test +suite build while still respecting the limits of certain simulators depended +upon by test executables. + +**Default**: 1 + +## `:which_ceedling` + +This is an advanced project option primarily meant for development work on +Ceedling itself. This setting tells the code that launches the Ceedling +application where to find the code to launch. + +This entry can be either a directory path or `gem`. + +See the section [Which Ceedling](../which-ceedling.md) for full details. + +**Default**: `gem` + +## `:use_backtrace` + +When a test executable encounters a ☠️ **Segmentation Fault** or other crash +condition, the executable immediately terminates and no further details for test +suite reporting are collected. + +But, fear not. You can bring your dead unit tests back to life. + +By default, in the case of a crash, Ceedling reruns the test executable for +each test case using a special mode to isolate that test case. In this way +Ceedling can iteratively identify which test cases are causing the crash or +exercising release code that is causing the crash. Ceedling then assembles the +final test reporting results from these individual test case runs. + +You have three options for this setting, `:none`, `:simple`, or `:gdb`. + +### `:none` +`:none` will simply cause a test report to list each test case as failed +due to a test executable crash. + +Sample Ceedling run output with backtrace `:none`: + +``` +👟 Executing +------------ +Running TestUsartModel.out... +☠️ ERROR: Test executable `TestUsartModel.out` seems to have crashed + +------------------- +FAILED TEST SUMMARY +------------------- +[test/TestUsartModel.c] + Test: testGetBaudRateRegisterSettingShouldReturnAppropriateBaudRateRegisterSetting + At line (24): "Test executable crashed" + + Test: testCrash + At line (37): "Test executable crashed" + + Test: testGetFormattedTemperatureFormatsTemperatureFromCalculatorAppropriately + At line (44): "Test executable crashed" + + Test: testShouldReturnErrorMessageUponInvalidTemperatureValue + At line (50): "Test executable crashed" + + Test: testShouldReturnWakeupMessage + At line (56): "Test executable crashed" + +----------------------- +❌ OVERALL TEST SUMMARY +----------------------- +TESTED: 5 +PASSED: 0 +FAILED: 5 +IGNORED: 0 +``` + +### `:simple` +`:simple` causes Ceedling to re-run each test case in the test executable +individually to identify and report the problematic test case(s). This is +the default option and is described above. + +Sample Ceedling run output with backtrace `:simple`: + +``` +👟 Executing +------------ +Running TestUsartModel.out... +☠️ ERROR: Test executable `TestUsartModel.out` seems to have crashed + +------------------- +FAILED TEST SUMMARY +------------------- +[test/TestUsartModel.c] + Test: testCrash + At line (37): "Test case crashed" + +----------------------- +❌ OVERALL TEST SUMMARY +----------------------- +TESTED: 5 +PASSED: 4 +FAILED: 1 +IGNORED: 0 +``` + +### `:gdb` +`:gdb` uses the [`gdb`][gdb] debugger to identify and report the troublesome +line of code triggering the crash. If this option is enabled, but `gdb` is +not available to Ceedling, project configuration validation will terminate +with an error at startup. + +Sample Ceedling run output with backtrace `:gdb`: + +``` +👟 Executing +------------ +Running TestUsartModel.out... +☠️ ERROR: Test executable `TestUsartModel.out` seems to have crashed + +------------------- +FAILED TEST SUMMARY +------------------- +[test/TestUsartModel.c] + Test: testCrash + At line (40): "Test case crashed >> Program received signal SIGSEGV, Segmentation fault. + 0x00005618066ea1fb in testCrash () at test/TestUsartModel.c:40 + 40 uint32_t i = *nullptr;" + +----------------------- +❌ OVERALL TEST SUMMARY +----------------------- +TESTED: 5 +PASSED: 4 +FAILED: 1 +IGNORED: 0 +``` + +**_Notes:_** + +1. The default of `:simple` only works in an environment capable of using + command line arguments (passed to the test executable). If you are targeting + a simulator with your test executable binaries, `:simple` is unlikely to + work for you. In the simplest case, you may simply fall back to `:none`. + With some work and using Ceedling's various features, much more sophisticated + options are possible. +1. The `:gdb` option currently only supports the native build platform. That is, + the `:gdb` backtrace option cannot handle backtrace for cross-compiled code + or any sort of simulator-based test fixture. + +[gdb]: https://www.sourceware.org/gdb/ + +**Default**: `:simple` + +

diff --git a/docs/mkdocs/configuration/reference/release-build.md b/docs/mkdocs/configuration/reference/release-build.md new file mode 100644 index 000000000..cb4feb6d6 --- /dev/null +++ b/docs/mkdocs/configuration/reference/release-build.md @@ -0,0 +1,62 @@ +# `:release_build` + +**Configuring a release build** + +!!! warning "Future configuration reorganization" + In future versions of Ceedling, release-related settings presently + organized beneath `:project` will be renamed and migrated to this section. + +## Example `:release_build` YAML + +```yaml +:release_build: + :output: top_secret.bin + :use_assembly: TRUE + :artifacts: + - build/release/out/c/top_secret.s19 +``` + +## `:output` + +The name of your release build binary artifact to be found in +_<build path>/artifacts/release/_. Ceedling sets the default artifact +file extension to that as is explicitly specified in the `:extension` +section or as is system specific otherwise. + +**Default**: `project.exe` or `project.out` + +## `:use_assembly` + +This option causes Ceedling to enable an assembler tool and add any +assembly code present in the project to the release artifact's build. + +The default assembler is the GNU tool `as`; it may be overridden +in the `:tools` section. + +The assembly files must be visible to Ceedling by way of `:paths` and +`:extension` settings for assembly files. + +**Default**: FALSE + +## `:artifacts` + +By default, Ceedling copies to the _<build path>/artifacts/release_ +directory the output of the release linker and (optionally) a map +file. Many toolchains produce other important output files as well. +Adding a file path to this list will cause Ceedling to copy that file +to the artifacts directory. + +The artifacts directory is helpful for organizing important build +output files and provides a central place for tools such as Continuous +Integration servers to point to build output. Selectively copying +files prevents incidental build cruft from needlessly appearing in the +artifacts directory. + +Note that [inline Ruby string expansion][inline-ruby-string-expansion] +is available in artifact paths. + +**Default**: `[]` (empty) + +[inline-ruby-string-expansion]: ../project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/reference/test-build.md b/docs/mkdocs/configuration/reference/test-build.md new file mode 100644 index 000000000..f9a8c1175 --- /dev/null +++ b/docs/mkdocs/configuration/reference/test-build.md @@ -0,0 +1,36 @@ +# `:test_build` + +**Configuring a test build** + +!!! warning "Future configuration reorganization" + In future versions of Ceedling, test-related settings presently + organized beneath `:project` will be renamed and migrated to this section. + +## Example `:test_build` YAML + +```yaml +:test_build: + :use_assembly: TRUE +``` + +## `:use_assembly` + +This option causes Ceedling to enable an assembler tool and collect a +list of assembly file sources for use in a test suite build. + +The default assembler is the GNU tool `as`; like all other tools, it +may be overridden in the `:tools` section. + +After enabling this feature, two conditions must be true in order to +inject assembly code into the build of a test executable: + +1. The assembly files must be visible to Ceedling by way of `:paths` and + `:extension` settings for assembly files. Here, assembly files would be + equivalent to C code files handled in the same ways. +1. Ceedling must be told into which test executable build to insert a + given assembly file. The easiest way to do so is with the + [`TEST_SOURCE_FILE()` build directive macro](../../testing-guide/build-directives.md). + +**Default**: FALSE + +

diff --git a/docs/mkdocs/configuration/reference/test-runner.md b/docs/mkdocs/configuration/reference/test-runner.md new file mode 100644 index 000000000..2b5e12515 --- /dev/null +++ b/docs/mkdocs/configuration/reference/test-runner.md @@ -0,0 +1,61 @@ +# `:test_runner` + +**Configure test runner generation** + +!!! warning + Unless you have advanced or unique needs, Unity test runner generation + configuration in Ceedling is generally not needed. + +## Test runner overview + +The format of Ceedling test files — the C files that contain unit test cases — +is intentionally simple. It’s pure code and all legit C with `#include` +statements, simple functions for test cases, and optional `setUp()` and +`tearDown()` functions. + +To create test executables, we need a `main()` and a variety of calls to the +Unity framework to “hook up” all your test cases into a test suite. You can do +this by hand, of course, but it’s tedious and needed updates as code evolves +are easily forgotten. + +## Unity & test runners + +Unity provides a script able to generate a test runner in C for you. It +relies on [Ceedling conventions][ceedling-conventions] used in your test files. +Ceedling takes this a step further by calling this script for you with all the +needed parameters. + +Test runner generation is configurable. The `:test_runner` section of your +Ceedling project file allows you to pass options to Unity’s runner generation +script. Based on other Ceedling options, Ceedling also sets certain test runner +generation configuration values for you. + +**[Test runner configuration options are documented in the Unity project][unity-runner-options].** + +## `:test_runner` ↳ `:cmdline_args` + +Before Ceedling 1.0.0, the test runner option `:cmdline_args` was needed +for certain advanced test suite features. This option is still needed, +but Ceedling now automatically sets it for you in the scenarios requiring it. + +!!! note "Environment limitations" + Be aware that `:cmdline_args` works well in desktop, native testing but + is generally unsupported by emulators running test executables. + + The idea of command line arguments passed to an executable is generally + only possible with desktop command line terminals. + +## Example `:test_runner` YAML + +```yaml +:test_runner: + # Insert additional #include statements in a generated runner + :includes: + - Foo.h + - Bar.h +``` + +[ceedling-conventions]: ../../testing-guide/conventions.md +[unity-runner-options]: https://github.com/ThrowTheSwitch/Unity/blob/master/docs/UnityHelperScriptsGuide.md#options-accepted-by-generate_test_runnerrb + +

diff --git a/docs/mkdocs/configuration/reference/tools.md b/docs/mkdocs/configuration/reference/tools.md new file mode 100644 index 000000000..9f6889424 --- /dev/null +++ b/docs/mkdocs/configuration/reference/tools.md @@ -0,0 +1,455 @@ +# `:tools` + +**Configuring command line tools used for build steps** + +Ceedling requires a variety of tools to work its magic. By default, the GNU +toolchain (`gcc`, `cpp`, `as` — and `gcov` via plugin) are configured and ready +for use with no additions to your project configuration YAML file. + +!!! tip "Mechanisms to configure tools without redefining them" + Sometimes Ceedling’s built-in tool configurations are _nearly_ what you need + but not quite. + + 1. If you only need to add some arguments to all uses of a tool’s command + line, Ceedling offers a shortcut to do so. See the + [shortcuts section of `:tools`][tool-definition-shortcuts] documentation for + details. + 1. If you need fine-grained control of the arguments Ceedling uses in the build + steps for test executables, see the documentation for [`:flags`][flags]. + Ceedling allows you to control the command line arguments for each test + executable build — with a variety of pattern matching options. + 1. If you need to link libraries — your own or standard options — please see the + [top-level `:libraries` section][libraries] available for your project + configuration. Ceedling supports a number of useful options for working with + pre-compiled libraries. If your library linking needs are super simple, the + shortcut in (1) might be the simplest option. + +[flags]: flags.md +[tool-definition-shortcuts]: #ceedling-tool-modification-shortcuts +[libraries]: libraries.md + +## Ceedling tools for test suite builds + +Our recommended approach to writing and executing test suites relies on the GNU +toolchain. _*Yes, even for embedded system work on platforms with their own, +proprietary C toolchain.*_ Please see +[this section of documentation][sweet-suite] to understand this recommendation +among all your options. + +You can and sometimes must run a Ceedling test suite in an emulator or on +target, and Ceedling allows you to do this through tool definitions documented +here. Generally, you'll likely want to rely on the default definitions. + +[sweet-suite]: ../../overview/test-environments.md#test-suite-execution-environments + +## Ceedling tools for release builds + +More often than not, release builds require custom tool definitions. The GNU +toolchain is configured for Ceedling release builds by default just as with test +builds. You'll likely need your own definitions for `:release_compiler`, +`:release_linker`, and possibly `:release_assembler`. + +## Ceedling plugin tools + +Ceedling plugins are free to define their own tools that are loaded into your +project configuration at startup. Plugin tools are defined using the same +mechanisms as Ceedling’s built-in tools and are called the same way. That is, +all features available to you for working with tools as an end user are +generally available for working with plugin-based tools. This presumes a plugin +author followed guidance and convention in creating any command line actions. + +## Ceedling tool definitions + +Contained in this section are details on Ceedling’s default tool definitions. +For sake of space, the entirety of a given definition is not shown. If you need +to get in the weeds or want a full example, see the file `defaults.rb` in +Ceedling’s lib/ directory. + +### Tool definition overview + +Listed below are the built-in tool names, corresponding to build steps along +with the numbered parameters that Ceedling uses to fill out a full command line +for the named tool. The full list of fundamental elements for a tool definition +are documented in the sections that follow along with examples. + +Not every numbered parameter listed immediately below must be referenced in a +Ceedling tool definition. If `${4}` isn't referenced by your custom tool, +Ceedling simply skips it while expanding a tool definition into a command line. + +The numbered parameters below are references that expand / are replaced with +actual values when the corresponding command line is constructed. If the values +behind these parameters are lists, Ceedling expands the containing reference +multiple times with the contents of the value. A conceptual example is +instructive… + +### Simplified tool definition / expansion example + +A partial tool definition: + +```yaml +:tools: + :power_drill: + :executable: dewalt.exe + :arguments: + - "--X${3}" +``` + +Let’s say that `${3}` is a list inside Ceedling, `[2, 3, 7]`. The expanded tool +command line for `:tools` ↳ `:power_drill` would look like this: + +```shell + > dewalt.exe --X2 --X3 --X7 +``` + +## Ceedling’s default build step tool definitions + +!!! warning "Preprocessing & Backtrace Tools Are Not Configurable" + Ceedling’s tool definitions for its preprocessing and backtrace features are + not documented here. Ceedling’s use of tools for these features are tightly + coupled to the options and output of those tools. Drop-in replacements using + other tools are not practically possible. Eventually, an improved plugin + system will provide options for integrating alternative tools. + +## `:test_compiler` + +Compiler for test & source-under-test code + + - `${1}`: Input source + - `${2}`: Output object + - `${3}`: Optional output list + - `${4}`: Optional output dependencies file + - `${5}`: Header file search paths + - `${6}`: Command line #defines + +**Default**: `gcc` + +## `:test_assembler` + +Assembler for test assembly code + + - `${1}`: input assembly source file + - `${2}`: output object file + - `${3}`: search paths + - `${4}`: #define symbols (accepted but ignored by GNU assembler) + +**Default**: `as` + +## `:test_linker` + +Linker to generate test fixture executables + + - `${1}`: input objects + - `${2}`: output binary + - `${3}`: optional output map + - `${4}`: optional library list + - `${5}`: optional library path list + +**Default**: `gcc` + +## `:test_fixture` + +Executable test fixture + + - `${1}`: simulator as executable with `${1}` as input binary file argument or native test executable + +**Default**: `${1}` + +## `:release_compiler` + +Compiler for release source code + + - `${1}`: input source + - `${2}`: output object + - `${3}`: optional output list + - `${4}`: optional output dependencies file + +**Default**: `gcc` + +## `:release_assembler` + +Assembler for release assembly code + + - `${1}`: input assembly source file + - `${2}`: output object file + - `${3}`: search paths + - `${4}`: #define symbols (accepted but ignored by GNU assembler) + +**Default**: `as` + +## `:release_linker` + +Linker for release source code + + - `${1}`: input objects + - `${2}`: output binary + - `${3}`: optional output map + - `${4}`: optional library list + - `${5}`: optional library path list + +**Default**: `gcc` + +### Tool definition configurable elements + +1. `:executable` - Command line executable (required). + + !!! tip "Spaces in an executable name" + If an executable contains a space (e.g. `Code Cruncher`), and the + shell executing the command line generated from the tool definition needs + the name quoted, add escaped quotes in the YAML: + + ```yaml + :tools: + :test_compiler: + :executable: \"Code Cruncher\" + ``` + +1. `:arguments` - List (array of strings) of command line arguments and + substitutions (required). + +1. `:name` - Simple name (i.e. "nickname") of tool beyond its executable name. + This is optional. If not explicitly set then Ceedling will form a name from + the tool’s YAML entry key. + +1. `:stderr_redirect` - Control of capturing `$stderr` messages + {`:none`, `:auto`, `:win`, `:unix`, `:tcsh`}. + Defaults to `:none` if unspecified. You may create a custom entry by + specifying a simple string instead of any of the recognized symbols. As an + example, the `:unix` symbol maps to the string `2>&1` that is automatically + inserted at the end of a command line. + + !!! warning "`$stderr` is rarely necessary" + Originally, `$stderr` redirection was often needed in early versions of + Ceedling. Shell output stream handling is now automatically handled. + This option is preserved for possible edge cases. + +1. `:optional` - By default a tool you define is required for operation. This + means a build will be aborted if Ceedling cannot find your tool’s executable + in your environment. However, setting `:optional` to `true` causes this + check to be skipped. This is most often needed in plugin scenarios where a + tool is only needed if an accompanying configuration option requires it. In + such cases, a programmatic option available in plugin Ruby code using the + Ceedling class `ToolValidator` exists to process tool definitions as needed. + +### Tool element runtime substitution + +To accomplish useful work on multiple files, a configured tool will most often +require that some number of its arguments or even the executable itself change +for each run. Consequently, every tool’s argument list and executable field +possess two means for substitution at runtime. + +Ceedling provides inline Ruby string expansion and a notation for populating +tool elements with dynamically gathered values within the build environment. + +#### Tool element runtime substitution: Inline Ruby string expansion + +`"#{...}"`: This notation is that of the beloved +[inline Ruby string expansion][inline-ruby-string-expansion] available in a +variety of configuration file sections. This string expansion occurs each time +a tool configuration is executed during a build. + +#### Tool element runtime substitution: Notational substitution + +A Ceedling tool’s other form of dynamic substitution relies on a `$` notation. +These `$` operators can exist anywhere in a string and can be decorated in any +way needed. To use a literal `$`, escape it as `\\$`. + +* `$`: Simple substitution for value(s) globally available within the runtime + (most often a string or an array). + +* `${#}`: When a Ceedling tool’s command line is expanded from its configured + representation, runs of that tool will be made with a parameter list of + substitution values. Each numbered substitution corresponds to a position in + a parameter list. + + * In the case of a compiler `${1}` will be a C code file path, and `${2}` + will be the file path of the resulting object file. + + * For a linker `${1}` will be an array of object files to link, and `${2}` + will be the resulting binary executable. + + * For an executable test fixture `${1}` is either the binary executable + itself (when using a local toolchain such as GCC) or a binary input file + given to a simulator in its arguments. + +## Example `:tools` YAML + +```yaml +:tools: + :test_compiler: + :executable: compiler # Exists in system search path + :name: 'acme test compiler' + :arguments: + - -I"${5}" # Expands to -I search paths from `:paths` section + build directive path macros + - -D"${6}" # Expands to all -D defined symbols from `:defines` section + - --network-license # Simple command line argument + - -optimize-level 4 # Simple command line argument + - "#{`args.exe -m acme.prj`}" # In-line Ruby call to shell out & build string of arguments + - -c ${1} # Source code input file + - -o ${2} # Object file output + + :test_linker: + :executable: /programs/acme/bin/linker.exe # Full file path + :name: 'acme test linker' + :arguments: + - ${1} # List of object files to link + - -l$-lib: # In-line YAML array substitution to link in foo-lib and bar-lib + - foo + - bar + - -o ${2} # Binary output artifact + + :test_fixture: + :executable: tools/bin/acme_simulator.exe # Relative file path to command line simulator + :name: 'acme test fixture' + :stderr_redirect: :win # Inform Ceedling what model of $stderr capture to use + :arguments: + - -mem large # Simple command line argument + - -f "${1}" # Binary executable input file for simulator +``` + +### `:tools` example blurb notes + +* `${#}` is a replacement operator expanded by Ceedling with various strings, + lists, etc. assembled internally. The meaning of each number is specific to + each predefined default tool (see documentation above). + +* See [search path order][##-search-path-order] to understand how the + `-I"${5}"` term is expanded. + +* At present, `$stderr` redirection is primarily used to capture errors from + test fixtures so that they can be displayed at the conclusion of a test run. + For instance, if a simulator detects a memory access violation or a divide by + zero error, this notice might go unseen in all the output scrolling past in a + terminal. + +* The built-in preprocessing tools _can_ be overridden with non-GCC + equivalents. However, this is highly impractical to do as preprocessing + features are quite dependent on the idiosyncrasies and features of the GCC + toolchain. + +### Example Test Compiler Tooling + +Resulting compiler command line construction from preceding example `:tools` +YAML blurb… + +```shell +> compiler -I"/usr/include" -I"project/tests" + -I"project/tests/support" -I"project/source" -I"project/include" + -DTEST -DLONG_NAMES -network-license -optimize-level 4 arg-foo + arg-bar arg-baz -c project/source/source.c -o + build/tests/out/source.o +``` + +Notes on compiler tooling example: + +- `arg-foo arg-bar arg-baz` is a fabricated example string collected from + `$stdout` as a result of shell execution of `args.exe`. +- The `-c` and `-o` arguments are fabricated examples simulating a single + compilation step for a test; `${1}` & `${2}` are single files. + +### Example Test Linker Tooling + +Resulting linker command line construction from preceding example `:tools` +YAML blurb… + +```shell +> \programs\acme\bin\linker.exe thing.o unity.o + test_thing_runner.o test_thing.o mock_foo.o mock_bar.o -lfoo-lib + -lbar-lib -o build\tests\out\test_thing.exe +``` + +Notes on linker tooling example: + +- In this scenario `${1}` is an array of all the object files needed to link a + test fixture executable. + +### Example Test Fixture Tooling + +Resulting test fixture command line construction from preceding example `:tools` +YAML blurb… + +```shell +> tools\bin\acme_simulator.exe -mem large -f "build\tests\out\test_thing.bin 2>&1" +``` + +Notes on test fixture tooling example: + +1. `:executable` could have simply been `${1}` if we were compiling and running + native executables instead of cross compiling. That is, if the output of the + linker runs on the host system, then the test fixture _is_ `${1}`. +1. We're using `$stderr` redirection to allow us to capture simulator error + messages to `$stdout` for display at the run’s conclusion. + +## Ceedling tool modification shortcuts + +Sometimes Ceedling’s default tool definitions are _this close_ to being just +what you need. But, darn, you need one extra argument on the command line, or +you just need to hack the tool executable. You'd love to get away without +overriding an entire tool definition just in order to tweak it. + +We got you. + +### Ceedling tool executable replacement + +Sometimes you need to do some sneaky stuff. We get it. This feature lets you +replace the executable of a tool definition — including an internal default — +with your own. + +To use this shortcut, simply add a configuration section to your project file +at the top-level, `:tools_` ↳ `:executable`. Of course, you +can combine this with the following modification option in a single block for +the tool. Executable replacement can make use of +[inline Ruby string expansion][inline-ruby-string-expansion]. + +See the list of tool names at the beginning of the `:tools` documentation to +identify the named options. Plugins can also include their own tool definitions +that can be modified with this same option. + +This example YAML... + +```yaml +:tools_test_compiler: + :executable: foo +``` + +... will produce the following: + +```shell + > foo +``` + +### Ceedling tool arguments addition shortcut + +Now, this little feature only allows you to add arguments to the end of a tool +command line. Not the beginning. And, you can't remove arguments with this +option. + +Further, this little feature is a blanket application across all uses of a +tool. If you need fine-grained control of command line flags in build steps per +test executable, please see the [`:flags` configuration documentation][flags]. + +To use this shortcut, simply add a configuration section to your project file +at the top-level, `:tools_` ↳ `:arguments`. Of course, you can +combine this with the preceding modification option in a single block for the +tool. + +See the list of tool names at the beginning of the `:tools` documentation to +identify the named options. Plugins can also include their own tool definitions +that can be modified with this same hack. + +This example YAML... + +```yaml +:tools_test_compiler: + :arguments: + - --flag # Add `--flag` to the end of all test C file compilation +``` + +... will produce the following (for the default executable): + +```shell + > gcc --flag +``` + +[inline-ruby-string-expansion]: ../project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/configuration/reference/unity.md b/docs/mkdocs/configuration/reference/unity.md new file mode 100644 index 000000000..bdf21b4e0 --- /dev/null +++ b/docs/mkdocs/configuration/reference/unity.md @@ -0,0 +1,54 @@ +# `:unity` + +**Configure Unity’s features** + +## Exmaple `:unity` YAML + +```yaml +:unity: + :defines: + - UNITY_INT_WIDTH=16 # 16 bit processor without support for 32 bit instructions + - UNITY_EXCLUDE_FLOAT # No floating point unit +``` + +## `:defines` + +Adds list of symbols used to configure Unity’s features in its source and +header files at compile time. + +See [Using Unity, CMock & CException](../../testing-guide/frameworks.md) for +much more on configuring and making use of these frameworks in your build. + +To manage overall command line length, these symbols are only added to +compilation when a Unity C source file is compiled. + +!!! note + No symbols must be set unless Unity’s defaults are inappropriate + for your environment and needs. + +**Default**: `[]` (empty) + +## `:use_param_tests` + +Configures Unity test runner generation and `#define`s for test compilation to +support Unity’s parameterized test cases. + +Example parameterized test case: + +```c +TEST_RANGE([5, 100, 5]) +void test_should_handle_divisible_by_5_for_parameterized_test_range(int num) { + TEST_ASSERT_EQUAL(0, (num % 5)); +} +``` + +See Unity documentation for more on parameterized test cases. + +!!! warning "Parameterized tests incompatible with preprocessing" + Unity’s parameterized tests are currently incompatible with Ceedling’s + preprocessing features enabled for test files. See more in + [Ceedling’s preprocessing documentation](../../testing-guide/conventions.md#preprocessing-gotchas). + +**Default**: false + +

diff --git a/docs/mkdocs/configuration/which-ceedling.md b/docs/mkdocs/configuration/which-ceedling.md new file mode 100644 index 000000000..9299418bb --- /dev/null +++ b/docs/mkdocs/configuration/which-ceedling.md @@ -0,0 +1,77 @@ +# Which Ceedling + +In certain scenarios you may need to run a different version of Ceedling. +Typically, Ceedling developers need this ability. But, it could come in +handy in certain advanced Continuous Integration build scenarios or some +sort of version behavior comparison. + +It’s not uncommon in Ceedling development work to have the last production +gem installed while modifying the application code in a locally cloned +repository. Or, you may be bouncing between local versions of Ceedling to +troubleshoot changes. + +Which Ceedling handling gives you options on what gets run. + +## Background + +Ceedling is usually packaged and installed as a Ruby Gem. This gem ends +up installed in an appropriate place by the `gem` package installer. +Inside the gem installation is the entire Ceedling project. The `ceedling` +command line launcher lives in `bin/` while the Ceedling application lives +in `lib/`. The code in `/bin` manages lots of startup details and base +configuration. Ultimately, it then launches the main application code from +`lib/`. + +The features and conventions controlling _which ceedling_ dictate which +application code the `ceedling` command line handler launches. + +!!! note "Ceedling development in `bin/`" + Working on the code in Ceedling’s `bin/` and need to run it while a gem is + installed? You must take the additional step of specifying the path to the + `ceedling` launcher in your filesystem. + + In Unix-like systems: + `> my/ceedling/changes/bin/ceedling `. + + On Windows systems: + `> ruby my\ceedling\changes\bin\ceedling `. + +## Options and precedence + +When Ceedling starts up, it evaluates a handful of conditions to determine +which Ceedling location to launch. + +The following are evaluated in order: + +1. Environment variable `WHICH_CEEDLING`. If this environment variable is + set, its value is used. +1. Configuration entry `:project` ↳ `:which_ceedling`. If this is set, + its value is used. +1. The path `vendor/ceedling`. If this path exists in your working + directory — typically because of a `--local` vendored installation at + project creation — its contents are used to launch Ceedling. +1. If none of the above exist, the `ceedling` launcher defaults to using + the `lib/` directory next to the `bin/` directory from which the + `ceedling` launcher is running. In the typical case this is the default + gem installation. + +!!! note "Configuration entry (2) does not make sense in some scenarios" + When running `ceedling new`, `ceedling examples`, or `ceedling example` + there is no project file to read. Similarly, `ceedling upgrade` does not + load a project file; it merely works with the directory structure and + contents of a project. In these cases, the environment variable is your + only option to set which Ceedling to launch. + +## Settings + +The environment variable and configuration entry for _Which Ceedling_ can +contain two values: + +1. The value `gem` indicates that the command line `ceedling` launcher + should run the application packaged alongside it in `lib/` (these + paths are typically found in the gem installation location). +1. A relative or absolute path in your file system. Such a path should + point to the top-level directory that contains Ceedling’s `bin/` and + `lib/` sub-directories. + +

diff --git a/docs/mkdocs/development/index.md b/docs/mkdocs/development/index.md new file mode 100644 index 000000000..bb19f77ac --- /dev/null +++ b/docs/mkdocs/development/index.md @@ -0,0 +1,48 @@ +# Development + +## Community & Contributing + +
+ +- :material-scale-balance: **[Code of Conduct][code-of-conduct]** + + --- + + The ThrowTheSwitch community follows a shared code of conduct. + Please familiarize yourself with it before participating. + +- :material-source-pull: **[Contributing][contributing]** + + --- + + Guidelines for contributing to this project — be it code, reviews, + documentation, or issue reports. + +
+ +## Projects + +
+ +- :fontawesome-solid-seedling: **Ceedling Development** + + --- + + ... + +- :material-puzzle-edit: **[Plugin Development Guide][plugin-dev]** + + --- + + Create custom Ceedling plugins using configuration, programmatic hook + methods, or Rake tasks. Covers architecture, conventions, and all + available build step hooks. + +
+ + +[plugin-dev]: plugins/index.md +[code-of-conduct]: https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/CODE_OF_CONDUCT.md +[contributing]: https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/CONTRIBUTING.md + +

diff --git a/docs/mkdocs/development/plugins/configuration.md b/docs/mkdocs/development/plugins/configuration.md new file mode 100644 index 000000000..9bd0b6ea8 --- /dev/null +++ b/docs/mkdocs/development/plugins/configuration.md @@ -0,0 +1,164 @@ +--- +toc_depth: 3 +--- + +# Plugin Option 1: Configuration + +The configuration option, surprisingly enough, provides Ceedling configuration +values. Configuration plugin values can supplement or override project +configuration values. + +Not long after Ceedling plugins were developed the `option:` feature was added +to Ceedling to merge in secondary configuration files. This feature is +typically a better way to manage multiple configurations and in many ways +supersedes a configuration plugin. + +That said, a configuration plugin is more capable than the `option:` feature and +can be appropriate in some circumstances. Further, Ceedling's configuration +plugin abilities are often a great way to provide configuration to +programmatic `Plugin` subclasses (Ceedling plugins options #2). + +## Three flavors of configuration plugins exist + +1. **YAML defaults.** The data of a simple YAML file is incorporated into + Ceedling's configuration defaults during startup. +1. **Programmatic (Ruby) defaults.** Ruby code creates a configuration hash + that Ceedling incorporates into its configuration defaults during startup. + This provides the greatest flexibility in creating configuration values. +1. **YAML configurations.** The data of a simple YAML file is incorporated into + Ceedling's configuration much like your project configuration file. + +## Example configuration plugin layout + +Project configuration file: + +```yaml +:plugins: + :load_paths: + - support/plugins + :enabled: + - zoom_zap +``` + +Ceedling project directory structure: + +(Third flavor of configuration plugin shown.) + +``` +project/ +├── project.yml +└── support/ + └── plugins/ + └── zoom_zap/ + └── config/ + └── zoom_zap.yml +``` + +## Ceedling configuration build & use + +Configuration is developed at startup by assembling defaults, collecting +user-configured settings, and then populating any missing values with defaults. + +Defaults: + +1. Ceedling loads its own defaults separately from your project configuration +1. Supporting framework defaults such as for CMock are populated into (1) +1. Any plugin defaults are merged with (2). + +Final project configuration: + +1. Your project file is loaded and any mixins merged +1. Supporting framework settings that depend on project configuration are populated +1. Plugin configurations are merged with the result of (1) and (2) +1. Defaults are populated into your project configuration +1. Path standardization, string replacement, and related occur throughout the final + configuration + +Merging means that existing simple configuration values are replaced or, in the +case of containers such as lists and hashes, values are added to. If no such +key/value pairs already exist, they are simply inserted into the configuration. + +Populating means inserting a configuration value if none already exists. As an +example, if Ceedling finds no compiler defined for test builds in your project +configuration, it populates your configuration with its own internal tool definition. + +A plugin may implement its own code to extract custom configuration from +the Ceedling project file. See the built-in plugins for examples. For instance, the +[Beep plugin](../../plugins/beep.md) makes use of a top-level `:beep` section in project +configuration. In such cases, it's typically wise to make use of a plugin's option for +defining default values. Configuration handling code is greatly simplified if values are +guaranteed to exist in some form. This eliminates a great deal of presence checking +and related code. + +## Configuration Plugin Flavors + +### Configuration Plugin Flavor A: YAML Defaults + +Naming and location convention: `/config/defaults.yml` + +Configuration values are defined inside a YAML file just as the Ceedling project +configuration file. + +Keys and values are defined in Ceedling's "base" configuration along with all +default values Ceedling loads at startup. If a particular key/value pair is +already set at the time the plugin attempts to set it, it will not be +redefined. + +YAML values are static apart from Ceedling's ability to perform string +substitution at configuration load time (see the [configuration reference][string-expansion] for more). +Programmatic Ruby defaults (next section) are more flexible but more +complicated. + +```yaml +# Any valid YAML is appropriate +:key: + :value: +``` + +### Configuration Plugin Flavor B: Programmatic (Ruby) Defaults + +Naming and location convention: `/config/defaults_.rb` + +Configuration values are defined in a Ruby hash returned by a "naked" function +`get_default_config()` in a Ruby file. The Ruby file is loaded and evaluated at +Ceedling startup. It can contain anything allowed in a Ruby script file but +must contain the accessor function. The returned hash's top-level keys will +live in Ceedling's configuration at the same level in the configuration +hierarchy as a Ceedling project file's top-level keys ('top-level' refers to +the left-most keys in the YAML, not to how "high" the keys are towards the top +of the file). + +Keys and values are defined in Ceedling's "base" configuration along with all +default values Ceedling loads at startup. If a particular key/value pair is +already set at the time the plugin attempts to set it, it will not be +redefined. + +This configuration option is more flexible than that documented in the previous +section as full Ruby execution is possible in creating the defaults hash. + +### Configuration Plugin Flavor C: YAML Values + +Naming and location convention: `/config/.yml` + +Configuration values are defined inside a YAML file just as the Ceedling project +configuration file. + +Keys and values are defined in Ceedling's "base" configuration along with all +default values Ceedling loads at startup. If a particular key/value pair is +already set at the time the plugin attempts to set it, it will not be +redefined. + +YAML values are static apart from Ceedling's ability to perform string +substitution at configuration load time (see the [configuration reference][string-expansion] for more). +Programmatic Ruby defaults (next section) are more flexible but more +complicated. + +```yaml +# Any valid YAML is appropriate +:key: + :value: +``` + +[string-expansion]: ../../configuration/project-file.md#inline-ruby-string-expansion + +

diff --git a/docs/mkdocs/development/plugins/index.md b/docs/mkdocs/development/plugins/index.md new file mode 100644 index 000000000..815d87bd4 --- /dev/null +++ b/docs/mkdocs/development/plugins/index.md @@ -0,0 +1,84 @@ +--- +toc_depth: 2 +--- + +# Developing Plugins for Ceedling + +This guide walks you through the process of creating custom plugins for +[Ceedling](https://github.com/ThrowTheSwitch/Ceedling). + +It is assumed that the reader has a working installation of Ceedling and some +basic usage experience, *i.e.* project creation/configuration and running tasks. + +Some experience with Ruby and Rake will be helpful but not absolutely required. +You can learn the basics as you go — often by looking at other, existing +Ceedling plugins or by simply searching for code examples online. + +## Development Roadmap & Notes + +(See Ceedling's _[release notes](https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/ReleaseNotes.md)_ for more.) + +* Ceedling 1.0 marks the beginning of moving all of Ceedling away from relying + on Rake. New, Rake-based plugins should not be developed. Rake dependencies + among built-in plugins will be refactored as the transition occurs. +* Ceedling's entire plugin architecture will be overhauled in future releases. + The current structure is too dependent on Rake and provides both too little + and too much access to Ceedling's core. +* Certain aspects of Ceedling's plugin structure have developed organically. + Consistency, coherence, and usability may not be high — particularly for + build step hook argument hashes and test results data structures used in + programmatic plugins. +* Because of iterating on Ceedling's core design and features, documentation + here may not always be perfectly up to date. + +--- + +## Custom Plugins Overview + +Ceedling plugins extend Ceedling without modifying its core code. They are +implemented in YAML and the Ruby programming language and are loaded by +Ceedling at runtime. + +Plugins provide the ability to customize the behavior of Ceedling at various +stages of a build — preprocessing, compiling, linking, building, testing, and +reporting. + +See the [`:plugins` configuration reference][plugins-config] for details of operation +and the [plugins overview][plugins-directory] for a directory of built-in plugins. + +## Plugin Conventions & Architecture + +Plugins are enabled and configured from within a Ceedling project's YAML +configuration file (`:plugins` section). + +Conventions & requirements: + +* Plugin configuration names, the containing directory names, and filenames + must: + * All match (i.e. identical names) + * Be snake_case (lowercase with connecting underscores). +* Plugins must be organized in a containing directory (the name of the plugin + as used in the project configuration `:plugins` ↳ `:enabled` list is its + containing directory name). +* A plugin's containing directory must be located in a Ruby load path. Load + paths may be added to a Ceedling project using the `:plugins` ↳ `:load_paths` + list. +* Rake plugins place their Rakefiles in the root of the containing plugin + directory. +* Programmatic plugins must contain either or both `config/` and `lib/` + subdirectories within their containing directories. +* Configuration plugins must place their files within a `config/` subdirectory + within the plugin's containing directory. + +Ceedling provides 3 options to customize its behavior through a plugin. Each +strategy is implemented with source files conforming to location and naming +conventions. These approaches can be combined. + +1. Configuration (YAML & Ruby) +1. `Plugin` subclass (Ruby) +1. Rake tasks (Ruby) + +[plugins-config]: ../../configuration/reference/plugins.md +[plugins-directory]: ../../plugins/index.md + +

diff --git a/docs/PluginDevelopmentGuide.md b/docs/mkdocs/development/plugins/plugin-subclass.md similarity index 57% rename from docs/PluginDevelopmentGuide.md rename to docs/mkdocs/development/plugins/plugin-subclass.md index a373b01d9..98622dc4f 100644 --- a/docs/PluginDevelopmentGuide.md +++ b/docs/mkdocs/development/plugins/plugin-subclass.md @@ -1,256 +1,16 @@ -# Developing Plugins for Ceedling - -This guide walks you through the process of creating custom plugins for -[Ceedling](https://github.com/ThrowTheSwitch/Ceedling). - -It is assumed that the reader has a working installation of Ceedling and some -basic usage experience, *i.e.* project creation/configuration and running tasks. - -Some experience with Ruby and Rake will be helpful but not absolutely required. -You can learn the basics as you go — often by looking at other, existing -Ceedling plugins or by simply searching for code examples online. - -## Contents - -* [Custom Plugins Overview](#custom-plugins-overview) -* [Plugin Conventions & Architecture](#plugin-conventions--architecture) - 1. [Configuration Plugin](#plugin-option-1-configuration) - 1. [Programmatic `Plugin` subclass](#plugin-option-2-plugin-subclass) - 1. [Rake Tasks Plugin](#plugin-option-3-rake-tasks) - -## Development Roadmap & Notes - -(See Ceedling's _[release notes](ReleaseNotes.md)_ for more.) - -* Ceedling 1.0 marks the beginning of moving all of Ceedling away from relying - on Rake. New, Rake-based plugins should not be developed. Rake dependencies - among built-in plugins will be refactored as the transition occurs. -* Ceedling's entire plugin architecture will be overhauled in future releases. - The current structure is too dependent on Rake and provides both too little - and too much access to Ceedling's core. -* Certain aspects of Ceedling's plugin structure have developed organically. - Consistency, coherence, and usability may not be high — particularly for - build step hook argument hashes and test results data structures used in - programmatic plugins. -* Because of iterating on Ceedling's core design and features, documentation - here may not always be perfectly up to date. - --- - -# Custom Plugins Overview - -Ceedling plugins extend Ceedling without modifying its core code. They are -implemented in YAML and the Ruby programming language and are loaded by -Ceedling at runtime. - -Plugins provide the ability to customize the behavior of Ceedling at various -stages of a build — preprocessing, compiling, linking, building, testing, and -reporting. - -See _[CeedlingPacket]_ for basic details of operation (`:plugins` configuration -section) and for a [directory of built-in plugins][plugins-directory]. - -[CeedlingPacket]: CeedlingPacket.md -[plugins-directory]: CeedlingPacket.md#ceedlings-built-in-plugins-a-directory - -# Plugin Conventions & Architecture - -Plugins are enabled and configured from within a Ceedling project's YAML -configuration file (`:plugins` section). - -Conventions & requirements: - -* Plugin configuration names, the containing directory names, and filenames - must: - * All match (i.e. identical names) - * Be snake_case (lowercase with connecting underscores). -* Plugins must be organized in a containing directory (the name of the plugin - as used in the project configuration `:plugins` ↳ `:enabled` list is its - containing directory name). -* A plugin's containing directory must be located in a Ruby load path. Load - paths may be added to a Ceedling project using the `:plugins` ↳ `:load_paths` - list. -* Rake plugins place their Rakefiles in the root of thecontaining plugin - directory. -* Programmatic plugins must contain either or both `config/` and `lib/` - subdirectories within their containing directories. -* Configuration plugins must place their files within a `config/` subdirectory - within the plugin's containing directory. - -Ceedling provides 3 options to customize its behavior through a plugin. Each -strategy is implemented with source files conforming to location and naming -conventions. These approaches can be combined. - -1. Configuration (YAML & Ruby) -1. `Plugin` subclass (Ruby) -1. Rake tasks (Ruby) - -# Plugin Option 1: Configuration - -The configuration option, surprisingly enough, provides Ceedling configuration -values. Configuration plugin values can supplement or override project -configuration values. - -Not long after Ceedling plugins were developed the `option:` feature was added -to Ceedling to merge in secondary configuration files. This feature is -typically a better way to manage nultiple configurations and in many ways -supersedes a configuration plugin. - -That said, a configuration plugin is more capable than the `option:` feature and -can be appropriate in some circumstances. Further, Ceedling's configuration -pluging abilities are often a great way to provide configuration to -programmatic `Plugin` subclasses (Ceedling plugins options #2). - -## Three flavors of configuration plugins exist - -1. **YAML defaults.** The data of a simple YAML file is incorporated into - Ceedling's configuration defaults during startup. -1. **Programmatic (Ruby) defaults.** Ruby code creates a configuration hash - that Ceedling incorporates into its configuration defaults during startup. - This provides the greatest flexibility in creating configuration values. -1. **YAML configurations.** The data of a simple YAML file is incorporated into - Ceedling's configuration much like your project configuration file. - -## Example configuration plugin layout - -Project configuration file: - -```yaml -:plugins: - :load_paths: - - support/plugins - :enabled: - - zoom_zap -``` - -Ceedling project directory sturcture: - -(Third flavor of configuration plugin shown.) - -``` -project/ -├── project.yml -└── support/ - └── plugins/ - └── zoom_zap/ - └── config/ - └── zoom_zap.yml -``` - -## Ceedling configuration build & use - -Configuration is developed at startup by assembling defaults, collecting -user-configured settings, and then populating any missing values with defaults. - -Defaults: - -1. Ceedling loads its own defaults separately from your project configuration -1. Supporting framework defaults such as for CMock are populated into (1) -1. Any plugin defaults are merged with (2). - -Final project configuration: - -1. Your project file is loaded and any mixins merged -1. Supporting framework settings that depend on project configuration are populated -1. Plugin configurations are merged with the result of (1) and (2) -1. Defaults are populated into your project configuration -1. Path standardization, string replacement, and related occur throughout the final - configuration - -Merging means that existing simple configuration valuees are replaced or, in the -case of containers such as lists and hashes, values are added to. If no such -key/value pairs already exist, they are simply inserted into the configuration. - -Populating means inserting a configuration value if none already exists. As an -example, if Ceedling finds no compiler defined for test builds in your project -configuration, it populates your configuration with its own internal tool definition. - -A plugin may implement its own code to use extract custom configuration from -the Ceedling project file. See the built-in plugins for examples. For instance, the -Beep plugin makes use of a top-level `:beep` section in project configuration. In -such cases, it's typically wise to make use of a plugin's option for defining -default values. Configuration handling code is greatly simplified if values are -guaranteed to exist in some form. This elimiates a great deal of presence checking -and related code. - -## Configuration Plugin Flavors - -### Configuration Plugin Flvaor A: YAML Defaults - -Naming and location convention: `/config/defaults.yml` - -Configuration values are defined inside a YAML file just as the Ceedling project -configuration file. - -Keys and values are defined in Ceedling's “base” configuration along with all -default values Ceedling loads at startup. If a particular key/value pair is -already set at the time the plugin attempts to set it, it will not be -redefined. - -YAML values are static apart from Ceedling's ability to perform string -substitution at configuration load time (see _[CeedlingPacket]_ for more). -Programmatic Ruby defaults (next section) are more flexible but more -complicated. - -```yaml -# Any valid YAML is appropriate -:key: - :value: -``` - -### Configuration Plugin Flvaor B: Programmatic (Ruby) Defaults - -Naming and location convention: `/config/defaults_.rb` - -Configuration values are defined in a Ruby hash returned by a “naked” function -`get_default_config()` in a Ruby file. The Ruby file is loaded and evaluated at -Ceedling startup. It can contain anything allowed in a Ruby script file but -must contain the accessor function. The returned hash's top-level keys will -live in Ceedling's configuration at the same level in the configuration -hierarchy as a Ceedling project file's top-level keys ('top-level' refers to -the left-most keys in the YAML, not to how “high” the keys are towards the top -of the file). - -Keys and values are defined in Ceedling's “base” configuration along with all -default values Ceedling loads at startup. If a particular key/value pair is -already set at the time the plugin attempts to set it, it will not be -redefined. - -This configuration option is more flexible than that documented in the previous -section as full Ruby execution is possible in creating the defaults hash. - -### Configuration Plugin Flvaor C: YAML Values - -Naming and location convention: `/config/.yml` - -Configuration values are defined inside a YAML file just as the Ceedling project -configuration file. - -Keys and values are defined in Ceedling's “base” configuration along with all -default values Ceedling loads at startup. If a particular key/value pair is -already set at the time the plugin attempts to set it, it will not be -redefined. - -YAML values are static apart from Ceedling's ability to perform string -substitution at configuration load time (see _[CeedlingPacket]_ for more). -Programmatic Ruby defaults (next section) are more flexible but more -complicated. - -```yaml -# Any valid YAML is appropriate -:key: - :value: -``` +toc_depth: 3 +--- # Plugin Option 2: `Plugin` Subclass Naming and location conventions: * `/lib/.rb` -* The plugin's class name must be the camelized version (a.k.a. “bumpy case") +* The plugin's class name must be the camelized version (a.k.a. "bumpy case") of the plugin filename — `whiz_bang.rb` ➡️ `WhizBang`. -This plugin option allows full programmatic ability connceted to any of a number +This plugin option allows full programmatic ability connected to any of a number of predefined Ceedling build steps. The contents of `.rb` must implement a class that subclasses @@ -258,7 +18,7 @@ The contents of `.rb` must implement a class that subclasses ## Example `Plugin` subclass -An incomplete `Plugin` subclass follows to illustate the basics. +An incomplete `Plugin` subclass follows to illustrate the basics. ```ruby # whiz_bang/lib/whiz_bang.rb @@ -293,7 +53,7 @@ Project configuration file: - whiz_bang ``` -Ceedling project directory sturcture: +Ceedling project directory structure: ``` project/ @@ -308,11 +68,11 @@ project/ It is possible and often convenient to add more `.rb` files to the containing `lib/` directory to allow good organization of plugin code. No Ceedling conventions exist for these supplemental code files. Only standard Ruby -constaints exists for these filenames and content. +constraints exist for these filenames and content. ## `Plugin` instance variables -Each `Plugin` sublcass has access to the following instance variables: +Each `Plugin` subclass has access to the following instance variables: * `@name` * `@ceedling` @@ -341,7 +101,7 @@ subclass. ### Multi-threaded protections Because Ceedling can run build operations in multiple threads, build step hook -handliers must be thread safe. Practically speaking, this generally requires +handlers must be thread safe. Practically speaking, this generally requires a `Mutex` object `synchronize()`d around any code that writes to or reads from a common data structure instantiated within a plugin. @@ -369,16 +129,17 @@ whose associated value is itself a hash with the following contents: } ``` -_**Note:**_ Test preprocessing steps are quite sophissticated and involve various -combination of tool executions. The `post_` preprocessing hooks do not inlucde shell -results. Future updates to Ceedling’s plugin system will create a more robust means -of attaching custom behaviors to test preprocessing or connecting your own preprocessing -pipeline with toolchains other than GCC. +!!! warning "Preprocessing Hook Limitations" + Test preprocessing steps are quite sophisticated and involve various combinations + of tool executions. The `post_` preprocessing hooks do not include shell results. + Future updates to Ceedling's plugin system will create a more robust means of + attaching custom behaviors to test preprocessing or connecting your own + preprocessing pipeline with toolchains other than GCC. ## `Plugin` hook methods `pre_mock_preprocess(arg_hash)` and `post_mock_preprocess(arg_hash)` These methods are called before and after execution of preprocessing for header -files to be mocked (see [CeedlingPacket] to understand preprocessing). If a +files to be mocked (see [Conventions & Behaviors][preprocessing] for preprocessing details). If a project does not enable preprocessing or a build does not include tests, these are not called. This pair of methods is called a number of times equal to the number of mocks in a test build. @@ -405,7 +166,7 @@ arg_hash = { ## `Plugin` hook methods `pre_test_preprocess(arg_hash)` and `post_test_preprocess(arg_hash)` These methods are called before and after execution of test file preprocessing -(see [CeedlingPacket] to understand preprocessing). If a project does not +(see [Conventions & Behaviors][preprocessing] for preprocessing details). If a project does not enable preprocessing or a build does not include tests, these are not called. This pair of methods is called a number of times equal to the number of test files in a test build. @@ -487,7 +248,7 @@ The argument `arg_hash` follows the structure below: ```ruby arg_hash = { :tool => { - # Hash holding compiler tool elements (see CeedlingPacket) + # Hash holding compiler tool properties — see ':tools' in the Project Configuration Reference }, # Symbol of the operation being performed, e.g. :compile, :assemble or :link :operation => :, @@ -525,7 +286,7 @@ The argument `arg_hash` follows the structure below: arg_hash = { # Hash holding linker tool properties. :tool => { - # Hash holding compiler tool elements (see CeedlingPacket) + # Hash holding compiler tool properties — see ':tools' in the Project Configuration Reference }, # Additional context passed by the calling function. # Ceedling provides :test or :release by default while plugins may provide another. @@ -558,7 +319,7 @@ The argument `arg_hash` follows the structure below: arg_hash = { # Hash holding execution tool properties. :tool => { - # Hash holding compiler tool elements (see CeedlingPacket) + # Hash holding compiler tool properties — see ':tools' in the Project Configuration Reference }, # Additional context passed by the calling function. # Ceedling provides :test or :release by default while plugins may provide another. @@ -604,7 +365,7 @@ This method is called when invoking the summary task, `ceedling summary`. This method facilitates logging the results of the last build without running the previous build again. -## Validating a plugin’s tools +## Validating a plugin's tools By default, Ceedling validates configured tools at startup according to a simple setting within the tool definition. This works just fine for default @@ -615,7 +376,7 @@ Ceedling can't find it in your `$PATH`. Similarly, it's irresponsible to skip validating a tool just because it may not be needed. Ceedling provides optional, programmatic tool validation for these cases. -`@ceedling]:tool_validator].validate()` can be forced to ignore a tool's +`@ceedling[:tool_validator].validate()` can be forced to ignore a tool's `required:` setting to validate it. In such a scenario, a plugin should configure its own tools as `:optional => true` but forcibly validate them at plugin startup if the plugin's configuration options require said tool. @@ -649,7 +410,7 @@ correspond directly to the collection of test files Ceedling processed in a given test build. It's common for this list of filepaths to be assembled from the `post_test_fixture_execute` build step execution hook. -The data that `assemble_test_results()` returns hss a structure as follows. In +The data that `assemble_test_results()` returns has a structure as follows. In this example, actual results from a single, real test file are presented as hash/array Ruby code with comments and with some edits to reduce line length. @@ -717,48 +478,6 @@ hash/array Ruby code with comments and with some edits to reduce line length. } ``` -# Plugin Option 3: Rake Tasks - -This plugin type adds custom Rake tasks to your project that can be run with `ceedling `. +[preprocessing]: ../../testing-guide/conventions.md#ceedling-preprocessing-behavior-for-your-tests -Naming and location conventions: `/.rake` - -## Example Rake task - -```ruby -# Only tasks with description are listed by `ceedling -T` -desc "Print hello world to console" -task :hello_world do - sh "echo Hello World!" -end -``` - -Resulting, example command line: - -```shell - > ceedling hello_world - > Hello World! -``` - -## Example Rake plugin layout - -Project configuration file: - -```yaml -:plugins: - :load_paths: - - support/plugins - :enabled: - - hello_world -``` - -Ceedling project directory sturcture: - -``` -project/ -├── project.yml -└── support/ - └── plugins/ - └── hello_world/ - └── hello_world.rake -``` \ No newline at end of file +

diff --git a/docs/mkdocs/development/plugins/rake-tasks.md b/docs/mkdocs/development/plugins/rake-tasks.md new file mode 100644 index 000000000..1f29d6cdf --- /dev/null +++ b/docs/mkdocs/development/plugins/rake-tasks.md @@ -0,0 +1,54 @@ +--- +toc_depth: 2 +--- + +# Plugin Option 3: Rake Tasks + +This plugin type adds custom Rake tasks to your project that can be run with `ceedling `. + +Naming and location conventions: `/.rake` + +!!! warning "Rake will be fully deprecated in the future" + The Ceedling project is working towards fully removing Rake as a runtime dependency. + +## Example Rake task + +```ruby +# Only tasks with description are listed by `ceedling -T` +desc "Print hello world to console" +task :hello_world do + sh "echo Hello World!" +end +``` + +Resulting, example command line: + +```shell + > ceedling hello_world + > Hello World! +``` + +## Example Rake plugin layout + +Project configuration file: + +```yaml +:plugins: + :load_paths: + - support/plugins + :enabled: + - hello_world +``` + +Ceedling project directory structure: + +``` +project/ +├── project.yml +└── support/ + └── plugins/ + └── hello_world/ + └── hello_world.rake +``` + +

diff --git a/docs/mkdocs/getting-started/command-line.md b/docs/mkdocs/getting-started/command-line.md new file mode 100644 index 000000000..0d248cd55 --- /dev/null +++ b/docs/mkdocs/getting-started/command-line.md @@ -0,0 +1,337 @@ +# Ceedling’s Command Line + +**Now what? How do I make it _Go_?** + +Every action in Ceedling is accomplished via the command line. We'll +cover project conventions and how to actually configure your project +in other sections. + +For now, let's talk about the command line. + +To run tests, build your release artifact, etc., you will be using the +trusty command line. Ceedling is transitioning away from being built +around Rake. As such, right now, interacting with Ceedling at the +command line involves two different conventions: + +1. **Application Commands.** Application commands tell Ceedling what to + to do with your project. These create projects, load project files, + begin builds, output version information, etc. These include rich + help and operate similarly to popular command line tools like `git`. +1. **Build & Plugin Tasks.** Build tasks actually execute test suites, + run release builds, etc. These tasks are created from your project + file. These are generated through Ceedling's Rake-based code and + conform to its conventions — simplistic help, no option flags, but + bracketed arguments. + +In the case of running builds, both come into play at the command line. + +The two classes of command line arguments are clearly labelled in the +summary of all commands provided by `ceedling help`. + +## Quick command line example to get you started + +To exercise the Ceedling command line quickly, follow these steps after +[installing Ceedling](installation.md): + +1. Open a terminal and chnage directories to a location suitable for + an example project. +1. Execute `ceedling example temp_sensor` in your terminal. The `example` + argument is an application command. +1. Change directories into the new _temp_sensor/_ directory. +1. Execute `ceedling test:all` in your terminal. The `test:all` is a + build task executed by the default (and omitted) `build` application + command. +1. Take a look at the build and test suite console output as well as + the _project.yml_ file in the root of the example project. + +## Ceedling application commands + +Ceedling provides robust command line help for application commands. +Execute `ceedling help` for a summary view of all application commands. +Execute `ceedling help ` for detailed help. + +_NOTE:_ Because the built-in command line help is thorough, we will only +briefly list and explain the available application commands. + +* `ceedling [no arguments]`: + + Runs the default build tasks. Unless set in the project file, Ceedling + uses a default task of `test:all`. To override this behavior, set your + own default tasks in the project file (see later section). + + --- + +* `ceedling build ` or `ceedling `: + + Runs the named build tasks. `build` is optional (i.e. `ceedling test:all` + is equivalent to `ceedling build test:all`). Various option flags + exist to control project configuration loading, verbosity levels, + logging, test task filters, etc. + + See next section to understand the build & plugin tasks this application + command is able to execute. Run `ceedling help build` to understand all + the command line flags that work with build & plugin tasks. + + --- + +* `ceedling dumpconfig`: + + Process project configuration and write final result to a YAML file. + Various option flags exist to control project configuration loading, + configuration manipulation, and configuration sub-section extraction. + + --- + +* `ceedling environment`: + + Lists project related environment variables: + + * All environment variable names and string values added to your + environment from within Ceedling and through the `:environment` + section of your configuration. This is especially helpful in + verifying the evaluation of any string replacement expressions in + your `:environment` config entries. + * All existing Ceedling-related environment variables set before you + ran Ceedling from the command line. + + --- + +* `ceedling example`: + + Extracts an example project from within Ceedling to your local + filesystem. The available examples are listed with + `ceedling examples`. Various option flags control whether the example + contains vendored Ceedling and/or a documentation bundle. + + --- + +* `ceedling examples`: + + Lists the available examples within Ceedling. To extract an example, + use `ceedling example`. + + --- + +* `ceedling help`: + + Displays summary help for all application commands and detailed help + for each command. `ceedling help` also loads your project + configuration (if available) and lists all build tasks from it. + Various option flags control what project configuration is loaded. + + --- + +* `ceedling new`: + + Creates a new project structure. Various option flags control whether + the new project contains vendored Ceedling, a documentation bundle, + and/or a starter project configuration file. + + --- + +* `ceedling upgrade`: + + Upgrade vendored installation of Ceedling for an existing project + along with any locally installed documentation bundles. + + --- + +* `ceedling version`: + + Displays version information for Ceedling and its components. Version output for Ceedling includes the Git Commit short SHA in Ceedling's build identifier and Ceedling's path of origin. + + ``` + 🌱 Welcome to Ceedling! + + Ceedling => #.#.#- + ---------------------- + + + Build Frameworks + ---------------------- + CMock => #.#.# + Unity => #.#.# + CException => #.#.# + ``` + + If the short SHA information is unavailable such as in local development, the SHA is omitted. The source for this string is generated and captured in the Gem at the time of Ceedling's automated build in CI. + +## Ceedling build & plugin tasks + +Build task are loaded from your project configuration. Unlike +application commands that are fixed, build tasks vary depending on your +project configuration and the files within your project structure. + +Ultimately, build & plugin tasks are executed by the `build` application +command (but the `build` keyword can be omitted — see above). + +* `ceedling paths:*`: + + List all paths collected from `:paths` entries in your YAML config + file where `*` is the name of any section contained in `:paths`. This + task is helpful in verifying the expansion of path wildcards / globs + specified in the `:paths` section of your config file. + + --- + +* `ceedling files:assembly` +* `ceedling files:header` +* `ceedling files:source` +* `ceedling files:support` +* `ceedling files:test` + + List all files and file counts collected from the relevant search + paths specified by the `:paths` entries of your YAML config file. The + `files:assembly` task will only be available if assembly support is + enabled in the `:release_build` or `:test_build` sections of your + configuration file. + + --- + +* `ceedling test:all`: + + Run all unit tests. + + --- + +* `ceedling test:*`: + + Execute the named test file or the named source file that has an + accompanying test. No path. Examples: `ceedling test:foo`, `ceedling + test:foo.c` or `ceedling test:test_foo.c` + + --- + +* `ceedling test:* --test-case= ` + Execute individual test cases which match `test_case_name`. + + For instance, if you have a test file _test_gpio.c_ containing the following + test cases (test cases are simply `void test_name(void)`: + + - `test_gpio_start` + - `test_gpio_configure_proper` + - `test_gpio_configure_fail_pin_not_allowed` + + … and you want to run only _configure_ tests, you can call: + + `ceedling test:gpio --test-case=configure` + + **Test case matching notes** + + * Test case matching is on sub-strings. `--test_case=configure` matches on + the test cases including the word _configure_, naturally. + `--test-case=gpio` would match all three test cases. + + --- + +* `ceedling test:* --exclude_test_case= ` + Execute test cases which do not match `test_case_name`. + + For instance, if you have file test_gpio.c with defined 3 tests: + + - `test_gpio_start` + - `test_gpio_configure_proper` + - `test_gpio_configure_fail_pin_not_allowed` + + … and you want to run only start tests, you can call: + + `ceedling test:gpio --exclude_test_case=configure` + + **Test case exclusion matching notes** + + * Exclude matching follows the same sub-string logic as discussed in the + preceding section. + + --- + +* `ceedling test:pattern[*]`: + + Execute any tests whose name and/or path match the regular expression + pattern (case sensitive). Example: `ceedling "test:pattern[(I|i)nit]"` + will execute all tests named for initialization testing. + + _NOTE:_ Quotes are likely necessary around the regex characters or + entire task to distinguish characters from shell command line operators. + + --- + +* `ceedling test:path[*]`: + + Execute any tests whose path contains the given string (case + sensitive). Example: `ceedling test:path[foo/bar]` will execute all tests + whose path contains foo/bar. _Notes:_ + + 1. Both directory separator characters `/` and `\` are valid. + 1. Quotes may be necessary around the task to distinguish the parameter's + characters from shell command line operators. + + --- + +* `ceedling release`: + + Build all source into a release artifact (if the release build option + is configured). + + --- + +* `ceedling release:compile:*`: + + Sometimes you just need to compile a single file dagnabit. Example: + `ceedling release:compile:foo.c` + + --- + +* `ceedling release:assemble:*`: + + Sometimes you just need to assemble a single file doggonit. Example: + `ceedling release:assemble:foo.s` + + --- + +* `ceedling summary`: + + If plugins are enabled, this task will execute the summary method of + any plugins supporting it. This task is intended to provide a quick + roundup of build artifact metrics without re-running any part of the + build. + + --- + +* `ceedling clean`: + + Deletes all toolchain binary artifacts (object files, executables), + test results, and any temporary files. Clean produces no output at the + command line unless verbosity has been set to an appreciable level. + + --- + +* `ceedling clobber`: + + Extends clean task's behavior to also remove generated files: test + runners, mocks, preprocessor output. Clobber produces no output at the + command line unless verbosity has been set to an appreciable level. + +## Command Line Tasks, Extra Credit + +### Combining Tasks At the Command Line + +Multiple build tasks can be executed at the command line. + +For example, `ceedling clobber test:all release` will remove all generated +files; build and run all tests; and then build all source — in that order. If +any task fails along the way, execution halts before the next task. + +Task order is executed as provided and can be important! Running `clobber` after +a `test:` or `release:` task will not accomplish much. + +### Build Directory and Revision Control + +The `clobber` task removes certain build directories in the +course of deleting generated files. In general, it's best not +to add to source control any Ceedling generated directories +below the root of your top-level build directory. That is, leave +anything Ceedling & its accompanying tools generate out of source +control (but go ahead and add the top-level build directory that +holds all that stuff if you want). + +

diff --git a/docs/mkdocs/getting-started/index.md b/docs/mkdocs/getting-started/index.md new file mode 100644 index 000000000..1c2a4bb87 --- /dev/null +++ b/docs/mkdocs/getting-started/index.md @@ -0,0 +1,35 @@ +# Getting Started + +
+ +- :material-rocket-launch: **[Quick Start](quick-start.md)** + + --- + + Steps from installation through running your first build tasks — + get up and running fast. + +- :material-download: **[Installation](installation.md)** + + --- + + Install Ceedling as a Ruby Gem or use a prepackaged _MadScienceLab_ + Docker image with Ceedling, GCC, and supporting tools already bundled. + +- :material-console: **[Command Line](command-line.md)** + + --- + + Application commands (project creation, help, config inspection) and + build & plugin tasks (test runs, release builds, clean, and more). + +- :material-file-code: **[Commented Sample Test File](../testing-guide/test-sample.md)** + + --- + + A sample test file illustrating the Ceedling conventions that make it go. + Includes a discussion of what gets compiled and linked into a test executable. + +
+ +

diff --git a/docs/mkdocs/getting-started/installation.md b/docs/mkdocs/getting-started/installation.md new file mode 100644 index 000000000..ad37ff536 --- /dev/null +++ b/docs/mkdocs/getting-started/installation.md @@ -0,0 +1,160 @@ +# Ceedling Installation & Set Up + +**How Exactly Do I Get Started?** + +You have two good options for installing and running Ceedling: + +1. The Ceedling Ruby Gem +1. Prepackaged _MadScienceLab_ Docker images + +The simplest way to get started with a local installation is to install +Ceedling as a Ruby gem. Gems are simply prepackaged Ruby-based software. +Other options exist, but the Ceedling Gem is the best option for a local +installation. However, you will also need a compiler toolchain (e.g. GNU +Compiler Collection) plus any supporting tools used by any plugins you +enabled. + +If you are familiar with the virtualization technology Docker, our premade +Docker images will get you started with Ceedling and all the accompanying +tools lickety split. Install Docker, pull down one of the _MadScienceLab_ +images and go. + +## Installation as a [Ruby Gem][ruby-gem] + +1. [Download and install Ruby][ruby-install]. Ruby 3 is required. + +1. Use Ruby's command line gem package manager to install Ceedling from + the [RubyGems repository][rubygems-repo]: `gem install ceedling`. + * Unity, CMock, and CException come along with Ceedling at no extra + charge. + * Installing from the RubyGems repo will also install Ceedling's + dependencies. +1. Execute Ceedling at the command line to export an example project + or create an empty Ceedling project in your filesystem (executing + `ceedling help` first is, well, helpful). + +[ruby-gem]: http://docs.rubygems.org/read/chapter/1 +[ruby-install]: http://www.ruby-lang.org/en/downloads/ +[rubygems-repo]: http://rubygems.org + +### Gem install notes + +Steps 1–2 above are a one-time affair for your local environment. +When steps 1-2 are completed once, only step 3 is needed for each new +code projects. + +If you are working with prerelease versions of Ceedling or some other +off-the-beaten-path installation scenario, you may want to directly +install the Ceedling .gem file attached to any of the Github releases. +No problem. + +The steps are similar to the preceding with two changes: + +1. `gem install --local ` +1. Any missing dependencies must be manually installed before +installation of the local Ceedling gem will succeed. A local +installation attempt will complain about any missing dependencies. +Simply `gem install` them by name. + +## _MadScienceLab_ Docker Images + +As an alternative to local installation, fully packaged Docker images containing Ruby, Ceedling, the GCC toolchain, and more are also available. [Docker][docker-overview] is a virtualization technology that provides self-contained software bundles that are a portable, well-managed alternative to local installation of tools like Ceedling. + +Four Docker image variants containing Ceedling and supporting tools exist. These four images are available for both Intel and ARM host platforms (Docker does the right thing based on your host environment). The latter includes ARM Linux and Apple's M-series macOS devices. + +1. **_[MadScienceLab][docker-image-base]_**. This image contains Ruby, Ceedling, CMock, Unity, CException, the GNU Compiler Collection (gcc), and a handful of essential C libraries and command line utilities. +1. **_[MadScienceLab Plugins][docker-image-plugins]_**. This image contains all of the above plus the command line tools that Ceedling's built-in plugins rely on. Naturally, it is quite a bit larger than option (1) because of the additional tools and dependencies. +1. **_[MadScienceLab ARM][docker-image-arm]_**. This image mirrors (1) with the compiler toolchain replaced with the GNU `arm-none-eabi` variant. +1. **_[MadScienceLab ARM + Plugins][docker-image-arm-plugins]_**. This image is (3) with the addition of all the complementary plugin tooling just like (2) provides. + +See the Docker Hub pages linked above for more documentation on these images. + +Just to be clear here, most users of the _MadScienceLab_ Docker images will probably care about the ability to run unit tests on your own host. If you are one of those users, no matter what host platform you are on — Intel or ARM — you'll want to go with (1) or (2) above. The tools within the image will automatically do the right thing within your environment. Options (3) and (4) are most useful for specialized cross-compilation scenarios. + +### Usage basics + +To use a _MadScienceLab_ image from your local terminal: + +1. [Install Docker][docker-install] +1. Determine: + 1. The local path of your Ceedling project + 1. The variant and revision of the Docker image you'll be using +1. Run the container with: + 1. The Docker `run` command and `-it --rm` command line options + 1. A Docker volume mapping from the root of your project to the default project path inside the container (_/home/dev/project_) + +See the command line examples in the following two sections. + +Note that all of these somewhat lengthy command lines lend themselves well to being wrapped up in simple helper scripts specific to your project and directory structure. + +### Run as an interactive terminal + +When the container launches as shown below, it will drop you into a Z-shell command line that has access to all the tools and utilities available within the container. In this usage, the Docker container becomes just another terminal, including ending its execution with `exit`. + +```shell + > docker run -it --rm -v /my/local/project/path:/home/dev/project throwtheswitch/madsciencelab-plugins:1.0.0 +``` + +Once the _MadScienceLab_ container's command line is available, to run Ceedling, execute it just as you would after installing Ceedling locally: + +```shell + ~/project > ceedling help +``` + +```shell + ~/project > ceedling new ... +``` + +```shell + ~/project > ceedling test:all +``` + +### Run as a command line utility + +Alternatively, you can run Ceedling through the _MadScienceLab_ Docker container directly from the command line as a command line utility. The general pattern is immediately below. + +```shell + > docker run --rm -v /my/local/project/path:/home/dev/project throwtheswitch/madsciencelab-plugins:1.0.0 +``` + +As a specific example, to run all tests in a suite, the command line would be this: + +```shell + > docker run --rm -v /my/local/project/path:/home/dev/project throwtheswitch/madsciencelab-plugins:1.0.0 ceedling test:all +``` + +In this usage, the container starts, executes Ceedling, and then ends. + +[docker-overview]: https://www.ibm.com/topics/docker +[docker-install]: https://www.docker.com/products/docker-desktop/ + +[docker-image-base]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab +[docker-image-plugins]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab-plugins +[docker-image-arm]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab-arm-none-eabi +[docker-image-arm-plugins]: https://hub.docker.com/repository/docker/throwtheswitch/madsciencelab-arm-none-eabi-plugins + +## Getting Started after installation + +1. Certain advanced features of Ceedling rely on `gcc` and `cpp` as + preprocessing tools. In most Linux systems, these tools are already available. + For Windows environments, we recommend the + [MinGW project](http://www.mingw.org/) (Minimalist GNU for Windows). This + represents an optional, additional setup / installation step to complement + the list above. Upon installing MinGW ensure your system path is updated or + set `:environment` ↳ `:path` in your project configuration (see `:environment` + section). + +1. Once Ceedling is installed, you'll want to start to integrate it with new + and old projects alike. If you wanted to start to work on a new project + named `foo`, Ceedling can create the skeleton of the project using `ceedling + new foo `. Likewise if you already have a project named `bar` + and you want to "inject" Ceedling into it, you would run `ceedling new bar + `, and Ceedling will create any files and directories it needs. + +1. Now that you have Ceedling integrated with a project, you can start using it. + A good starting point is to enable the [plugin](../plugins/index.md) + `module_generator` in your project configuration file and create a source + + test code module to get accustomed to Ceedling by issuing the command + `ceedling 'module:create[name]'`. + +

diff --git a/docs/mkdocs/getting-started/quick-start.md b/docs/mkdocs/getting-started/quick-start.md new file mode 100644 index 000000000..712f02e18 --- /dev/null +++ b/docs/mkdocs/getting-started/quick-start.md @@ -0,0 +1,67 @@ +# Quick Start + +## Quick Start Steps + +Below is a quick overview of how to get started from Ceedling installation +through running build tasks. Jump down just a teeny bit to see what the Ceedling +command line looks like and navigate to all the documentation for the steps +listed immediately below. + +1. [Install Ceedling][quick-start-1] +1. Create a project + * Use Ceedling to generate an example project, or + * Add a Ceedling project file to the root of an existing project, or + * Create a project from scratch: + 1. Create a project directory + 1. Add source code and optionally test code however you'd like it organized + 1. Create a Ceedling project file in the root of your project directory +1. Run Ceedling tasks from the working directory of your project + +Ceedling requires a command line C toolchain be available in your path. It's +flexible enough to work with most anything on any platform. By default, Ceedling +is ready to work with [GCC] out of the box (we recommend the [MinGW] project +on Windows). + +A common build strategy with tooling other than GCC is to use your target +toolchain for release builds (with or without Ceedling) but rely on Ceedling + +GCC for test builds (more on all this [here][overview]). + +[GCC]: https://gcc.gnu.org +[MinGW]: http://www.mingw.org/ +[overview]: ../overview/index.md + +## Command Line & Build Tasks + +Once you have Ceedling installed, you always have access to `ceedling help`. + +And, once you have Ceedling installed, you have options for project creation +using Ceedling's application commands: + +* `ceedling new ` +* `ceedling examples` to list available example projects and + `ceedling example ` to create a readymade sample + project whose project file you can copy and modify. + +Once you have a Ceedling project file and a project directory structure for your +code, Ceedling build tasks go like this: + +* `ceedling test:MyCodeModule`, or +* `ceedling test:all`, or +* `ceedling release`, or, if you fancy and have the GCov plugin enabled, +* `ceedling clobber test:all gcov:all release --log --verbosity=obnoxious` + +## Quick Start Documentation + +* [Installation][quick-start-1] +* [Sample test code file + Example Ceedling projects][quick-start-2] +* [Simple project file][quick-start-3] +* [Ceedling at the command line][quick-start-4] +* [All your project configuration file options][quick-start-5] + +[quick-start-1]: installation.md +[quick-start-2]: ../testing-guide/test-sample.md +[quick-start-3]: ../overview/build-system.md#simple-sample-project-file +[quick-start-4]: command-line.md +[quick-start-5]: ../configuration/index.md + +

diff --git a/docs/mkdocs/help.md b/docs/mkdocs/help.md new file mode 100644 index 000000000..d95de8fcb --- /dev/null +++ b/docs/mkdocs/help.md @@ -0,0 +1,49 @@ +# Help + +!!! sponsorship "Please consider sponsoring this project" + Ceedling, Unity, CMock, and CException are free and open source, + maintained by volunteers. [GitHub Sponsors](https://github.com/sponsors/ThrowTheSwitch) + help fund ongoing maintenance, new features, and the infrastructure + to host and develop the Ceedling Suite of tools. + +
+ +- :material-bug: **[Submit an Issue][ceedling-issues]** + + --- + + Found a bug or want to suggest a feature? Open an issue in the Ceedling + GitHub repository. + +- :material-forum: **[Discussion Forums][forums]** + + --- + + Trying to understand a feature or work through a testing problem? + The ThrowTheSwitch community forums are the place to ask. + +- :material-handshake: **[Ceedling Assist][ceedling-assist]** + + --- + + Paid training, customizations, and support contracts are available + through [Ceedling Assist][ceedling-assist] offered by [ThingamaByte]. + +- :material-school: **[Dr. Surly’s School For Mad Scientists][drsurlys-school]** + + --- + + A curriculum hosted at [Udemy](https://udemy.com) to promote good C development + techniques and embedded software practices through testing. So you too can + take over the world. + +
+ +[ceedling-issues]: https://github.com/ThrowTheSwitch/Ceedling/issues +[forums]: https://www.throwtheswitch.org/forums +[ceedling-assist]: https://www.thingamabyte.com/ceedlingassist +[ThingamaByte]: https://www.thingamabyte.com/ +[thingamabyte-ceedling]: https://www.thingamabyte.com/ceedling +[drsurlys-school]: https://www.throwtheswitch.org/dr-surlys-school + +

diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md new file mode 100644 index 000000000..68c617a32 --- /dev/null +++ b/docs/mkdocs/index.md @@ -0,0 +1,211 @@ +# Ceedling Packet + +Ceedling is a fancypants build system that greatly simplifies building +C projects. While it can certainly build release targets, it absolutely +shines at running unit test suites. + +Ceedling and its suite of frameworks, including Unity and CMock, were developed +for use on platforms from heavy duty workstations to teeny tiny microcontrollers. +Features handy for low-level development have made these tools popular with +embedded systems developers. + +!!! tip "New to Ceedling?" + Jump straight to the [Quick Start][quick-start] — installation, + project set up, and your first build tasks all in one place. + +!!! feature "New in Ceedling 1.1.0 — Partials" + A [_Partial_](testing-guide/partials/index.md) is your C code sliced and diced + to expose functional elements for testing that you could not otherwise + access without rewriting your source code. Think of Partials as a scalpel + for testing your code. + +## Overview + +
+ +- :material-hammer-wrench: **[A Build System for C][build-system]** + + --- + + Generate a complete test and release build from a single YAML file. + Provides a minimal sample project configuration and an explanation of Ceedling’s + design philosophy. + +- :material-toolbox: **[Tools & Frameworks][tools-and-frameworks]** + + --- + + Ruby, Rake, YAML, Unity, CMock, and CException explained — the pieces that make + Ceedling possible and how they fit together. + +- :material-test-tube: **[Test Environments][test-environments]** + + --- + + Native host builds, emulator-based runs, and on-target execution — choose the + right test suite strategy for your project. + +
+ +## Getting Started + +
+ +- :material-rocket-launch: **[Quick Start][quick-start]** + + --- + + Ready to go? Let’s go. + +- :material-download: **[Ceedling Installation & Set Up][installation]** + + --- + + Installing Ceedling and its prerequisites. + +- :material-console: **[Ceedling’s Command Line.][command-line]** + + --- + + Now what? How do I make it _Go_? + +
+ +## Unit Testing + +
+ +- :material-help-circle: **[How Does a Test Case Even Work?][test-cases]** + + --- + + A brief overview of what a test case is with simple examples illustrating + how test cases work. + +- :material-file-code: **[Commented Sample Test File][test-sample]** + + --- + + A sample test file illustrating test case creation and the conventions + that make it work. Includes a discussion of how test executables get built. + +- :material-layers: **[Anatomy of a Test Suite][test-suite-anatomy]** + + --- + + How a unit test grows up to become a test suite. + +- :material-link-variant: **[Using Unity, CMock & CException][frameworks]** + + --- + + Ceedling links together Unity, CMock, and CException — each of which can + require configuration of their own. Ceedling facilitates this. + +- :material-book-open-page-variant: **[Important Conventions & Behaviors][conventions]** + + --- + + Much of testing in Ceedling is accomplished by convention. + Code and files structured and named in certain ways trigger sophisticated build + features. + +- :material-content-cut: **[Partials][partials]** + + --- + + Partials are like a scalpel for your source code. A generated partial allows + you to test and mock parts of your code you could not otherwise access + without rewriting it first. +
+ +## Project Configuration + +
+ +- :material-file-import: **[How to Load a Project Configuration][configuration-loading]** + + --- + You have options, my friend. Load your base configuration via command line + flag, environment variable, or default file. Add Mixins to merge configuration + for different build scenarios. + +- :material-file-cog: **[The Mighty Project Configuration File][configuration-project-file]** + + --- + + Everything you need to know about the project configuration file. All in + glorious YAML. + +- :material-book-open-variant: **[Project Configuration Reference][configuration-reference]** + + --- + + Exhaustive documentation for all project configuration options — project + paths, testing features, plugins, and much more. + +- :material-clipboard-play-multiple-outline: **[Parallel Builds][parallel-builds]** + + --- + + Configure Ceedling to take advantage of multiple CPU cores for faster build + steps and test suite execution. + +- :material-directions-fork: **[Which Ceedling?][which-ceedling]** + + --- + + Sometimes you may need to point to a different Ceedling to run. + +
+ +## Advanced & Extending + +
+ +- :material-pound: **[Build Directive Macros][build-directives]** + + --- + + Code macros to accomplish build goals when Ceedling's conventions aren't + quite enough. + +- :material-puzzle-plus: **[Ceedling Plugins][plugins]** + + --- + + Ceedling is extensible with built-in plugins for code coverage, test reporting, + CI integration, file scaffolding, sophisticated release builds, and more. + +- :material-database: **[Global Collections][global-collections]** + + --- + + Globally available Ruby lists of paths, files, and more — useful for advanced + project customization and plugin development. + +
+ +[quick-start]: getting-started/quick-start.md +[build-system]: overview/build-system.md +[tools-and-frameworks]: overview/tools-and-frameworks.md +[testing-abilities]: overview/testing-abilities.md +[test-environments]: overview/test-environments.md +[test-cases]: testing-guide/test-cases.md +[test-sample]: testing-guide/test-sample.md +[test-suite-anatomy]: testing-guide/test-suite-anatomy.md +[partials]: testing-guide/partials/index.md +[installation]: getting-started/installation.md +[command-line]: getting-started/command-line.md +[conventions]: testing-guide/conventions.md +[frameworks]: testing-guide/frameworks.md +[configuration-loading]: configuration/loading.md +[configuration-project-file]: configuration/project-file.md +[configuration-reference]: configuration/reference/index.md +[parallel-builds]: configuration/parallel-builds.md +[which-ceedling]: configuration/which-ceedling.md +[build-directives]: testing-guide/build-directives.md +[plugins]: plugins/index.md +[global-collections]: configuration/global-collections.md + +

diff --git a/docs/mkdocs/overview/build-system.md b/docs/mkdocs/overview/build-system.md new file mode 100644 index 000000000..12135a21e --- /dev/null +++ b/docs/mkdocs/overview/build-system.md @@ -0,0 +1,115 @@ +# A Build System for All Your C Mad Scientisting Needs + +Ceedling allows you to generate an entire test and release build +environment for a C project from a single, short YAML configuration +file. It truly shines at supporting unit testing and managing test +builds. + +Ceedling and its bundled frameworks, Unity, CMock, and CException, don't +want to brag, but they're also quite adept at supporting the tiniest of +embedded processors, the beefiest 64-bit powerhouses available, and +everything in between. + +Assembling build environments for C projects — especially with +automated unit tests — is a pain. No matter the all-purpose build +environment tool you use, configuration is tedious and requires +considerable glue code to pull together the necessary tools and +libraries to run unit tests. The Ceedling bundle handles all this +for you. + +## Simple Sample Project File + +For a project including Unity/CMock unit tests and using the default +toolchain `gcc`, the configuration file could be as simple as this: + +```yaml +:project: + :build_root: project/build/ + :release_build: TRUE + +:paths: + :test: + - tests/** + :source: + - source/** + :include: + - inc/** +``` + +!!! tip "Want to see a real world project configuration?" + See this [commented project file][example-config-file] + for a much more complete and sophisticated example of a project + configuration. + +From the command line, to run all your unit tests, you would run +`ceedling test:all`. To build the release version of your project, +you would simply run `ceedling release`. That's it! + +Of course, many more advanced options allow you to configure +your project with a variety of features to meet a variety of needs. +Ceedling can work with practically any command line toolchain +and directory structure – all by way of the configuration file. + +See the later [configuration section][project-configuration] for +way more details on your project configuration options. + +A facility for [plugins](../plugins/index.md) also allows you to +extend Ceedling's capabilities for needs such as custom code metrics +reporting, build artifact packaging, and much more. A variety of +built-in plugins come with Ceedling. + +[example-config-file]: ../snapshot/assets/project.yml +[project-configuration]: ../configuration/index.md + +## What's with this name? + +Glad you asked. Ceedling is tailored for unit tested C projects and is built +upon Rake, a Make replacement implemented in the Ruby scripting language. + +So, we've got C, our Rake, and the fertile soil of a build environment in which +to grow and tend your project and its unit tests. Ta da — _Ceedling_. + +Incidentally, though Rake was the backbone of the earliest versions of +Ceedling, it is now being phased out incrementally in successive releases. +The name Ceedling is not going away, however! + +## “Tailored for unit-tested C projects”? + +Well, we like to write unit tests for our C code to make it lean and +mean — that whole [Test-Driven Development][tdd] thing. + +Along the way, this style of writing C code spawned two +tools to make the job easier: + +1. A unit test framework for C called _Unity_ +1. A mocking library called _CMock_ + +And, though it's not directly related to testing, a C framework for +exception handling called _CException_ also came along. + +[tdd]: http://en.wikipedia.org/wiki/Test-driven_development + +These tools and frameworks are great, but they require quite +a bit of environment support to pull them all together in a convenient, +usable fashion. We started off with Rakefiles to assemble everything. +These ended up being quite complicated and had to be hand-edited +or created anew for each new project. Ceedling replaces all that +tedium and rework with a configuration file that ties everything +together. + +Though Ceedling is tailored for unit testing, it can also go right +ahead and build your final binary release artifact for you as well. +That said, Ceedling is more powerful as a unit test build environment +than it is a general purpose release build environment. Complicated +projects including separate bootloaders or multiple library builds, +etc. are not necessarily its strong suit (but the +[`dependencies`](../plugins/dependencies.md) plugin can +accomplish quite a bit here). + +It's quite common and entirely workable to host Ceedling and your +test suite alongside your existing release build setup. That is, you +can use make, Visual Studio, SCons, Meson, etc. for your release build +and Ceedling for your test build. Your two build systems will simply +"point" to the same project code. + +

diff --git a/docs/mkdocs/overview/index.md b/docs/mkdocs/overview/index.md new file mode 100644 index 000000000..469572d94 --- /dev/null +++ b/docs/mkdocs/overview/index.md @@ -0,0 +1,29 @@ +# Overview + +
+ +- :material-hammer-wrench: **[A Build System for C](build-system.md)** + + --- + + Generate a complete test and release build environment from a single + YAML file. Includes a minimal sample project configuration and an + explanation of Ceedling‘s design philosophy. + +- :material-toolbox: **[Tools & Frameworks](tools-and-frameworks.md)** + + --- + + Ruby, Rake, YAML, Unity, CMock, and CException explained — the pieces + that make Ceedling possible and how they fit together. + +- :material-test-tube: **[Test Environments](test-environments.md)** + + --- + + Native host builds, emulator-based runs, and on-target execution — + choose the right test suite strategy for your project. + +
+ +

diff --git a/docs/mkdocs/overview/test-environments.md b/docs/mkdocs/overview/test-environments.md new file mode 100644 index 000000000..8a9ae73a6 --- /dev/null +++ b/docs/mkdocs/overview/test-environments.md @@ -0,0 +1,33 @@ +# Test Suite Execution Environments + +**All your sweet, sweet test suite options** + +Ceedling, Unity, and CMock help you create and run test suites using any +of the following approaches. For more on this topic, please see this +[handy dandy article][tts-which-build] and/or follow the links for each +item listed below. + +[tts-which-build]: https://throwtheswitch.org/build/which + +1. **[Native][tts-build-native].** This option builds and runs code on your + host system. + * In the simplest case this means you are testing code that is intended + to run on the same sort of system as the test suite. Your test + compiler toolchain is the same as your release compiler toolchain. + * However, a native build can also mean your test compiler is different + than your release compiler. With some thought and effort, code for + another platform can be tested on your host system. This is often + the best approach for embedded and other specialized development. +1. **[Emulator][tts-build-cross].** In this option, you build your test code with your target's + toolchain, and then run the test suite using an emulator provided for + that target. This is a good option for embedded and other specialized + development — if an emulator is available. +1. **[On target][tts-build-cross].** The Ceedling bundle of tools can create test suites that + run on a target platform directly. Particularly in embedded development + — believe it or not — this is often the option of last resort. That is, + you should probably go with the other options in this list. + +[tts-build-cross]: https://throwtheswitch.org/build/cross +[tts-build-native]: https://throwtheswitch.org/build/native + +

diff --git a/docs/mkdocs/overview/tools-and-frameworks.md b/docs/mkdocs/overview/tools-and-frameworks.md new file mode 100644 index 000000000..78750d9d8 --- /dev/null +++ b/docs/mkdocs/overview/tools-and-frameworks.md @@ -0,0 +1,115 @@ +# So Many Tools and Acronyms + +**Hold on. Back up. Unity? CMock? CException? Ruby? Rake? YAML?** + +## Ceedling suite frameworks + +### Unity + +[Unity] is a [unit test framework][unit-testing] for C. It provides facilities +for test assertions, executing tests, and collecting / reporting test +results. Unity derives its name from its implementation in a single C +source file (plus two C header files) and from the nature of its +implementation - Unity will build in any C toolchain and is configurable +for even the very minimalist of processors. + +[Unity]: http://github.com/ThrowTheSwitch/Unity +[unit-testing]: http://en.wikipedia.org/wiki/Unit_testing + +### CMock + +[CMock] is a tool written in Ruby able to generate [function mocks & stubs][test-doubles] +in C code from a given C header file. Mock functions are invaluable in +[interaction-based unit testing][interaction-based-tests]. +CMock's generated C code uses Unity. + + Through a [plugin][FFF-plugin], Ceedling also supports +[FFF], _Fake Function Framework_, for [fake functions][test-doubles] as an +alternative to CMock's mocks and stubs. + +[CMock]: http://github.com/ThrowTheSwitch/CMock +[test-doubles]: https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da +[FFF]: https://github.com/meekrosoft/fff +[FFF-plugin]: ../plugins/fff.md +[interaction-based-tests]: http://martinfowler.com/articles/mocksArentStubs.html + +### CException + +[CException] is a C source and header file that provide a simple +[exception mechanism][exn] for C by way of wrapping up the +[setjmp / longjmp][setjmp] standard library calls. Exceptions are a much +cleaner and preferable alternative to managing and passing error codes +up your return call trace. + +[CException]: http://github.com/ThrowTheSwitch/CException +[exn]: http://en.wikipedia.org/wiki/Exception_handling +[setjmp]: http://en.wikipedia.org/wiki/Setjmp.h + +## Core tools + +### Ruby + +[Ruby] is a handy scripting language like Perl or Python. It's a modern, +full featured language that happens to be quite handy for accomplishing +tasks like code generation or automating one's workflow while developing +in a compiled language such as C. + +[Ruby]: http://www.ruby-lang.org/en/ + +### Rake + +!!! warning "Migrating away from Rake" + Ceedling would not exist today if not for the help Rake provided to + get off the ground. As Ceedling matured it became apparent that + Rake had become a limitation. The project is slowly removing its + dependency on Rake. + +[Rake] is a utility written in Ruby for accomplishing dependency +tracking and task automation common to building software. It's a modern, +more flexible replacement for [Make]. + +Rakefiles are Ruby files, but they contain build targets similar +in nature to that of Makefiles (but you can also run Ruby code in +your Rakefile). + +[Rake]: http://rubyrake.org/ +[Make]: http://en.wikipedia.org/wiki/Make_(software) + +### YAML + +[YAML] is a "human friendly data serialization standard for all +programming languages." It's kinda like a markup language but don't +call it that. With a YAML library, you can [serialize] data structures +to and from the file system in a textual, human readable form. Ceedling +uses a serialized data structure as its configuration input. + +YAML has some advanced features that can greatly +[reduce duplication][yaml-anchors-aliases] in a configuration file +needed in complex projects. YAML anchors and aliases are beyond the scope +of this document but may be of use to advanced Ceedling users. Note that +Ceedling does anticipate the use of YAML aliases. It proactively flattens +YAML lists to remove any list nesting that results from the convenience of +aliasing one list inside another. + +[YAML]: http://en.wikipedia.org/wiki/Yaml +[serialize]: http://en.wikipedia.org/wiki/Serialization +[yaml-anchors-aliases]: https://blog.daemonl.com/2016/02/yaml.html + +--- + +## Dependencies and Bundled Tools + +* By using the preferred installation options of the Ruby Ceedling gem or + the prepackaged Docker images, all Ceedling dependencies will be installed + for you. (See [installation section](../getting-started/installation.md).) + +* Regardless of installation method, Unity, CMock, and CException are bundled + with Ceedling. Ceedling is designed to glue them all together for your + project as seamlessly as possible. + +* YAML support is included with Ruby. It requires no special installation + or configuration. If your project file contains properly formatted YAML + with the recognized names and options (see later sections), you are good + to go. + +

diff --git a/plugins/beep/README.md b/docs/mkdocs/plugins/beep.md similarity index 96% rename from plugins/beep/README.md rename to docs/mkdocs/plugins/beep.md index bc06b2c68..2ee666c21 100644 --- a/plugins/beep/README.md +++ b/docs/mkdocs/plugins/beep.md @@ -1,13 +1,13 @@ -# Ceedling Plugin: Beep +# Beep Hear a useful beep at the end of a build. -# Plugin Overview +## Plugin Overview Are you getting too distracted surfing the internet, chatting with coworkers, or swordfighting while a long build runs? A friendly beep will let you know it's time to pay attention again. -# Setup +## Setup To use this plugin, it must be enabled: @@ -17,14 +17,14 @@ To use this plugin, it must be enabled: - beep ``` -# Configuration +## Configuration Beep includes a default configuration. By just enabling the plugin, the simplest cross-platform sound mechanism (`:bell` below) is automatically enabled for both build completion and build error events. If you would like to customize your beeps, the following explains your options. -## Events +### Events When this plugin is enabled, a beep is sounded when: @@ -33,7 +33,7 @@ When this plugin is enabled, a beep is sounded when: To change the default sound for each event, define `:on_done` and `:on_error` beneath a top-level `:beep` entry in your configuration file. See example below. -## Sound options +### Sound options The following options are fixed. At present, this plugin does not expose customization settings. @@ -77,7 +77,7 @@ The following options are fixed. At present, this plugin does not expose customi [say]: https://ss64.com/mac/say.html -## Adding arguments to a beep tool +### Adding arguments to a beep tool Each of the sound options above map to a command line tool that Ceedling executes. @@ -93,7 +93,7 @@ To add additional arguments, a feature of Ceedling's project file handling allow - ... # Add any aguments as a list of strings ``` -## Example beep configurations in YAML +### Example beep configurations in YAML Enabling the plugin and event handlers with beep tool selections: @@ -127,7 +127,8 @@ Adding an argument to a beep tool: ``` -# Notes +## Notes * Some terminal emulators intercept and/or silence beeps. Remote terminal sessions can add further complication. Be sure to check relevant configuration options to accomplish what you want. +

diff --git a/plugins/bullseye/README.md b/docs/mkdocs/plugins/bullseye.md similarity index 83% rename from plugins/bullseye/README.md rename to docs/mkdocs/plugins/bullseye.md index 7702c4a1c..fb708764c 100644 --- a/plugins/bullseye/README.md +++ b/docs/mkdocs/plugins/bullseye.md @@ -1,19 +1,13 @@ -Bullseye Code Coverage Plugin -============================= - -# June 1, 2024 Bullseye Plugin Disabled - -Until the Bullseye Plugin can be updated for compatibility with Ceedling >= 1.0.0, -it has been disabled. - -(The key hurdle is access to a license for the proprietary Bullseye coverage tooling.) - -# Plugin Overview +# Bullseye Plugin for integrating Bullseye code coverage tool into Ceedling projects. This plugin requires a working license to Bullseye code coverage tools. The tools must be within the path or the path should be added to the environment in the -`project.yml file`. +project configuration. + +!!! warning "Bullseye Plugin Disabled" + [June 1, 2024] Until the Bullseye Plugin can be updated for compatibility + with Ceedling >= 1.0.0, it has been disabled. ## Configuration @@ -79,3 +73,5 @@ by Ceedling. The following is a typical configuration example: ```sh ceedling bullseye:all utils:bullseye ``` + +

diff --git a/plugins/command_hooks/README.md b/docs/mkdocs/plugins/command-hooks.md similarity index 92% rename from plugins/command_hooks/README.md rename to docs/mkdocs/plugins/command-hooks.md index 175ee8f8f..fcdda0788 100644 --- a/plugins/command_hooks/README.md +++ b/docs/mkdocs/plugins/command-hooks.md @@ -1,12 +1,12 @@ -# Ceedling Plugin: Command Hooks +# Command Hooks Easily run command line tools and scripts at various points in a Ceedling build. -# Plugin Overview +## Plugin Overview This plugin allows you to skip creating a full Ceedling plugin for many common use cases. It links Ceedling's programmatic `Plugin` code hooks to easily managed tool definitions. -# Setup +## Setup To use this plugin, it must be enabled: @@ -16,9 +16,9 @@ To use this plugin, it must be enabled: - command_hooks ``` -# Configuration +## Configuration -## Overview +### Overview To connect utilties or scripts to build step hooks, Ceedling tools must be defined. @@ -26,7 +26,7 @@ A Ceedling tool is just a YAML blob that gathers together a handful of settings Example Ceedling tools follow. When enabled, this plugin ensures any tools you define are executed by the corresponding build step hook they are organized beneath. The configurtion of enabled hooks and tools happens in a top-level `:command_hooks:` block within your project configuration. One or more tools can be attached to a build step hook. -## Tool lists +### Tool lists A command hook can execute one or more tools. @@ -36,7 +36,7 @@ If multiple tools are needed, they must be organized as entries in a YAML list. See the commented examples below. -## Tool definitions +### Tool definitions Each Ceedling tool requires an `:executable` string and an optional `:arguments` list. See _[CeedlingPacket][ceedling-packet]_ documentation for project configuration [`:tools`][tools-doc] entries to understand how to craft your argument list and other tool options. @@ -44,7 +44,7 @@ At present, this plugin passes at most one runtime parameter for use in a hook's [tools-doc]: https://github.com/ThrowTheSwitch/Ceedling/blob/test/ceedling_0_32_rc/docs/CeedlingPacket.md#tools-configuring-command-line-tools-used-for-build-steps -## Hook logging +### Hook logging In addition to the standard Ceedling tool definition elements, a hook configuration entry may optionally include a `:logging` setting. @@ -56,7 +56,7 @@ When logging is enabled and logging conditions are appropriate, any output from * Debug logging naturally displays hook output as part of normal tool execution logging. It is not duplicated by hook logging. * At Normal verbosity, blank hook output is not logged at all; Obnoxious verbosity will display blank output as ``. -## Command Hooks example configuration YAML +### Command Hooks example configuration YAML ```yaml :command_hooks: @@ -89,7 +89,7 @@ When logging is enabled and logging conditions are appropriate, any output from - memory_report.txt ``` -# Available Build Step Hooks +## Available Build Step Hooks Define any of the following entries within a top-level `:command_hooks:` section of your Ceedling project file to automagically connect utilities or scripts to build process steps. @@ -106,49 +106,49 @@ As an example, consider a Ceedling project with ten test files and seventeen moc * 10 occurences of the `:pre_test_fixture_execute` and `:post_test_fixture_execute` hooks for running test executables and gathering the results of the tests cases they contain. * 1 occurence of the `:post_build` hook unless a build error occurred (`:post_error` would be called isntead). -## `:pre_build` +### `:pre_build` Called once just before Ceedling executes any tasks. No parameters are provided for a tool's argument list when the hook is called. -## `:post_build` +### `:post_build` Called once just before Ceedling terminates. No parameters are provided for a tool's argument list when the hook is called. -## `:post_error` +### `:post_error` Called once just after any build failure and just before Ceedling terminates. No parameters are provided for a tool's argument list when the hook is called. -## `:pre_test` +### `:pre_test` Called just before each test begins its build pipeline and just after all context for that build has been gathered. The parameter available to a tool (`${1}`) when the hook is called is the test's filepath. -## `:post_test` +### `:post_test` Called just after each test completes its build and execution. The parameter available to a tool (`${1}`) when the hook is called is the test's filepath. -## `:pre_release` +### `:pre_release` Called once just before a release build begins. No parameters are provided for a tool's argument list when the hook is called. -## `:post_release` +### `:post_release` Called once just after a release build finishes. No parameters are provided for a tool's argument list when the hook is called. -## `:pre_mock_preprocess` +### `:pre_mock_preprocess` If mocks are enabled and preprocessing is in use, this is called just before each header file to be mocked is preprocessed. @@ -156,27 +156,27 @@ The parameter available to a tool (`${1}`) when the hook is called is the filepa See _[CeedlingPacket][ceedling-packet]_ for details on how Ceedling preprocessing operates. -[ceedling-packet]: ../docs/CeedlingPacket.md +[ceedling-packet]: ../configuration/index.md -## `:post_mock_preprocess` +### `:post_mock_preprocess` If mocks are enabled and preprocessing is in use, this is called just after each header file to be mocked is preprocessed. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the header file to be mocked. -## `:pre_mock_generate` +### `:pre_mock_generate` If mocks are enabled, this is called just before each header file to be mocked is processed by mock generation. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the header file to be mocked. -## `:post_mock_generate` +### `:post_mock_generate` If mocks are enabled, this is called just after each mock generation. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the header file to be mocked. -## `:pre_test_preprocess` +### `:pre_test_preprocess` If preprocessing is in use, this is called just before each test file is preprocessed before runner generation. @@ -184,7 +184,7 @@ The parameter available to a tool (`${1}`) when the hook is called is the test's See _[CeedlingPacket][ceedling-packet]_ for details on how Ceedling preprocessing operates. -## `:post_test_preprocess` +### `:post_test_preprocess` If preprocessing is in use, this is called just after each test file is preprocessed. @@ -192,50 +192,52 @@ The parameter available to a tool (`${1}`) when the hook is called is the test's See _[CeedlingPacket][ceedling-packet]_ for details on how Ceedling preprocessing operates. -## `:pre_runner_generate` +### `:pre_runner_generate` Called just before each test file is processed by test runner generation. The parameter available to a tool (`${1}`) when the hook is called is the test's filepath. -## `:post_runner_generate` +### `:post_runner_generate` Called just after each test runner is generated. The parameter available to a tool (`${1}`) when the hook is called is the test's filepath. -## `:pre_compile_execute` +### `:pre_compile_execute` Called just before each C or assembly file is compiled. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the file to be compiled. -## `:post_compile_execute` +### `:post_compile_execute` Called just after each file compilation. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the input file that was compiled. -## `:pre_link_execute` +### `:pre_link_execute` Called just before any binary artifact—test or release—is linked. The parameter available to a tool (`${1}`) when the hook is called is the binary output artifact's filepath. -## `:post_link_execute` +### `:post_link_execute` Called just after a binary artifact is linked. The parameter available to a tool (`${1}`) when the hook is called is the binary output artifact's filepath. -## `:pre_test_fixture_execute` +### `:pre_test_fixture_execute` Called just before each test is executed in its corresponding test fixture. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the binary artifact to be executed by the fixture. -## `:post_test_fixture_execute` +### `:post_test_fixture_execute` Called just after each test's fixture is executed and test results are collected. The parameter available to a tool (`${1}`) when the hook is called is the filepath of the binary artifact that was executed by the fixture. + +

diff --git a/plugins/compile_commands_json_db/README.md b/docs/mkdocs/plugins/compile-commands-json-db.md similarity index 96% rename from plugins/compile_commands_json_db/README.md rename to docs/mkdocs/plugins/compile-commands-json-db.md index 871b24bff..c4e78440c 100644 --- a/plugins/compile_commands_json_db/README.md +++ b/docs/mkdocs/plugins/compile-commands-json-db.md @@ -1,8 +1,8 @@ -# Ceedling Plugin: JSON Compilation Database +# JSON Compilation Database Language Server Protocol (LSP) support for Clang tooling. -# Background +## Background Syntax highlighting and code completion are hard. Historically each editor or IDE has implemented their own and then competed amongst themselves to offer the best experience for developers. Good syntax highlighting can be so valuable as to outweigh the consideration of alternate editors. If implementing sytnax highlighting and related features in a tool is hard for one language — and it is — imagine doing it for dozens of them. Further, on the flip side, imagine the complexities involved for a developer working with multiple languages at once. @@ -12,7 +12,7 @@ In June of 2016, Microsoft with Red Hat and Codenvy got together to create the [ [lsp-community]: https://langserver.org/ [lsp-tools]: https://microsoft.github.io/language-server-protocol/implementors/tools/ -# Plugin Overview +## Plugin Overview For C and C++ projects, perhaps the most popular LSP server is the [`clangd`][clangd] backend. In order to provide features like _go to definition_, `clangd` needs to understand how to build a project so that it can discover all the pieces to the puzzle. Because of the various flavors of builds Ceedling supports and especially because of the complexities of test suite builds, components of a build can easily go missing from the view of `clangd`. @@ -23,7 +23,7 @@ Once enabled, this plugin generates the database as `/artifacts/comp [clangd]: https://clangd.llvm.org [json-compilation-database]: https://clang.llvm.org/docs/JSONCompilationDatabase.html -# Setup +## Setup Enable the plugin in your Ceedling project file by adding `compile_commands_json_db` to the list of enabled plugins. @@ -33,8 +33,10 @@ Enable the plugin in your Ceedling project file by adding `compile_commands_json - compile_commands_json_db ``` -# Configuration +## Configuration There is no additional configuration necessary to run this plugin. `clangd` will search your build directory for the JSON compilation database, but in some instances on Unix-asbed platforms it can be easier and necessary to symlink the file into the root directory of your project (e.g. `ln -s ./build/artifacts/compile_commands.json .`). + +

diff --git a/plugins/dependencies/README.md b/docs/mkdocs/plugins/dependencies.md similarity index 88% rename from plugins/dependencies/README.md rename to docs/mkdocs/plugins/dependencies.md index c5c930015..435a2d47c 100644 --- a/plugins/dependencies/README.md +++ b/docs/mkdocs/plugins/dependencies.md @@ -1,18 +1,17 @@ -ceedling-dependencies -===================== +# Dependencies -Plugin for supporting release dependencies. It's rare for an embedded project to +Plugin for supporting release dependencies. It’s rare for an embedded project to be built completely free of other libraries and modules. Some of these may be standard internal libraries. Some of these may be 3rd party libraries. In either -case, they become part of the project's ecosystem. +case, they become part of the project’s ecosystem. This plugin is intended to make that relationship easier. It allows you to specify a source for dependencies. If required, it will automatically grab the appropriate version of that dependency. -Most 3rd party libraries have a method of building already in place. While we'd +Most 3rd party libraries have a method of building already in place. While we’d love to convert the world to a place where everything downloads with a test suite -in Ceedling, that's not likely to happen anytime soon. Until then, this plugin +in Ceedling, that’s not likely to happen anytime soon. Until then, this plugin will allow the developer to specify what calls Ceedling should make to oversee the build process of those third party utilities. Are they using Make? CMake? A custom series of scripts that only a mad scientist could possibly understand? No @@ -22,7 +21,7 @@ will make it happen whenever it notices that the output artifacts are missing. Output artifacts? Sure! Things like static and dynamic libraries, or folders containing header files that might want to be included by your release project. -So how does all this magic work? +## So how does all this magic work? First, you need to add the Dependencies plugin to your list of enabled plugins. Then, we'll add a new comfiguration section called `:dependencies`. There, you can list as many @@ -63,13 +62,10 @@ it! In the end, it'll look something like this: - include/** ``` -Let's take a deeper look at each of these features. - -The Starting Dash & Name ------------------------- +## Starting Dash & Name Yes, that opening dash tells the dependencies plugin that the rest of these fields -belong to our first dependency. If we had a second dependency, we'd have another +belong to our first dependency. If we had a second dependency, we’d have another dash, lined up with the first, and followed by all the fields indented again. By convention, we use the `:name` field as the first field for each tool. Ceedling @@ -79,16 +75,15 @@ it easier for us to see the name of each dependency with starting dash. The name field is only used to print progress while we're running Ceedling. You may call the name of the field whatever you wish. -Working Paths -------------- +## Working Paths All paths are collected under `:dependencies` ↳ `:paths`. The `:source` field allows us -to specify where the source code for each of our dependencies is stored. By default, it's +to specify where the source code for each of our dependencies is stored. By default, it’s the same as the `:fetch` path, which is where source will be fetched TO when fetching the dependency from elsewhere. All commands to build this dependency will be executed from the `:source` location. Temporary data will be placed in the `:build` location. Unless you're -using one of Ceedling's built-in builders, you'll need to learn where the tool you're using to -build places it's built artifacts , and list that here. Finally, the output +using one of Ceedling’s built-in builders, you'll need to learn where the tool you're using to +build places it’s built artifacts , and list that here. Finally, the output artifacts will be referenced to this location. You override this by specifying a `:artifact` path. In summary: @@ -106,8 +101,7 @@ source for your dependency in you repo. All artifacts are relative to the appropriate `:artifact` path. So if there are multiple include dirs, choose the highest level and make the rest relative from there. -Fetching Dependencies ---------------------- +## Fetching Dependencies The `:dependencies` plugin supports the ability to automatically fetch your dependencies for you... using some common methods of fetching source. This section contains only a @@ -127,12 +121,10 @@ couple of fields: - `:executable` -- This is a YAML list of commands to execute when using the `:custom` method Some notes: - -The `:source` location for fetching a `:zip` or `:gzip` file is relative to the `:paths` ↳ `:source` +* The `:source` location for fetching a `:zip` or `:gzip` file is relative to the `:paths` ↳ `:source` folder. -Environment Variables ---------------------- +## Environment Variables Many build systems support customization through environment variables. By specifying an array of environment variables, the Dependencies plugin will customize the shell environment @@ -144,7 +136,7 @@ within a specific dependency’s configuration is only for the shell environment that dependency. The format and abilities of the two `:environment` configuration sections are also different. -Environment variables may be specified in three ways. Let's look at one of each: +Environment variables may be specified in three ways. Let’s look at one of each: ```yaml :dependencies: @@ -156,7 +148,7 @@ Environment variables may be specified in three ways. Let's look at one of each: ``` In the first example, you see the most straightforward method. The environment variable -`ARCHITECTURE` is set to the value `ARM9`. That's it. Simple. +`ARCHITECTURE` is set to the value `ARM9`. That’s it. Simple. The next two options modify an existing symbol. In the first one, we use `+=`, which tells Ceedling to add the define `ADD_AWESOMENESS` to the environment variable `CFLAGS`. The second @@ -170,11 +162,10 @@ If we had been modifying `PATH` instead, we might have had to use a `:` on a unu Windows. Second, removing an argument will have no effect on the argument if that argument isn't found -precisely. It's case sensitive and the entire string must match. If symbol doesn't already exist, +precisely. It’s case sensitive and the entire string must match. If symbol doesn't already exist, it WILL after executing this command... however it will be assigned to nothing. -Building Dependencies ---------------------- +## Building Dependencies The heart of the `:dependencies` plugin is the ability for you, the developer, to specify the build process for each of your dependencies. You will need to have any required tools installed @@ -184,12 +175,11 @@ The steps are specified as an array of strings. Ceedling will execute those step specified, moving from step to step unless an error is encountered. By the end of the process, the artifacts should have been created by your process... otherwise an error will be produced. -Artifacts ---------- +## Artifacts These are the outputs of the build process. There are there types of artifacts. Any dependency may have none or some of these. Calling out these files tells Ceedling that they are important. -Your dependency's build process may produce many other files... but these are the files that +Your dependency’s build process may produce many other files... but these are the files that Ceedling understands it needs to act on. ### `static_libraries` @@ -213,7 +203,7 @@ to produce them. ### `includes` Often when libraries are built, the same process will output a collection of includes so that -your release code knows how to interact with that library. It's the public API for that library. +your release code knows how to interact with that library. It’s the public API for that library. By specifying the directories that will contain these includes (don't specify the files themselves, Ceedling only needs the directories), Ceedling is able to automatically add these to its internal include list. This allows these files to be used while building your release code, as well we making @@ -221,12 +211,11 @@ them mockable during unit testing. ### `source` -It's possible that your external dependency will just produce additional C files as its output. +It’s possible that your external dependency will just produce additional C files as its output. In this case, Ceedling is able to automatically add these to its internal source list. This allows these files to be used while building your release code. -Tasks -===== +## Tasks Once configured correctly, the `:dependencies` plugin should integrate seamlessly into your workflow and you shouldn't have to think about it. In the real world, that doesn't always happen. @@ -259,7 +248,7 @@ build directory... just in case you clobbered them. ### `paths:include` Maybe you want to verify that all the include paths are correct. If you query Ceedling with this -request, it will list all the header file paths that it's found, including those produced by +request, it will list all the header file paths that it’s found, including those produced by dependencies. ### `files:include` @@ -267,8 +256,7 @@ dependencies. Maybe you want to take that query further and actually get a list of ALL the header files Ceedling has found, including those belonging to your dependencies. -Custom Tools -============ +## Custom Tools You can optionally specify a compiler, assembler, and linker, just as you would a release build: @@ -318,4 +306,4 @@ source and/or assembly files into the specified library: - THESE_GET_USED_DURING_COMPILATION ``` -Happy Testing! +

diff --git a/plugins/fff/README.md b/docs/mkdocs/plugins/fff.md similarity index 74% rename from plugins/fff/README.md rename to docs/mkdocs/plugins/fff.md index ddc818037..6ac4898ea 100644 --- a/plugins/fff/README.md +++ b/docs/mkdocs/plugins/fff.md @@ -1,35 +1,29 @@ -# A Fake Function Framework Plugin for Ceedling +# Fake Function Framework for Ceedling -This plugin causes Ceedling to use the [Fake Function Framework](https://github.com/meekrosoft/fff) for mocking instead of CMock, the default mocking framework packages with Ceedling. +This plugin causes Ceedling to use the [Fake Function Framework](https://github.com/meekrosoft/fff) for mocking instead of CMock, the default mocking framework packaged with Ceedling. Using _FFF_ provides less strict mocking than CMock and affords more loosely-coupled tests. This Ceedling 1.x plugin incorporates a snapshot of _FFF_ version 0.1.1 and supersedes a separately available [FFF Ceedling plugin project](https://github.com/ElectronVector/fake_function_framework). The built-in _FFF_ plugin that now comes with Ceedling was derived from the ElectronVector project and is now maintained along with Ceedling and tracks its updates. -### Thanks +!!! note "Special thanks to Matt Chernosky" + [Matt Chernosky](http://www.electronvector.com) originally developed this plugin + as an adapter for _FFF_. It's a well-loved piece of the Ceedling ecosystem, + and we really appreciate his support through the years. -A special thanks to [Matt Chernosky](http://www.electronvector.com) for developing this plugin originally. It's a well-loved piece of the Ceedling -ecosystem and we really appreciate his support through the years. +## Enable the plugin -### Enable the plug-in. - -The plug-in is enabled from within your project.yml file. +The plugin is enabled from within your project.yml file. In the `:plugins` configuration, add `fff` to the list of enabled plugins: ```yaml :plugins: - :load_paths: - - vendor/ceedling/plugins :enabled: - - report_tests_pretty_stdout - - module_generator - fff ``` -*Note that you could put the plugin source in some other loaction. -In that case you'd need to add a new path the `:load_paths`.* -## How to use it +## How to use You use fff with Ceedling the same way you used to use CMock. @@ -77,7 +71,7 @@ test_whenThePowerReadingIsLessThan5_thenTheStatusLedIsNotTurnedOn(void) } ``` -## Test that a single function was called with the correct argument +### Test that a single function was called with the correct argument ```c void @@ -92,7 +86,7 @@ test_whenTheVolumeKnobIsMaxed_thenVolumeDisplayIsSetTo11(void) } ``` -## Test that calls are made in a particular sequence +### Test that calls are made in a particular sequence ```c void @@ -110,7 +104,7 @@ test_whenTheModeSelectButtonIsPressed_thenTheDisplayModeIsCycled(void) } ``` -## Fake a return value from a function +### Fake a return value from a function ```c void @@ -127,7 +121,7 @@ test_givenTheDisplayHasAnError_whenTheDeviceIsPoweredOn_thenTheDisplayIsPoweredD } ``` -## Fake a function with a value returned by reference +### Fake a function with a value returned by reference ```c void @@ -152,9 +146,9 @@ test_givenTheUserHasTypedSleep_whenItIsTimeToCheckTheKeyboard_theDisplayIsPowere } ``` -## Fake a function with a function pointer parameter +### Fake a function with a function pointer parameter -``` +```c void test_givenNewDataIsAvailable_whenTheDisplayHasUpdated_thenTheEventIsComplete(void) { @@ -209,34 +203,45 @@ test_whenTheDeviceIsReset_thenTheStatusLedIsTurnedOff() ## Test setup -All of the fake functions, and any fff global state are all reset automatically between each test. +All of the fake functions and any fff global state are all reset automatically between each test. ## CMock configuration -Use still use some of the CMock configuration options for setting things like the mock prefix, and for including additional header files in the mock files. +We still use some CMock configuration options for setting things like the mock prefix and for including additional header files in the mock files. ```yaml :cmock: :mock_prefix: mock_ :includes: - - + - ... :includes_h_pre_orig_header: - - + - ... :includes_h_post_orig_header: - - + - ... :includes_c_pre_header: - - + - ... :includes_c_post_header: ``` -## Running the tests +## FFF examples + +See the [FFF example project][fff-example-project]. This project illustrates how to use the plugin with full-size examples. + +!!! warning "Versioning" + The example project link is to the latest in the repository. + It is not explicitly versioned to correspond to this documentation. + That said, FFF and the plugin are relatively stable. + +### Running example tests + +Unit and integration tests exist for the plugin itself. + +These tests are run with the default `rake` task packaged in the [FFF plugin][fff-plugin]. -There are unit and integration tests for the plug-in itself. -These are run with the default `rake` task. -The integration test runs the tests for the example project in examples/fff_example. -For the integration tests to succeed, this repository must be placed in a Ceedling tree in the plugins folder. +The integration test runs the FFF-based unit tests within the [example project][fff-example-project]. +That is, the FFF examples are executed as part of Ceedling continuous integration. -## More examples +[fff-example-project]: https://github.com/ThrowTheSwitch/Ceedling/tree/master/plugins/fff/examples/fff_example +[fff-plugin]: https://github.com/ThrowTheSwitch/Ceedling/tree/master/plugins/fff -There is an example project in examples/fff_example. -It shows how to use the plug-in with some full-size examples. +

diff --git a/docs/mkdocs/plugins/gcov/examples.md b/docs/mkdocs/plugins/gcov/examples.md new file mode 100644 index 000000000..1dddc0931 --- /dev/null +++ b/docs/mkdocs/plugins/gcov/examples.md @@ -0,0 +1,92 @@ +# Example Usage + +!!! note + Unless disabled, basic coverage summaries are always printed to the + console regardless of report generation options. + +## Automatic report generation (default) + +If coverage report generation is configured, the plugin defaults to running +reports after any `gcov:` task. + +```yaml +:plugins: + :enabled: + - gcov + +:gcov: + :utilities: + - gcovr # Enabled by default -- shown for completeness + :report_task: FALSE # Disabled by default -- shown for completeness + :reports: # See later section for report configuration + - HtmlBasic + + ... # Further configuration for reporting (not shown) + +``` + +```shell + > ceedling gcov:all +``` + +## Report generation manual task + +If the `:report_task:` configuration option is enabled, reports are not +automatically generaed after test suite coverage builds. Instead, report +generation is triggered by the `report:gcov` task. + +```yaml +:plugins: + :enabled: + - gcov + +:gcov: + :utilities: + - gcovr # Enabled by default -- shown for completeness + :report_task: TRUE + :reports: # See later section for report configuration + - HtmlBasic # Enabled by default -- shown for completeness + + ... # Further configuration for reporting (not shown) + +``` + +With the separate reporting task enabled, it can be used like any other Ceedling task. + +```shell + > ceedling gcov:all report:gcov +``` + +or + +```shell + > ceedling gcov:all + + > ceedling report:gcov +``` + +## Full report generation + +```yaml +:plugins: + :enabled: + - gcov + +:gcov: + :summaries: FALSE # Simple coverage summaries to console disabled + :reports: # `gcovr` tool enabled by default + - HtmlDetailed + - Text + - Cobertura + :gcovr: # `gcovr` common and report-specific options + :report_root: "../../" # Atypical layout -- project.yml is inside a subdirectoy below + :sort_percentage: TRUE + :sort_uncovered: FALSE + :html_medium_threshold: 60 + :html_high_threshold: 85 + :print_summary: TRUE + :threads: 4 + :keep: FALSE +``` + +

diff --git a/docs/mkdocs/plugins/gcov/gcovr.md b/docs/mkdocs/plugins/gcov/gcovr.md new file mode 100644 index 000000000..d2507aee9 --- /dev/null +++ b/docs/mkdocs/plugins/gcov/gcovr.md @@ -0,0 +1,220 @@ +# GCovr Configuration + +All reports generated by `gcovr` are found in `/artifacts/gcov/gcovr/`. + +## HTML reports + +Generation of HTML reports may be modified with the following configuration items. + +```yaml +:gcov: + :gcovr: + # HTML report filename. + :html_artifact_filename: + + # Use 'title' as title for the HTML report. + # Default is 'Head'. (gcovr --html-title) + :html_title: + + # If the coverage is below MEDIUM, the value is marked as low coverage in the HTML report. + # MEDIUM has to be lower than or equal to value of html_high_threshold. + # If MEDIUM is equal to value of html_high_threshold the report has only high and low coverage. + # Default is 75.0. (gcovr --html-medium-threshold) + :html_medium_threshold: 75 + + # If the coverage is below HIGH, the value is marked as medium coverage in the HTML report. + # HIGH has to be greater than or equal to value of html_medium_threshold. + # If HIGH is equal to value of html_medium_threshold the report has only high and low coverage. + # Default is 90.0. (gcovr -html-high-threshold) + :html_high_threshold: 90 + + # Set to 'true' to use absolute paths to link the 'detailed' reports. + # Defaults to relative links. (gcovr --html-absolute-paths) + :html_absolute_paths: <true|false> + + # Override the declared HTML report encoding. Defaults to UTF-8. (gcovr --html-encoding) + :html_encoding: <html_encoding> +``` + +## Cobertura XML reports + +Generation of Cobertura XML reports may be modified with the following configuration items. + +```yaml +:gcov: + :gcovr: + # Set to 'true' to pretty-print the Cobertura XML report, otherwise set to 'false'. + # Defaults to disabled. (gcovr --xml-pretty) + :cobertura_pretty: <true|false> + + # Override default Cobertura XML report filename. + :cobertura_artifact_filename: <filename> +``` + +## SonarQube XML reports + +Generation of SonarQube XML reports may be modified with the following configuration items. + +```yaml +:gcov: + :gcovr: + # Override default SonarQube XML report filename. + :sonarqube_artifact_filename: <filename> +``` + +## JSON reports + +Generation of JSON reports may be modified with the following configuration items. + +```yaml +:gcov: + :gcovr: + # Set to 'true' to pretty-print the JSON report, otherwise set 'false'. + # Defaults to disabled. (gcovr --json-pretty) + :json_pretty: <true|false> + + # Override default JSON report filename. + :json_artifact_filename: <filename> +``` + +## Text reports + +Generation of text reports may be modified with the following configuration items. +Text reports may be printed to the console or output to a file. + +```yaml +:gcov: + :gcovr: + # Override default text report filename. + :text_artifact_filename: <filename> +``` + +## Common GCovr options + +A number of options exist to control which files are considered part of a +coverage report. This Ceedling gcov plugin itself handles the most important +aspect — only source files under test are compiled with coverage. Tests, mocks, +and test runners, are not compiled with coverage. + +!!! note "`gcovr` accepts only one report root path" + `gcovr` will only accept a single path for `:report_root`. In typical usage, + this is of no concern as it is handled automatically. In unusual project + layouts, you may need to specify a folder that encompasses _all_ build folders + containing coverage result files and optionally, selectively exclude patterns + of paths or files. For instance, if your Ceedling project file is not at the + root of your project, you may need set `:report_root` as well as + `:report_exclude` and `:exclude_directories`. + +```yaml +:gcov: + :gcovr: + # The root directory of your source files. Defaults to ".", the current directory. + # File names are reported relative to this root. The report_root is the default report_include. + # Default if unspecified: "." + :report_root: <path> + + # Load the specified configuration file. + # Defaults to gcovr.cfg in the report_root directory. (gcovr --config) + :config_file: <config_file> + + # Exit with a status of 2 if the total line coverage is less than MIN percentage. + # Can be ORed with exit status of other fail options. (gcovr --fail-under-line) + :fail_under_line: <1-100> + + # Exit with a status of 4 if the total branch coverage is less than MIN percentage. + # Can be ORed with exit status of other fail options. (gcovr --fail-under-branch) + :fail_under_branch: <1-100> + + # Exit with a status of 8 if the total decision coverage is less than MIN percentage. + # Can be ORed with exit status of other fail options. (gcovr --fail-under-decision) + :fail_under_decision: <1-100> + + # Exit with a status of 16 if the total function coverage is less than MIN percentage. + # Can be ORed with exit status of other fail options. (gcovr --fail-under-function) + :fail_under_function: <1-100> + + # If the fail options above are set, specify whether those conditions should break a build. + # The default option is false and simply logs a warning without breaking the build. + :exception_on_fail: <true|false> + + # Select the source file encoding. + # Defaults to the system default encoding (UTF-8). (gcovr --source-encoding) + :source_encoding: <encoding> + + # Report the branch coverage instead of the line coverage. For text report only. (gcovr --branches). + :branches: <true|false> + + # Sort entries by increasing number of uncovered lines. + # For text and HTML report. (gcovr --sort-uncovered) + :sort_uncovered: <true|false> + + # Sort entries by increasing percentage of uncovered lines. + # For text and HTML report. (gcovr --sort-percentage) + :sort_percentage: <true|false> + + # Print a small report to stdout with line & branch percentage coverage. + # This is in addition to other reports. (gcovr --print-summary). + :print_summary: <true|false> + + # Keep only source files that match this filter. (gcovr --filter). + # Filters are regular expressions (ex: "^src") + :report_include: <filter> + + # Exclude source files that match this filter. (gcovr --exclude). + # Filters are regular expressions (ex: "^vendor.*|^build.*|^test.*|^lib.*") + :report_exclude: <filter> + + # Keep only gcov data files that match this filter. (gcovr --gcov-filter). + # Filters are regular expressions + :gcov_filter: <filter> + + # Exclude gcov data files that match this filter. (gcovr --gcov-exclude). + # Filters are regular expressions + :gcov_exclude: <filter> + + # Exclude directories that match this filter while searching + # raw coverage files. (gcovr --exclude-directories). + # Filters are regular expressions + :exclude_directories: <filters> + + # Use a particular gcov executable. (gcovr --gcov-executable). + # (This may be appropriate and necessary in special circumstances. + # Please review Ceedling's options for modifying tools first.) + :gcov_executable: <cmd> + + # Exclude branch coverage from lines without useful + # source code. (gcovr --exclude-unreachable-branches). + :exclude_unreachable_branches: <true|false> + + # For branch coverage, exclude branches that the compiler + # generates for exception handling. (gcovr --exclude-throw-branches). + :exclude_throw_branches: <true|false> + + # For Gcovr 6.0+, multiple instances of the same function in coverage results can + # cause a fatal error. Since Ceedling can test multiple build variations of the + # same source function, this is bad. + # Default value for Gcov plugin is 'merge-use-line-max'. See Gcovr docs for more. + # https://gcovr.com/en/stable/guide/merging.html + :merge_mode_function: <...> + + # Use existing gcov files for analysis. Default: False. (gcovr --use-gcov-files) + :use_gcov_files: <true|false> + + # Skip lines with parse errors in GCOV files instead of + # exiting with an error. (gcovr --gcov-ignore-parse-errors). + :gcov_ignore_parse_errors: <true|false> + + # Override normal working directory detection. (gcovr --object-directory) + :object_directory: <path> + + # Keep gcov files after processing. (gcovr --keep). + :keep: <true|false> + + # Delete gcda files after processing. (gcovr --delete). + :delete: <true|false> + + # Set the number of threads to use in parallel. (gcovr -j). + :threads: <count> +``` + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/index.md b/docs/mkdocs/plugins/gcov/index.md new file mode 100644 index 000000000..cf952090b --- /dev/null +++ b/docs/mkdocs/plugins/gcov/index.md @@ -0,0 +1,68 @@ +# GCov + +The `gcov` plugin integrates the code coverage abilities of the GNU compiler +collection with test builds. It provides simple coverage metrics by default and +can optionally produce sophisticated coverage reports. + +<div class="grid cards" markdown> + +- :material-information-outline: **[Overview][overview]** + + --- + + How the plugin works, task types, summaries vs. reports. + +- :material-tag-outline: **[Tool Versions][tool-versions]** + + --- + + Compatibility notes for `gcov`, `gcovr`, and `ReportGenerator`. + +- :material-cog-outline: **[Set Up & Configuration][setup]** + + --- + + Toolchain requirements, enabling the plugin, and report generation setup. + +- :material-lightning-bolt: **[Example Usage][examples]** + + --- + + Examples for automatic and manual report generation. + +- :material-file-chart-outline: **[Reporting Configuration][reporting]** + + --- + + Available report types and the `:reports` YAML option. + +- :material-wrench-outline: **[GCovr Configuration][gcovr]** + + --- + + All `:gcovr` YAML options for HTML, XML, JSON, text, and common settings. + +- :material-file-cog-outline: **[ReportGenerator Configuration][reportgenerator]** + + --- + + All `:report_generator` YAML options. + +- :material-lifebuoy: **[Advanced & Troubleshooting][troubleshooting]** + + --- + + Advanced configuration and troubleshooting solutions. + +</div> + +[overview]: overview.md +[tool-versions]: tool-versions.md +[setup]: setup.md +[examples]: examples.md +[reporting]: reporting.md +[gcovr]: gcovr.md +[reportgenerator]: reportgenerator.md +[troubleshooting]: troubleshooting.md + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/overview.md b/docs/mkdocs/plugins/gcov/overview.md new file mode 100644 index 000000000..ff45cd8c7 --- /dev/null +++ b/docs/mkdocs/plugins/gcov/overview.md @@ -0,0 +1,145 @@ +# Plugin Overview + +!!! note + When enabled, this plugin creates a new set of `gcov:` tasks that mirror + Ceedling’s existing `test:` tasks. A `gcov:` task executes one or more tests + with coverage enabled for the source files exercised by those tests. + + `gcov:` tasks entirely duplicate `test:` tasks and test builds because of + the needs of coverage instrumentation at compile time. + +This plugin also provides an extensive set of options for generating various +coverage reports for your project. The simplest is text-based coverage +summaries printed to the console after a `gcov:` test task is executed. + +## Simple Coverage Summaries + +In its simplest usage, this plugin outputs coverage statistics to the console +for each source file exercised by a test. These console-based coverage +summaries are provided after the standard Ceedling test results summary. Other +than enabling the plugin and ensuring `gcov` is installed, no further set up +is necessary to produce these summaries. + +!!! tip + [Automatic summaries may be disabled](setup.md#disabling-automatic-coverage-summaries). + +When the Gcov plugin is active it enables Ceedling tasks like this: + +```shell + > ceedling gcov:Model +``` + +… that then generate output like this: + +``` +-------------------------- +GCOV: OVERALL TEST SUMMARY +-------------------------- +TESTED: 1 +PASSED: 1 +FAILED: 0 +IGNORED: 0 + +--------------------------- +GCOV: CODE COVERAGE SUMMARY +--------------------------- + +TestModel +--------- +Model.c | Lines executed:100.00% of 4 +Model.c | No branches +Model.c | No calls +TimerModel.c | Lines executed:0.00% of 3 +TimerModel.c | No branches +TimerModel.c | No calls +``` + +## Advanced Coverage Reports + +For more advanced visualizations and reporting, this plugin also supports a +variety of report generation options. + +Advanced report generation uses [gcovr] and / or [ReportGenerator] to generate +HTML, XML, JSON, or text-based reports from coverage-instrumented test runs. +See the tools' respective sites for examples of the reports they can generate. + +In the default configuration, if reports are enabled, this plugin automatically +generates reports in the build’s `artifacts/` directory after each execution of +a `gcov:` task. + +An optional setting documented below disables automatic report generation, +providing a separate Ceedling task instead. Reports can then be generated +on demand after test suite runs. + +[gcovr]: https://www.gcovr.com/ +[ReportGenerator]: https://reportgenerator.io + +## Summaries vs. Reports + +Coverage summaries and coverage reports provide different levels of fidelity +and usability. Summaries are relatively unsophisticated while reports are +sophisticated. As such, both provide different capabilities and levels of +usability. + +### Coverage summaries + +Optional coverage summaries are intentionally simple. They require no +configuration and, to oversimplify, are largely filtered output from the `gcov` +tool. + +Coverage summaries are reported to the console for each source file exercised by +the tests executed by `gcov:` tasks. That is, coverage summaries correspond to +the tests executed, and in turn, the source code that your tests call. This +could be all tests (and thus all source code) or a subset of tests (and some +subset of source code). The `gcov` tool is run multiple times after test suite +execution in direct relation to the set of tests you ran with `gcov:` testing +tasks. In short, the scope of coverage summaries is guaranteed to match the +test suite you run. + +Coverage summaries do not include any sort of grand total, final tallies. This +is the domain of full coverage reports. + +Note that Ceedling can exercise the same source code under multiple scenarios +using multiple test files. Practically, this means that the same source file +may be listed in the coverage summaries more than once. That said, its coverage +statistics will be the same each time — the aggregate result of all tests that +exercised it. + +### Coverage reports + +Coverage reports provide both much more detail and better overviews of coverage +than the console-based coverage summaries. However, with this comes the need +for more sophisticated configuration and certain caveats on what is reported. + +Later sections detail how to configure the reports this plugin can generate. + +Of note is a consequence of how reports are generated and the limits of the +tools that do so. Reports are generated using coverage results on disk. The +report generation tools slurp up the coverage results they find in the `gcov/` +build output directory. This means that previous test suite runs can "pollute" +coverage reports. The solution is simple if blunt — run the `clobber` task +before running a coverage-instrumented test suite. This will yield a coverage +report with scope that matches that of the test suite you run. + +Both the `gcovr` and `reportgeneator` reporting utilities include powerful +filters that can limit the scope of reports. Hypothetically, it’s possible for +coverage reports to have the same clear scope as coverage summaries. However, +in large projects, these filters would cause impractically long command lines. +Both tools provide configuration file options that would solve the command line +problem. However, this feature is "experimental" for `gcovr` and considerable +work to implement for both reporting utilities. At present, running +`ceedling clobber` before generating reports is the best option to ensure +accurate reports. + +## References + +Much of the text describing report generations options in this document was +taken from the [Gcovr User Guide][gcovr-user-guide] and the +[ReportGenerator Wiki][report-generator-wiki]. + +The text is repeated here to provide as useful documenation as possible. + +[gcovr-user-guide]: https://www.gcovr.com/en/stable/guide.html +[report-generator-wiki]: https://github.com/danielpalme/ReportGenerator/wiki + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/reportgenerator.md b/docs/mkdocs/plugins/gcov/reportgenerator.md new file mode 100644 index 000000000..1584df6c7 --- /dev/null +++ b/docs/mkdocs/plugins/gcov/reportgenerator.md @@ -0,0 +1,58 @@ +# ReportGenerator Configuration + +The `ReportGenerator` utility may be configured with the following configuration items. + +All generated reports are found in `<build root>/artifacts/gcov/ReportGenerator/`. + +```yaml +:gcov: + :report_generator: + # Optional directory for storing persistent coverage information. + # Can be used in future reports to show coverage evolution. + :history_directory: <path> + + # Optional plugin files for custom reports or custom history storage (separated by semicolon). + :plugins: <plugin.dll>;<*.dll> + + # Optional list of assemblies that should be included or excluded in the report (separated by semicolon). + # Exclusion filters take precedence over inclusion filters. + # Wildcards are allowed, but not regular expressions. + :assembly_filters: +<included>;-<excluded> + + # Optional list of classes that should be included or excluded in the report (separated by semicolon). + # Exclusion filters take precedence over inclusion filters. + # Wildcards are allowed, but not regular expressions. + :class_filters: +<included>;-<excluded> + + # Optional list of files that should be included or excluded in the report (separated by semicolon). + # Exclusion filters take precedence over inclusion filters. + # Wildcards are allowed, but not regular expressions. + # Example: "-./vendor/*;-./build/*;-./test/*;-./lib/*;+./src/*" + :file_filters: +<included>;-<excluded> + + # The verbosity level of the log messages. + # Values: Verbose, Info, Warning, Error, Off (defaults to Warning) + :verbosity: <level> + + # Optional tag or build version. + :tag: <tag> + + # Optional list of one or more regular expressions to exclude gcov notes files that match these filters. + :gcov_exclude: + - <regex> + - ... + + # Optionally set the number of threads to use in parallel. Defaults to 1. + :threads: <count> + + # Optional list of one or more command line arguments to pass to Report Generator. + # Useful for configuring Risk Hotspots and Other Settings. + # https://github.com/danielpalme/ReportGenerator/wiki/Settings + # Note: This can be accomplished with Ceedling's tool configuration options outside of plugin + # configuration but is supported here to collect configuration options in one place. + :custom_args: + - <argument> + - ... +``` + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/reporting.md b/docs/mkdocs/plugins/gcov/reporting.md new file mode 100644 index 000000000..7fde8e9a3 --- /dev/null +++ b/docs/mkdocs/plugins/gcov/reporting.md @@ -0,0 +1,139 @@ +# Reporting Configuration + +Various reports are available. Each must be enabled in `:gcov` ↳ `:reports`. + +If no report types are specified, report generation (but not coverage summaries) +is disabled regardless of any other setting. + +Most report types can only be generated by `gcovr` or `ReportGenerator`. Some +can be generated by both. This means that your selection of report is impacted by +which generation utility is enabled. In fact, in some cases, the same report type +could be generated by each utility (to different artifact build output folders). + +Reports are configured with: + +1. General or common options for each report generation utility +1. Specific options for types of report per each report generation utility + +For tool-specific plugin configuration options, see: + +- [GCovr Configuration](gcovr.md) +- [ReportGenerator Configuration](reportgenerator.md) + +See the [GCovr User Guide][gcovr-user-guide] and the +[ReportGenerator Wiki][report-generator-wiki] for full details on using +these tools. + +[gcovr-user-guide]: https://www.gcovr.com/en/stable/guide.html +[report-generator-wiki]: https://github.com/danielpalme/ReportGenerator/wiki + +| Report option | gcovr | ReportGenerator | +|---|:---:|:---:| +| `HtmlBasic` | ✓ | ✓ | +| `HtmlDetailed` | ✓ | ✓ | +| `Text` | ✓ | ✓ | +| `Cobertura` | ✓ | ✓ | +| `SonarQube` | ✓ | ✓ | +| `JSON` | ✓ | | +| `HtmlInline` | | ✓ | +| `HtmlInlineAzure` | | ✓ | +| `HtmlInlineAzureDark` | | ✓ | +| `HtmlChart` | | ✓ | +| `MHtml` | | ✓ | +| `Badges` | | ✓ | +| `CsvSummary` | | ✓ | +| `Latex` | | ✓ | +| `LatexSummary` | | ✓ | +| `PngChart` | | ✓ | +| `TeamCitySummary` | | ✓ | +| `lcov` | | ✓ | +| `Xml` | | ✓ | +| `XmlSummary` | | ✓ | + +```yaml +:gcov: + # Specify one or more reports to generate. + # Defaults to HtmlBasic. + :reports: + # Generate an HTML summary report. + # Supported utilities: gcovr, ReportGenerator + - HtmlBasic + + # Generate an HTML report with line by line coverage of each source file. + # Supported utilities: gcovr, ReportGenerator + - HtmlDetailed + + # Generate a Text report, which may be output to the console with gcovr or a file in both gcovr and ReportGenerator. + # Supported utilities: gcovr, ReportGenerator + - Text + + # Generate a Cobertura XML report. + # Supported utilities: gcovr, ReportGenerator + - Cobertura + + # Generate a SonarQube XML report. + # Supported utilities: gcovr, ReportGenerator + - SonarQube + + # Generate a JSON report. + # Supported utilities: gcovr + - JSON + + # Generate a detailed HTML report with CSS and JavaScript included in every HTML page. Useful for build servers. + # Supported utilities: ReportGenerator + - HtmlInline + + # Generate a detailed HTML report with a light theme and CSS and JavaScript included in every HTML page for Azure DevOps. + # Supported utilities: ReportGenerator + - HtmlInlineAzure + + # Generate a detailed HTML report with a dark theme and CSS and JavaScript included in every HTML page for Azure DevOps. + # Supported utilities: ReportGenerator + - HtmlInlineAzureDark + + # Generate a single HTML file containing a chart with historic coverage information. + # Supported utilities: ReportGenerator + - HtmlChart + + # Generate a detailed HTML report in a single file. + # Supported utilities: ReportGenerator + - MHtml + + # Generate SVG and PNG files that show line and / or branch coverage information. + # Supported utilities: ReportGenerator + - Badges + + # Generate a single CSV file containing coverage information per file. + # Supported utilities: ReportGenerator + - CsvSummary + + # Generate a single TEX file containing a summary for all files and detailed reports for each files. + # Supported utilities: ReportGenerator + - Latex + + # Generate a single TEX file containing a summary for all files. + # Supported utilities: ReportGenerator + - LatexSummary + + # Generate a single PNG file containing a chart with historic coverage information. + # Supported utilities: ReportGenerator + - PngChart + + # Command line output interpreted by TeamCity. + # Supported utilities: ReportGenerator + - TeamCitySummary + + # Generate a text file in lcov format. + # Supported utilities: ReportGenerator + - lcov + + # Generate a XML file containing a summary for all classes and detailed reports for each class. + # Supported utilities: ReportGenerator + - Xml + + # Generate a single XML file containing a summary for all files. + # Supported utilities: ReportGenerator + - XmlSummary +``` + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/setup.md b/docs/mkdocs/plugins/gcov/setup.md new file mode 100644 index 000000000..a850e0b23 --- /dev/null +++ b/docs/mkdocs/plugins/gcov/setup.md @@ -0,0 +1,165 @@ +# Set up & Configuration + +## Toolchain dependencies + +### GNU Compiler Collection + +This plugin relies on the GNU compiler collection. Coverage instrumentation +is enabled through `gcc` compiler flags. Coverage-insrumented executables +(i.e. test suites) output coverage result files to disk when run. `gcov`, +`gcovr`, and `reportgenerator` (the tools managed by this plugin) all produce +their coverage tallies from these files. `gcov` is part of the GNU compiler +collection. The other tools — detailed below — require separate installation. + +Ceedling’s default toolchain is the same as needed by this plugin. If you +are already running Ceedling test suites with the GNU compiler toolchain, +you are good to go. If you are using another toolchain for test suite and/or +release builds you will need to install the GNU compiler collection to use +this plugin. Depending on your needs you may also need to install the reporting +utilities, `gcovr` and/or `reportgenerator`. + +### `gcovr` and `reportgenerator`’s dependence on `gcov` + +Both the `gcovr` and `reportgenerator` tools depend on the `gcov` tool. This +dependency plays out in two different ways. In both cases, the report +generation utilities ingest `gcov`’s output to produce their artifacts. As +such, `gcov` must be available in your environment if using report generation. + +1. `gcovr` calls `gcov` directly. + + Because it calls `gcov` directly, you are limited as to the + advanced Ceedling features you can employ to modify `gcov`’s execution. + However, with a configuration option (see below) you can instruct `gcovr` + to call something other than `gcov` (e.g. a script that intercepts and + modifies how `gcovr` calls out to `gcov`). + + `gcovr` instructs `gcov` to generate `.gcov` files that it processes and + discards. A `gcovr` option documented below will retain the `.gcov` files. + +2. `reportgenerator` expects the existence of `.gcov` files to do its work. + This Ceedling plugin calls `gcov` appropriately to generate the `.gcov` + files `reportgenerator` needs before then calling the report utility. + + You can use Ceedling’s features to modify how `gcov` is run before + `reportgenerator`. + +## Enable this plugin + +To use this plugin it must be enabled in your Ceedling project file: + +```yaml +:plugins: + :enabled: + - gcov +``` + +This simple configuration will create new `gcov:` command line tasks to run +tests with source coverage and output simple coverage summaries to the console +as above. + +## Disabling automatic coverage summaries + +To disable the coverage summaries generated immediately following `gcov:` tasks, +simply add the following to a top-level `:gcov:` section in your project +configuration file. + +```yaml +:plugins: + :enabled: + - gcov + +:gcov: + :summaries: FALSE +``` + +## Report generation + +To generate reports: + +1. GCovr and / or ReportGenerator must installed or otherwise ready to run in + Ceedling’s environment. +1. Reporting options must be configured in your project file beneath a `:gcov:` + entry. + +The next sections explain each of these steps. + +## Modified Condition / Decision Coverage + +As of version 14, the GNU Compiler Collection supports MC/DC. If your environment +contains a minimum of GCC 14 you can enable MC/DC in coverage summaries. + +If your environment contains a minimum of GCC 14 and GCovr 8, you can enable MC/DC +in your generated coverage reports. + +```yaml +:plugins: + :enabled: + - gcov + +:gcov: + :mcdc: TRUE +``` + +### Reporting utilities installation + +!!! tip "Variants of the `madsciencelab` Docker images come with these tools preinstalled" + See [the Docker image options](../../getting-started/installation.md#madsciencelab-docker-images) + for running Ceedling. + +[gcovr] is available on any platform supported by Python. + +`gcovr` can be installed via pip like this: + +```shell + > pip install gcovr +``` + +[ReportGenerator] is available on any platform supported by .Net. + +`ReportGenerator` can be installed via .NET Core like so: + +```shell + > dotnet tool install -g dotnet-reportgenerator-globaltool +``` + +Either or both of `gcovr` or `ReportGenerator` may be used. Only one must +be installed for advanced report generation. + +[gcovr]: https://www.gcovr.com/ +[ReportGenerator]: https://reportgenerator.io + +### Enabling reporting utilities + +If reports are configured (see next sections) but no `:utilities:` subsection +exists, this plugin defaults to using `gcovr` for report generation. + +Otherwise, enable Gcovr and / or ReportGenerator to create coverage reports. + +```yaml +:gcov: + :utilities: + - gcovr # Use `gcovr` to create reports (default if no :utilities set). + - ReportGenerator # Use `ReportGenerator` to create reports. +``` + +### Automatic and manual report generation + +By default, if reports are specified, this plugin automatically generates +reports after any `gcov:` task is executed. To disable this behavior, add +`:report_task: TRUE` to your project file’s `:gcov:` configuration. + +With this setting enabled, an additional Ceedling task `report:gcov` is enabled. +It may be executed after `gcov:` tasks to generate the configured reports. + +For small projects, the default behavior is likely preferred. This alernative +setting allows large or complex projects to execute potentially time intensive +report generation only when desired. + +Enabling the manual report generation task looks like this: + +```yaml +:gcov: + :report_task: TRUE +``` + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/tool-versions.md b/docs/mkdocs/plugins/gcov/tool-versions.md new file mode 100644 index 000000000..9ad10680b --- /dev/null +++ b/docs/mkdocs/plugins/gcov/tool-versions.md @@ -0,0 +1,60 @@ +# Supported tool versions + +_**Last updated:**_ May 17, 2026 + +At the time of the last major updates to the Gcov plugin, the following notes +on version compatibility were known to be accurate. + +Keep in mind that for proper functioning, you do not necessarily need to +install all the tooks the Gcov plugin works with. Depending on configuration +options documented in later sections, any of the following tool combinations +may be sufficient for your needs: + +1. `gcov` +1. `gcov` + `gcovr` +1. `gcov` + `reportgenerator` +1. `gcov` + `gcovr` + `reportgenerator` + +## `gcov` + +The Gcov plugin is known to work with `gcov` packaged with GNU Compiler +Collection 12 through at least 15. + +The maintainers of `gcov` introduced significant behavioral changes for version +12. Previous versions of `gcov` had a simple exit code scheme with only a +single non-zero exit code upon fatal errors. Since version 12 `gcov` emits a +variety of exit codes even if the noted issue is a non-fatal error. The Gcov +plugin's logic assumes version 12 behavior and processes failure messages and +exit codes appropriately, taking into account plugin configuration options. + +The Gcov plugin should be compatible with versions of `gcov` before version 12. +That is, its improved `gcov` exit handling should not be broken by the prior +simpler behavior. The Gcov plugin dependes on the `gcov` command line and has +been compatible with it as far back as `gcov` version 7. + +Because long file paths are quite common in software development scenarios, by +default, the Gcov plugin depends on the `gcov` `-x` flag. This flag hashes long +file paths to ensure they are not a problem for certain platforms' file +systems. This flag became available with `gcov` version 7. We do not recommend +using `gcov` version 6 and earlier. And, in fact, because of the Gcov plugin's +dependence on the `gcov` `-x` flag, attempting to use it will fail. + +GNU Compiler Collection 14 introduced changes in how coverage is instrumented. +The `gcov` plugin implemented a revised means of processing coverage that is +forward compatible with GCC 14+ and backwards compatible to the earliest +versions of the collection. + +## `gcovr` + +The Gcov plugin is known to work with `gcovr` 5.2 through `gcovr` 8.x. The +Gcov plugin supports `gcovr` command line conventions since version 4.2 and +attempts to support `gcovr` command lines before version 4.2. We recommend +using `gcovr` 5 and later. + +## `reportgenerator` + +The Gcov plugin is known to work with `reportgenerator` 5.2.4. The command line +for executing `reportgenerator` that the Gcov plugin relies on has largely been +stable since version 4. We recommend using `reportgenerator` 5.0 and later. + +<br/><br/> diff --git a/docs/mkdocs/plugins/gcov/troubleshooting.md b/docs/mkdocs/plugins/gcov/troubleshooting.md new file mode 100644 index 000000000..cfcf90eab --- /dev/null +++ b/docs/mkdocs/plugins/gcov/troubleshooting.md @@ -0,0 +1,78 @@ +# Advanced & Troubleshooting + +## Advanced usage + +Details of interest for this plugin to be modified or made use of using +Ceedling’s advanced features are primarily contained in +[defaults_gcov.rb](../../snapshot/plugins/gcov/config/defaults_gcov.rb) and [defaults.yml](../../snapshot/plugins/gcov/config/defaults.yml). + +## "gcovr not found" + +`gcovr` is a Python-based application. Depending on the particulars of its +installation and your platform, you may encounter a "gcovr not found" error. +This is usually related to complications of running a Python script as an +executable. + +### Check your `PATH` + +The problem may be as simple to solve as ensuring your user or system path +include the path to `python` and/or the `gcovr` script. `gcovr` may be +successfully installed and findable by Python; this does not necessarily +mean that shell commands Ceedling spawns can find these tools. + +Options: + +1. Modify your user or system path to include your Python installation, `gcovr` + location, or both. +1. Use Ceedling’s `:environment` project configuration with its special + handling of `PATH` to modify the search path Ceedling accesses when it + executes shell commands. xample below. + +```yaml +:environment: + - :path: # Concatenates the following with OS-specific path separator + - <path to add> # Add Python and/or `gcovr` path + - "#{ENV['PATH']}" # Fetch existing path entries +``` + +### Redefine `gcovr` to call Python directly + +Another solution is simple in concept. Instead of calling `gcovr` directly, call +`python` with the `gcovr` script as a command line argument (followed by all of +the configured `gcovr` arguments). + +To implement the solution, we make use of two features: + +* `gcovr`’s tool `:executable` definition that looks up an environment variable. +* Ceedling’s `:environment` settings to redefine `gcovr`. + +Gcovr’s tool defintion, like many of Ceedling’s tool defintions, defaults to an +environment variable (`GCOVR`) if it is defined. If we set that environment +variable to call Python with the path to the `gcovr` script, Ceedling will call +that instead of only `gcovr`. Ceedling enables you to set environment variables +that only exist while it runs. + +In your project file: + +```yaml +:environment: + # Fill in / omit paths on your system as appropritate to your circumstances + - :gcovr: <path>/python <path>/gcovr +``` + +Alternatively, a slightly more elegant approach may work in some cases: + +```yaml +:environment: + - ":gcovr: python #{`which gcovr`}" # Shell out to look up the path to gcovr +``` + +A variation of this concept relies on Python’s knowledge of its runtime +environment and packages: + +```yaml +:environment: + - :gcovr: python -m gcovr # Call the gcovr module +``` + +<br/><br/> diff --git a/docs/mkdocs/plugins/index.md b/docs/mkdocs/plugins/index.md new file mode 100644 index 000000000..163e11903 --- /dev/null +++ b/docs/mkdocs/plugins/index.md @@ -0,0 +1,190 @@ +# Ceedling Plugins + +Ceedling includes a number of built-in plugins. Each plugin linked below +includes full documentation of its capabilities and configuration options. + +!!! tip "Need a CI test build report?" + [Test Suite Report Log Factory](report-tests-log-factory.md) provides + a handful of popular reports and the ability to create your own. + +!!! tip "Have custom needs?" + Many find the handy-dandy [Command Hooks plugin](command-hooks.md) + is often enough as it allows you to connect your own scripts and tools + to Ceedling build steps. Alternatively, you can + [create your own plugins](../development/plugins/index.md). + +See the [`:plugins` configuration reference](../configuration/reference/plugins.md) +for how to enable built-in plugins and load custom plugins in your project. + +### Console Test Results + +<div class="grid cards" markdown> + +- :material-monitor-screenshot: **[`report_tests_pretty_stdout`](report-tests-pretty-stdout.md)** + + --- + + The default plugin for printing test results to the console. Produces a + well-formatted list of summary statistics, ignored and failed tests, and + any extraneous output collected from test fixtures. + +- :material-monitor-screenshot: **[`report_tests_ide_stdout`](report-tests-ide-stdout.md)** + + --- + + Formats test results for the console such that an IDE executing Ceedling + tasks can recognize file paths and line numbers in test failures and link + them for direct file navigation. + +- :material-monitor-screenshot: **[`report_tests_gtestlike_stdout`](report-tests-gtestlike-stdout.md)** + + --- + + Prints test results to the console in a format that mimics + [Google Test's output][gtest-sample-output] — both human readable and + recognized by a variety of reporting tools, IDEs, and CI servers. + +- :material-monitor-screenshot: **[`report_tests_teamcity_stdout`](report-tests-teamcity-stdout.md)** + + --- + + Processes test results into [TeamCity] service messages printed to the + console, allowing the CI server to extract build steps and test results + from software builds. + +</div> + +### Test Results Reports + +<div class="grid cards" markdown> + +- :material-file-chart: **[`report_tests_log_factory`](report-tests-log-factory.md)** + + --- + + Produces any or all of four useful test suite reports in JSON, JUnit XML, + CppUnit XML, or HTML format. Also provides a mechanism for custom reports + with a small amount of Ruby rather than a full plugin. + +- :material-file-chart: **[`report_tests_raw_output_log`](report-tests-raw-output-log.md)** + + --- + + Captures extraneous console output generated by test executables — + typically for debugging — to log files named after the test executables. + +- :material-file-chart: **[`report_build_warnings_log`](report-build-warnings-log.md)** + + --- + + Scans the output of build tools for console warning notices and produces + a simple text file that collects all such warning messages. + +</div> + +### Code Coverage + +<div class="grid cards" markdown> + +- :material-percent: **[`gcov`](gcov/index.md)** + + --- + + Adds Ceedling tasks to execute tests with GNU code coverage + instrumentation. Manages use of [gcov], optional Python-based [GCovr], + and .Net-based [ReportGenerator] to produce coverage reports in a + variety of formats. + +- :material-percent: **[`bullseye`](bullseye.md)** + + --- + + Adds Ceedling tasks to execute tests with code coverage instrumentation + provided by the commercial [Bullseye] tool, which provides visualization + and report generation from the coverage results. + +</div> + +### Mocking & Test Doubles + +<div class="grid cards" markdown> + +- :material-ghost: **[`fff`](fff.md)** + + --- + + Replaces Ceedling’s CMock-based mock generation with the + [Fake Function Framework][FFF] — an alternative approach to + [test doubles][test-doubles] using simple fake functions. + +<!--Spacer so the single fff card does not stretch across multiple grid columns--> +<div style="visibility:hidden"></div> + +</div> + +### Build Integration + +<div class="grid cards" markdown> + +- :material-connection: **[`command_hooks`](command-hooks.md)** + + --- + + Connects Ceedling's build events to tool entries you define in your + project configuration. Easily attach your own scripts or command line + utilities to build steps without writing a full custom plugin. + +- :material-connection: **[`compile_commands_json_db`](compile-commands-json-db.md)** + + --- + + Creates a [JSON Compilation Database][json-compilation-database] — a + `compile_commands.json` file useful to any editor or IDE that implements + [`clangd`][clangd] Language Server Protocol support. + +- :material-connection: **[`beep`](beep.md)** + + --- + + Provides a simple audio notification when a test build completes suite + execution or fails due to a build error, intended to support developers + running time-consuming test suites locally in the background. + +</div> + +### Project & Release Builds + +<div class="grid cards" markdown> + +- :material-package-variant-closed: **[`module_generator`](module-generator.md)** + + --- + + Saves time in Test-Driven Development by generating a templated triplet + of source file, header file, and test file — scaffolded such that they + refer to one another — with convenient command line tasks. + +- :material-package-variant-closed: **[`dependencies`](dependencies.md)** + + --- + + Manages release build dependencies (e.g. static libraries) including + fetching those dependencies and calling a given dependency‘s build + process to generate the components needed by your Ceedling release + build target. + +</div> + +[gtest-sample-output]: https://subscription.packtpub.com/book/programming/9781800208988/11/ch11lvl1sec31/controlling-output-with-google-test +[TeamCity]: https://jetbrains.com/teamcity +[gcov]: http://gcc.gnu.org/onlinedocs/gcc/Gcov.html +[GCovr]: https://www.gcovr.com/ +[ReportGenerator]: https://reportgenerator.io +[Bullseye]: http://www.bullseye.com +[FFF]: https://github.com/meekrosoft/fff +[test-doubles]: https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da +[json-compilation-database]: https://clang.llvm.org/docs/JSONCompilationDatabase.html +[clangd]: https://clangd.llvm.org +[IDEs]: https://www.throwtheswitch.org/ide + +<br/><br/> diff --git a/plugins/module_generator/README.md b/docs/mkdocs/plugins/module-generator.md similarity index 93% rename from plugins/module_generator/README.md rename to docs/mkdocs/plugins/module-generator.md index 4b1544257..5730bb049 100644 --- a/plugins/module_generator/README.md +++ b/docs/mkdocs/plugins/module-generator.md @@ -1,18 +1,17 @@ -ceedling-module-generator -========================= +# Module Generator -## Plugin Overview +This plugin adds a pair of new commands to Ceedling allowing you to generate or remove +module skeletons according to predefined templates. -The module_generator plugin adds a pair of new commands to Ceedling, allowing -you to make or remove modules according to predefined templates. With a single call, -Ceedling can generate a source, header, and test file for a new module. If given a -pattern, it can even create a series of submodules to support specific design patterns. -Finally, it can just as easily remove related modules, avoiding the need to delete -each individually. +## Overview +With a single call, Ceedling can generate a source, header, and test file for a new +module. If given a pattern, it can even create a series of submodules to support +specific design patterns. Finally, it can just as easily remove related modules, +avoiding the need to delete each individually. Let's say, for example, that you want to create a single module named `MadScience`. -``` +```sh ceedling module:create[MadScience] ``` @@ -22,12 +21,12 @@ specified a different default (see configuration). It will create three files: `MadScience.c`, `MadScience.h`, and `TestMadScience.c`. *NOTE* that it is important that there are no spaces between the brackets. We know, it's annoying... but it's the rules. -### Patterns +## Patterns You can also create an entire pattern of files. To do that, just add a second argument to the pattern ID. Something like this: -``` +```sh ceedling module:create[SecretLair,mch] ``` @@ -44,10 +43,11 @@ The module generator understands the following patterns: - `mch` -- generate 9 files for the model-conductor-hardware pattern - `mvp` -- generate 9 files for the model-view-presenter pattern -### Paths +## Paths The directories found in the project `:paths:` are reused. You can also specify an alternative default generation path using: -``` + +```yaml :module_generator: :path_src: src/ :path_inc: src/ @@ -60,13 +60,13 @@ It can do that too! You can give it a hint as to where to find your files. The p here is fairly basic, but it is usually sufficient. It works perfectly if your directory structure matches a common pattern. For example, let's say you issue this command: -``` +```sh ceedling module:create[lab:SecretLair,mch] ``` Say your directory structure looks like this: -``` +```yaml :paths: :source: - lab/src @@ -86,7 +86,7 @@ In this case, the `lab:` hint would make the module generator guess you want you Instead, if your directory structure looks like this: -``` +```yaml :paths: :source: - src/** #this might contain subfolders lab, lair, and other @@ -129,7 +129,7 @@ subdirectory is supposed to be there. If the subdir is NOT there, it will automa Let's try an example with our previous path specification: -``` +```yaml :paths: :source: - src/** #this might contain subfolders lab, lair, and other @@ -141,7 +141,7 @@ Let's try an example with our previous path specification: Then, use the following command: -``` +```sh ceedling module:create[newlab/SecretLair] ``` @@ -166,7 +166,7 @@ a file that Ceedling can see, when it can't. Similarly, you can create stubs for all functions in a header file just by making a single call to your handy `stub` feature, like this: -``` +```sh ceedling module:stub[SecretLair] ``` @@ -184,7 +184,7 @@ defaults for your own needs. For example, new source and header files will be au placed in the `src/` folder while tests will go in the `test/` folder. That's great if your project follows the default ceedling structure... but what if you have a different structure? -``` +```yaml :module_generator: :naming: :bumpy :includes: @@ -204,7 +204,7 @@ Now I've redirected the location where modules are going to be generated. You can make it so that all of your files are generated with a standard include list. This is done by adding to the `:includes` array. For example: -``` +```yaml :module_generator: :includes: :tst: @@ -222,7 +222,7 @@ put that corporate copyright notice (or maybe a copyleft notice, if that's your Notice there is a separate template for source files, include files, and test files. Also, your boilerplates can optionally contain `%1$s` which will inject the filename into that spot. -``` +```yaml :module_generator: :boilerplates: :src: | @@ -246,7 +246,7 @@ your boilerplates can optionally contain `%1$s` which will inject the filename i You can specify the "#ifdef TEST" at the top of the test files with a custom define. This example will put a "#ifdef CEEDLING_TEST" at the top of the test files. -``` +```yaml :module_generator: :test_define: CEEDLING_TEST ``` @@ -264,4 +264,4 @@ Your options for `:naming:` are as follows: - `:snake` - snake_case_is_all_lower_and_uses_underscores - `:caps` - CAPS_FEELS_LIKE_YOU_ARE_SCREAMING - +<br/><br/> diff --git a/plugins/report_build_warnings_log/README.md b/docs/mkdocs/plugins/report-build-warnings-log.md similarity index 93% rename from plugins/report_build_warnings_log/README.md rename to docs/mkdocs/plugins/report-build-warnings-log.md index 1a71faf1c..b284b492e 100644 --- a/plugins/report_build_warnings_log/README.md +++ b/docs/mkdocs/plugins/report-build-warnings-log.md @@ -1,8 +1,8 @@ -# Ceedling Plugin: Build Warnings Log +# Build Warnings Log Capture build process warnings from command tools to a plain text log file. -# Plugin Overview +## Overview This plugin captures warning messages output by command line tools throughout a build. At the end of a build, any collected warning messages are written to one @@ -19,7 +19,7 @@ linking. Log files are written to `<build root>/artifacts/<context>/`. -# Setup +## Setup Enable the plugin in your Ceedling project file: @@ -29,7 +29,7 @@ Enable the plugin in your Ceedling project file: - report_build_warnings_log ``` -# Configuration +## Configuration To change the default filename of `warning.log`, add your desired filename to your configuration file using `:report_build_warnings_log:` ↳ `:filename`. @@ -38,3 +38,5 @@ your configuration file using `:report_build_warnings_log:` ↳ `:filename`. :report_build_warnings_log: :filename: more_better_filename.ext ``` + +<br/><br/> diff --git a/plugins/report_tests_gtestlike_stdout/README.md b/docs/mkdocs/plugins/report-tests-gtestlike-stdout.md similarity index 95% rename from plugins/report_tests_gtestlike_stdout/README.md rename to docs/mkdocs/plugins/report-tests-gtestlike-stdout.md index 802be6c76..5375bbde3 100644 --- a/plugins/report_tests_gtestlike_stdout/README.md +++ b/docs/mkdocs/plugins/report-tests-gtestlike-stdout.md @@ -1,8 +1,8 @@ -# Ceedling Plugin: GTest-like Test Suite Console Report +# GTest-like Console Report Prints to the console ($stdout) test suite results in a GTest-like format. -# Plugin Overview +## Plugin Overview This plugin is intended to be used in place of the more commonly used "pretty" test report plugin. Like its sibling, this plugin ollects raw test results from @@ -12,7 +12,7 @@ readable summary form — specifically the GoogleTest format. This plugin is most useful when using an IDE or working with a CI system that understands the GTest console logging format. -# Setup +## Setup Enable the plugin in your Ceedling project by adding `report_tests_gtestlike_stdout` to the list of enabled plugins instead of any @@ -24,13 +24,13 @@ other `report_tests_*_stdout` plugin. - report_tests_gtestlike_stdout ``` -# Configuration +## Configuration No additional configuration is needed once the plugin is enabled. -# Plugin Output +## Plugin Output -## Ceedling mapped to GoogleTest reporting elements +### Ceedling mapped to GoogleTest reporting elements Ceedling's conventions and output map to GTest format as the following: @@ -47,7 +47,7 @@ collect and format statistics until the end of a build. This plugin duplicates the tense of the wording in a GTest report, but it is unintentionally somewhat misleading. -## Example output (snippet) +### Example output (snippet) The GTest format is verbose. It lists all tests with success and failure results. @@ -94,3 +94,5 @@ test/TestModel.c(21): error: Function TaskScheduler_Init() called more times tha 1 FAILED TESTS ``` + +<br/><br/> diff --git a/plugins/report_tests_ide_stdout/README.md b/docs/mkdocs/plugins/report-tests-ide-stdout.md similarity index 93% rename from plugins/report_tests_ide_stdout/README.md rename to docs/mkdocs/plugins/report-tests-ide-stdout.md index 03d466550..71b4a7689 100644 --- a/plugins/report_tests_ide_stdout/README.md +++ b/docs/mkdocs/plugins/report-tests-ide-stdout.md @@ -1,8 +1,8 @@ -# Ceedling Plugin: IDE Test Suite Console Report +# IDE Test Suite Console Report Prints to the console ($stdout) test suite results with a test failure filepath and line number format understood by nearly any IDE. -# Plugin Overview +## Plugin Overview This plugin is intended to be used in place of the more commonly used "pretty" test report plugin. Like its sibling, this plugin ollects raw test results from @@ -18,23 +18,23 @@ standard of a sort recognized almost universally. The end result is that test failures in your IDE's build window can become links that jump directly to failing test cases. -# Setup +## Setup Enable the plugin in your project.yml by adding `report_tests_ide_stdout` to the list of enabled plugins instead of any other `report_tests_*_stdout` plugin. -``` YAML +```YAML :plugins: :enabled: - report_tests_ide_stdout ``` -# Configuration +## Configuration No additional configuration is needed once the plugin is enabled. -# Example Output +## Example Output ```sh > ceedling test:Model @@ -60,3 +60,4 @@ BUILD FAILURE SUMMARY Unit test failures. ``` +<br/><br/> diff --git a/plugins/report_tests_log_factory/README.md b/docs/mkdocs/plugins/report-tests-log-factory.md similarity index 94% rename from plugins/report_tests_log_factory/README.md rename to docs/mkdocs/plugins/report-tests-log-factory.md index 95dcd3857..0aa991e2e 100644 --- a/plugins/report_tests_log_factory/README.md +++ b/docs/mkdocs/plugins/report-tests-log-factory.md @@ -1,8 +1,8 @@ -# Ceedling Plugin: Test Suite Report Log Factory +# Test Suite Report Log Factory Generate one or more built-in test suite reports — JSON, JUnit XML, CppUnit XML, or HTML — or create your own. -# Plugin Overview +## Plugin Overview Test reports are handy for all sorts of reasons. Various build and reporting tools are able to generate, visualize, or otherwise process results encoded in handy container formats including JSON and XML. @@ -17,7 +17,7 @@ This plugin generates reports after test builds, storing them in your project `a With a limited amount of Ruby code, you can also create your own report without creating an entire Ceedling plugin. -# _User Beware_ +## _User Beware_ Test reports often lack well managed standards or even much documentation at all. Different revisions of the formats exist as do different flavors of the same version produced by different tools. @@ -26,7 +26,7 @@ If a test report produced by this plugin does not work for your needs or is not 1. Use a script or other tool to post-process the report into a format that works for you. You might be surprised how many of these hacks are commonly necessary and exist peppered throughout online forums. You can incorporate any such post-processing step by enabling the `command_hooks` Ceedling plugin (lower in the plugin list than this plugin) and configuring a Ceedling tool to run the needed transformation. 1. Use Ceedling's abilities plus features of this plugin (documented below) to generate your own test report with a minimal amount of Ruby code. -# Setup +## Setup Enable the plugin in your Ceedling project file by adding `report_tests_log_factory` to the list of enabled plugins. @@ -38,7 +38,7 @@ Enable the plugin in your Ceedling project file by adding `report_tests_log_fact All generated reports are written to `<build root>/artifacts/<context>`. Your Ceedling project file specifies `<build root>` as a required entry for any build. Your build's context defaults to `test`. Certain other test build plugins (e.g. GCov) provide a different context (e.g. `gcov`) for test builds, generally named after themselves. That is, for example, if this plugin is used in conjunction with a GCov coverage build, the reports will end up in a subdirectory other than `test/`, `gcov/`. -# Configuration +## Configuration Enable the reports you wish to generate — `json`, `junit`, and/or `cppunit` — within the `:report_tests_log_factory` ↳ `:reports` configuration list. @@ -71,9 +71,9 @@ To change the output filename, specify it with the `:filename` key beneath the r :filename: 'more_better_filename.ext' ``` -# Built-in Reports +## Built-in Reports -## Execution duration values +### Execution duration values Some test reporting formats include the execution time (duration) for aspects of a test suite run. Various granularities exist from the total time for all tests to the time of each suite (per the relevant defition of a suite) to the time required to run individual test cases. See _CeedlingPacket_ for the details on time duration values. @@ -91,7 +91,7 @@ _Note:_ Most test cases are quite short, and most computers are quite fast. As s [Unity]: https://github.com/ThrowTheSwitch/Unity -## JSON Format +### JSON Format [JSON] is “a lightweight data-interchange format.” JSON serializes common data structures into a human readable form. The format has several pros, including the ability for entirely different programming languages to ingest JSON and recreate these data structures. As such, this makes JSON a good report generation option as the result can be easily programmatically manipulated and put to use. @@ -99,7 +99,7 @@ Something like XML is a general purpose structure for, well, structuring data. X The JSON this plugin generates uses an ad hoc set of data structures following no standard — though any other test framework outputting test results in JSON may look fairly similar. -### Example JSON configuration YAML +#### Example JSON configuration YAML ```yaml :plugins: @@ -117,7 +117,7 @@ The JSON this plugin generates uses an ad hoc set of data structures following n [JSON]: https://www.json.org/ -### Example JSON test report +#### Example JSON test report In the following example a single test file _TestUsartModel.c_ exercised four test cases. Two test cases passed, one test case failed, and one test case was ignored. @@ -160,14 +160,14 @@ In the following example a single test file _TestUsartModel.c_ exercised four te } ``` -## JUnit XML Format +### JUnit XML Format [JUnit] holds a certain position among testing tools. While it is an xUnit-style framework specific to unit testing Java, it has influenced how Continuous Integration build tools operate, and its [JUnit XML report format][junit-xml-format] has become something of a defacto standard for test reports in any language. The JUnit XML format has been revised in various ways over time but generally has more available documentation than some other formats. [JUnit]: https://junit.org/ [junit-xml-format]: https://docs.getxray.app/display/XRAY/Taking+advantage+of+JUnit+XML+reports -### Example JUnit configuration YAML +#### Example JUnit configuration YAML ```yaml :plugins: @@ -183,7 +183,7 @@ In the following example a single test file _TestUsartModel.c_ exercised four te :filename: junit_tests_report.xml ``` -### Example JUnit test report +#### Example JUnit test report In the following example a single test file _TestUsartModel.c_ exercised four test cases. Two test cases passed, one test case failed, and one test case was ignored (a.k.a. “skipped” in JUnit lingo). @@ -209,13 +209,13 @@ In mapping a Ceedling test suite to JUnit convetions, a Ceedling _test file_ bec </testsuites> ``` -## CppUnit XML Format +### CppUnit XML Format [CppUnit] is an xUnit-style port of the JUnit framework to C/C++. Documentation for its XML test report is scattered and not easily linked. [CppUnit]: https://freedesktop.org/wiki/Software/cppunit/ -### Example CppUnit configuration YAML +#### Example CppUnit configuration YAML ```yaml :plugins: @@ -231,7 +231,7 @@ In mapping a Ceedling test suite to JUnit convetions, a Ceedling _test file_ bec :filename: cppunit_tests_report.xml ``` -### Example CppUnit test report +#### Example CppUnit test report In the following example a single test file _TestUsartModel.c_ exercised four test cases. Two test cases passed, one test case failed, and one test case was ignored. @@ -278,11 +278,11 @@ In mapping a Ceedling test suite to CppUnit convetions, a CppUnit test name is t </TestRun> ``` -## HTML Format +### HTML Format This plugin creates an adhoc HTML page in a single file. -### Example HTML configuration YAML +#### Example HTML configuration YAML ```yaml :plugins: @@ -298,11 +298,11 @@ This plugin creates an adhoc HTML page in a single file. :filename: tests_report.html ``` -### Example HTML test report +#### Example HTML test report ![](sample_html_report.png) -# Creating Your Own Custom Report +## Creating Your Own Custom Report Creating your own report requires three steps: @@ -310,7 +310,7 @@ Creating your own report requires three steps: 1. Create a Ruby file in the directory from (1) per instructions that follow. 1. Enable your new report in your `:report_tests_log_factory` Ceedling configuration. -## Custom report configuration +### Custom report configuration Configuration steps, (1) and (3) above, are documented by example below. Conventions simplify the Ruby programming and require certain naming rules that extend into your project configuration. @@ -326,7 +326,7 @@ Configuration steps, (1) and (3) above, are documented by example below. Convent - fancy_shmancy # Your custom report must follow naming rules (below) ``` -## Custom `TestsReporter` Ruby code +### Custom `TestsReporter` Ruby code To create a custom report, here's what you gotta do: @@ -343,7 +343,7 @@ Overriding the default filename of your custom report happens just as it does fo You may access `:report_tests_log_factory` configuration for your custom report using a handy utility method documented in a later section. -### Sample `TestReporter` custom subclass +#### Sample `TestReporter` custom subclass The following code creates a simple, dummy report of the _FancyShmancy_ variety (note the name correspondence to the example configuration YAML above). @@ -404,17 +404,17 @@ class FancyShmancyTestsReporter < TestsReporter end ``` -### Plugin hooks & test results data structure +#### Plugin hooks & test results data structure See [_PluginDevelopmentGuide_][custom-plugins] for documentation of the test results data structure (i.e. the `results` method arguments in above sample code). See this plugin's built-in `TestsReports` subclasses — `json_tests_reporter.rb`, `junit_tests_reporter.rb`, and `cppunit_tests_reporter.rb` — for examples of using test results. -[custom-plugins]: ../docs/PluginDevelopmentGuide.md +[custom-plugins]: ../development/plugins/index.md -### `TestsReporter` utility methods +#### `TestsReporter` utility methods -#### Configuration access: `fetch_config_value(*keys)` +##### Configuration access: `fetch_config_value(*keys)` You may call the private method `fetch_config_value(*keys)` of the parent class `TestReporters` from your custom subclass to retrieve configuration entries. @@ -422,7 +422,7 @@ This method automatically indexes into `:report_tests_log_factory` configuration `fetch_config_value(*keys)` expects a list of keys and only accesses configuration beneath `:report_tests_log_factory` ↳ `:<custom_report>`. -##### Example _FancyShmancy_ configuration + `TestsReporter.fetch_config_value()` calls +###### Example _FancyShmancy_ configuration + `TestsReporter.fetch_config_value()` calls ```yaml report_tests_log_factory: @@ -444,3 +444,5 @@ fetch_config_value( :standardize, :filters ) => ['/^Foo/', '/Bar$/'] fetch_config_value( :does, :not, :exist ) => nil ``` + +<br/><br/> diff --git a/plugins/report_tests_pretty_stdout/README.md b/docs/mkdocs/plugins/report-tests-pretty-stdout.md similarity index 90% rename from plugins/report_tests_pretty_stdout/README.md rename to docs/mkdocs/plugins/report-tests-pretty-stdout.md index a0a368982..2aea441c5 100644 --- a/plugins/report_tests_pretty_stdout/README.md +++ b/docs/mkdocs/plugins/report-tests-pretty-stdout.md @@ -1,15 +1,15 @@ -# Ceedling Plugin: Pretty Test Suite Console Report +# Pretty Test Suite Console Report Prints to the console ($stdout) simple, readable test suite results. -# Plugin Overview +## Plugin Overview This plugin is intended to be the default option for formatting a test suite's results when displayed at the console. It collects raw test results from the individual test executables of your test suite and presents them in a more readable summary form. -# Setup +## Setup Enable the plugin in your project.yml by adding `report_tests_pretty_stdout` to the list of enabled plugins instead of any other `report_tests_*_stdout` @@ -21,11 +21,11 @@ plugin. - report_tests_pretty_stdout ``` -# Configuration +## Configuration No additional configuration is needed once the plugin is enabled. -# Example Output +## Example Output ```sh > ceedling test:Model @@ -52,3 +52,5 @@ BUILD FAILURE SUMMARY --------------------- Unit test failures. ``` + +<br/><br/> diff --git a/plugins/report_tests_raw_output_log/README.md b/docs/mkdocs/plugins/report-tests-raw-output-log.md similarity index 92% rename from plugins/report_tests_raw_output_log/README.md rename to docs/mkdocs/plugins/report-tests-raw-output-log.md index 560d4b116..6b5913b25 100644 --- a/plugins/report_tests_raw_output_log/README.md +++ b/docs/mkdocs/plugins/report-tests-raw-output-log.md @@ -1,9 +1,9 @@ -# Ceedling Plugin: Raw Test Output Logs +# Raw Test Output Logs Capture extra console output — typically `printf()`-style statements — from test cases to log files. -# Plugin Overview +## Plugin Overview This plugin gathers and filters console output from test executables into log files. Though not required, it is usually used in addition to the @@ -14,7 +14,7 @@ Debugging in unit tested code is often accomplished with simple `printf()`- style calls to dump information to the console. This plugin's log files can be helpful in supporting debugging efforts or quality validation. -## Test executable output +### Test executable output Ceedling and Unity cooperate to extract console statements from test executable runs. Unity-based test executables print test case pass/fail status messages @@ -25,7 +25,7 @@ output, to format it and present summaries at the console. This plugin captures the unrecognized output to log files. -## Log files +### Log files Log files are only created if test executables produce console output apart from expected Unity test results as described above. Log files are named for the @@ -35,7 +35,7 @@ Builds are differentiated by build context — `test`, `release`, or plugin-modified build (e.g. `gcov`). Log files are written to `<build root>/artifacts/<context>/<test file>.raw.log`. -# Setup +## Setup Enable the plugin in your Ceedling project: @@ -45,6 +45,8 @@ Enable the plugin in your Ceedling project: - report_tests_raw_output_log ``` -# Configuration +## Configuration -No additional configuration is needed once the plugin is enabled. \ No newline at end of file +No additional configuration is needed once the plugin is enabled. + +<br/><br/> diff --git a/plugins/report_tests_teamcity_stdout/README.md b/docs/mkdocs/plugins/report-tests-teamcity-stdout.md similarity index 95% rename from plugins/report_tests_teamcity_stdout/README.md rename to docs/mkdocs/plugins/report-tests-teamcity-stdout.md index 82204db0e..9b8c75fea 100644 --- a/plugins/report_tests_teamcity_stdout/README.md +++ b/docs/mkdocs/plugins/report-tests-teamcity-stdout.md @@ -1,8 +1,8 @@ -# Ceedling Plugin: TeamCity Test Suite Console Report +# TeamCity Test Suite Console Report Prints to the console ($stdout) test suite build events and results in a format understood by the CI product TeamCity. -# Plugin Overview +## Plugin Overview This plugin is intended to be used within [TeamCity] Continuous Integration (CI) builds. It processes Ceedling test suites and executable output into @@ -18,7 +18,7 @@ options on enabling the build in CI but disabling it locally. [service-messages]: https://www.jetbrains.com/help/teamcity/service-messages.html -# Setup +## Setup Enable the plugin in your Ceedling project file by adding `report_tests_teamcity_stdout`. @@ -29,7 +29,7 @@ Enable the plugin in your Ceedling project file by adding - report_tests_teamcity_stdout ``` -# Configuration +## Configuration All the `report_tests_*_stdout` plugins may be enabled in various combinations. But, some combinations may not make a great deal of sense. The TeamCity @@ -53,14 +53,14 @@ Ceedling provides Mixins for applying configurations settings on top of your base project configuraiton file. See the [Mixins documentation][ceedling-mixins] for full details. -[ceedling-mixins]: ../docs/CeedlingPacket.md#base-project-configuration-file-mixins-section-entries +[ceedling-mixins]: ../configuration/loading.md#base-configuration-file-mixins-entries As an example, you might enable the plugin in the main project file that is committed to your repository while disabling the plugin in your local user project file that is ignored by your repository. In this way, the plugin would run on a TeamCity build server but not in your local development environment. -# Example Output +## Example Output TeamCity's convention for identifying tests uses the naming convention of the underlying Java language in which TeamCity is written, @@ -92,3 +92,5 @@ This plugin maps Ceedling conventions to TeamCity test service messages as ##teamcity[testIgnored name='test.test/TestUsartModel.testShouldReturnWakeupMessage' flowId='15'] ##teamcity[testSuiteFinished name='TestUsartModel' flowId='15'] ``` + +<br/><br/> diff --git a/plugins/report_tests_log_factory/sample_html_report.png b/docs/mkdocs/plugins/sample_html_report.png similarity index 100% rename from plugins/report_tests_log_factory/sample_html_report.png rename to docs/mkdocs/plugins/sample_html_report.png diff --git a/docs/mkdocs/project/release-history.md b/docs/mkdocs/project/release-history.md new file mode 100644 index 000000000..a1b907b31 --- /dev/null +++ b/docs/mkdocs/project/release-history.md @@ -0,0 +1,37 @@ +# Release History + +## Versioned Release Artifacts +Ceedling releases are available as: + +* [Repository build artifacts](https://github.com/ThrowTheSwitch/Ceedling/releases) +* [Ruby gems](https://rubygems.org/gems/ceedling) for installation by the `gem` tool +* [Docker images](https://hub.docker.com/r/throwtheswitch/madsciencelab) + +## History + +Ceedling’s release history is documented in three files that live in the +repository and follow [keepachangelog.com](https://keepachangelog.com/en/1.0.0/) +conventions. These documents span the cumulative project history and are +maintained standalone rather than versioned with this documentation. + +### Release Notes + +Detailed [release notes][release-notes] for each release covering new features, behavioral changes, +bug fixes, and known issues. This is the primary reference for understanding what changed between +versions. + +### Breaking Changes + +A focused list of [breaking changes][breaking-changes] across releases. Consult this document when +upgrading to understand what in your project configuration or test code may need to be updated. + +### Changelog + +A concise, chronological log of [all changes][changelog]. Useful for a quick scan of what changed +in any given release. + +[release-notes]: https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/ReleaseNotes.md +[breaking-changes]: https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/BreakingChanges.md +[changelog]: https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/Changelog.md + +<br/><br/> diff --git a/docs/CeedlingUpgrade.md b/docs/mkdocs/project/upgrade.md similarity index 99% rename from docs/CeedlingUpgrade.md rename to docs/mkdocs/project/upgrade.md index c75aba595..d8b327183 100644 --- a/docs/CeedlingUpgrade.md +++ b/docs/mkdocs/project/upgrade.md @@ -80,4 +80,4 @@ If you have custom plugins installed to your project, the plugin architecture ha revisions and it may or may not be compatible at this time. Again, this is a problem which should not exist soon. - +<br/><br/> diff --git a/docs/mkdocs/reference/build-directives.md b/docs/mkdocs/reference/build-directives.md new file mode 100644 index 000000000..42f2b832b --- /dev/null +++ b/docs/mkdocs/reference/build-directives.md @@ -0,0 +1,37 @@ +# Test Build Directive Macros Reference + +For detailed usage guidance, purpose explanations, and examples for these +macros, see [Test Build Directive Macros](../testing-guide/build-directives.md). + +Both macros are defined in `unity.h` and evaluate to empty strings at compile +time — they serve only as text markers for Ceedling's scanner. `#include "unity.h"` +must appear above their use in a test file. + +!!! warning "Incompatible with conditional preprocessor blocks" + `TEST_SOURCE_FILE()` and `TEST_INCLUDE_PATH()` cannot be enclosed in + `#ifdef` or other conditional compilation preprocessor statements. See + [preprocessing gotchas](../testing-guide/conventions.md#preprocessing-gotchas) + for details. + +## `TEST_SOURCE_FILE("filepath")` + +Inject a specific source file into a test executable's build. Use this when +a source file has no corresponding header file that Ceedling can discover by +convention, or when you need to explicitly include an assembly file (`.s`) in +a test build that has assembly support enabled. + +- Argument: a filepath string using forward slashes +- The file must exist within Ceedling's source file collection +- Multiple uses per test file are allowed, one per line + +## `TEST_INCLUDE_PATH("path")` + +Add a header search path to an individual test executable's compiler +invocation. This supplements — it does not replace — the `:paths` ↳ `:include` +entries from your project file. + +- Argument: a path string using forward slashes +- Paths are relative to the working directory from which `ceedling` is executed +- Multiple uses per test file are allowed, one per line + +<br/><br/> diff --git a/docs/mkdocs/reference/command-line.md b/docs/mkdocs/reference/command-line.md new file mode 100644 index 000000000..0b5d51749 --- /dev/null +++ b/docs/mkdocs/reference/command-line.md @@ -0,0 +1,299 @@ +# Command Line Reference + +For a conceptual overview of how application commands and build & plugin +tasks work together along with quick-start examples, see +[_Getting started: The command line_](../getting-started/command-line.md). + +!!! note "Documentation convention" + The `*` used in this reference is a stand-in for: + + * One of several available options + * A matching string (e.g. test filename) + * A regex + +## Application commands + +!!! tip "Detailed command line help at the, well, command line" + Ceedling provides robust help for application commands. + Execute `ceedling help` for a summary view of all application commands. + Execute `ceedling help <command>` for detailed help. + + Because the built-in command line help is thorough, the following + reference includes brief entries. + +--- + +### `ceedling [no arguments]` + +Runs the default build tasks. Unless set in the project file, Ceedling +uses a default task of `test:all`. To override this behavior, set your +own default tasks in the project file (see later section). + +--- + +### `ceedling build <tasks...>` or `ceedling <tasks...>` + +Runs the named build tasks. `build` is optional (i.e. `ceedling test:all` +is equivalent to `ceedling build test:all`). Various option flags +exist to control project configuration loading, verbosity levels, +logging, test task filters, etc. + +See [next section](#build-plugin-tasks) to understand the build & plugin +tasks this application command is able to execute. Run `ceedling help build` +to understand all the command line flags that work with build & plugin tasks. + +--- + +### `ceedling check` + +Process project configuration for validity and to check for any warnings +or automated overrides. No builds occur. Various option flags exist to +control project configuration loading and configuration manipulation. + +--- + +### `ceedling dumpconfig` + +Process project configuration and write final result to a YAML file. +Various option flags exist to control project configuration loading, +configuration manipulation, and configuration sub-section extraction. + +--- + +### `ceedling environment` + +Lists project related environment variables: + +* All environment variable names and string values added to your + environment from within Ceedling and through the `environment` + section of your configuration. This is especially helpful in + verifying the evaluation of any string replacement expressions in + your `environment` config entries. +* All existing Ceedling-related environment variables set before you + ran Ceedling from the command line. + +--- + +### `ceedling example` + +Extracts an example project from within Ceedling to your local +filesystem. The available examples are listed with +`ceedling examples`. Various option flags control whether the example +contains vendored Ceedling and/or a documentation bundle. + +--- + +### `ceedling examples` + +Lists the available examples within Ceedling. To extract an example, +use `ceedling example`. + +--- + +### `ceedling help` + + Displays summary help for all application commands and detailed help + for each command. `ceedling help` also loads your project + configuration (if available) and lists all build tasks from it. + Various option flags control what project configuration is loaded. + +--- + +### `ceedling new` + + Creates a new project structure. Various option flags control whether + the new project contains vendored Ceedling, a documentation bundle, + and/or a starter project configuration file. + +--- + +### `ceedling upgrade` + + Upgrade vendored installation of Ceedling for an existing project + along with any locally installed documentation bundles. + +--- + +### `ceedling version` + +Displays version information for Ceedling and its components. Version +output for Ceedling includes the Git Commit short SHA in Ceedling’s +build identifier and Ceedling’s path of origin. + +``` +🌱 Welcome to Ceedling! + + Ceedling => #.#.#-<Short SHA> + ---------------------- + <Ceedling install path> + + Build Frameworks + ---------------------- + CMock => #.#.# + Unity => #.#.# + CException => #.#.# +``` + +If the short SHA information is unavailable such as in local +development, the SHA is omitted. The source for this string is +generated and captured in the Ruby Gem at the time of Ceedling’s +automated build in CI. + +## Build & plugin tasks + +Build task are loaded from your project configuration. Unlike +application commands that are fixed, build tasks vary depending on your +project configuration and the files within your project structure. + +Ultimately, build & plugin tasks are executed by the +[`build` application command](#ceedling-build-tasks-or-ceedling-tasks) +(but the `build` keyword can be omitted — see above). + +!!! warning "Quotes in shell command line parsing" + Quotes may be necessary around any tasks using bracket notation or that + can make use of wildcards or regexes. Your shell will likely need quotes + to distinguish the parameter’s characters from shell command line + operators. + +--- + +### `ceedling paths:*` + +List all paths collected from `paths` entries in your YAML config +file where `*` is the name of any section contained in `paths`. This +task is helpful in verifying the expansion of path wildcards / globs +specified in the `paths` section of your config file. + +--- + +### `ceedling files:*` +* `ceedling files:assembly` +* `ceedling files:header` +* `ceedling files:source` +* `ceedling files:support` +* `ceedling files:test` + +List all files and file counts collected from the relevant search +paths specified by the `paths` entries of your YAML config file. + +The `files:assembly` task will only be available if assembly support +is enabled in the [`:release_build`](../configuration/reference/release-build.md) +or [`:test_build`](../configuration/reference/test-build.md) +sections of your configuration file. + +--- + +### `ceedling test:all` + +Run all unit tests. + +--- + +### `ceedling test:*` + +Execute the named test file or the named source file that has an +accompanying test. No path. Examples: `ceedling test:foo`, `ceedling +test:foo.c` or `ceedling test:test_foo.c` + +#### `ceedling test:* --test-case=<test_case_name> ` +Execute individual test cases which match `test_case_name`. + +For instance, if you have a test file _test_gpio.c_ containing the following +test cases (test cases are simply `void test_name(void)`). + +- `test_gpio_start` +- `test_gpio_configure_proper` +- `test_gpio_configure_fail_pin_not_allowed` + +… and you want to run only _configure_ tests, you can call: + +`ceedling test:gpio --test-case=configure` + +!!! note + Test case matching is on sub-strings. `--test_case=configure` matches on + the test cases including the word _configure_, naturally. + `--test-case=gpio` would match all three test cases. + +#### `ceedling test:* --exclude_test_case=<test_case_name> ` +Execute test cases which do not match `test_case_name`. + +For instance, if you have file test_gpio.c with defined 3 tests: + +- `test_gpio_start` +- `test_gpio_configure_proper` +- `test_gpio_configure_fail_pin_not_allowed` + +… and you want to run only start tests, you can call: + +`ceedling test:gpio --exclude_test_case=configure` + +!!! note + Exclude matching follows the same sub-string logic as discussed in the + preceding section. + +--- + +### `ceedling test:pattern[*]` + +Execute any tests whose name and/or path match the regular expression +pattern (case sensitive). Example: `ceedling "test:pattern[(I|i)nit]"` +will execute all tests named for initialization testing. + +--- + +### `ceedling test:path[*]` + +Execute any tests whose path contains the given string (case +sensitive). Example: `ceedling test:path[foo/bar]` will execute all tests +whose path contains foo/bar. + +Both directory separator characters `/` and `\` are valid. + +--- + +### `ceedling release` + +Build all source into a release artifact (if the release build option +is configured). + +--- + +### `ceedling release:compile:*` + +Sometimes you just need to compile a single file dagnabit. + +Example: `ceedling release:compile:foo.c` + +--- + +### `ceedling release:assemble:*` + +Sometimes you just need to assemble a single file doggonit. Example: +`ceedling release:assemble:foo.s` + +--- + +### `ceedling summary` + +If plugins are enabled, this task will execute the summary method of +any plugins supporting it. This task is intended to provide a quick +roundup of build artifact metrics without re-running any part of the +build. + +--- + +### `ceedling clean` + +Deletes all toolchain binary artifacts (object files, executables), +test results, and any temporary files. Clean produces no output at the +command line unless verbosity has been set to an appreciable level. + +--- + +### `ceedling clobber` + +Extends clean task’s behavior to also remove generated files: test +runners, mocks, preprocessor output. Clobber produces no output at the +command line unless verbosity has been set to an appreciable level. + +<br/><br/> diff --git a/docs/mkdocs/reference/environment-vars.md b/docs/mkdocs/reference/environment-vars.md new file mode 100644 index 000000000..0f78f791b --- /dev/null +++ b/docs/mkdocs/reference/environment-vars.md @@ -0,0 +1,60 @@ +# Environment Variables Reference + +See [Getting Started → Environment Variables](../configuration/environment-vars.md) +for context on when and why to use these variables. + +## Project configuration + +### `CEEDLING_PROJECT_FILE` + +Points Ceedling at your project configuration file. This is +[one of several options](../configuration/loading.md) for specifying the +project file location. + +### `CEEDLING_MIXIN_#` + +Environment variables named `CEEDLING_MIXIN_#` — where `#` is any positive +integer — specify filepaths to mixin YAML files to be merged into your base +project configuration. Multiple mixin environment variables are merged in +ascending numeric order (e.g. `CEEDLING_MIXIN_1` before `CEEDLING_MIXIN_5` +before `CEEDLING_MIXIN_99`). + +See [_Loading Configuration_](../configuration/loading.md#mixin-environment-variables) +for the full details on Mixin environment variables. + +## Console output — `CEEDLING_DECORATORS` + +Forces Ceedling's console logging decorators (fancy Unicode characters, emoji, +and color) on or off. + +!!! warning "`CEEDLING_DECORATORS` cannot be set with `:environment`" + Ceedling's logger must load before any environment variables are processed + in your project configuration. As such, `CEEDLING_DECORATORS` can only be set + in your environment before Ceedling runs. + +By default, Ceedling makes an educated guess as to which platforms can best +support decorators. Some platforms (we're looking at you, Windows) do not +typically have default font support in their terminals for these features. So, +by default this feature is disabled on problematic platforms while enabled on +others. + +Set `CEEDLING_DECORATORS` to `true` (`1`) to force decorators on, or `false` +(`0`) to force them off. + +Example with decorators enabled: + +``` +----------------------- +❌ OVERALL TEST SUMMARY +----------------------- +TESTED: 6 +PASSED: 5 +FAILED: 1 +IGNORED: 0 +``` + +If you find a monospaced font that provides emojis, etc. and works with Windows' +command prompt, you can (1) Install the font (2) change your command prompt's +font (3) set `CEEDLING_DECORATORS` to `true`. + +<br/><br/> diff --git a/docs/mkdocs/reference/gcov-plugin.md b/docs/mkdocs/reference/gcov-plugin.md new file mode 100644 index 000000000..7cdbc73ef --- /dev/null +++ b/docs/mkdocs/reference/gcov-plugin.md @@ -0,0 +1,537 @@ +# Coverage Reporting + +The `gcov` plugin is one of Ceedling’s most used features. It orchestrates GCC +coverage instrumentation and use of the `gcov` tool to generate code coverage +for test builds. Optionally, it can generate HTML, XML, JSON, and text reports +via the GCovr and/or ReportGenerator reporting tools. + +See the full [Gcov Plugin documentation](../plugins/gcov/index.md) for concepts, +installation, troubleshooting, and worked examples. + +```yaml +:plugins: + :enabled: + - gcov + +:gcov: + :summaries: FALSE + +:gcov: + :utilities: + - gcovr + +:gcov: + :reports: + - HtmlBasic +``` + +--- + +## `gcov` Plugin Settings + +Options directly under `:gcov:`. + +### `:summaries` + +Enable or disable automatic console coverage summaries printed after each +`gcov:` task. + +**Default:** `TRUE` + +```yaml +:gcov: + :summaries: FALSE +``` + +--- + +### `:mcdc` + +Enable or disable Modified Condition / Decision Coverage in coverage results. +This feature is dependent on minimum tool versions. + +**Default:** `FALSE` + +```yaml +:gcov: + :mcdc: TRUE +``` + +--- + +### `:utilities` + +List of report generation utilities to enable. Valid values are `gcovr` and +`ReportGenerator`. Either or both may be specified. + +**Default:** `[gcovr]` (when `:reports:` is configured and `:utilities:` is absent) + +```yaml +:gcov: + :utilities: + - gcovr + - ReportGenerator +``` + +--- + +### `:report_task` + +When `TRUE`, disables automatic report generation after `gcov:` tasks and +instead enables a separate `report:gcov` Ceedling task for on-demand report +generation. + +**Default:** `FALSE` + +```yaml +:gcov: + :report_task: TRUE +``` + +--- + +### `:reports` + +List of report types to generate. When empty or absent, report generation +(but not coverage summaries) is disabled. + +```yaml +:gcov: + :reports: + - HtmlBasic + - HtmlDetailed + - ... +``` + +| Report option | gcovr | ReportGenerator | +|---|:---:|:---:| +| `HtmlBasic` | ✓ | ✓ | +| `HtmlDetailed` | ✓ | ✓ | +| `Text` | ✓ | ✓ | +| `Cobertura` | ✓ | ✓ | +| `SonarQube` | ✓ | ✓ | +| `JSON` | ✓ | | +| `HtmlInline` | | ✓ | +| `HtmlInlineAzure` | | ✓ | +| `HtmlInlineAzureDark` | | ✓ | +| `HtmlChart` | | ✓ | +| `MHtml` | | ✓ | +| `Badges` | | ✓ | +| `CsvSummary` | | ✓ | +| `Latex` | | ✓ | +| `LatexSummary` | | ✓ | +| `PngChart` | | ✓ | +| `TeamCitySummary` | | ✓ | +| `lcov` | | ✓ | +| `Xml` | | ✓ | +| `XmlSummary` | | ✓ | + +--- + +## GCovr Settings + +All options below live under `:gcov:` ↳ `:gcovr:`. Reports are written to +`<build root>/artifacts/gcov/gcovr/`. + +### `:report_root` + +Root directory of your source files; file names in reports are relative to +this path. + +**Default:** `"."` (current directory) + +--- + +### `:config_file` + +Load a `gcovr` configuration file. (`gcovr --config`) + +**Default:** `gcovr.cfg` in the `:report_root` directory, if present. + +--- + +### `:fail_under_line` + +Exit with status 2 if total line coverage is below this percentage. +(`gcovr --fail-under-line`) + +**Values:** `1`–`100` + +--- + +### `:fail_under_branch` + +Exit with status 4 if total branch coverage is below this percentage. +(`gcovr --fail-under-branch`) + +**Values:** `1`–`100` + +--- + +### `:fail_under_decision` + +Exit with status 8 if total decision coverage is below this percentage. +(`gcovr --fail-under-decision`) + +**Values:** `1`–`100` + +--- + +### `:fail_under_function` + +Exit with status 16 if total function coverage is below this percentage. +(`gcovr --fail-under-function`) + +**Values:** `1`–`100` + +--- + +### `:exception_on_fail` + +When any `:fail_under_*` threshold is set, raise a build-breaking exception +instead of only logging a warning. + +**Default:** `FALSE` + +--- + +### `:source_encoding` + +Source file character encoding. (`gcovr --source-encoding`) + +**Default:** system default (typically `UTF-8`) + +--- + +### `:branches` + +Report branch coverage instead of line coverage. Applies to text reports only. +(`gcovr --branches`) + +--- + +### `:sort_uncovered` + +Sort report entries by increasing number of uncovered lines. Applies to text +and HTML reports. (`gcovr --sort-uncovered`) + +--- + +### `:sort_percentage` + +Sort report entries by increasing percentage of uncovered lines. Applies to +text and HTML reports. (`gcovr --sort-percentage`) + +--- + +### `:print_summary` + +Print a brief line- and branch-coverage summary to stdout in addition to any +other reports. (`gcovr --print-summary`) + +--- + +### `:report_include` + +Keep only source files matching this regular expression filter. +(`gcovr --filter`) + +**Example:** `"^src"` + +--- + +### `:report_exclude` + +Exclude source files matching this regular expression filter. +(`gcovr --exclude`) + +**Example:** `"^vendor.*|^build.*|^test.*|^lib.*"` + +--- + +### `:gcov_filter` + +Keep only `.gcov` data files matching this regular expression filter. +(`gcovr --gcov-filter`) + +--- + +### `:gcov_exclude` + +Exclude `.gcov` data files matching this regular expression filter. +(`gcovr --gcov-exclude`) + +--- + +### `:exclude_directories` + +Exclude directories matching this regular expression filter when searching for +raw coverage files. (`gcovr --exclude-directories`) + +--- + +### `:gcov_executable` + +Use a specific `gcov` executable instead of the one on `PATH`. +(`gcovr --gcov-executable`) + +--- + +### `:exclude_unreachable_branches` + +Exclude branch coverage from lines without useful source code. +(`gcovr --exclude-unreachable-branches`) + +--- + +### `:exclude_throw_branches` + +Exclude compiler-generated exception-handling branches from branch coverage. +(`gcovr --exclude-throw-branches`) + +--- + +### `:merge_mode_function` + +Controls how `gcovr` handles multiple coverage entries for the same function +(e.g. when Ceedling tests the same source under multiple build configurations). + +**Default:** `merge-use-line-max` + +See the [gcovr merging docs](https://gcovr.com/en/stable/guide/merging.html) +for all valid values. + +--- + +### `:use_gcov_files` + +Use existing `.gcov` files on disk instead of running `gcov` again. +(`gcovr --use-gcov-files`) + +**Default:** `FALSE` + +--- + +### `:gcov_ignore_parse_errors` + +Skip lines with parse errors in `.gcov` files rather than exiting with an +error. (`gcovr --gcov-ignore-parse-errors`) + +--- + +### `:object_directory` + +Override normal working-directory detection for `.gcda` / `.gcno` files. +(`gcovr --object-directory`) + +--- + +### `:keep` + +Retain intermediate `.gcov` files after processing. (`gcovr --keep`) + +--- + +### `:delete` + +Delete `.gcda` files after processing. (`gcovr --delete`) + +--- + +### `:threads` + +Number of parallel threads to use during report generation. (`gcovr -j`) + +--- + +### `:html_artifact_filename` + +Override the default HTML report output filename. + +--- + +### `:html_title` + +Title text for the HTML report. (`gcovr --html-title`) + +**Default:** `Head` + +--- + +### `:html_medium_threshold` + +Coverage percentage below which a value is marked as low coverage in the HTML +report. Must be ≤ `:html_high_threshold`. (`gcovr --html-medium-threshold`) + +**Default:** `75.0` + +--- + +### `:html_high_threshold` + +Coverage percentage below which a value is marked as medium coverage in the +HTML report. Must be ≥ `:html_medium_threshold`. (`gcovr --html-high-threshold`) + +**Default:** `90.0` + +--- + +### `:html_absolute_paths` + +Use absolute paths when linking to detailed reports in the HTML output. +(`gcovr --html-absolute-paths`) + +**Default:** relative links + +--- + +### `:html_encoding` + +Override the declared character encoding in the HTML report. +(`gcovr --html-encoding`) + +**Default:** `UTF-8` + +--- + +### `:cobertura_pretty` + +Pretty-print the Cobertura XML report. (`gcovr --xml-pretty`) + +**Default:** `FALSE` + +--- + +### `:cobertura_artifact_filename` + +Override the default Cobertura XML report output filename. + +--- + +### `:sonarqube_artifact_filename` + +Override the default SonarQube XML report output filename. + +--- + +### `:json_pretty` + +Pretty-print the JSON report. (`gcovr --json-pretty`) + +**Default:** `FALSE` + +--- + +### `:json_artifact_filename` + +Override the default JSON report output filename. + +--- + +### `:text_artifact_filename` + +Override the default text report output filename. When unset the text report +is printed to the console. + +--- + +## ReportGenerator Settings + +All options below live under `:gcov:` ↳ `:report_generator:`. Reports are +written to `<build root>/artifacts/gcov/ReportGenerator/`. + +### `:history_directory` + +Optional directory for storing persistent coverage history, enabling +coverage-trend charts across builds. + +--- + +### `:plugins` + +Optional semicolon-separated list of plugin DLL files for custom report types +or custom history storage. + +**Example:** `plugin.dll;*.dll` + +--- + +### `:assembly_filters` + +Optional semicolon-separated list of assembly inclusion/exclusion filters. +Prefix `+` to include, `-` to exclude. Exclusions take precedence. Wildcards +allowed (not regular expressions). + +**Example:** `+MyLib;-ThirdParty` + +--- + +### `:class_filters` + +Optional semicolon-separated list of class inclusion/exclusion filters. Same +prefix and wildcard rules as `:assembly_filters`. + +--- + +### `:file_filters` + +Optional semicolon-separated list of file inclusion/exclusion filters. Same +prefix and wildcard rules as `:assembly_filters`. + +**Example:** `"-./vendor/*;-./build/*;-./test/*;+./src/*"` + +--- + +### `:verbosity` + +Verbosity level for ReportGenerator log output. + +**Values:** `Verbose`, `Info`, `Warning`, `Error`, `Off` + +**Default:** `Warning` + +--- + +### `:tag` + +Optional build tag or version label embedded in the report. + +--- + +### `:gcov_exclude` + +Optional list of regular expressions; `.gcov` notes files whose paths match +are excluded from report generation. + +```yaml +:gcov: + :report_generator: + :gcov_exclude: + - <regex> +``` + +--- + +### `:threads` + +Number of parallel threads to use during report generation. + +**Default:** `1` + +--- + +### `:custom_args` + +Optional list of additional command-line arguments passed directly to +ReportGenerator. Useful for configuring Risk Hotspots and other settings not +covered by the options above. See the +[ReportGenerator settings wiki](https://github.com/danielpalme/ReportGenerator/wiki/Settings). + +```yaml +:gcov: + :report_generator: + :custom_args: + - <argument> +``` + +<br/><br/> diff --git a/docs/mkdocs/reference/global-collections.md b/docs/mkdocs/reference/global-collections.md new file mode 100644 index 000000000..ca92d4381 --- /dev/null +++ b/docs/mkdocs/reference/global-collections.md @@ -0,0 +1,160 @@ +# Global Collections Reference + +Collections are Ruby arrays and Rake FileLists (that act like +arrays). Ceedling populates and assembles these by +processing the project configuration, using internal knowledge, +expanding path globs, etc. at startup. + +Collections are globally available Ruby constants and are +typically used in Rakefiles, plugins, and Ruby scripts where +the contents tend to be especially handy for crafting custom +functionality. + +!!! warning "Collections are no longer a core component of Ceedling" + As Ceedling has grown in sophistication and as many of its + features now operate per test executable, the utility of and + number of collections has dwindled. + + Once upon a time, nearly all Ceedling actions happened in + bulk and with the same collections used for all tasks. This + is no longer true. + +## File collections + +* `COLLECTION_PROJECT_OPTIONS`: + + All project option files with path found in the configured + options paths having the configured YAML file extension. + +* `COLLECTION_ALL_TESTS`: + + All files with path found in the configured test paths + having the configured source file extension. + +* `COLLECTION_ALL_ASSEMBLY`: + + All files with path found in the configured source and + test support paths having the configured assembly file + extension. + +* `COLLECTION_ALL_SOURCE`: + + All files with path found in the configured source paths + having the configured source file extension. + +* `COLLECTION_ALL_HEADERS`: + + All files with path found in the configured include, + support, and test paths having the configured header file + extension. + +* `COLLECTION_ALL_SUPPORT`: + + All files with path found in the configured test support + paths having the configured source file extension. + +## Path collections + +* `COLLECTION_PATHS_INCLUDE`: + + All configured include paths. + +* `COLLECTION_PATHS_SOURCE`: + + All configured source paths. + +* `COLLECTION_PATHS_SUPPORT`: + + All configured support paths. + +* `COLLECTION_PATHS_TEST`: + + All configured test paths. + +* `COLLECTION_PATHS_SOURCE_AND_INCLUDE`: + + All configured source and include paths. + +* `COLLECTION_PATHS_SOURCE_INCLUDE_VENDOR`: + + All configured source and include paths plus applicable + vendor paths (Unity's source path plus CMock and + CException's source paths if mocks and exceptions are + enabled). + +* `COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE`: + + All configured test, support, source, and include paths. + +* `COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE_VENDOR`: + + All test, support, source, include, and applicable + vendor paths (Unity's source path plus CMock and + CException's source paths if mocks and exceptions are + enabled). + +* `COLLECTION_PATHS_RELEASE_TOOLCHAIN_INCLUDE`: + + All configured release toolchain include paths. + +* `COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE`: + + All configured test toolchain include paths. + +* `COLLECTION_PATHS_VENDOR`: + + Unity's source path plus CMock and CException's source + paths if mocks and exceptions are enabled. + +## Build input collections + +* `COLLECTION_RELEASE_BUILD_INPUT`: + + * All files with path found in the configured source + paths having the configured source file extension. + * If exceptions are enabled, the source files for + CException. + * If assembly support is enabled, all assembly files + found in the configured paths having the configured + assembly file extension. + +* `COLLECTION_EXISTING_TEST_BUILD_INPUT`: + + * All files with path found in the configured source + paths having the configured source file extension. + * All files with path found in the configured test + paths having the configured source file extension. + * Unity's source files. + * If exceptions are enabled, the source files for + CException. + * If mocks are enabled, the C source files for CMock. + * If assembly support is enabled, all assembly files + found in the configured paths having the configured + assembly file extension. + + This collection does not include .c files generated by + Ceedling and its supporting frameworks at build time + (e.g. test runners and mocks). Further, this collection + does not include source files added to a test + executable's build list with the `TEST_SOURCE_FILE()` + build directive macro. + +## Options and artifacts + +* `COLLECTION_VENDOR_FRAMEWORK_SOURCES`: + + Unity plus CMock, and CException's .c filenames (without + paths) if mocks and exceptions are enabled. + +* `COLLECTION_RELEASE_ARTIFACT_EXTRA_LINK_OBJECTS`: + + If exceptions are enabled, CException's .c filenames + (without paths) remapped to configured object file + extension. + +* `COLLECTION_TEST_FIXTURE_EXTRA_LINK_OBJECTS`: + + All test support source filenames (without paths) + remapped to configured object file extension. + +<br/><br/> diff --git a/docs/mkdocs/reference/index.md b/docs/mkdocs/reference/index.md new file mode 100644 index 000000000..9ea355a41 --- /dev/null +++ b/docs/mkdocs/reference/index.md @@ -0,0 +1,65 @@ +# Reference + +The Reference section is a quick-lookup companion to Ceedling's narrative +documentation. Where the other sections explain concepts and walk you through +workflows, these pages collect every command, macro, variable, constant, and +configuration section into compact, scannable lists. + +<div class="grid cards" markdown> + +- :material-console: **[Command Line][command-line]** + + --- + + All application commands and build task syntax. + +- :material-book-open-variant: **[Project Configuration][project-configuration]** + + --- + + Project configuration sections, project loading, and modifying + a project configuration with mixins. + +- :material-pound: **[Test Build Directives][build-directives]** + + --- + + Macros for affecting the build configuration for an individual test file. + +- :material-variable: **[Environment Variables][environment-vars]** + + --- + + Environment variables recognized by Ceedling. + +- :material-pound: **[Partials Macros][partials-macros]** + + --- + + All function selection and static variable access macros used with + [Partials](../testing-guide/partials/index.md) + +- :material-chart-bar: **[Coverage Reporting][coverage-reporting]** + + --- + + All [GCov plugin](../plugins/gcov/index.md) configuration options including + those for advanced reports with GCovr and ReportGenerator. + +- :material-database: **[Global Collections][global-collections]** + + --- + + All globally-accessible Ruby constants assembled at startup. + +</div> + +[command-line]: command-line.md +[project-configuration]: project-configuration.md +[build-directives]: build-directives.md +[environment-vars]: environment-vars.md +[partials-macros]: partials-macros.md +[global-collections]: global-collections.md +[coverage-reporting]: gcov-plugin.md + +<br/><br/> diff --git a/docs/mkdocs/reference/partials-macros.md b/docs/mkdocs/reference/partials-macros.md new file mode 100644 index 000000000..22c1b2770 --- /dev/null +++ b/docs/mkdocs/reference/partials-macros.md @@ -0,0 +1,79 @@ +# Partials Macros Reference + +!!! note "Documentation convention" + `*` is a stand-in or wildcard to refer to all variations of a particular macro type. + +--- + +## Partial directive macros + +!!! tip + For detailed usage guidance, conventions, and examples, see [Partial Directive Macros](../testing-guide/partials/directives.md). + +### Module selection macros + +#### Test Partials macros + +These macros expand to a string literal that names a generated header +file to be used with `#include`. `#include "ceedling.h"` must precede +their use. + +* **`TEST_PARTIAL_ALL_MODULE(module)`**<br/>Select all functions in the module to be testable by Partial. +* **`TEST_PARTIAL_PUBLIC_MODULE(module)`**<br/>Select all public (non-`static`, non-`inline`) functions in the module to be testable by Partial. +* **`TEST_PARTIAL_PRIVATE_MODULE(module)`**<br/>Select all private (`static` and `inline`) functions in the module to be testable by Partial. +* **`TEST_PARTIAL_MODULE(module)`**<br/>Begin with an empty function set to be testable by Partial; functions must be added explicitly via `TEST_PARTIAL_CONFIG`. + +`TEST_PARTIAL_*` macros expand to an implementation header filename. + +#### Mock Partials macros + +* **`MOCK_PARTIAL_ALL_MODULE(module)`**<br/>Select all functions in the module to be mockable by Partial. +* **`MOCK_PARTIAL_PUBLIC_MODULE(module)`**<br/>Select all public (non-`static`, non-`inline`) functions in the module to be mockable by Partial. +* **`MOCK_PARTIAL_PRIVATE_MODULE(module)`**<br/>Select all private (`static` and `inline`) functions in the module to be mockable by Partial. +* **`MOCK_PARTIAL_MODULE(module)`**<br/>Begin with an empty function set to be mockable by Partial; functions must be added explicitly via `MOCK_PARTIAL_CONFIG`. + +`MOCK_PARTIAL_*` macros expand to a mockable interface header filename. + +### Function list configuration macros + +These macros are statements (not to be used with `#include` directives) +that refine the set of functions selected by the use of any module selection +macro above. + +* **`TEST_PARTIAL_CONFIG(module, func...)`**<br/>Add or subtract functions from the test Partial’s function set. +* **`MOCK_PARTIAL_CONFIG(module, func...)`**<br/>Add or subtract functions from the mock Partial’s function set. + +### Function list addition and subtraction + +!!! tip + For the full rules on which additions and subtractions are valid with + each `*_MODULE` variant, see + [Partial directive macros](../testing-guide/partials/directives.md#partials-function-list-configuration-macros). + +* Prefix a function name with `-` (`-func`) to exclude it from the Partial. +* Use no prefix or `+` (`+func`) to include it. + +A function explicitly added to one side is automatically removed from the +complementary Partial to prevent duplicate symbol linker errors. + +--- + +## Promoted static variable access + +!!! tip + For the full explanation of how Ceedling promotes function-scoped `static` + variables to module scope and when to use this macro, see + [Accessing Static Variables](../testing-guide/partials/variables.md). + +**`PARTIAL_LOCAL_VAR(function_name, variable_name)`** + +Access a function-scoped `static` variable that Ceedling has promoted to +module scope in a generated Partial. The macro expands to the promoted +identifier `partial_<function_name>_<variable_name>`. + +- Defined in `ceedling.h`; requires `#include "ceedling.h"` above its use +- Both arguments must be literal C identifiers — not strings or runtime values +- Can appear anywhere a variable name is legal: expressions, assertions, + assignments + +<br/><br/> diff --git a/docs/mkdocs/reference/project-configuration.md b/docs/mkdocs/reference/project-configuration.md new file mode 100644 index 000000000..619f7f00f --- /dev/null +++ b/docs/mkdocs/reference/project-configuration.md @@ -0,0 +1,46 @@ +# Project Configuration Reference + +Ceedling's project configuration is assembled from one or more sources and +merged into a single in-memory data structure before any build begins. The +base configuration comes from a YAML project file. That base can be extended +by [Mixins](../configuration/reference/mixins.md) — additional YAML files +merged in after the base is loaded. Mixins can be specified from the command +line, via environment variables, or from within the project file using the +`:mixins:` section listed below. + +For the full details on how loading and merging work — including the search +order for the default project file and all Mixin sources — see +[Loading Configuration](../configuration/loading.md). For the environment +variables that influence configuration loading, see +[Environment Variables](../configuration/environment-vars.md). + +For conceptual overview and project file conventions, see +[Configuration](../configuration/index.md). + +## Configuration sections + +Each top-level key in your project YAML file corresponds to one configuration +section. All sections are optional; Ceedling supplies defaults for anything +left unset. + +| Section | Description | +|---|---| +| **[`:project`](../configuration/reference/project.md)** | Build root, build modes, version info, and global project switches | +| **[`:mixins`](../configuration/reference/mixins.md)** | Load and merge additional YAML configuration files into the base project configuration | +| **[`:test_build`](../configuration/reference/test-build.md)** | Test-specific build options: assembly support, mock generation, preprocessor use, and more | +| **[`:release_build`](../configuration/reference/release-build.md)** | Release artifact build options and output settings | +| **[`:paths`](../configuration/reference/paths.md)** | Source, test, include, support, library, and vendor path globs | +| **[`:files`](../configuration/reference/files.md)** | Explicit file inclusion and exclusion overrides | +| **[`:environment`](../configuration/reference/environment.md)** | Set and export environment variables from within the project configuration | +| **[`:extension`](../configuration/reference/extension.md)** | File extension strings for source, header, object, binary, and other file types | +| **[`:defines`](../configuration/reference/defines.md)** | Per-context, per-file preprocessor symbol definitions | +| **[`:flags`](../configuration/reference/flags.md)** | Compiler, linker, assembler, and preprocessor flags per build context | +| **[`:libraries`](../configuration/reference/libraries.md)** | Test and release libraries plus linker search paths | +| **[`:unity`](../configuration/reference/unity.md)** | Unity test framework configuration (defines, helper file paths, etc.) | +| **[`:cmock`](../configuration/reference/cmock.md)** | CMock mock generation configuration | +| **[`:test_runner`](../configuration/reference/test-runner.md)** | Test runner generation configuration | +| **[`:cexception`](../configuration/reference/cexception.md)** | CException configuration | +| **[`:plugins`](../configuration/reference/plugins.md)** | Enable built-in plugins and configure custom plugin load paths | +| **[`:tools`](../configuration/reference/tools.md)** | Define or override compiler, linker, and other tool invocations | + +<br/><br/> diff --git a/docs/mkdocs/snapshot.yml b/docs/mkdocs/snapshot.yml new file mode 100644 index 000000000..df3562ed3 --- /dev/null +++ b/docs/mkdocs/snapshot.yml @@ -0,0 +1,10 @@ +# Files snapshotted from the project tree into docs/snapshot/ for versioned documentation. +# Paths are relative to the project root. Each file or directory is copied to docs/snapshot/<path>, +# preserving directory structure. +# +# Run `rake docs:snapshot` (or `rake docs:build`) to refresh. + +files: + - assets/project.yml + - plugins/gcov/config/defaults_gcov.rb + - plugins/gcov/config/defaults.yml diff --git a/docs/mkdocs/snapshot/assets/project.yml b/docs/mkdocs/snapshot/assets/project.yml new file mode 100644 index 000000000..d693cbe4f --- /dev/null +++ b/docs/mkdocs/snapshot/assets/project.yml @@ -0,0 +1,388 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +--- +:project: + # how to use ceedling. If you're not sure, leave this as `gem` and `?` + :which_ceedling: gem + :ceedling_version: '?' + + # optional features. If you don't need them, keep them turned off for performance + :use_mocks: TRUE + :use_test_preprocessor: :none # options are :none, :mocks, :tests, or :all + :use_backtrace: :simple # options are :none, :simple, or :gdb + + # tweak the way ceedling handles automatic tasks + :build_root: build + :test_file_prefix: test_ + :default_tasks: + - test:all + + # performance options. If your tools start giving mysterious errors, consider + # dropping this to 1 to force single-tasking + :test_threads: 8 + :compile_threads: 8 + + # enable release build (more details in release_build section below) + :release_build: FALSE + +# Specify where to find mixins and any that should be enabled automatically +:mixins: + :enabled: [] + :load_paths: [] + +# further details to configure the way Ceedling handles test code +:test_build: + :use_assembly: FALSE + +# further details to configure the way Ceedling handles release code +:release_build: + :output: MyApp.out + :use_assembly: FALSE + :artifacts: [] + +# Plugins are optional Ceedling features which can be enabled. Ceedling supports +# a variety of plugins which may effect the way things are compiled, reported, +# or may provide new command options. Refer to the readme in each plugin for +# details on how to use it. +:plugins: + :load_paths: [] + :enabled: + #- beep # beeps when finished, so you don't waste time waiting for ceedling + - module_generator # handy for quickly creating source, header, and test templates + #- gcov # test coverage using gcov. Requires gcc, gcov, and a coverage analyzer like gcovr + #- bullseye # test coverage using bullseye. Requires bullseye for your platform + #- command_hooks # write custom actions to be called at different points during the build process + #- compile_commands_json_db # generate a compile_commands.json file + #- dependencies # automatically fetch 3rd party libraries, etc. + #- fake_function_framework # use FFF instead of CMock + + # Report options (You'll want to choose one stdout option, but may choose multiple stored options if desired) + #- report_build_warnings_log + #- report_tests_gtestlike_stdout + #- report_tests_ide_stdout + #- report_tests_log_factory + - report_tests_pretty_stdout + #- report_tests_raw_output_log + #- report_tests_teamcity_stdout + +# Specify which reports you'd like from the log factory +:report_tests_log_factory: + :reports: + - json + - junit + - cppunit + - html + +# override the default extensions for your system and toolchain +:extension: + #:header: .h + #:source: .c + #:assembly: .s + #:dependencies: .d + #:object: .o + :executable: .out + #:testpass: .pass + #:testfail: .fail + +# This is where Ceedling should look for your source and test files. +# see documentation for the many options for specifying this. +:paths: + :test: + - +:test/** + - -:test/support + :source: + - src/** + :include: + - src/** # In simple projects, this entry often duplicates :source + :support: + - test/support + :libraries: [] + +# You can even specify specific files to add or remove from your test +# and release collections. Usually it's better to use paths and let +# Ceedling do the work for you! +:files: + :test: [] + :source: [] + +# Compilation symbols to be injected into builds +# See documentation for advanced options: +# - Test name matchers for different symbols per test executable build +# - Referencing symbols in multiple lists using advanced YAML +# - Specifiying symbols used during test preprocessing +:defines: + :test: + - TEST # Simple list option to add symbol 'TEST' to compilation of all files in all test executables + :release: [] + + # Enable to inject name of a test as a unique compilation symbol into its respective executable build. + :use_test_definition: FALSE + +# Configure additional command line flags provided to tools used in each build step +# :flags: +# :release: +# :compile: # Add '-Wall' and '--02' to compilation of all files in release target +# - -Wall +# - --O2 +# :test: +# :compile: +# '(_|-)special': # Add '-pedantic' to compilation of all files in all test executables with '_special' or '-special' in their names +# - -pedantic +# '*': # Add '-foo' to compilation of all files in all test executables +# - -foo + +# Configuration Options specific to CMock. See CMock docs for details +:cmock: + # Core configuration + :plugins: # What plugins should be used by CMock? + - :ignore + - :callback + :verbosity: 2 # the options being 0 errors only, 1 warnings and errors, 2 normal info, 3 verbose + :when_no_prototypes: :warn # the options being :ignore, :warn, or :erro + + # File configuration + :skeleton_path: '' # Subdirectory to store stubs when generated (default: '') + :mock_prefix: 'mock_' # Prefix to append to filenames for mocks + :mock_suffix: '' # Suffix to append to filenames for mocks + + # Parser configuration + :strippables: ['(?:__attribute__\s*\([ (]*.*?[ )]*\)+)'] + :attributes: + - __ramfunc + - __irq + - __fiq + - register + - extern + :c_calling_conventions: + - __stdcall + - __cdecl + - __fastcall + :treat_externs: :exclude # the options being :include or :exclude + :treat_inlines: :exclude # the options being :include or :exclude + + # Type handling configuration + #:unity_helper_path: '' # specify a string of where to find a unity_helper.h file to discover custom type assertions + :treat_as: # optionally add additional types to map custom types + uint8: HEX8 + uint16: HEX16 + uint32: UINT32 + int8: INT8 + bool: UINT8 + #:treat_as_array: {} # hint to cmock that these types are pointers to something + #:treat_as_void: [] # hint to cmock that these types are actually aliases of void + :memcmp_if_unknown: true # allow cmock to use the memory comparison assertions for unknown types + :when_ptr: :compare_data # hint to cmock how to handle pointers in general, the options being :compare_ptr, :compare_data, or :smart + + # Mock generation configuration + :weak: '' # Symbol to use to declare weak functions + :enforce_strict_ordering: true # Do we want cmock to enforce ordering of all function calls? + :fail_on_unexpected_calls: true # Do we want cmock to fail when it encounters a function call that wasn't expected? + :callback_include_count: true # Do we want cmock to include the number of calls to this callback, when using callbacks? + :callback_after_arg_check: false # Do we want cmock to enforce an argument check first when using a callback? + #:includes: [] # You can add additional includes here, or specify the location with the options below + #:includes_h_pre_orig_header: [] + #:includes_h_post_orig_header: [] + #:includes_c_pre_header: [] + #:includes_c_post_header: [] + #:array_size_type: [] # Specify a type or types that should be used for array lengths + #:array_size_name: 'size|len' # Specify a name or names that CMock might automatically recognize as the length of an array + :exclude_setjmp_h: false # Don't use setjmp when running CMock. Note that this might result in late reporting or out-of-order failures. + +# Configuration options specific to Unity. +:unity: + :defines: + - UNITY_EXCLUDE_FLOAT + +# You can optionally have ceedling create environment variables for you before +# performing the rest of its tasks. +:environment: [] +# :environment: +# # List enforces order allowing later to reference earlier with inline Ruby substitution +# - :var1: value +# - :var2: another value +# - :path: # Special PATH handling with platform-specific path separators +# - #{ENV['PATH']} # Environment variables can use inline Ruby substitution +# - /another/path/to/include + +# LIBRARIES +# These libraries are automatically injected into the build process. Those specified as +# common will be used in all types of builds. Otherwise, libraries can be injected in just +# tests or releases. These options are MERGED with the options in supplemental yaml files. +:libraries: + :placement: :end + :flag: "-l${1}" + :path_flag: "-L ${1}" + :system: [] # for example, you might list 'm' to grab the math library + :test: [] + :release: [] + +################################################################ +# PLUGIN CONFIGURATION +################################################################ + +# Add -gcov to the plugins list to make sure of the gcov plugin +# You will need to have gcov and gcovr both installed to make it work. +# For more information on these options, see docs in plugins/gcov +:gcov: + :summaries: TRUE # Enable simple coverage summaries to console after tests + :report_task: FALSE # Disabled dedicated report generation task (this enables automatic report generation) + :utilities: + - gcovr # Use gcovr to create the specified reports (default). + #- ReportGenerator # Use ReportGenerator to create the specified reports. + :reports: # Specify one or more reports to generate. + # Make an HTML summary report. + - HtmlBasic + # - HtmlDetailed + # - Text + # - Cobertura + # - SonarQube + # - JSON + # - HtmlInline + # - HtmlInlineAzure + # - HtmlInlineAzureDark + # - HtmlChart + # - MHtml + # - Badges + # - CsvSummary + # - Latex + # - LatexSummary + # - PngChart + # - TeamCitySummary + # - lcov + # - Xml + # - XmlSummary + :gcovr: + # :html_artifact_filename: TestCoverageReport.html + # :html_title: Test Coverage Report + :html_medium_threshold: 75 + :html_high_threshold: 90 + # :html_absolute_paths: TRUE + # :html_encoding: UTF-8 + +# :module_generator: +# :naming: :snake #options: :bumpy, :camel, :caps, or :snake +# :includes: +# :tst: [] +# :src: [] +# :boilerplates: +# :src: "" +# :inc: "" +# :tst: "" + +# :dependencies: +# :libraries: +# - :name: WolfSSL +# :source_path: third_party/wolfssl/source +# :build_path: third_party/wolfssl/build +# :artifact_path: third_party/wolfssl/install +# :fetch: +# :method: :zip +# :source: \\shared_drive\third_party_libs\wolfssl\wolfssl-4.2.0.zip +# :environment: +# - CFLAGS+=-DWOLFSSL_DTLS_ALLOW_FUTURE +# :build: +# - "autoreconf -i" +# - "./configure --enable-tls13 --enable-singlethreaded" +# - make +# - make install +# :artifacts: +# :static_libraries: +# - lib/wolfssl.a +# :dynamic_libraries: +# - lib/wolfssl.so +# :includes: +# - include/** + +# :command_hooks: +# :pre_mock_preprocess: +# :post_mock_preprocess: +# :pre_test_preprocess: +# :post_test_preprocess: +# :pre_mock_generate: +# :post_mock_generate: +# :pre_runner_generate: +# :post_runner_generate: +# :pre_compile_execute: +# :post_compile_execute: +# :pre_link_execute: +# :post_link_execute: +# :pre_test_fixture_execute: +# :post_test_fixture_execute: +# :pre_test: +# :post_test: +# :pre_release: +# :post_release: +# :pre_build: +# :post_build: +# :post_error: + +################################################################ +# TOOLCHAIN CONFIGURATION +################################################################ + +#:tools: +# Ceedling defaults to using gcc for compiling, linking, etc. +# As [:tools] is blank, gcc will be used (so long as it's in your system path) +# See documentation to configure a given toolchain for use +# :tools: +# :test_compiler: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :test_linker: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :test_assembler: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :test_fixture: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :test_includes_preprocessor: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :test_file_preprocessor: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :test_file_preprocessor_directives: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :release_compiler: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :release_linker: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :release_assembler: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +# :release_dependencies_generator: +# :executable: +# :arguments: [] +# :name: +# :optional: FALSE +... diff --git a/docs/mkdocs/snapshot/plugins/gcov/config/defaults.yml b/docs/mkdocs/snapshot/plugins/gcov/config/defaults.yml new file mode 100644 index 000000000..bd5a3966a --- /dev/null +++ b/docs/mkdocs/snapshot/plugins/gcov/config/defaults.yml @@ -0,0 +1,32 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +--- +:gcov: + :summaries: TRUE # Enable simple coverage summaries to console after tests + :report_task: FALSE # Disabled dedicated report generation task (this enables automatic report generation) + :mcdc: FALSE # MC/DC (modified condition/decision) coverage — requires GCC 14+ and gcovr 8+ + + :utilities: + - gcovr # Defaults to `gcovr` as report generation utility + + :reports: [] # User must specify a report to enable report generation + + :gcovr: + :report_root: "." # Gcovr defaults to scanning for results starting in working directory + :config_file: ~ # Specify the location of the GCOVR configuration file to use that instead of settings config here + + # For v6.0+ merge coverage results for same function tested multiple times + # The default behavior is 'strict' which will cause a gcovr exception for many users + :merge_mode_function: merge-use-line-max + + :report_generator: + :verbosity: Warning # Default verbosity + :collection_paths_source: [] # Explicitly defined as default empty array to simplify option validation code + :custom_args: [] # Explicitly defined as default empty array to simplify option validation code + :gcov_exclude: [] # Explicitly defined as default empty array to simplify option validation code +... diff --git a/docs/mkdocs/snapshot/plugins/gcov/config/defaults_gcov.rb b/docs/mkdocs/snapshot/plugins/gcov/config/defaults_gcov.rb new file mode 100644 index 000000000..3f0d499a6 --- /dev/null +++ b/docs/mkdocs/snapshot/plugins/gcov/config/defaults_gcov.rb @@ -0,0 +1,128 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +DEFAULT_GCOV_COMPILER_TOOL = { + :executable => FilePathUtils.os_executable_ext('gcc').freeze, + :name => 'default_gcov_compiler'.freeze, + :optional => false.freeze, + :arguments => [ + "-g".freeze, + "-fprofile-arcs".freeze, + "-ftest-coverage".freeze, + "-I\"${5}\"".freeze, # Per-test executable search paths + "-D\"${6}\"".freeze, # Per-test executable defines + "-DGCOV_COMPILER".freeze, + "-DCODE_COVERAGE".freeze, + "-c \"${1}\"".freeze, + "-o \"${2}\"".freeze, + # gcc's list file output options are complex; no use of ${3} parameter in default config + "-MMD".freeze, + "-MF \"${4}\"".freeze, + ].freeze + } + +DEFAULT_GCOV_LINKER_TOOL = { + :executable => FilePathUtils.os_executable_ext('gcc').freeze, + :name => 'default_gcov_linker'.freeze, + :optional => false.freeze, + :arguments => [ + "-g".freeze, + "-fprofile-arcs".freeze, + "-ftest-coverage".freeze, + "${1}".freeze, + "${5}".freeze, + "-o \"${2}\"".freeze, + "${4}".freeze, + ].freeze + } + +DEFAULT_GCOV_FIXTURE_TOOL = { + :executable => '${1}'.freeze, + :name => 'default_gcov_fixture'.freeze, + :optional => false.freeze, + :arguments => [].freeze + } + +# Produce summaries printed to console +DEFAULT_GCOV_SUMMARY_TOOL = { + :executable => FilePathUtils.os_executable_ext('gcov').freeze, + :name => 'default_gcov_summary'.freeze, + :optional => true.freeze, + :arguments => [ + "-n".freeze, # --no-output + "-p".freeze, # --preserve-paths + "-b".freeze, # --branch-probabilities + "-o \"${2}\"".freeze, # --object-directory + "\"${1}\"".freeze + ].freeze + } + +# Produce .gcov files (used in conjunction with ReportGenerator) +DEFAULT_GCOV_REPORT_TOOL = { + :executable => FilePathUtils.os_executable_ext('gcov').freeze, + :name => 'default_gcov_report'.freeze, + :optional => true.freeze, + :arguments => [ + "-b".freeze, # --branch-probabilities + "-c".freeze, # --branch-counts + "-r".freeze, # --relative-only + "-x".freeze, # --hash-filenames + "${1}".freeze + ].freeze + } + +# Produce reports with `gcovr` +DEFAULT_GCOV_GCOVR_REPORT_TOOL = { + # No extension handling -- `gcovr` is generally an extensionless Python script + :executable => 'gcovr'.freeze, + :name => 'default_gcov_gcovr_report'.freeze, + :optional => true.freeze, + :arguments => [ + "${1}".freeze + ].freeze + } + +# Produce reports with `reportgenerator` +DEFAULT_GCOV_REPORTGENERATOR_REPORT_TOOL = { + :executable => FilePathUtils.os_executable_ext('reportgenerator').freeze, + :name => 'default_gcov_reportgenerator_report'.freeze, + :optional => true.freeze, + :arguments => [ + "${1}".freeze + ].freeze + } + +# Used internally to query GCC version at startup +DEFAULT_GCOV_GCC_VERSION_TOOL = { + :executable => FilePathUtils.os_executable_ext('gcc').freeze, + :name => 'default_gcov_gcc_version'.freeze, + :optional => false.freeze, + :arguments => ["--version"].freeze + } + +# Used internally to query gcovr version at startup +DEFAULT_GCOV_GCOVR_VERSION_TOOL = { + # No extension handling -- `gcovr` is generally an extensionless Python script + :executable => 'gcovr'.freeze, + :name => 'default_gcov_gcovr_version'.freeze, + :optional => true.freeze, + :arguments => ["--version"].freeze + } + +def get_default_config + return :tools => { + :gcov_compiler => DEFAULT_GCOV_COMPILER_TOOL, + :gcov_linker => DEFAULT_GCOV_LINKER_TOOL, + :gcov_fixture => DEFAULT_GCOV_FIXTURE_TOOL, + :gcov_summary => DEFAULT_GCOV_SUMMARY_TOOL, + :gcov_gcc_version => DEFAULT_GCOV_GCC_VERSION_TOOL, + :gcov_gcovr_version => DEFAULT_GCOV_GCOVR_VERSION_TOOL, + :gcov_report => DEFAULT_GCOV_REPORT_TOOL, + :gcov_gcovr_report => DEFAULT_GCOV_GCOVR_REPORT_TOOL, + :gcov_reportgenerator_report => DEFAULT_GCOV_REPORTGENERATOR_REPORT_TOOL, + } +end diff --git a/docs/mkdocs/testing-guide/build-directives.md b/docs/mkdocs/testing-guide/build-directives.md new file mode 100644 index 000000000..23ac65200 --- /dev/null +++ b/docs/mkdocs/testing-guide/build-directives.md @@ -0,0 +1,153 @@ +# Build Directive Macros + +## Overview of Build Directive Macros + +Ceedling supports a small number of build directive macros. At present, +these macros are only for use in test files. + +By placing these macros in your test files, you may control aspects of an +individual test executable's build from within the test file itself. + +These macros are actually defined in Unity, but they evaluate to empty +strings. That is, the macros do nothing and only serve as text markers for +Ceedling to parse. But, by placing them in your test files they +communicate instructions to Ceedling when scanned at the beginning of a +test build. + +**_Notes:_** + +- Since these macros are defined in _unity.h_, it's essential to + `#include "unity.h"` before making use of them in your test file. + Typically, _unity.h_ is referenced at or near the top of a test file + anyhow, but this is an important detail to call out. +- **`TEST_SOURCE_FILE()` and `TEST_INCLUDE_PATH()`, new in Ceedling + 1.0.0, are incompatible with enclosing conditional compilation C + preprocessing statements.** See + [Ceedling's preprocessing documentation](conventions.md#preprocessing-gotchas) + for more details. + +## `TEST_SOURCE_FILE()` + +### `TEST_SOURCE_FILE()` Purpose + +The `TEST_SOURCE_FILE()` build directive allows the simple injection of +a specific source file into a test executable's build. + +The Ceedling [convention](conventions.md) of compiling and linking +any C file that corresponds in name to an `#include`d header file does +not always work. A given source file may not have a header file that +corresponds directly to its name. In some specialized cases, a source +file may not rely on a header file at all. + +Attempting to `#include` a needed C source file directly is both ugly and +can cause various build problems with duplicated symbols, etc. + +`TEST_SOURCE_FILE()` is the way to cleanly and simply add a given C file +to the executable built from a test file. `TEST_SOURCE_FILE()` is also one +of the best methods for adding an assembly file to the build of a given +test executable—if assembly support is enabled for test builds. + +### `TEST_SOURCE_FILE()` Usage + +The argument for the `TEST_SOURCE_FILE()` build directive macro is a +single filename or filepath as a string enclosed in quotation marks. Use +forward slashes for path separators. The filename or filepath must be +present within Ceedling's source file collection. + +To understand your source file collection: + +- See the documentation for project file configuration section + [`:paths`](../configuration/reference/paths.md). +- Dump a listing your project's source files with the command line task + `ceedling files:source`. + +Multiple uses of `TEST_SOURCE_FILE()` are perfectly fine. You'll likely +want one per line within your test file. + +### `TEST_SOURCE_FILE()` Example + +```c +/* + * Test file test_mycode.c to exercise functions in mycode.c. + */ + +#include "unity.h" // Contains TEST_SOURCE_FILE() definition +#include "support.h" // Needed symbols and macros +//#include "mycode.h" // Header file corresponding to mycode.c by convention does not exist + +// Tell Ceedling to compile and link mycode.c as part of the test_mycode executable +TEST_SOURCE_FILE("foo/bar/mycode.c") + +// --- Unit test framework calls --- + +void setUp(void) { + ... +} + +void test_MyCode_FooBar(void) { + ... +} +``` + +## `TEST_INCLUDE_PATH()` + +### `TEST_INCLUDE_PATH()` Purpose + +The `TEST_INCLUDE_PATH()` build directive allows a header search path to +be injected into the build of an individual test executable. + +Unless you have a pretty funky C project, generally at least one search path entry +is necessary for every test executable build. That path can come from a `:paths` +↳ `:include` entry in your project configuration or by using `TEST_INCLUDE_PATH()` +in a test file. + +Please see [Configuring Your Header File Search Paths](conventions.md#search-paths-for-test-builds) +for an overview of Ceedling's options and conventions for header file search paths. + +### `TEST_INCLUDE_PATH()` Usage + +`TEST_INCLUDE_PATH()` entries in your test file are only an additive customization. +The path will be added to the base / common path list specified by +`:paths` ↳ `:include` in the project file. If no list is specified in your project +configuration, `TEST_INCLUDE_PATH()` entries will comprise the entire header search +path list. + +The argument for the `TEST_INCLUDE_PATH()` build directive macro is a single +filepath as a string enclosed in quotation marks. Use forward slashes for +path separators. + +**_Note_**: At present, a limitation of the `TEST_INCLUDE_PATH()` build directive +macro is that paths are relative to the working directory from which you are +executing `ceedling`. A change to your working directory could require updates to +the path arguments of dall instances of `TEST_INCLUDE_PATH()`. + +Multiple uses of `TEST_INCLUDE_PATH()` are perfectly fine. You'll likely want one +per line within your test file. + +### `TEST_INCLUDE_PATH()` Example + +```c +/* + * Test file test_mycode.c to exercise functions in mycode.c. + */ + +#include "unity.h" // Contains TEST_INCLUDE_PATH() definition +#include "somefile.h" // Needed symbols and macros + +// Add the following to the compiler's -I search paths used to +// compile all components comprising the test_mycode executable. +TEST_INCLUDE_PATH("foo/bar/") +TEST_INCLUDE_PATH("/usr/local/include/baz/") + +// --- Unit test framework calls --- + +void setUp(void) { + ... +} + +void test_MyCode_FooBar(void) { + ... +} +``` + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/conventions.md b/docs/mkdocs/testing-guide/conventions.md new file mode 100644 index 000000000..fd5d07fa6 --- /dev/null +++ b/docs/mkdocs/testing-guide/conventions.md @@ -0,0 +1,631 @@ +# Important Conventions & Behaviors + +**How to get things done and understand what's happening during builds** + +## Directory Structure, Filenames & Extensions + +Much of Ceedling's functionality is driven by collecting files +matching certain patterns inside the paths it's configured +to search. See the documentation for the `:extension` section +of your configuration file (found in the [configuration reference](../configuration/index.md)) to +configure the file extensions Ceedling uses to match and collect +files. Test file naming is covered later in this section. + +Test files and source files must be segregated by directories. +Any directory structure will do. Tests can be held in subdirectories +within source directories, or tests and source directories +can be wholly separated at the top of your project's directory +tree. + +## Search Paths for Test Builds + +Test builds in C are fairly complex. Each test file becomes a test +executable. Each test executable needs generated runner code and +optionally generated mocks. Slicing and dicing what files are +compiled and linked and how search paths are assembled is tricky +business. That's why Ceedling exists in the first place. Because +of these issues, search paths, in particular, require quite a bit +of special handling. + +Unless your project is relying exclusively on `extern` statements and +uses no mocks for testing, Ceedling _**must**_ be told where to find +header files. Without search path knowledge, mocks cannot be generated, +and test file compilation will fail for lack of symbol definitions +and function declarations. + +Ceedling provides two mechanisms for configuring search paths: + +1. The [`:paths` ↳ `:include`](../configuration/reference/paths.md) section within your + project file (or mixin files). +1. The [`TEST_INCLUDE_PATH(...)`](build-directives.md#test_include_path) build directive + macro. This is only available within test files. + +In testing contexts, you have three options for assembling the core of +the search path list used by Ceedling for test builds: + +1. List all search paths within the `:paths` ↳ `:include` subsection + of your project file. This is the simplest and most common approach. +1. Create the search paths for each test file using calls to the + `TEST_INCLUDE_PATH(...)` build directive macro within each test file. +1. Blending the preceding options. In this approach the subsection + within your project file acts as a common, base list of search + paths while the build directive macro allows the list to be + expanded upon for each test file. This method is especially helpful + for large and/or complex projects in trimming down + problematically long compiler command lines. + +As for the complete search path list for test builds created by Ceedling, +it is assembled from a variety of sources. In order: + +1. Mock generation build path (if mocking is enabled) +1. Paths provided via `TEST_INCLUDE_PATH(...)` build directive macro +1. Any paths within `:paths` ↳ `:test` list containing header files +1. `:paths` ↳ `:support` list from your project configuration +1. `:paths` ↳ `:include` list from your project configuration +1. `:paths` ↳ `:libraries` list from your project configuration +1. Internal path for Unity's unit test framework C code +1. Internal paths for CMock and CException's C code (if respective + features enabled) +1. `:paths` ↳ `:test_toolchain_include` list from your project + configuration + +The paths lists above are documented in detail in the discussion of +project configuration. + +_**Notes:**_ + +* The order of your `:paths` entries directly translates to the ordering + of search paths. +* The logic of the ordering above is essentially that: + * Everything above (5) should have precedence to allow test-specific + symbols, function signatures, etc. to be found before that of your + source code under test. This is the necessary pattern for effective + testing and test builds. + * Everything below (5) is supporting symbols and function signatures + for your source code. Your source code should be processed before + these for effective builds generally. +* (3) is a balancing act. It is entirely possible that test developers + will choose to create common files of symbols and supporting code + necessary for unit tests and choose to organize it alongside their + test files. A test build must be able to find these references. At the + same time it is highly unlikely every test directory path in a project + is necessary for a test build — particularly in large and sophisticated + projects. To reduce overall search path length and problematic command + lines, this convention tailors the search path. This is low risk + tailoring but could cause gotchas in edge cases or when Ceedling is + combined with other tools. Any other such tailoring is avoided as it + could too easily cause maddening build problems. +* Remember that the ordering of search paths is impacted by the merge + order of any Mixins. Paths specified with Mixins will be added to + path lists in your project configuration in the order of merging. + +## Search Paths for Release Builds + +Unlike test builds, release builds are relatively straightforward. Each +source file is compiled into an object file. All object files are linked. +A Ceedling release build may optionally compile and link in CException +and can handle linking in libraries as well. + +Search paths for release builds are configured with `:paths` ↳ `:include` +in your project configuration. That's about all there is to it. + +## Conventions for Source Files & Binary Release Artifacts + +Your binary release artifact results from the compilation and +linking of all source files Ceedling finds in the specified source +directories. At present only source files with a single (configurable) +extension are recognized. That is, `*.c` and `*.cc` files will not +both be recognized - only one or the other. See the configuration +options and defaults in the documentation for the `:extension` +sections of your configuration file (found in the [configuration reference](../configuration/index.md)). + +## Conventions for Test Files & Executable Test Fixtures + +Ceedling builds each individual test file with its accompanying +source file(s) into a single, monolithic test fixture executable. + +### Test File Naming Convention + +Ceedling recognizes test files by a naming convention — a (configurable) +prefix such as "`test_`" at the beginning of the file name with the same +file extension as used by your C source files. See the configuration options +and defaults in the documentation for the `:project` and `:extension` +sections of your configuration file (found in the [configuration reference](../configuration/index.md)). + +Depending on your configuration options, Ceedling can recognize +a variety of test file naming patterns in your test search paths. +For example, `test_some_super_functionality.c`, `TestYourSourceFile.cc`, +or `testing_MyAwesomeCode.C` could each be valid test file +names. Note, however, that Ceedling can recognize only one test +file naming convention per project. + +### Conventions for Source and Mock Files to Be Compiled & Linked + +Ceedling knows what files to compile and link into each individual +test executable by way of the `#include` list contained in each +test file and optional test directive macros. + +The `#include` list directs Ceedling in two ways: + +1. Any C source files in the configured project directories + corresponding to `#include`d header files will be compiled and + linked into the resulting test fixture executable. +1. If you are using mocks, header files with the appropriate + mocking prefix (e.g. `mock_foo.h`) direct Ceedling to find the + source header file (e.g. `foo.h`), generate a mock from it, and + compile & link that generated code into into the test executable + as well. + +Sometimes the source file you need to add to your test executable has +no corresponding header file — e.g. `file_abc.h` contains symbols +present in `file_xyz.c`. In these cases, you can use the test +directive macro `TEST_SOURCE_FILE(...)` to tell Ceedling to compile +and link the desired source file into the test executable (see +[macro documentation](build-directives.md)). + +That was a lot of information and many clauses in a very few +sentences; the commented example test file code that follows in a +bit will make it clearer. + +### Convention for Test Case Functions + Test Runner Generation + +By naming your test functions according to convention, Ceedling +will extract and collect into a generated test runner C file the +appropriate calls to all your test case functions. This runner +file handles all the execution minutiae so that your test file +can be quite simple. As a bonus, you'll never forget to wire up +a test function to be executed. + +In this generated runner lives the `main()` entry point for the +resulting test executable. There are no configurable options for +the naming convention of your test case functions. + +A test case function signature must have these elements: + +1. `void` return +1. `void` parameter list +1. A function name prepended with lowercase "`test`". + +In other words, a test function signature should look like this: +`void test<any_name_you_like>(void)`. + +## Ceedling preprocessing behavior for your tests + +### Preprocessing feature background and overview + +Ceedling and CMock are advanced tools that both perform fairly sophisticated +parsing. + +However, neither of these tools fully understands the entire C language, +especially C's preprocessing statements. + +If your test files rely on macros and `#ifdef` conditionals used in certain +ways (see examples below), there's a chance that Ceedling will break on trying +to process your test files, or, alternatively, your test suite will build but +not execute as expected. + +Similarly, generating mocks of header files with macros and `#ifdef` +conditionals around or in function signatures can get weird. Of course, it's +often in sophisticated projects with complex header files that mocking is most +desired in the first place. + +Ceedling includes an optional ability to preprocess the following files before +then extracting test cases and functions to be mocked with text parsing. + +1. Your test files, or +1. Mockable header files, or +1. Both of the above + +See the [`:project` ↳ `:use_test_preprocessor`][project-settings] project +configuration setting. + +This Ceedling feature uses `gcc`'s preprocessing mode and the `cpp` preprocessor +tool to strip down / expand test files and headers to their raw code content +that can then be parsed as text by Ceedling and CMock. These tools must be in +your search path if Ceedling's preprocessing is enabled. + +**Ceedling's test preprocessing abilities are directly tied to the features and +output of `gcc` and `cpp`. The default Ceedling tool definitions for these should +not be redefined for other toolchains. It is highly unlikely to work for you. +Future Ceedling improvements will allow for a plugin-style ability to use your +own tools in this highly specialized capacity.** + +[project-settings]: ../configuration/reference/project.md + +### Ceedling preprocessing limitations and gotchas + +#### Preprocessing limitations cheatsheet + +Ceedling's preprocessing abilities are generally quite useful — especially in +projects with multiple build configurations for different feature sets or +multiple targets, legacy code that cannot be refactored, and complex header +files provided by vendors. + +However, best applying Ceedling's preprocessing abilities requires understanding +how the feature works, when to use it, and its limitations. + +At a high level, Ceedling's preprocessing is applicable for cases where macros +or conditional compilation preprocessing statements (e.g. `#ifdef`): + +* Generate or hide/reveal your test files' `#include` statements. +* Generate or hide/reveal your test files' test case function signatures + (e.g. `void test_foo()`. +* Generate or hide/reveal mockable header files' `#include` statements. +* Generate or hide/reveal header files' mockable function signatures. + +**_NOTE:_ You do not necessarily need to enable Ceedling's preprocessing only +because you have preprocessing statements in your test files or mockable header +files. The feature is only truly needed if your project meets the conditions +above.** + +The sections that follow flesh out the details of the bulleted list above. + +#### Preprocessing gotchas + +**_IMPORTANT:_ As of Ceedling 1.0.0, Ceedling's test preprocessing feature +has a limitation that affects Unity features triggered by the following macros.** + +* `TEST_CASE()` +* `TEST_RANGE()` + +`TEST_CASE()` and `TEST_RANGE()` are Unity macros that are positional in a file +in relation to the test case functions they modify. While Ceedling's test file +preprocessing can preserve these macro calls, their position cannot be preserved. + +That is, Ceedling's preprocessing and these Unity features are not presently +compatible. Note that it _is_ possible to enable preprocessing for mockable +header files apart from enabling it for test files. See the documentation for +`:project` ↳ `:use_test_preprocessing`. This can allow test preprocessing in the +common cases of sophtisticate mockable headers while Unity's `TEST_CASE()` and +`TEST_RANGE()` are utilized in a test file untouched by preprocessing. + +**_IMPORTANT:_ The following new build directive macro `TEST_INCLUDE_PATH()` +available in Ceedling 1.0.0 is incompatible with enclosing conditional +compilation C preprocessing statements:** + +Wrapping `TEST_INCLUDE_PATH()` in conditional compilation statements +(e.g. `#ifdef`) will not behave as you expect. This macro is used as a marker +for advanced abilities discovered by Ceedling parsing a test file as plain text. +Whether or not Ceedling preprocessing is enabled, Ceedling will always discover +this marker macro in the plain text of a test file. + +Why is `TEST_INCLUDE_PATH()` incompatible with `#ifdef`? Well, it's because of +a cyclical dependency that cannot be resolved. In order to perform test +preprocessing, we need a full complement of `#include` search paths. These +could be provided, in part, by `TEST_INCLUDE_PATH()`. But, if we allow +`TEST_INCLUDE_PATH()` to be placed within conditional compilation C +preprocessing statements, our search paths may be different after test +preprocessing! The only solution is to disallow this and scan a test file as +plain text looking for this macro at the beginning of a test build. + +**_Notes:_** + +* `TEST_SOURCE_FILE()` _can_ be placed within conditional compilation + C preprocessing statements. +* `TEST_INCLUDE_PATH()` & `TEST_SOURCE_FILE()` can be "hidden" from Ceedling's + text scanning with traditional C comments. + +### Preprocessing of your test files + +When preprocessing is enabled for test files, Ceedling will expand preprocessor +statements in test files before extracting `#include` conventions and test case +signatures. That is, preprocessing output is used to generate test runners +and assemble the components of a test executable build. + +!!! tip "Preprocessing Not Needed Inside Test Functions" + Conditional directives _inside_ test case functions generally do not require + Ceedling's test preprocessing ability. Assuming your code is correct, the C + preprocessor within your toolchain will do the right thing for you in your + test build. Read on for more details and the other cases of interest. + +Test file preprocessing by Ceedling is applicable primarily when conditional +preprocessor directives generate the `#include` statements for your test file +and/or generate or enclose full test case functions. Ceedling will not be able +to properly discover your `#include` statements or test case functions unless +they are plainly available in an expanded, raw code version of your test file. +Ceedling's preprocessing abilities provide that expansion. + +#### Examples of when Ceedling preprocessing **_is_** needed for test files + +Generally, Ceedling preprocessing is needed when: + +1. `#include` statements are generated by macros +1. `#include` statements are conditionally present due to `#ifdef` statements +1. Test case function signatures are generated by macros +1. Test case function signatures are conditionaly present due to `#ifdef` statements + +```c +// #include conventions are not recognized for anything except #include "..." statements +INCLUDE_STATEMENT_MAGIC("header_file") +``` +```c +// Test file scanning will always see this #include statement +#ifdef BUILD_VARIANT_A +#include "mock_FooBar.h" +#endif +``` +```c +// Test runner generation scanning will see the test case function signature and think this test case exists in every build variation +#ifdef MY_SUITE_BUILD +void test_some_test_case(void) { + TEST_ASSERT_EQUALS(...); +} +#endif +``` +```c +// Test runner generation will not recognize this as a test case when scanning the file +void TEST_CASE_MAGIC("foo_bar_case") { + TEST_ASSERT_EQUALS(...); +} +``` + +#### Examples of when test preprocessing is **_not_** needed for test files + +```c +// Code inside a test case is simply code that your toolchain will expand and build as you desire +// You can manage your compile time symbols with the :defines section of your project configuration file +void test_some_test_case(void) { +#ifdef BUILD_VARIANT_A + TEST_ASSERT_EQUALS(...); +#endif + +#ifdef BUILD_VARIANT_B + TEST_ASSERT_EQUALS(...); +#endif +} +``` + +### Preprocessing of mockable header files + +When preprocessing is enabled for mocking, Ceedling will expand preprocessor +statements in header files before generating mocks from them. CMock requires +a clear look at function definitions and types in order to do its work. + +Header files with preprocessor directives and conditional macros can easily +obscure details from CMock's limited C parser. Advanced C projects tend +to rely on preprocessing directives and macros to accomplish everything from +build variants to OS calls to register access to managing proprietary language +extensions. + +Mocking is often most useful in complicated codebases. As such Ceedling's +preprocessing abilities tend to be quite necessary to properly expand header +files so CMock can parse them. + +#### Examples of when Ceedling preprocessing **_is_** needed for mockable headers + +Generally, Ceedling preprocessing is needed when: + +1. Function signatures are formed by macros +1. Function signatures are conditionaly present due to surrounding `#ifdef` + statements +1. Macros expand to become function decorators, return types, or parameters + +**_Important Notes:_** + +* Sometimes CMock's parsing features can be configured to handle scenarios + that fall within (3) above. CMock can match and remove most text strings, + match and replace certain text strings, map custom types to mockable + alternatives, and be extended with a Unity helper to handle complex and + compound types. See [CMock]'s documentation for more. + +* Test preprocessing causes any macros or symbols in a mockable header to + "disappear" in the generated mock. It's quite common to have needed symbols + or macros in a header file that do not directly impact the function + signatures to be mocked. This can break compilation of your test suite. + + Possible solutions to this problem include: + + 1. Move symbols and macros in your header file that do not impact function + signatures to another source header file that will not be filtered + by Ceedling's header file preprocessing. + 1. If (1) is not possible, you may duplicate the needed symbols and macros + in a header file that is only available in your test build search paths + and include it in your test file. + +```c +// Header file scanning will see this function signature but mistakenly mock the name of the macro +void FUNCTION_SIGNATURE_MAGIC(...); +``` + +```c +// Header file scanning will always see this function signature +#ifdef BUILD_VARIANT_A +unsigned int someFunction(void); +#endif +``` + +```c +// Header file scanning will either fail for this function signature or extract erroneous type names +INLINE_MAGIC RETURN_TYPE_MAGIC someFunction(PARAMETER_MAGIC); +``` + +## Execution time (duration) reporting in Ceedling operations & test suites + +### Ceedling's logged run times + +Ceedling logs two execution times for every project run. + +It first logs the set up time necessary to process your project file, parse code +files, build an internal representation of your project, etc. This duration does +not capture the time necessary to load the Ruby runtime itself. + +``` +Ceedling set up completed in 223 milliseconds +``` + +Secondly, each Ceedling run also logs the time necessary to run all the tasks +you specify at the command line. + +``` +Ceedling operations completed in 1.03 seconds +``` + +### Ceedling test suite and Unity test executable run durations + +A test suite comprises one or more Unity test executables (see +[Anatomy of a Test Suite](test-suite-anatomy.md)). Ceedling times indvidual Unity +test executable run durations. It also sums these into a total test suite +execution time. These duration values are typically used in generating test +reports via plugins. + +Not all test report formats utilize duration values. For those that do, some +effort is usually required to map Ceedling duration values to a relevant test +suite abstraction within a given test report format. + +Because Ceedling can execute builds with multiple threads, care must be taken +to interpret test suite duration values — particularly in relation to +Ceedling's logged run times. + +In a multi-threaded build it's quite common for the logged Ceedling project run +time to be less than the total suite time in a test report. In multi-threaded +builds on multi-core machines, test executables are run on different processors +simultaneously. As such, the total on-processor time in a test report can +exceed the operation time Ceedling itself logs to the console. Further, because +multi-threading tends to introduce context switching and processor scheduling +overhead, the run duration of a test executable may be reported as longer than +a in a comparable single-threaded build. + +### Unity test case run times + +Individual test case exection time tracking is specifically a [Unity] feature +(see its documentation for more details). If enabled and if your platform +supports the time mechanism Unity relies on, Ceedling will automatically +collect test case time values — generally made use of by test report plugins. + +To enable test case duration measurements, they must be enabled as a Unity +compilation option. Add `UNITY_INCLUDE_EXEC_TIME` to Unity's compilation +symbols (`:unity` ↳ `:defines`) in your Ceedling project file (see example +below). Unity test case durations as reported by Ceedling default to 0 if the +compilation option is not set. + +```yaml +:unity: + :defines: + - UNITY_INCLUDE_EXEC_TIME +``` + +_NOTE:_ Most test cases are quite short, and most computers are quite fast. As + such, Unity test case execution time is often reported as 0 milliseconds as + the CPU execution time for a test case typically remains in the microseconds + range. Unity would require special rigging that is inconsistently available + across platforms to measure test case durations at a finer resolution. + +## The Magic of Dependency Tracking + +Previous versions of Ceedling used features of Rake to offer +various kinds of smart rebuilds--that is, only regenerating files, +recompiling code files, or relinking executables when changes within +the project had occurred since the last build. Optional Ceedling +features discovered "deep dependencies" such that, for example, a +change in a header file several nested layers deep in `#include` +statements would cause all the correct test executables to be +updated and run. + +These features have been temporarily disabled and/or removed for +test suites and remain in limited form for release build while +Ceedling undergoes a major overhaul. + +Please see the [Release Notes](https://github.com/ThrowTheSwitch/Ceedling/blob/master/docs/ReleaseNotes.md). + +### Notes on (Not So) Smart Rebuids + +* New features that are a part of the Ceedling overhaul can + significantly speed up test suite execution and release builds + despite the present behavior of brute force running all build + steps. See the discussion of enabling multi-threaded builds in + later sections. + +* When smart rebuilds return, they will further speed up builds as + will other planned optimizations. + +## Ceedling's Build Output (Files, That Is) + +Ceedling requires a top-level build directory for all the stuff +that it, the accompanying test tools, and your toolchain generate. +That build directory's location is configured in the top-level +`:project` section of your configuration file (discussed in the +[configuration reference](../configuration/index.md)). There +can be a ton of generated files. By and large, you can live a full +and meaningful life knowing absolutely nothing at all about +the files and directories generated below the root build directory. + +As noted already, it's good practice to add your top-level build +directory to source control but nothing generated beneath it. +you'll spare yourself headache if you let Ceedling delete and +regenerate files and directories in a non-versioned corner +of your project's filesystem beneath the top-level build directory. + +The `artifacts/` directory is the one and only directory you may +want to know about beneath the top-level build directory. The +subdirectories beneath `artifacts` will hold your binary release +target output (if your project is configured for release builds) +and will serve as the conventional location for plugin output. +This directory structure was chosen specifically because it +tends to work nicely with Continuous Integration setups that +recognize and list build artifacts for retrieval / download. + +## Build _Errors_ vs. Test _Failures_. Oh, and Exit Codes. + +### Errors vs. Failures + +Ceedling will run a specified build until an **_error_**. An error +refers to a build step encountering an unrecoverable problem. Files +not found, nonexistent paths, compilation errors, missing symbols, +plugin exceptions, etc. are all errors that will cause Ceedling +to immediately end a build. + +A **_failure_** refers to a test failure. That is, an assertion of +an expected versus actual value failed within a unit test case. +A test failure will not stop a build. Instead, the suite will run +to completion with test failures collected and reported along with +all test case statistics. + +### Ceedling Exit Codes + +In its default configuration, Ceedling terminates with an exit code +of `1`: + + * On any build error and immediately terminates upon that build + error. + * On any test case failure but runs the build to completion and + shuts down normally. + +This behavior can be especially handy in Continuous Integration +environments where you typically want an automated CI build to break +upon either build errors or test failures. + +If this exit code convention for test failures does not work for you, +no problem-o. You may be of the mind that running a test suite to +completion should yield a successful exit code (even if tests failed). +Add the following to your project file to force Ceedling to finish a +build with an exit code of 0 even upon test case failures. + +```yaml +# Ceedling terminates with happy `exit(0)` even if test cases fail +:test_build: + :graceful_fail: true +``` + +If you use the option for graceful failures in CI, you'll want to +rig up some kind of logging monitor that scans Ceedling's test +summary report sent to `$stdout` and/or a log file. Otherwise, you +could have a successful build but failing tests. + +### Notes on Unity Test Executable Exit Codes + +Ceedling works by collecting multiple Unity test executables together +into a test suite (more here: [Anatomy of a Test Suite](test-suite-anatomy.md)). + +A Unity test executable's exit code is the number of failed tests. An +exit code of `0` means all tests passed while anything larger than zero +is the number of test failures. + +Because of platform limitations on how big an exit code number can be +and because of the logical complexities of distinguishing test failure +counts from build errors or plugin problems, Ceedling conforms to a +much simpler exit code convention than Unity: `0` = 🙂 while `1` = ☹️. + +[CMock]: http://github.com/ThrowTheSwitch/CMock +[Unity]: http://github.com/ThrowTheSwitch/Unity + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/frameworks.md b/docs/mkdocs/testing-guide/frameworks.md new file mode 100644 index 000000000..3ca3d6f10 --- /dev/null +++ b/docs/mkdocs/testing-guide/frameworks.md @@ -0,0 +1,192 @@ +# Using Unity, CMock & CException + +If you jumped ahead to this section but do not follow some of the +lingo here, please jump back to an [earlier section for definitions +and helpful links](../overview/tools-and-frameworks.md#so-many-tools-and-acronyms). + +## How Ceedling supports, well, its supporting frameworks + +If you are using Ceedling for unit testing, this means you are using Unity, +the C testing framework. Unity is fully built-in and enabled for test builds. +It cannot be disabled. + +If you want to use mocks in your test cases, you'll need to enable mocking +and configure CMock with `:project` ↳ `:use_mocks` and the `:cmock` section +of your project configuration respectively. CMock is fully supported by +Ceedling but generally requires some set up for your project’s needs. + +If you are incorporating CException into your release artifact, you'll need +to enable exceptions and configure CException with `:project` ↳ +`:use_exceptions` and the `:cexception` section of your project +configuration respectively. Enabling CException makes it available in both +release builds and test builds. + +This section provides a high-level view of how the various tools become +part of your builds and fit into Ceedling’s configuration file. Ceedling’s +configuration file is discussed in detail in the next section. + +See [Unity], [CMock], and [CException]’s project documentation for all +your configuration options. Ceedling offers facilities for providing these +frameworks their compilation and configuration settings. Discussing +these tools and all their options in detail is beyond the scope of Ceedling +documentation. + +## Unity Configuration + +Unity is wholly compiled C code. As such, its configuration is entirely +controlled by a variety of compilation symbols. These can be configured +in Ceedling’s `:unity` project settings. + +### Example Unity configurations + +#### Itty bitty processor & toolchain with limited test execution options + +```yaml +:unity: + :defines: + - UNITY_INT_WIDTH=16 # 16 bit processor without support for 32 bit instructions + - UNITY_EXCLUDE_FLOAT # No floating point unit +``` + +#### Great big gorilla processor that grunts and scratches + +```yaml +:unity: + :defines: + - UNITY_SUPPORT_64 # Big memory, big counters, big registers + - UNITY_LINE_TYPE=\"unsigned int\" # Apparently, we're writing lengthy test files, + - UNITY_COUNTER_TYPE=\"unsigned int\" # and we've got a ton of test cases in those test files + - UNITY_FLOAT_TYPE=\"double\" # You betcha +``` + +#### Example Unity configuration header file + +Sometimes, you may want to funnel all Unity configuration options into a +header file rather than organize a lengthy `:unity` ↳ `:defines` list. Perhaps your +symbol definitions include characters needing escape sequences in YAML that are +driving you bonkers. + +```yaml +:unity: + :defines: + - UNITY_INCLUDE_CONFIG_H +``` + +```c +// unity_config.h +#ifndef UNITY_CONFIG_H +#define UNITY_CONFIG_H + +#include "uart_output.h" // Helper library for your custom environment + +#define UNITY_INT_WIDTH 16 +#define UNITY_OUTPUT_START() uart_init(F_CPU, BAUD) // Helper function to init UART +#define UNITY_OUTPUT_CHAR(a) uart_putchar(a) // Helper function to forward char via UART +#define UNITY_OUTPUT_COMPLETE() uart_complete() // Helper function to inform that test has ended + +#endif +``` + +### Routing Unity’s report output + +Unity defaults to using `putchar()` from C’s standard library to +display test results. + +For more exotic environments than a desktop with a terminal — e.g. +running tests directly on a non-PC target — you have options. + +For instance, you could create a routine that transmits a character via +RS232 or USB. Once you have that routine, you can replace `putchar()` +calls in Unity by overriding the function-like macro `UNITY_OUTPUT_CHAR`. + +Even though this override can also be defined in Ceedling YAML, most +shell environments do not handle parentheses as command line arguments +very well. Consult your toolchain and shell documentation. + +If redefining the function and macros breaks your command line +compilation, all necessary options and functionality can be defined in +`unity_config.h`. Unity will need the `UNITY_INCLUDE_CONFIG_H` symbol in the +`:unity` ↳ `:defines` list of your Ceedling project file (see example above). + +## CMock Configuration + +CMock is enabled in Ceedling by default. However, no part of it enters a +test build unless mock generation is triggered in your test files. +Triggering mock generation is done by an `#include` convention. See the +section on [Ceedling conventions and behaviors](conventions.md) for more. + +You are welcome to disable CMock in the `:project` block of your Ceedling +configuration file. This is typically only useful in special debugging +scenarios or for Ceedling development itself. + +CMock is a mixture of Ruby and C code. CMock’s Ruby components generate +C code for your unit tests. CMock’s base C code is compiled and linked into +a test executable in the same way that any C file is — including Unity, +CException, and generated mock C code, for that matter. + +CMock’s code generation can be configured using YAML similar to Ceedling +itself. Ceedling’s project file is something of a container for CMock’s +YAML configuration (Ceedling also uses CMock’s configuration, though). + +See the documentation for the top-level [`:cmock`][cmock-yaml-config] +section within Ceedling’s project file. + +[cmock-yaml-config]: ../configuration/reference/cmock.md + +Like Unity and CException, CMock’s C components are configured at +compilation with symbols managed in your Ceedling project file’s +`:cmock` ↳ `:defines` section. + +### Example CMock configurations + +```yaml +:project: + # Shown for completeness -- CMock enabled by default in Ceedling + :use_mocks: TRUE + +:cmock: + :when_no_prototypes: :warn + :enforce_strict_ordering: TRUE + :defines: + # Memory alignment (packing) on 16 bit boundaries + - CMOCK_MEM_ALIGN=1 + :plugins: + - :ignore + :treat_as: + uint8: HEX8 + uint16: HEX16 + uint32: UINT32 + int8: INT8 + bool: UINT8 +``` + +## CException Configuration + +Like Unity, CException is wholly compiled C code. As such, its +configuration is entirely controlled by a variety of `#define` symbols. +These can be configured in Ceedling’s `:cexception` ↳ `:defines` project +settings. + +Unlike Unity which is always available in test builds and CMock that +defaults to available in test builds, CException must be enabled +if you wish to use it in your project. + +### Example CException configurations + +```yaml +:project: + # Enable CException for both test and release builds + :use_exceptions: TRUE + +:cexception: + :defines: + # Possible exception codes of -127 to +127 + - CEXCEPTION_T='signed char' + +``` + +[Unity]: http://github.com/ThrowTheSwitch/Unity +[CMock]: http://github.com/ThrowTheSwitch/CMock +[CException]: http://github.com/ThrowTheSwitch/CException + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/index.md b/docs/mkdocs/testing-guide/index.md new file mode 100644 index 000000000..a0e5c8af1 --- /dev/null +++ b/docs/mkdocs/testing-guide/index.md @@ -0,0 +1,80 @@ +# Testing Guide + +!!! danger "Test file naming in Windows" + **_Test filenames should not include “patch” or “setup”_**. + Test filenames become test executables. Windows Installer Detection + Technology (part of UAC) requires administrator privileges to run + executables with this naming. + +## Test Cases & Test Suites + +<div class="grid cards" markdown> + +- :material-help-circle: **[How Does a Test Case Even Work?][test-cases]** + + --- + + A brief overview of how test cases work with simple examples illustrating + assertions and mocks. + +- :material-file-code: **[Commented Sample Test File][test-sample]** + + --- + + A sample test file illustrating the Ceedling conventions that make it go. + Includes a discussion of what gets compiled and linked into a test executable. + +- :material-layers: **[Anatomy of a Test Suite][test-suite-anatomy]** + + --- + + How a unit test grows up to become a test suite — what a test executable + is, why there are multiple, and Ceedling’s role in building and running them. + +</div> + +## Testing with Ceedling + +<div class="grid cards" markdown> + +- :material-book-open-page-variant: **[Important Conventions & Behaviors][conventions]** + + --- + + Much of what Ceedling accomplishes is by convention. Code and file structures + and naming trigger sophisticated test build features. Also covers search paths, + file extensions, preprocessing, and more. + +- :material-link-variant: **[Using Unity, CMock & CException][frameworks]** + + --- + + Ceedling connects the Unity, CMock, and CException frameworks — each of which + can require configuration of its own. Ceedling facilitates this. + +- :material-content-cut: **[Partials][partials]** + + --- + + Partials are like a scalpel for your source code. A generated partial allows + you to test and mock parts of your code you could not otherwise access + without rewriting it first. + +- :material-pound: **[Build Directive Macros][build-directives]** + + --- + + In-test macros to accomplish build goals when Ceedling’s conventions aren’t + quite enough — adding source files, handling include paths, and more. + +</div> + +[test-cases]: test-cases.md +[test-sample]: test-sample.md +[test-suite-anatomy]: test-suite-anatomy.md +[conventions]: conventions.md +[frameworks]: frameworks.md +[build-directives]: build-directives.md +[partials]: partials/index.md + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/configuration.md b/docs/mkdocs/testing-guide/partials/configuration.md new file mode 100644 index 000000000..103d36531 --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/configuration.md @@ -0,0 +1,67 @@ +# Configuring Partials + +!!! note "Documentation convention" + `*` is a stand-in or wildcard to refer to all variations of a particular macro type. + +## Enabling Partials + +Enable Partials in your project YAML under the `:project:` block: + +```yaml +:project: + :use_partials: true +``` + +See the [:project: configuration reference](../../configuration/reference/project.md#use_partials) +for the full setting description. + +Enabling `:use_partials:` automatically: + +- Enables `:use_mocks` as Partials depend on CMock’s mocking infrastructure. +- Supersedes CMock’s `:treat_inlines` setting. With Partials, Ceedling manages + inline function exposure directly. + +## `#include "ceedling.h"` + +Every test file that uses Partials must include `ceedling.h` before any Partial +directive macros: + +```c +#include "unity.h" +#include "ceedling.h" // Required -- defines all Partial directive macros +``` + +`ceedling.h` defines the `TEST_PARTIAL_*`, `MOCK_PARTIAL_*`, `*_CONFIG`, and +`PARTIAL_LOCAL_VAR()` macros. Without this include, the macros are undefined. + +## Partials macro categories + +See the [Partials Macros Reference](../../reference/partials-macros.md) for the complete listing. + +### [Module selection](../../reference/partials-macros.md#module-selection-macros) + +`TEST_PARTIAL_*_MODULE(module)` / `MOCK_PARTIAL_*_MODULE(module)` + +Used as the argument to an `#include` directive to select which functions from a module +are gathered into a Test Partial or Mock Partial and establish the base function set. +These macros also expand into the unique names of the generated Partials header files. + +### [Function list configuration](../../reference/partials-macros.md#function-list-configuration-macros) + +`TEST_PARTIAL_CONFIG(module, func...)` / `MOCK_PARTIAL_CONFIG(module, func...)` + +Statements (not `#include` arguments) that add or subtract individual functions +from the base set established by the module selection macro. + +### [Static variable access](../../reference/partials-macros.md#promoted-static-variable-access) + +!!! note + Module-scope static variables are automatically `extern`ed in a generated Partial + and can be accessed directly in your test cases. + +`PARTIAL_LOCAL_VAR(function_name, variable_name)` + +Accesses a function-scoped `static` variable that Ceedling has promoted to +module scope in a generated Partial. + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/conventions.md b/docs/mkdocs/testing-guide/partials/conventions.md new file mode 100644 index 000000000..1b1e2799e --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/conventions.md @@ -0,0 +1,68 @@ +# Conventions and Terminology + +## Modules + +In Ceedling Partials, a _module_ is a C source file, a C header file, or a matched +source + header pair sharing the same base filename. The base filename — +without its extension — is the _module name_. + +| Files present | Module name | +|---|---| +| `sensor.c` and `sensor.h` | `sensor` | +| `sensor.h` only | `sensor` | +| `sensor.c` only | `sensor` | + +When both a source file and a header file share a name, Ceedling treats them +as a single unit. Both files are read when generating a Partial. When only +one file is present, only that file is read. + +All Partial directive macros take a module name — a bare filename stem with +no extension, no path, and no quotation marks: + +```c +// Module name: 'sensor' +// Not "sensor.c" (no quotation marks) or "path/to/sensor" +#include TEST_PARTIAL_PRIVATE_MODULE(sensor) +``` + +## Public / Private Functions + +C has no access modifiers. Every function with external linkage is — from the +language's perspective — equally visible at link time. In the context of +Partials, Ceedling uses the more modern terms _public_ and _private_ to +describe a practical distinction based on function decorators: + +### Private functions + +“Private” functions carry one or more of the following keywords anywhere in +their declaration or definition: + +* `static` +* `inline` +* `__inline` +* `__inline__` +* `__forceinline` + +A `static` function has internal linkage. It is invisible to the linker +outside its containing translation unit, and therefore cannot be called or +mocked from a test build without special handling. `inline` functions may be +folded away by the compiler entirely. Partials use decorators to organize +lists of functions for testing and mocking, but the decorators are stripped +in the resulting generated code. + +!!! note "Because of preproccesing only the “private” keywords are recognized" + The preprocesing steps that are part of generating Partials expand any + macros (e.g. `INLINE` or `STATICINLINE`) to the actual keywords decorating + function signatures. As such, only the keywords above must be handled. + +### Public functions + +“Public” functions are everything else — functions with no visibility- +restricting decorator and ordinary external linkage. + +This public/private distinction is one set of filters for assembling a list +of functions each `_MODULE` macro selects. The filtering and collection is +documented in detail in the +[Partials function-selection by macro](directives.md#partials-function-selection-by-macro) section. + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/directives.md b/docs/mkdocs/testing-guide/partials/directives.md new file mode 100644 index 000000000..31a9fb82e --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/directives.md @@ -0,0 +1,207 @@ +# Partial Directive Macros + +All Partial configuration is expressed through C macros placed +in your test file. No separate configuration file is required. The macros +require `#include "ceedling.h"` present in the test file above and before +their use. + +The [Partial `_MODULE` macros](#partials-function-selection-by-macro) accomplish the following: + +1. Expand to a filename for the preceding `#include` directive. +1. Provide a module name for Ceedling to process for the resulting Partial. +1. Assemble a base set of functions to test or mock. + +The optional Partial `_CONFIG` macros modify the base set of functions +from (3). Modifying a base set of functions is documented in detail in the +[Partials function list configuration macros](#partials-function-list-configuration-macros) +section. + +!!! tip "Configuring Partials" + For enabling and configuring Partials, see [Configuring Partials](configuration.md). + +## Directive Macros Example + +```c +// Required before we can use the Partials directive macros +#include "ceedling.h" + +// Mock all `static` and/or `inline` functions of `mymodule` +#include MOCK_PARTIAL_PRIVATE_MODULE(mymodule) + +// But, remove `InternalHelper()` from the mocks +MOCK_PARTIAL_CONFIG(mymodule, -_InternalHelper) +``` + +!!! tip "Enable obnoxious level logging to inspect Partials generation" + Add `--verbosity obnoxious` (or `-v 4`) to your `test:` command line invocation + for detailed logging of the C extraction and Partials generations for your test + build. The logged lists of functions and variables can help you fine tune or + troubleshoot your Partials configuration. + +## `#include` conventions + +The `_MODULE` macros each expand to a **string literal** that names a +generated header file. This means you use them as the argument to `#include`: + +```c +// Must come first -- defines all Partial macros +#include "ceedling.h" + +#include TEST_PARTIAL_*_MODULE(sensor) +// ↑ Expands to: #include "ceedling_partial_sensor_impl.h" +``` + +A `TEST_PARTIAL_*_MODULE` macro always names an implementation header. + +```c +#include "ceedling.h" + +#include MOCK_PARTIAL_*_MODULE(sensor) +// ↑ Expands to: #include "mock_ceedling_partial_sensor_interface.h" +``` + +A `MOCK_PARTIAL_*_MODULE` macro always names a mockable interface header. + +The filters in place of `*` in the macro names — `PUBLIC`, `PRIVATE`, `ALL`, +and none — tell Ceedling how to initialize internal function lists (that +can be optionally modified) towards injecting the collected functions into +each Partial. + +## Partials function-selection by macro + +Each test or mock Partial is independently configured by exactly +one `_MODULE` macro call. The macro determines the _base set_ of +functions that Ceedling collects towards generating a Partial. Once +the base set of functions is determined, explicit additions or +subtractions can be applied with `_CONFIG` macros. + +This scheme gives you the, test author, full control of which functions +are injected into which type of Partial while avoiding laboriously +listing each function individually. + +### Partials base function filters + +* `[TEST/MOCK]_PARTIAL_ALL_MODULE()` +* `[TEST/MOCK]_PARTIAL_PUBLIC_MODULE()` +* `[TEST/MOCK]_PARTIAL_PRIVATE_MODULE()` +* `[TEST/MOCK]_PARTIAL_MODULE()` + +| Macro | Base set of functions | Additions | Subtractions | +|---:|---|---|---| +| `_ALL_MODULE` | All functions | Forbidden | Any function | +| `_PUBLIC_MODULE` | All public functions | Add private | Remove public | +| `_PRIVATE_MODULE` | All private functions | Add public | Remove private | +| `_MODULE` | Empty | Any function (at least one) | Forbidden | + +#### Example base sets of function by filter + +| Functions | `ALL` | `PUBLIC` | `PRIVATE` | None | +|---:|---|---|---|---| +| void foo(void) | foo | foo | bar | | +| static void bar(void) | bar | baz | oof | | +| int baz(void) | baz | | | | +| inline int oof(int) | oof | | | | + +**Notes:** + +* `*_PARTIAL_MODULE` requires at least one addition via `*_PARTIAL_CONFIG` + (see next section). +* `*_PARTIAL_ALL_MODULE` with no subtractions adds every module function to + base set of functions. +* Each module can appear in **at most one** `TEST_PARTIAL_*_MODULE` and + `MOCK_PARTIAL_*_MODULE` macro within a given test file. + +### Partials function list configuration macros + +`TEST_PARTIAL_CONFIG` and `MOCK_PARTIAL_CONFIG` refine the base set of functions +filtered by the corresponding `_MODULE` macro. Both `_CONFIG` macros require at +least one function name argument beyond the module name. + +Note that no quotation marks are needed. + +```c +TEST_PARTIAL_CONFIG(module, func1, func2, ...) +MOCK_PARTIAL_CONFIG(module, func1, func2, ...) +``` + +Similar to the convenion in Ceedling‘s `paths:` and `files:` YAML configuration +sections, each function name argument is treated as an **addition** or a +**subtraction** depending on an optional prefix character: + +| Prefix | Meaning | +|---:|---| +| _(none)_ or `+` | Add this function to the Partial<br/>(`<function>` or `+<function>`) | +| `-` | Exclude this function from the Partial<br/>(`-<function>`) | + +```c +TEST_PARTIAL_CONFIG(module, +func1, +func2, -func3) +MOCK_PARTIAL_CONFIG(module, func1, func2, -func3) +``` + +#### Addition & subtraction rules by mode + +| Macro | Filter | Subtraction target | Addition target | +|---:|---|---|---| +| `_PUBLIC_MODULE` | Public | Public functions only | Private functions | +| `_PRIVATE_MODULE` | Private | Private functions only | Public functions | +| `_MODULE` | Accumulate | Forbidden | Any function (one required) | +| `_ALL_MODULE` | Deduct | Any function | Forbidden | + +## `TEST_` / `MOCK_` Partials exclusion + +Any function explicitly added on one side via a Partial `_CONFIG` macro is +**automatically removed** from the complementary function list (if it exists). +This prevents the same function from accidentally appearing both in a Partial +implementation and Partial mock, which would produce a duplicate symbol linker +error. + +```c +// `_InternalHelper()` added to the test side +// while automatically removed from the mock side. +#include TEST_PARTIAL_PRIVATE_MODULE(mymodule) +TEST_PARTIAL_CONFIG(mymodule, _InternalHelper) + +// `_InternalHelper()` will NOT appear in the mock. +#include MOCK_PARTIAL_PUBLIC_MODULE(mymodule) +``` + +## Partials configuration examples + +### Test a specific private function; Mock everything else + +```c +#include "ceedling.h" + +#include TEST_PARTIAL_MODULE(sensor) // Starts empty +TEST_PARTIAL_CONFIG(sensor, _ConvertRaw) // Add exactly this one function + +#include MOCK_PARTIAL_ALL_MODULE(sensor) // Starts with all functions +// `_ConvertRaw()` is automatically excluded from the Partial mock +``` + +### Test all functions except one; Mock nothing + +```c +#include "ceedling.h" + +#include TEST_PARTIAL_ALL_MODULE(sensor) // All functions +TEST_PARTIAL_CONFIG(sensor, -Sensor_Init) // Subtract one +``` + +### Test public functions plus one private helper; Mock selected private functions + +```c +#include "ceedling.h" + +// Test: Start with all public functions +// and add one private function +#include TEST_PARTIAL_PUBLIC_MODULE(sensor) +TEST_PARTIAL_CONFIG(sensor, _ConvertRaw) + +// Mock: Start with no functions +// and add a private function +#include MOCK_PARTIAL_MODULE(sensor) +MOCK_PARTIAL_CONFIG(sensor, _ReadIOValue) +``` + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/example.md b/docs/mkdocs/testing-guide/partials/example.md new file mode 100644 index 000000000..e87a371e7 --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/example.md @@ -0,0 +1,148 @@ +# Partials Walk-Through Example + +!!! tip "Configuring Partials" + Before writing test files that use Partials, see [Configuring Partials](configuration.md) + to enable the feature in your project and learn how to configure Partials. + +## Temperature sensor module + +Imagine a temperature sensor code module… + +```c +// sensor.h ----------------------------------------------- +void Sensor_Init(void); +int Sensor_ReadCelsius(void); +``` + +```c +// sensor.c ----------------------------------------------- +#include "sensor.h" +#include "hal.h" // Hardware Abstraction Layer (to be mocked) + +// Private helper -- static, not visible outside this translation unit +static int _ConvertRawToMilliCelsius(uint16_t raw) +{ + return (int)raw * 10 - 40000; +} + +void Sensor_Init(void) +{ + HAL_SensorEnable(); +} + +int Sensor_ReadCelsius(void) +{ + uint16_t raw = HAL_SensorRead(); + return _ConvertRawToMilliCelsius(raw) / 1000; +} +``` + +## Testing & mocking a `static` helper + +!!! tip + Ceedling comes with example projects. The example project `wondrous_forest` + demonstrates the use of Partials in realistic code. + Use [`ceedling example`](../../getting-started/command-line.md#ceedling-application-commands) + to export the `wondrous_forest` project. + +You as a test author want to: + +1. Test the `static` helper `_ConvertRawToMilliCelsius()` directly +2. Mock the `static` helper `_ConvertRawToMilliCelsius()` while testing `Sensor_ReadCelsius()` + +Partials allows you to accomplish both of these goals with no changes to +_sensor.c_ even though `_ConvertRawToMilliCelsius()` is static. + +!!! warning "A Function Cannot Be Both Tested and Mocked" + A core restriction of the C language remains here! `_ConvertRawToMilliCelsius()` + cannot be both tested and mocked in the same test. Attempting to do so would + duplicate the function and cause a doubly-defined symbol failure during linking. + We solve this by simply creating two peer test files for the different Partials + usage scenarios. + +## Using the testable partial + +```c +// test_sensor_partials_test.c ----------------------------------- +#include "unity.h" +#include "ceedling.h" // Required -- defines Partial directive macros +#include "mock_hal.h" // Traditional mocking still available + +// Make all functions in the sensor module available for direct testing +#include TEST_PARTIAL_ALL_MODULE(sensor) + +// Test the `static` conversion helper internal to source module under test +void test_ConvertRawToMilliCelsius(void) +{ + // `_ConvertRawToMilliCelsius()` is accessible in the Partial + // linked in this test executable build + TEST_ASSERT_EQUAL_INT(-40000, _ConvertRawToMilliCelsius(0)); +} +``` + +### Partials processing step-by-step + +1. Reads `sensor.c` and `sensor.h` and extracts all function definitions. +1. `TEST_PARTIAL_ALL_MODULE()` instructs Partial generation to gather and + expose all functions from the `sensor` module to be made testable. +1. Generates `ceedling_partial_sensor_impl.c` containing all source functions + including `_ConvertRawToMilliCelsius()` (stripped of `static` decorator). +1. Compiles and links the Partial implementation source (in place of the original + `sensor` source module) and the test file into the test executable. The + symbols and includes of `sensor.h` and `sensor.c` are duplicated in the + Partials while the original `sensor.c` is omitted from the build. + +## Using the mockable partial + +```c +// test_sensor_partials_mocks.c ----------------------------------- +#include "unity.h" +#include "ceedling.h" // Required -- defines Partial directive macros +#include "mock_hal.h" // Traditional mocking still available + +// Create two complementary Partials: +// 1. All the non-static functions for testing +// 2. Mocked static functions to be used in test cases +// +// We need both Partials with non-overlapping lists of functions in +// order to separate the testable functions from the mocked functions +// extracted from the same source module. + +// Make all the non-static functions available for testing +#include TEST_PARTIAL_PUBLIC_MODULE(sensor) + +// Make the static helper function available as a mock +#include MOCK_PARTIAL_PRIVATE_MODULE(sensor) + +// Test Sensor_ReadCelsius() using a mock of the helper function +void test_Sensor_ReadCelsius(void) +{ + // Traditional mock of external HAL interface + HAL_SensorRead_ExpectAndReturn(1234); + + // Partial mock of `static` function internal to source module under test + _ConvertRawToMilliCelsius_ExpectAndReturn(1234, 1000); + + TEST_ASSERT_EQUAL_INT(1, Sensor_ReadCelsius()); +} +``` + +### Partials processing step-by-step + +1. Reads `sensor.c` and `sensor.h`, extracts all function definitions. +1. Classifies `_ConvertRawToMilliCelsius` as **private** (from the `static` + decorator). +1. Generates `ceedling_partial_sensor_interface.h` containing only + `_ConvertRawToMilliCelsius()`. +1. Collects all non-static functions in the `sensor` module and + generates `ceedling_partial_sensor_impl.h` and + `ceedling_partial_sensor_impl.c` containing those functions segregated + from the mockable function signature organized in (3). +1. Runs CMock on the Partial interface header to produce mock source. +1. Compiles and links the mocked Partial interface source from (3), the + Partial implementation source from (4) in place of the original `sensor` + source module, and the test file into the test executable. The + symbols and includes of `sensor.h` and `sensor.c` are duplicated in the + Partials while the original `sensor.c` is omitted from the build. + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/index.md b/docs/mkdocs/testing-guide/partials/index.md new file mode 100644 index 000000000..4962a15f6 --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/index.md @@ -0,0 +1,79 @@ +# Partials + +A _Partial_ is your C code sliced and diced to expose elements for testing +that you could not otherwise access without rewriting your source code. +Think of Partials as a unit testing scalpel. + +Partials are useful when a module under test contains: + +* **`static` or `inline` functions** — These become accessible within + your test code. +* **File-scoped `static` variables** — The `static` keyword is stripped, + and the variable is automatically made `extern` for easy access within + your test code. +* **Function-scoped `static` variables** — These are promoted from + function scope to module scope so they can be accessed in your + test code. Apart from necessary renaming, this works identically to + file-scoped `static` variables. + +!!! warning "Limitations of Partials" + Partials are new to Ceedling with 1.1.0. Carving up C code is tricky + business. Complex code _may_ break Ceedling’s lexing or its assumptions + on symbol ordering. Some issues may be [bugs to be reported](../../help.md) + while others may be complexities that Partials are simply unable to resolve. + +--- + +<div class="grid cards" markdown> + +- :material-magnify: **[What Is a Partial?][overview]** + + --- + + How Ceedling generates testable and mockable Partials from your C source + under test with a step-by-step simple example. + +- :material-format-list-text: **[Conventions & Terminology][conventions]** + + --- + + What is a _module_? _“Public”_ and _“private”_ functions in a programming + language that has no such terminology. + +- :material-cog: **[Configuration][configuration]** + + --- + + How to enable Partials in your project, include `ceedling.h`, and an + overview of directive macro categories. + +- :material-school: **[Walk-Through Example][example]** + + --- + + A complete end-to-end demonstration of Test Partials and Mock Partials. + +- :material-pound: **[Partial Directive Macros][directives]** + + --- + + * Generation macros used with `#include` for creating Partials. + * Config macros for fine-tuning your Partials. + +- :material-variable: **[Accessing Static Variables][variables]** + + --- + + How to test file-scoped and function-scoped `static` variables + via Partials. + +</div> + +[overview]: overview.md +[conventions]: conventions.md +[directives]: directives.md +[variables]: variables.md +[configuration]: configuration.md +[example]: example.md + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/overview.md b/docs/mkdocs/testing-guide/partials/overview.md new file mode 100644 index 000000000..bcf8facb7 --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/overview.md @@ -0,0 +1,73 @@ +# What Is a Partial? + +## Slicing and dicing your C code + +Ceedling reads your real source and header files, extracts their C contents, +and generates new C files that comprise a Partial. + +Ceedling can create two kinds of Partials: + +1. A **_Test Partial_** for exposing otherwise inaccessible functions and + variables to your test case assertions. +1. A **_Mock Partial_** for mocking otherwise inaccessible functions in your + source code. + +When a test file references a Partial, Ceedling excludes the original source +file from that test executable's build. The generated Partial +source is compiled and linked in place of the original source C. + +!!! note "Purpose-specific C language handling" + Ceedling includes its own custom, purpose-specific lexing for recognizing + C language elements. This was the best way to handle all cases across + platforms. This custom lexer even handles many compiler extensions + (e.g. Microsoft's `__declspec` and GCC's `__attribute__()`). + + On the upside, this approach provides everything Partials need. On the + downside, until exercised by a great deal of real world code, this custom + C handling will likely have failings and gaps. + +## Partials creation step-by-step + +When creating a Partial, Ceedling: + +1. Runs your source code under test through the preprocessor to: + 1. Handle `#ifdef` blocks and strip comments. + 1. Collect the `#include` directives. + 1. Expand decorator macros (e.g. `STATICINLINE`) in function signatures. +1. Lexes the code under test to collect its individual C language elements + (e.g. `typedef`s, macros, functions, variables, etc.). +1. Generates a new set of C files in a _partials/_ build directory. The + contents of these new files are: + 1. Reconstructed with the `#include` directives from (1) and elements + from (2). + 1. Reorganized and slightly altered so the C elements can be accessed by + assertions and mocks in your test cases. Functions are stripped of + `static` and `inline`. Variables are stripped of `static` and `extern`ed. +1. Structures the test build to omit the original source file from the + resulting test executable. Generated Partials are self-sufficient stand-ins + for the original C code from which the Partials are derived. +1. Maps the reorganized functions in generated Partials back to the + original source module's filepath and line numbers (using GCC's `#line` + directive) for correct test coverage reporting. + +!!! tip "Walk-through example" + See [Partials Walk-Through Example](example.md) for a complete end-to-end + demonstration of Test Partials and Mock Partials. + +## Generated files + +| Partial | Purpose | Generated filename pattern | +|---|---|---| +| Testable header | <ul><li>Declares functions</li><li>`extern`s variables</li></ul> | `ceedling_partial_<module>_impl.h` | +| Testable source | <ul><li>Defines functions</li><li>Defines `static`-less variables</li></ul> | `ceedling_partial_<module>_impl.c` | +| Mockable header | <ul><li>Declares function signatures</li></ul> | `ceedling_partial_<module>_interface.h` | + +Ceedling uses CMock to generate mocks from Partials interface header files +just as it does for any other mockable header files. + +!!! danger "Do not directly access generated Partials files" + You as the test author will never directly interact with generated + Partials C files. Do not modify these generated files or + incorporate them into your tests except with the accompanying macros. + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/partials/variables.md b/docs/mkdocs/testing-guide/partials/variables.md new file mode 100644 index 000000000..11a3e7cde --- /dev/null +++ b/docs/mkdocs/testing-guide/partials/variables.md @@ -0,0 +1,244 @@ +# Accessing Static Variables + +## File-Scoped Static Variables + +A file-scoped `static` variable is declared at the top level of a `.c` file +with the `static` keyword. Like a `static` function, it has _internal linkage_ +-- the linker cannot see it outside the translation unit in which it is defined. +This means test code in a separate translation unit cannot read or write it, +making it impossible to inspect state or reset it between test cases without +modifying the production source. + +When Ceedling generates a Partial it automatically copies every file-scoped +`static` variable found in the source module into the Partial and strips the +`static` keyword. The resulting definition in the generated `_impl.c` file +has external linkage. A matching `extern` declaration is emitted in the +generated `_impl.h` header. Including any `TEST_PARTIAL_*_MODULE` macro brings +that `extern` declaration into scope, causing the variable to accessible in +test code directly by its **original name** — no renaming or helper macro is +required. + +### Partial file-scoped static variable example + +Extending the sensor module with a file-scoped error counter: + +```c +// sensor.c ----------------------------------------------- +// File-scoped; invisible outside sensor.c +static uint32_t g_error_count = 0; + +int Sensor_ReadCelsius(void) +{ + uint16_t raw = HAL_SensorRead(); + // Sentinel value signals hardware error + if (raw == 0xFFFF) { + g_error_count++; + return -1; + } + return _ConvertRawToMilliCelsius(raw) / 1000; +} +``` + +Ceedling strips `static` when generating the test Partial: + +```c +// ceedling_partial_sensor_impl.c (generated) --------------- +// `static` stripped -- now has external linkage +uint32_t g_error_count = 0; +// ... +int Sensor_ReadCelsius(void) +{ + uint16_t raw = HAL_SensorRead(); + if (raw == 0xFFFF) { + g_error_count++; + return -1; + } + return _ConvertRawToMilliCelsius(raw) / 1000; +} +``` + +```c +// ceedling_partial_sensor_impl.h (generated) --------------- +// `extern` declaration -- immediately available in test code +extern uint32_t g_error_count; +// ... +``` + +Because `#include TEST_PARTIAL_*_MODULE()` automatically causes the generated +Partial header file to be included in your test, the `extern`ed variable is +immediately available to you in your test. + +In the test file, the variable is accessed directly by its original name: + +```c +// test_sensor_partial.c ----------------------------------- +#include "unity.h" +#include "ceedling.h" +#include "mock_hal.h" + +// Brings `extern g_error_count` into scope +#include TEST_PARTIAL_ALL_MODULE(sensor) + +void setUp(void) { + // Reset to known state before each test + g_error_count = 0; +} + +void test_ReadCelsius_counts_hardware_errors(void) +{ + // Simulate hardware error + HAL_SensorRead_ExpectAndReturn(0xFFFF); + TEST_ASSERT_EQUAL_INT(-1, Sensor_ReadCelsius()); + // Access the previously inaccessible `g_error_count` + TEST_ASSERT_EQUAL_UINT32(1, g_error_count); +} +``` + +## Function-scoped static variables + +C allows variables to be declared `static` inside a function body. Unlike a +local variable, a function-scoped `static` variable persists across calls — +its storage is allocated once and retains its value for the lifetime of the +program. This persistence makes these variables useful for call counters, +cached state, accumulated error totals, etc. + +In ordinary C, a function-scoped `static` variable is completely inaccessible +outside its containing function; the C standard does not provide any way to +take its address or read its value from another translation unit. This makes +it impossible to inspect or reset it from a test. + +When Ceedling generates a Partial, it automatically promotes all function-scoped +`static` variables to module scope in the generated implementation files. +The original declaration inside the function body is replaced with a no-op +statement so that source line mappings for coverage reporting remain accurate. + +### Renaming to prevent collisions + +Multiple functions in the same module may each contain a function-scoped +`static` variable with the same name — for example, both `Foo_Init()` and +`Foo_Reset()` might each have `static uint32_t call_count = 0;`. Promoting +both to module scope without renaming would produce a duplicate symbol error +at compilation. + +Ceedling resolves this by prepending a prefix of `partial_` and the containing +function's name to each promoted variable's name: + +``` +partial_<function_name>_<variable_name> +``` + +Example renaming: + +| Original declaration (inside function) | Containing function | Promoted name | +|---|---|---| +| `static uint32_t call_count = 0;` | `Sensor_ReadCelsius` | `partial_Sensor_ReadCelsius_call_count` | +| `static bool initialized = false;` | `Sensor_Init` | `partial_Sensor_Init_initialized` | +| `static int error_count;` | `Sensor_Init` | `partial_Sensor_Init_error_count` | + +The promoted variable is defined in the generated implementation source +(`ceedling_partial_<module>_impl.c`) and declared `extern` in the generated +implementation header (`ceedling_partial_<module>_impl.h`). Including the +implementation header via a `TEST_PARTIAL_*_MODULE` macro therefore makes all +promoted variables available to your test code. + +### `PARTIAL_LOCAL_VAR()` macro to access promoted function-scoped static variables + +Typing `partial_Sensor_ReadCelsius_call_count` throughout a test file is +error-prone. The `PARTIAL_LOCAL_VAR` macro, defined in `ceedling.h`, assembles +the promoted name from its two components: + +```c +PARTIAL_LOCAL_VAR(function_name, variable_name) +// Expands to: partial_<function_name>_<variable_name> +``` + +`PARTIAL_LOCAL_VAR()` is not a function call. The macro Expands to a simple +C identifier. It can appear anywhere a variable name is legal — in expressions, +assertions, and assignments. + +### Example use of `PARTIAL_LOCAL_VAR()` + +Extending the sensor module from earlier examples: + +```c +// sensor.c ----------------------------------------------- +int Sensor_ReadCelsius(void) +{ + // Function-scoped static variable that tracks total calls + static uint32_t sample_count = 0; + sample_count++; + + uint16_t raw = HAL_SensorRead(); + return _ConvertRawToMilliCelsius(raw) / 1000; +} +``` + +Ceedling promotes `sample_count` to `partial_Sensor_ReadCelsius_sample_count`. + +In the copy of `Sensor_ReadCelsius()` organized in a generated Partial, +Ceedling replaces the variable declaration inside the function with a no-op +to preserve code coverage line tracking. + +```c +// ceedling_partial_sensor_impl.c (generated) --------------- +// ... +int Sensor_ReadCelsius(void) +{ + (void)0; /* static uint32_t sample_count = 0 */ + partial_Sensor_ReadCelsius_sample_count++; + + uint16_t raw = HAL_SensorRead(); + return _ConvertRawToMilliCelsius(raw) / 1000; +} +``` + +Ceedling simultaneously organizes an `extern` statement in the generated +Partial header file. + +```c +// ceedling_partial_sensor_impl.h (generated) --------------- +// ... +extern uint32_t partial_Sensor_ReadCelsius_sample_count; +// ... +``` + +Because `#include TEST_PARTIAL_*_MODULE()` automatically causes the generated +Partial header file to be included in your test, the promoted, renamed, and +`extern`ed variable is immediately available to you in your test. + +In your test file, `PARTIAL_LOCAL_VAR()` makes the variable accessible for both +reset and assertion: + +```c +// test_sensor_partial.c ----------------------------------- +#include "unity.h" +#include "ceedling.h" +#include TEST_PARTIAL_PRIVATE_MODULE(sensor) + +void setUp(void) +{ + // Reset promoted static back to its initial value before each test + PARTIAL_LOCAL_VAR(Sensor_ReadCelsius, sample_count) = 0; +} + +void test_ReadCelsius_increments_sample_count_on_each_call(void) +{ + HAL_SensorRead_ExpectAndReturn(1000); + Sensor_ReadCelsius(); + TEST_ASSERT_EQUAL_UINT32(1, PARTIAL_LOCAL_VAR(Sensor_ReadCelsius, sample_count)); + + HAL_SensorRead_ExpectAndReturn(2000); + Sensor_ReadCelsius(); + TEST_ASSERT_EQUAL_UINT32(2, PARTIAL_LOCAL_VAR(Sensor_ReadCelsius, sample_count)); +} +``` + +### What `PARTIAL_LOCAL_VAR()` cannot do + +`PARTIAL_LOCAL_VAR` is a token-pasting macro — it constructs a C identifier at +compile time. It cannot be used with a runtime string or a variable holding a +function name. Both arguments must be literal tokens that match the original +C identifiers exactly (the function name and the variable name as they appear +in the source file). + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/test-cases.md b/docs/mkdocs/testing-guide/test-cases.md new file mode 100644 index 000000000..9379a5220 --- /dev/null +++ b/docs/mkdocs/testing-guide/test-cases.md @@ -0,0 +1,194 @@ +# How Does a Test Case Even Work? + +## Behold assertions + +In its simplest form, a test case is just a C function with no +parameters and no return value that packages up logical assertions. +If no assertions fail, the test case passes. Technically, an empty +test case function is a passing test since there can be no failing +assertions. + +Ceedling relies on the [Unity] project for its unit test framework +(i.e. the thing that provides assertions and counts up passing +and failing tests). + +An assertion is simply a logical comparison of expected and actual +values. Unity provides a wide variety of different assertions to +cover just about any scenario you might encounter. Getting +assertions right is actually a bit tricky. Unity does all that +hard work for you and has been thoroughly tested itself and battle +hardened through use by many, many developers. + +## Super simple passing test case + +```c +#include "unity.h" + +void test_case(void) { + TEST_ASSERT_TRUE( (1 == 1) ); +} +``` + +## Super simple failing test case + +```c +#include "unity.h" + +void test_a_different_case(void) { + TEST_ASSERT_TRUE( (1 == 2) ); +} +``` + +## Realistic simple test case + +In reality, we're probably not testing the static value of an integer +against itself. Instead, we're calling functions in our source code +and making assertions against return values. + +```c +#include "unity.h" +#include "my_math.h" + +void test_some_sums(void) { + TEST_ASSERT_EQUALS( 5, mySum( 2, 3) ); + TEST_ASSERT_EQUALS( 6, mySum( 0, 6) ); + TEST_ASSERT_EQUALS( -12, mySum( 20, -32) ); +} +``` + +If an assertion fails, the test case fails. As soon as an assertion +fails, execution within that test case stops. + +Multiple test cases can live in the same test file. When all the +test cases are run, their results are tallied into simple pass +and fail metrics with a bit of metadata for failing test cases such +as line numbers and names of test cases. + +Ceedling and Unity work together to both automatically run your test +cases and tally up all the results. + +## Sample test case output + +Successful test suite run: + +``` +-------------------- +OVERALL TEST SUMMARY +-------------------- +TESTED: 49 +PASSED: 49 +FAILED: 0 +IGNORED: 0 +``` + +A test suite with a failing test: + +``` +------------------- +FAILED TEST SUMMARY +------------------- +[test/TestModel.c] + Test: testInitShouldCallSchedulerAndTemperatureFilterInit + At line (21): "Function TaskScheduler_Init() called more times than expected." + +-------------------- +OVERALL TEST SUMMARY +-------------------- +TESTED: 49 +PASSED: 48 +FAILED: 1 +IGNORED: 0 +``` + +## Advanced test cases with mocks + +Often you want to test not just what a function returns but how +it interacts with other functions. + +The simple test cases above work well at the "edges" of a +codebase (libraries, state management, some kinds of I/O, etc.). +But, in the messy middle of your code, code calls other code. +One way to handle testing this is with [mock functions][mocks] and +[interaction-based testing][interaction-based-tests]. + +Mock functions are functions with the same interface as the real +code the mocks replace. A mocked function allows you to control +how it behaves and wrap up assertions within a higher level idea +of expectations. + +What is meant by an expectation? Well… We _expect_ a certain +function is called with certain arguments and that it will return +certain values. With the appropriate code inside a mocked function +all of this can be managed and checked. + +You can write your own mocks, of course. But, it's generally better +to rely on something else to do it for you. Ceedling uses the [CMock] +framework to perform mocking for you. + +Here's some sample code you might want to test: + +```c +#include "other_code.h" + +void doTheThingYo(mode_t input) { + mode_t result = processMode(input); + if (result == MODE_3) { + setOutput(OUTPUT_F); + } + else { + setOutput(OUTPUT_D); + } +} +``` + +And, here's what test cases using mocks for that code could look +like: + +```c +#include "mock_other_code.h" + +void test_doTheThingYo_should_enableOutputF(void) { + // Mocks + processMode_ExpectAndReturn(MODE_1, MODE_3); + setOutput_Expect(OUTPUT_F); + + // Function under test + doTheThingYo(MODE_1); +} + +void test_doTheThingYo_should_enableOutputD(void) { + // Mocks + processMode_ExpectAndReturn(MODE_2, MODE_4); + setOutput_Expect(OUTPUT_D); + + // Function under test + doTheThingYo(MODE_2); +} +``` + +Remember, the generated mock code you can't see here has a whole bunch +of smarts and Unity assertions inside it. CMock scans header files and +then generates mocks (C code) from the function signatures it finds in +those header files. It's kinda magical. + +## That was the basics, but you'll need more + +For more on the assertions and mocking shown above, consult the +documentation for [Unity] and [CMock] or the resources in +Ceedling's [README](https://github.com/ThrowTheSwitch/Ceedling/blob/master/README.md). + +Ceedling, Unity, and CMock rely on a variety of +[conventions to make your life easier][conventions-and-behaviors]. +Read up on these to understand how to build up test cases +and test suites. + +Also take a look at the very next sections for more examples +and details on how everything fits together. + +[Unity]: http://github.com/ThrowTheSwitch/Unity +[CMock]: http://github.com/ThrowTheSwitch/CMock +[mocks]: https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da +[interaction-based-tests]: http://martinfowler.com/articles/mocksArentStubs.html +[conventions-and-behaviors]: conventions.md + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/test-sample.md b/docs/mkdocs/testing-guide/test-sample.md new file mode 100644 index 000000000..85210f0ec --- /dev/null +++ b/docs/mkdocs/testing-guide/test-sample.md @@ -0,0 +1,107 @@ +# Commented Sample Test File + +**Here is a beautiful test file to help get you started…** + +!!! note "This sample test only illustrates basic assertions and mocks" + Dealing with complex and legacy code may require the use of + [Partials](partials/index.md) for accessing `static` / `inline` + functions and `static` variables. Partials allow you to test these + C elements without rewriting your source code. + +## Core concepts in code + +After absorbing this sample code, you'll have context for much +of the rest of the documentation. + +The sample test file below demonstrates the following: + +1. Making use of the Unity & CMock test frameworks. +1. Adding the source under test (`foo.c`) to the final test + executable by convention (`#include "foo.h"`). +1. Adding two mocks to the final test executable by convention + (`#include "mock_bar.h` and `#include "mock_baz.h`). +1. Adding a source file with no matching header file to the test + executable with a test build directive macro + `TEST_SOURCE_FILE("more.c")`. +1. Creating two test cases with mock expectations and Unity + assertions. + +All other conventions and features are documented in the sections +that follow. + +!!! tip + The `temp_sensor` example project is a real world project that expands + on the style of test code below. + [See below](#ceedling-includes-example-projects) for instructions on + how to access example projects. + +```c +// test_foo.c ----------------------------------------------- +#include "unity.h" // Compile/link in Unity test framework +#include "types.h" // Header file with no *.c file -- no compilation/linking +#include "foo.h" // Corresponding source file, foo.c, under test will be compiled and linked +#include "mock_bar.h" // bar.h will be found and mocked as mock_bar.c + compiled/linked in; +#include "mock_baz.h" // baz.h will be found and mocked as mock_baz.c + compiled/linked in + +TEST_SOURCE_FILE("more.c") // foo.c depends on symbols from more.c, but more.c has no matching more.h + +void setUp(void) {} // Every test file requires this function; + // setUp() is called by the generated runner before each test case function + +void tearDown(void) {} // Every test file requires this function; + // tearDown() is called by the generated runner after each test case function + +// A test case function +void test_Foo_Function1_should_Call_Bar_AndGrill(void) +{ + Bar_AndGrill_Expect(); // Function from mock_bar.c that instructs our mocking + // framework to expect Bar_AndGrill() to be called once + TEST_ASSERT_EQUAL(0xFF, Foo_Function1()); // Foo_Function1() is under test (Unity assertion): + // (a) Calls Bar_AndGrill() from bar.h + // (b) Returns a byte compared to 0xFF +} + +// Another test case function +void test_Foo_Function2_should_Call_Baz_Tec(void) +{ + Baz_Tec_ExpectAnd_Return(1); // Function from mock_baz.c that instructs our mocking + // framework to expect Baz_Tec() to be called once and return 1 + TEST_ASSERT_TRUE(Foo_Function2()); // Foo_Function2() is under test (Unity assertion) + // (a) Calls Baz_Tec() in baz.h + // (b) Returns a value that can be compared to boolean true +} + +// end of test_foo.c ---------------------------------------- +``` + +## Ceedling actions + +From the test file specified above Ceedling will generate +`test_foo_runner.c`. This runner file will contain `main()` and will call +both of the example test case functions. + +The final test executable will be `test_foo.exe` (Windows) or `test_foo.out` +for Unix-based systems (extensions are configurable). Based on the `#include` +list and test directive macro above, the test executable will be the output +of the linker having processed `unity.o`, `foo.o`, `mock_bar.o`, `mock_baz.o`, +`more.o`, `test_foo.o`, and `test_foo_runner.o`. + +Ceedling finds the needed code files, generates mocks, generates a runner, +compiles all the code files, and links everything into the test executable. +Ceedling will then run the test executable and collect test results from it +to be reported to the developer at the command line. + +## Ceedling includes example projects + +Ceedling comes with entire example projects you can extract. + +1. Execute `ceedling examples` in your terminal to list available example + projects. +1. Execute `ceedling example <project> [destination]` to extract the + named example project. + +You can inspect the _project.yml_ file and source & test code. Run +`ceedling help` from the root of the example projects to see what you can +do, or just go nuts with `ceedling test:all`. + +<br/><br/> diff --git a/docs/mkdocs/testing-guide/test-suite-anatomy.md b/docs/mkdocs/testing-guide/test-suite-anatomy.md new file mode 100644 index 000000000..4c725710a --- /dev/null +++ b/docs/mkdocs/testing-guide/test-suite-anatomy.md @@ -0,0 +1,58 @@ +# Anatomy of a Test Suite + +A Ceedling test suite is composed of one or more individual test executables. + +The [Unity] project provides the actual framework for test case assertions +and unit test sucess/failure accounting. If mocks are enabled, [CMock] builds +on Unity to generate mock functions from source header files with expectation +test accounting. Ceedling is the glue that combines these frameworks, your +project's toolchain, and your source code into a collection of test +executables you can run as a singular suite. + +## What is a test executable? + +Put simply, in a Ceedling test suite, each test file becomes a test executable. +Your test code file becomes a single test executable. + +`test_foo.c` ➡️ `test_foo.out` (or `test_foo.exe` on Windows) + +A single test executable generally comprises the following. Each item in this +list is a C file compiled into an object file. The entire list is linked into +a final test executable. + +* One or more release C code files under test (`foo.c`) +* `unity.c`. +* A test C code file (`test_foo.c`). +* A generated test runner C code file (`test_foo_runner.c`). `main()` is located + in the runner. +* If using mocks: + * `cmock.c` + * One more mock C code files generated from source header files (`mock_bar.c`) + +## Why multiple individual test executables in a suite? + +For several reasons: + +* This greatly simplifies the building of your tests. +* C lacks any concept of namespaces or reflection abilities able to segment and + distinguish test cases. +* This allows the same release code to be built differently under different + testing scenarios. Think of how different `#define`s, compiler flags, and + linked libraries might come in handy for different tests of the same + release C code. One source file can be built and tested in different ways + with multiple test files. + +## Ceedling's role in your test suite + +A test executable is not all that hard to create by hand, but it can be tedious, +repetitive, and error-prone. + +What Ceedling provides is an ability to perform the process repeatedly and simply +at the push of a button, alleviating the tedium and any forgetfulness. Just as +importantly, Ceedling also does all the work of running each of those test +executables and tallying all the test results. + +[Unity]: http://github.com/ThrowTheSwitch/Unity +[CMock]: http://github.com/ThrowTheSwitch/CMock + +<br/><br/> diff --git a/examples/temp_sensor/project.yml b/examples/temp_sensor/project.yml index 50fa52a5a..47128769f 100644 --- a/examples/temp_sensor/project.yml +++ b/examples/temp_sensor/project.yml @@ -7,6 +7,7 @@ --- :project: + :name: "Temperature Sensor" # how to use ceedling. If you're not sure, leave this as `gem` and `?` :which_ceedling: gem :ceedling_version: '?' @@ -14,9 +15,7 @@ # optional features. If you don't need them, keep them turned off for performance :use_mocks: TRUE :use_test_preprocessor: :all # options are :none, :mocks, :tests, or :all - :use_deep_preprocessor: :none # options are :none, :mocks, :tests, or :all :use_backtrace: :none # options are :none, :simple, or :gdb - :use_decorators: :auto # decorate Ceedling's output text. options are :auto, :all, or :none # tweak the way ceedling handles automatic tasks @@ -63,7 +62,6 @@ #- command_hooks # write custom actions to be called at different points during the build process #- compile_commands_json_db # generate a compile_commands.json file #- dependencies # automatically fetch 3rd party libraries, etc. - #- subprojects # managing builds and test for static libraries #- fake_function_framework # use FFF instead of CMock # Report options (You'll want to choose one stdout option, but may choose multiple stored options if desired) @@ -93,7 +91,6 @@ :executable: .out #:testpass: .pass #:testfail: .fail - #:subprojects: .a # This is where Ceedling should look for your source and test files. # see documentation for the many options for specifying this. @@ -254,16 +251,6 @@ # :includes: # - include/** -# :subprojects: -# :paths: -# - :name: libprojectA -# :source: -# - ./subprojectA/source -# :include: -# - ./subprojectA/include -# :build_root: ./subprojectA/build -# :defines: [] - # :command_hooks: # :pre_mock_preprocess: # :post_mock_preprocess: diff --git a/examples/wondrous_forest/README.md b/examples/wondrous_forest/README.md new file mode 100644 index 000000000..fe99c1385 --- /dev/null +++ b/examples/wondrous_forest/README.md @@ -0,0 +1,40 @@ +# Ceedling Wondrous Forest Example + +Welcome to the _**Wondrous Forest**_ example — a forest environmental monitoring +station written in C that showcases Ceedling’s Partials feature. + +## What This Example Demonstrates + +Ceedling Partials expose `static`, `inline`, and `static inline` C functions +and `static` variables for unit testing — something impossible under normal C +linkage rules. This project uses every major Partials pattern alongside +traditional mock-based testing so you can see how the two approaches mix. + +## Directory Layout + +``` +src/ Source modules (sensors, alert manager, event queue, monitor) +test/ Test files — Partials-based and traditional +project.yml Ceedling configuration (note :use_partials: TRUE) +mixin/ Optional configuration add-ons (gcov coverage) +``` + +## Partials Patterns Used + +| Test File | Pattern | +|-------------------------|---------------------------------------------------------------| +| TestTemperatureSensor.c | `TEST_PARTIAL_ALL_MODULE` — public + private functions | +| TestHumiditySensor.c | `TEST_PARTIAL_ALL_MODULE` + `PARTIAL_LOCAL_VAR()` | +| TestLightSensor.c | `TEST_PARTIAL_PUBLIC_MODULE` + `MOCK_PARTIAL_PRIVATE_MODULE` | +| TestSoilMoisture.c | `TEST_PARTIAL_ALL_MODULE` + `PARTIAL_LOCAL_VAR()` | +| TestAlertManager.c | `TEST_PARTIAL_ALL_MODULE` + traditional mock alongside | +| TestEventQueue.c | `TEST_PARTIAL_ALL_MODULE` + file-scope static access | +| TestForestMonitor.c | `TEST_PARTIAL_PUBLIC_MODULE` + `MOCK_PARTIAL_PRIVATE_MODULE` | +| TestSensorHal.c | Traditional — HAL has no private statics | +| TestUartDriver.c | Traditional — UART driver has no private statics | + +## Running the Tests + +```bash +ceedling test:all +``` diff --git a/examples/wondrous_forest/mixin/add_gcov.yml b/examples/wondrous_forest/mixin/add_gcov.yml new file mode 100644 index 000000000..12144d06a --- /dev/null +++ b/examples/wondrous_forest/mixin/add_gcov.yml @@ -0,0 +1,22 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +--- + +:plugins: + :enabled: + - gcov + +:gcov: + :utilities: + - gcovr + :reports: + - HtmlDetailed + :gcovr: + :html_medium_threshold: 75 + :html_high_threshold: 90 +... diff --git a/examples/wondrous_forest/project.yml b/examples/wondrous_forest/project.yml new file mode 100644 index 000000000..d85255146 --- /dev/null +++ b/examples/wondrous_forest/project.yml @@ -0,0 +1,86 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +--- +:project: + :name: "Wondrous Forest" + :which_ceedling: gem + :ceedling_version: '?' + + :use_partials: TRUE + :use_mocks: TRUE + :use_test_preprocessor: :all + + :build_root: build + :test_file_prefix: Test + :default_tasks: + - test:all + + :test_threads: 8 + :compile_threads: 8 + + :release_build: FALSE + +:test_build: + :use_assembly: FALSE + +:release_build: + :output: ForestMonitor.out + :use_assembly: FALSE + :artifacts: [] + +:plugins: + :load_paths: [] + :enabled: + - module_generator + - report_tests_pretty_stdout + +:extension: + :executable: .out + +:paths: + :test: + - +:test/** + :source: + - src/** + :include: + - src/** + :libraries: [] + +:defines: + :test: + - TEST + +:flags: + :test: + :compile: + '*': + - '-std=c99' + +:cmock: + :mock_prefix: Mock + :when_no_prototypes: :warn + :enforce_strict_ordering: TRUE + :plugins: + - :ignore + - :callback + :treat_as: + uint8: HEX8 + uint16: HEX16 + uint32: UINT32 + int8: INT8 + bool: UINT8 + +:libraries: + :placement: :end + :flag: "-l${1}" + :path_flag: "-L ${1}" + :system: + - m + :test: [] + :release: [] +... diff --git a/examples/wondrous_forest/src/AlertManager.c b/examples/wondrous_forest/src/AlertManager.c new file mode 100644 index 000000000..d7abe3024 --- /dev/null +++ b/examples/wondrous_forest/src/AlertManager.c @@ -0,0 +1,125 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "AlertManager.h" +#include "UartDriver.h" + +#define TEMP_HIGH_THRESHOLD_MC (40000) +#define TEMP_LOW_THRESHOLD_MC (-10000) +#define HUMIDITY_HIGH_THRESHOLD (90u) +#define HUMIDITY_LOW_THRESHOLD (10u) +#define SOIL_DRY_THRESHOLD (20u) + +static AlertEntry_t s_alert_table[ALERT_MANAGER_MAX_ALERTS]; +static uint8 s_alert_count; + +static int8 AlertManager__FindFreeSlot(void) +{ + uint8 i; + for (i = 0u; i < ALERT_MANAGER_MAX_ALERTS; i++) + { + if (!s_alert_table[i].active) { return (int8)i; } + } + return -1; +} + +static void AlertManager__RaiseAlert(AlertSeverity_t severity, EventType_t event_type) +{ + int8 slot = AlertManager__FindFreeSlot(); + if (slot < 0) { return; } + + s_alert_table[(uint8)slot].severity = severity; + s_alert_table[(uint8)slot].event_type = event_type; + s_alert_table[(uint8)slot].active = true; + s_alert_count++; + + switch (severity) + { + case ALERT_SEVERITY_CRITICAL: UartDriver_SendByte('!'); break; + case ALERT_SEVERITY_HIGH: UartDriver_SendByte('^'); break; + case ALERT_SEVERITY_MEDIUM: UartDriver_SendByte('~'); break; + default: UartDriver_SendByte('.'); break; + } +} + +static AlertSeverity_t AlertManager__TempSeverity(int32 milli_celsius) +{ + if (milli_celsius > TEMP_HIGH_THRESHOLD_MC) { return ALERT_SEVERITY_HIGH; } + if (milli_celsius < TEMP_LOW_THRESHOLD_MC) { return ALERT_SEVERITY_MEDIUM; } + return ALERT_SEVERITY_NONE; +} + +void AlertManager_Init(void) +{ + uint8 i; + for (i = 0u; i < ALERT_MANAGER_MAX_ALERTS; i++) + { + s_alert_table[i].active = false; + s_alert_table[i].severity = ALERT_SEVERITY_NONE; + s_alert_table[i].event_type = EVENT_NONE; + } + s_alert_count = 0u; +} + +void AlertManager_EvaluateTemperature(int32 milli_celsius) +{ + AlertSeverity_t sev = AlertManager__TempSeverity(milli_celsius); + if (sev == ALERT_SEVERITY_NONE) { return; } + + EventType_t evt = (milli_celsius > TEMP_HIGH_THRESHOLD_MC) ? EVENT_TEMP_HIGH : EVENT_TEMP_LOW; + AlertManager__RaiseAlert(sev, evt); +} + +void AlertManager_EvaluateHumidity(uint8 percent) +{ + if (percent > HUMIDITY_HIGH_THRESHOLD) + { + AlertManager__RaiseAlert(ALERT_SEVERITY_LOW, EVENT_HUMIDITY_HIGH); + } + else if (percent < HUMIDITY_LOW_THRESHOLD) + { + AlertManager__RaiseAlert(ALERT_SEVERITY_MEDIUM, EVENT_HUMIDITY_LOW); + } +} + +void AlertManager_EvaluateSoilMoisture(uint8 percent) +{ + if (percent < SOIL_DRY_THRESHOLD) + { + AlertManager__RaiseAlert(ALERT_SEVERITY_HIGH, EVENT_SOIL_DRY); + } +} + +AlertSeverity_t AlertManager_GetHighestSeverity(void) +{ + AlertSeverity_t highest = ALERT_SEVERITY_NONE; + uint8 i; + for (i = 0u; i < ALERT_MANAGER_MAX_ALERTS; i++) + { + if (s_alert_table[i].active && s_alert_table[i].severity > highest) + { + highest = s_alert_table[i].severity; + } + } + return highest; +} + +uint8 AlertManager_GetActiveAlertCount(void) +{ + return s_alert_count; +} + +void AlertManager_ClearAll(void) +{ + uint8 i; + for (i = 0u; i < ALERT_MANAGER_MAX_ALERTS; i++) + { + s_alert_table[i].active = false; + } + s_alert_count = 0u; +} diff --git a/examples/wondrous_forest/src/AlertManager.h b/examples/wondrous_forest/src/AlertManager.h new file mode 100644 index 000000000..9566cc946 --- /dev/null +++ b/examples/wondrous_forest/src/AlertManager.h @@ -0,0 +1,30 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef ALERT_MANAGER_H +#define ALERT_MANAGER_H + +#include "Types.h" + +#define ALERT_MANAGER_MAX_ALERTS (8u) + +typedef struct +{ + AlertSeverity_t severity; + EventType_t event_type; + bool active; +} AlertEntry_t; + +void AlertManager_Init(void); +void AlertManager_EvaluateTemperature(int32 milli_celsius); +void AlertManager_EvaluateHumidity(uint8 percent); +void AlertManager_EvaluateSoilMoisture(uint8 percent); +AlertSeverity_t AlertManager_GetHighestSeverity(void); +uint8 AlertManager_GetActiveAlertCount(void); +void AlertManager_ClearAll(void); + +#endif /* ALERT_MANAGER_H */ diff --git a/examples/wondrous_forest/src/EventQueue.c b/examples/wondrous_forest/src/EventQueue.c new file mode 100644 index 000000000..49164826c --- /dev/null +++ b/examples/wondrous_forest/src/EventQueue.c @@ -0,0 +1,73 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "EventQueue.h" + +static ForestEvent_t s_queue_storage[EVENT_QUEUE_CAPACITY]; +static uint8 s_head; +static uint8 s_tail; +static uint8 s_count; + +static inline uint8 EventQueue__AdvanceIndex(uint8 index) +{ + return (uint8)((index + 1u) % EVENT_QUEUE_CAPACITY); +} + +static inline bool EventQueue__IsEmpty(uint8 count) +{ + return count == 0u; +} + +static inline bool EventQueue__IsFull(uint8 count) +{ + return count >= EVENT_QUEUE_CAPACITY; +} + +void EventQueue_Init(void) +{ + s_head = 0u; + s_tail = 0u; + s_count = 0u; +} + +bool EventQueue_Push(const ForestEvent_t* event) +{ + if (event == NULL) { return false; } + if (EventQueue__IsFull(s_count)) { return false; } + + s_queue_storage[s_tail] = *event; + s_tail = EventQueue__AdvanceIndex(s_tail); + s_count++; + return true; +} + +bool EventQueue_Pop(ForestEvent_t* event_out) +{ + if (event_out == NULL) { return false; } + if (EventQueue__IsEmpty(s_count)) { return false; } + + *event_out = s_queue_storage[s_head]; + s_head = EventQueue__AdvanceIndex(s_head); + s_count--; + return true; +} + +bool EventQueue_IsEmpty(void) +{ + return EventQueue__IsEmpty(s_count); +} + +bool EventQueue_IsFull(void) +{ + return EventQueue__IsFull(s_count); +} + +uint8 EventQueue_Count(void) +{ + return s_count; +} diff --git a/examples/wondrous_forest/src/EventQueue.h b/examples/wondrous_forest/src/EventQueue.h new file mode 100644 index 000000000..26c448c26 --- /dev/null +++ b/examples/wondrous_forest/src/EventQueue.h @@ -0,0 +1,22 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef EVENT_QUEUE_H +#define EVENT_QUEUE_H + +#include "Types.h" + +#define EVENT_QUEUE_CAPACITY (16u) + +void EventQueue_Init(void); +bool EventQueue_Push(const ForestEvent_t* event); +bool EventQueue_Pop(ForestEvent_t* event_out); +bool EventQueue_IsEmpty(void); +bool EventQueue_IsFull(void); +uint8 EventQueue_Count(void); + +#endif /* EVENT_QUEUE_H */ diff --git a/examples/wondrous_forest/src/ForestMonitor.c b/examples/wondrous_forest/src/ForestMonitor.c new file mode 100644 index 000000000..77c5e1d46 --- /dev/null +++ b/examples/wondrous_forest/src/ForestMonitor.c @@ -0,0 +1,88 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "ForestMonitor.h" +#include "TemperatureSensor.h" +#include "HumiditySensor.h" +#include "SoilMoisture.h" +#include "AlertManager.h" +#include "EventQueue.h" +#include "UartDriver.h" + +static MonitorState_t s_current_state; +static uint32 s_tick_counter; + +static MonitorState_t ForestMonitor__NextState(MonitorState_t current) +{ + switch (current) + { + case MONITOR_STATE_IDLE: return MONITOR_STATE_SAMPLING; + case MONITOR_STATE_SAMPLING: return MONITOR_STATE_EVALUATING; + case MONITOR_STATE_EVALUATING: return (AlertManager_GetActiveAlertCount() > 0u) + ? MONITOR_STATE_ALERTING + : MONITOR_STATE_REPORTING; + case MONITOR_STATE_ALERTING: return MONITOR_STATE_REPORTING; + case MONITOR_STATE_REPORTING: return MONITOR_STATE_IDLE; + default: return MONITOR_STATE_IDLE; + } +} + +void ForestMonitor_Init(void) +{ + s_current_state = MONITOR_STATE_IDLE; + s_tick_counter = 0u; + AlertManager_Init(); + EventQueue_Init(); +} + +void ForestMonitor_Tick(void) +{ + s_tick_counter++; + + switch (s_current_state) + { + case MONITOR_STATE_IDLE: + break; + + case MONITOR_STATE_SAMPLING: + TemperatureSensor_Sample(); + HumiditySensor_Sample(); + SoilMoisture_Sample(); + break; + + case MONITOR_STATE_EVALUATING: + AlertManager_EvaluateTemperature(TemperatureSensor_GetMilliCelsius()); + AlertManager_EvaluateHumidity(HumiditySensor_GetPercent()); + AlertManager_EvaluateSoilMoisture(SoilMoisture_GetPercent()); + break; + + case MONITOR_STATE_ALERTING: + UartDriver_SendString("ALERT\r\n"); + break; + + case MONITOR_STATE_REPORTING: + UartDriver_SendString("OK\r\n"); + AlertManager_ClearAll(); + break; + + default: + break; + } + + s_current_state = ForestMonitor__NextState(s_current_state); +} + +MonitorState_t ForestMonitor_GetState(void) +{ + return s_current_state; +} + +bool ForestMonitor_HasPendingAlerts(void) +{ + return AlertManager_GetActiveAlertCount() > 0u; +} diff --git a/examples/wondrous_forest/src/ForestMonitor.h b/examples/wondrous_forest/src/ForestMonitor.h new file mode 100644 index 000000000..a7611b54f --- /dev/null +++ b/examples/wondrous_forest/src/ForestMonitor.h @@ -0,0 +1,18 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef FOREST_MONITOR_H +#define FOREST_MONITOR_H + +#include "Types.h" + +void ForestMonitor_Init(void); +void ForestMonitor_Tick(void); +MonitorState_t ForestMonitor_GetState(void); +bool ForestMonitor_HasPendingAlerts(void); + +#endif /* FOREST_MONITOR_H */ diff --git a/examples/wondrous_forest/src/HumiditySensor.c b/examples/wondrous_forest/src/HumiditySensor.c new file mode 100644 index 000000000..5859eb07b --- /dev/null +++ b/examples/wondrous_forest/src/HumiditySensor.c @@ -0,0 +1,57 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "HumiditySensor.h" +#include "SensorHal.h" + +static uint8 s_current_percent; +static uint8 s_sample_count; +static bool s_initialized; + +static inline uint8 HumiditySensor__ClampPercent(int32 value) +{ + if (value < (int32)HUMIDITY_PERCENT_MIN) { return (uint8)HUMIDITY_PERCENT_MIN; } + if (value > (int32)HUMIDITY_PERCENT_MAX) { return (uint8)HUMIDITY_PERCENT_MAX; } + return (uint8)value; +} + +static uint8 HumiditySensor__RawToPercent(uint16 raw_counts) +{ + return HumiditySensor__ClampPercent((int32)(((uint32)raw_counts * 100ul) / (uint32)ADC_MAX_COUNTS)); +} + +void HumiditySensor_Init(void) +{ + s_current_percent = 0u; + s_sample_count = 0u; + s_initialized = true; + SensorHal_StartConversion(SENSOR_CHANNEL_HUMIDITY); +} + +bool HumiditySensor_Sample(void) +{ + /* Function-scoped static: rolling sum for average. Exposed via Partials as + * partial_HumiditySensor_Sample_s_rolling_sum. */ + static uint32 s_rolling_sum = 0u; + + if (!s_initialized) { return false; } + if (!SensorHal_IsChannelReady(SENSOR_CHANNEL_HUMIDITY)) { return false; } + + uint16 raw = SensorHal_ReadChannel(SENSOR_CHANNEL_HUMIDITY); + s_current_percent = HumiditySensor__RawToPercent(raw); + s_rolling_sum += (uint32)s_current_percent; + s_sample_count++; + + SensorHal_StartConversion(SENSOR_CHANNEL_HUMIDITY); + return true; +} + +uint8 HumiditySensor_GetPercent(void) +{ + return s_current_percent; +} diff --git a/examples/wondrous_forest/src/HumiditySensor.h b/examples/wondrous_forest/src/HumiditySensor.h new file mode 100644 index 000000000..b775df526 --- /dev/null +++ b/examples/wondrous_forest/src/HumiditySensor.h @@ -0,0 +1,17 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef HUMIDITY_SENSOR_H +#define HUMIDITY_SENSOR_H + +#include "Types.h" + +void HumiditySensor_Init(void); +bool HumiditySensor_Sample(void); +uint8 HumiditySensor_GetPercent(void); + +#endif /* HUMIDITY_SENSOR_H */ diff --git a/examples/wondrous_forest/src/LightSensor.c b/examples/wondrous_forest/src/LightSensor.c new file mode 100644 index 000000000..88b2fde85 --- /dev/null +++ b/examples/wondrous_forest/src/LightSensor.c @@ -0,0 +1,51 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "LightSensor.h" +#include "SensorHal.h" + +static uint32 s_lux_value; +static uint32 s_nighttime_threshold_lux; + +/* Full-scale 4095 counts = 100 000 lux. */ +PRIVATE uint32 LightSensor__ConvertRawToLux(uint16 raw_counts) +{ + return ((uint32)raw_counts * 100000ul) / (uint32)ADC_MAX_COUNTS; +} + +PRIVATE_INLINE bool LightSensor__IsNighttime(uint32 lux) +{ + return lux < s_nighttime_threshold_lux; +} + +void LightSensor_Init(uint32 nighttime_threshold_lux) +{ + s_lux_value = 0u; + s_nighttime_threshold_lux = nighttime_threshold_lux; + SensorHal_StartConversion(SENSOR_CHANNEL_LIGHT); +} + +bool LightSensor_Sample(void) +{ + if (!SensorHal_IsChannelReady(SENSOR_CHANNEL_LIGHT)) { return false; } + + uint16 raw = SensorHal_ReadChannel(SENSOR_CHANNEL_LIGHT); + s_lux_value = LightSensor__ConvertRawToLux(raw); + SensorHal_StartConversion(SENSOR_CHANNEL_LIGHT); + return true; +} + +uint32 LightSensor_GetLux(void) +{ + return s_lux_value; +} + +bool LightSensor_IsNighttime(void) +{ + return LightSensor__IsNighttime(s_lux_value); +} diff --git a/examples/wondrous_forest/src/LightSensor.h b/examples/wondrous_forest/src/LightSensor.h new file mode 100644 index 000000000..e54dd4bd2 --- /dev/null +++ b/examples/wondrous_forest/src/LightSensor.h @@ -0,0 +1,18 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef LIGHT_SENSOR_H +#define LIGHT_SENSOR_H + +#include "Types.h" + +void LightSensor_Init(uint32 nighttime_threshold_lux); +bool LightSensor_Sample(void); +uint32 LightSensor_GetLux(void); +bool LightSensor_IsNighttime(void); + +#endif /* LIGHT_SENSOR_H */ diff --git a/examples/wondrous_forest/src/SensorHal.c b/examples/wondrous_forest/src/SensorHal.c new file mode 100644 index 000000000..78eb9a1d3 --- /dev/null +++ b/examples/wondrous_forest/src/SensorHal.c @@ -0,0 +1,62 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "SensorHal.h" + +/* In test builds, substitute static arrays for hardware register addresses. + * This prevents segfaults on host systems and lets TestSensorHal.c call the + * public API with deterministic (zero-initialized) register state. */ +#ifdef TEST +static uint32 s_adc_status[SENSOR_CHANNEL_COUNT] = {0u, 0u, 0u, 0u}; +static uint16 s_adc_data[SENSOR_CHANNEL_COUNT] = {0u, 0u, 0u, 0u}; +static uint32 s_adc_ctrl[SENSOR_CHANNEL_COUNT] = {0u, 0u, 0u, 0u}; +static uint32 s_sys_tick = 0u; + +#define ADC_STATUS_REG(ch) s_adc_status[(uint8)(ch)] +#define ADC_DATA_REG(ch) s_adc_data[(uint8)(ch)] +#define ADC_CTRL_REG(ch) s_adc_ctrl[(uint8)(ch)] +#define SYS_TICK_REG s_sys_tick +#else +#define ADC_BASE_ADDR (0x40012000UL) +#define ADC_STATUS_REG(ch) (*((volatile uint32*)(ADC_BASE_ADDR + 0x00u + (uint32)(ch) * 0x10u))) +#define ADC_DATA_REG(ch) (*((volatile uint16*)(ADC_BASE_ADDR + 0x04u + (uint32)(ch) * 0x10u))) +#define ADC_CTRL_REG(ch) (*((volatile uint32*)(ADC_BASE_ADDR + 0x08u + (uint32)(ch) * 0x10u))) +#define SYS_TICK_REG (*((volatile uint32*)(0xE000E018UL))) +#endif + +#define ADC_STATUS_READY_BIT (1u << 0) +#define ADC_CTRL_START_BIT (1u << 0) + +void SensorHal_Init(void) +{ + uint8 ch; + for (ch = 0u; ch < (uint8)SENSOR_CHANNEL_COUNT; ch++) + { + ADC_CTRL_REG(ch) = 0u; + } +} + +uint16 SensorHal_ReadChannel(SensorChannel_t channel) +{ + return (uint16)(ADC_DATA_REG(channel) & (uint16)ADC_MAX_COUNTS); +} + +bool SensorHal_IsChannelReady(SensorChannel_t channel) +{ + return (ADC_STATUS_REG(channel) & ADC_STATUS_READY_BIT) != 0u; +} + +void SensorHal_StartConversion(SensorChannel_t channel) +{ + ADC_CTRL_REG(channel) |= ADC_CTRL_START_BIT; +} + +uint32 SensorHal_GetTimestampMs(void) +{ + return SYS_TICK_REG; +} diff --git a/examples/wondrous_forest/src/SensorHal.h b/examples/wondrous_forest/src/SensorHal.h new file mode 100644 index 000000000..e03f3a233 --- /dev/null +++ b/examples/wondrous_forest/src/SensorHal.h @@ -0,0 +1,19 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef SENSOR_HAL_H +#define SENSOR_HAL_H + +#include "Types.h" + +void SensorHal_Init(void); +uint16 SensorHal_ReadChannel(SensorChannel_t channel); +bool SensorHal_IsChannelReady(SensorChannel_t channel); +void SensorHal_StartConversion(SensorChannel_t channel); +uint32 SensorHal_GetTimestampMs(void); + +#endif /* SENSOR_HAL_H */ diff --git a/examples/wondrous_forest/src/SoilMoisture.c b/examples/wondrous_forest/src/SoilMoisture.c new file mode 100644 index 000000000..013b31b7a --- /dev/null +++ b/examples/wondrous_forest/src/SoilMoisture.c @@ -0,0 +1,61 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "SoilMoisture.h" +#include "SensorHal.h" + +#define SOIL_DRY_THRESHOLD_PERCENT (20u) + +static uint8 s_moisture_percent; +static uint8 s_total_sample_count; + +/* Resistive sensor: lower counts = wetter; mapping is inverted. */ +static uint8 SoilMoisture__RawToPercent(uint16 raw_counts) +{ + uint32 inverted = (uint32)ADC_MAX_COUNTS - (uint32)raw_counts; + return (uint8)((inverted * 100ul) / (uint32)ADC_MAX_COUNTS); +} + +void SoilMoisture_Init(void) +{ + s_moisture_percent = 0u; + s_total_sample_count = 0u; + SensorHal_StartConversion(SENSOR_CHANNEL_SOIL); +} + +bool SoilMoisture_Sample(void) +{ + /* Function-scoped static: accumulates raw ADC totals for drift detection. + * Exposed via Partials as partial_SoilMoisture_Sample_s_raw_accumulator. */ + static uint32 s_raw_accumulator = 0u; + + if (!SensorHal_IsChannelReady(SENSOR_CHANNEL_SOIL)) { return false; } + + uint16 raw = SensorHal_ReadChannel(SENSOR_CHANNEL_SOIL); + s_raw_accumulator += (uint32)raw; + s_moisture_percent = SoilMoisture__RawToPercent(raw); + s_total_sample_count++; + + SensorHal_StartConversion(SENSOR_CHANNEL_SOIL); + return true; +} + +uint8 SoilMoisture_GetPercent(void) +{ + return s_moisture_percent; +} + +bool SoilMoisture_IsDry(void) +{ + return s_moisture_percent < SOIL_DRY_THRESHOLD_PERCENT; +} + +uint8 SoilMoisture_GetSampleCount(void) +{ + return s_total_sample_count; +} diff --git a/examples/wondrous_forest/src/SoilMoisture.h b/examples/wondrous_forest/src/SoilMoisture.h new file mode 100644 index 000000000..32f369959 --- /dev/null +++ b/examples/wondrous_forest/src/SoilMoisture.h @@ -0,0 +1,19 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef SOIL_MOISTURE_H +#define SOIL_MOISTURE_H + +#include "Types.h" + +void SoilMoisture_Init(void); +bool SoilMoisture_Sample(void); +uint8 SoilMoisture_GetPercent(void); +bool SoilMoisture_IsDry(void); +uint8 SoilMoisture_GetSampleCount(void); + +#endif /* SOIL_MOISTURE_H */ diff --git a/examples/wondrous_forest/src/TemperatureSensor.c b/examples/wondrous_forest/src/TemperatureSensor.c new file mode 100644 index 000000000..604f613f3 --- /dev/null +++ b/examples/wondrous_forest/src/TemperatureSensor.c @@ -0,0 +1,67 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "TemperatureSensor.h" +#include "SensorHal.h" + +static float s_calibration_offset; +static int32 s_last_milli_celsius; +static bool s_reading_valid; + +/* Linearized approximation: full-scale 4095 counts maps 0 C to 85 C. */ +static int32 TemperatureSensor__RawToMilliCelsius(uint16 raw_counts) +{ + return (int32)(((uint32)raw_counts * 85000ul) / (uint32)ADC_MAX_COUNTS); +} + +static bool TemperatureSensor__IsInRange(int32 milli_c) +{ + return (milli_c >= (int32)TEMP_CELSIUS_MIN * 1000) && + (milli_c <= (int32)TEMP_CELSIUS_MAX * 1000); +} + +void TemperatureSensor_Init(float calibration_offset_celsius) +{ + /* Tracks re-initialization count across the lifetime of the module. */ + static uint32 s_init_count = 0u; + s_init_count++; + + s_calibration_offset = calibration_offset_celsius; + s_last_milli_celsius = 0; + s_reading_valid = false; + + SensorHal_StartConversion(SENSOR_CHANNEL_TEMP); +} + +bool TemperatureSensor_Sample(void) +{ + uint16 raw; + int32 milli_c; + + if (!SensorHal_IsChannelReady(SENSOR_CHANNEL_TEMP)) { return false; } + + raw = SensorHal_ReadChannel(SENSOR_CHANNEL_TEMP); + milli_c = TemperatureSensor__RawToMilliCelsius(raw); + milli_c += (int32)(s_calibration_offset * 1000.0f); + + s_reading_valid = TemperatureSensor__IsInRange(milli_c); + s_last_milli_celsius = s_reading_valid ? milli_c : s_last_milli_celsius; + + SensorHal_StartConversion(SENSOR_CHANNEL_TEMP); + return s_reading_valid; +} + +int32 TemperatureSensor_GetMilliCelsius(void) +{ + return s_last_milli_celsius; +} + +bool TemperatureSensor_IsValid(void) +{ + return s_reading_valid; +} diff --git a/examples/wondrous_forest/src/TemperatureSensor.h b/examples/wondrous_forest/src/TemperatureSensor.h new file mode 100644 index 000000000..a106453fe --- /dev/null +++ b/examples/wondrous_forest/src/TemperatureSensor.h @@ -0,0 +1,18 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef TEMPERATURE_SENSOR_H +#define TEMPERATURE_SENSOR_H + +#include "Types.h" + +void TemperatureSensor_Init(float calibration_offset_celsius); +bool TemperatureSensor_Sample(void); +int32 TemperatureSensor_GetMilliCelsius(void); +bool TemperatureSensor_IsValid(void); + +#endif /* TEMPERATURE_SENSOR_H */ diff --git a/examples/wondrous_forest/src/Types.h b/examples/wondrous_forest/src/Types.h new file mode 100644 index 000000000..4e99760b1 --- /dev/null +++ b/examples/wondrous_forest/src/Types.h @@ -0,0 +1,84 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef TYPES_H +#define TYPES_H + +#include <stdint.h> +#include <stdbool.h> + +typedef uint8_t uint8; +typedef uint16_t uint16; +typedef uint32_t uint32; +typedef int8_t int8; +typedef int16_t int16; +typedef int32_t int32; + +#ifndef NULL +#define NULL ((void*)0) +#endif + +#define PRIVATE static +#define PRIVATE_INLINE static inline + +#define ADC_MAX_COUNTS (4095u) +#define ADC_VREF_MV (3300u) + +#define TEMP_CELSIUS_MIN (-40) +#define TEMP_CELSIUS_MAX (85) +#define HUMIDITY_PERCENT_MIN (0u) +#define HUMIDITY_PERCENT_MAX (100u) +#define SOIL_MOISTURE_MIN (0u) +#define SOIL_MOISTURE_MAX (100u) + +typedef enum +{ + ALERT_SEVERITY_NONE = 0, + ALERT_SEVERITY_LOW = 1, + ALERT_SEVERITY_MEDIUM = 2, + ALERT_SEVERITY_HIGH = 3, + ALERT_SEVERITY_CRITICAL = 4 +} AlertSeverity_t; + +typedef enum +{ + SENSOR_CHANNEL_TEMP = 0, + SENSOR_CHANNEL_HUMIDITY = 1, + SENSOR_CHANNEL_LIGHT = 2, + SENSOR_CHANNEL_SOIL = 3, + SENSOR_CHANNEL_COUNT = 4 +} SensorChannel_t; + +typedef enum +{ + EVENT_NONE = 0, + EVENT_TEMP_HIGH = 1, + EVENT_TEMP_LOW = 2, + EVENT_HUMIDITY_HIGH = 3, + EVENT_HUMIDITY_LOW = 4, + EVENT_LIGHT_CHANGE = 5, + EVENT_SOIL_DRY = 6, + EVENT_ALERT_TRIGGERED = 7 +} EventType_t; + +typedef struct +{ + EventType_t type; + uint32 timestamp_ms; + int32 value; +} ForestEvent_t; + +typedef enum +{ + MONITOR_STATE_IDLE = 0, + MONITOR_STATE_SAMPLING = 1, + MONITOR_STATE_EVALUATING = 2, + MONITOR_STATE_ALERTING = 3, + MONITOR_STATE_REPORTING = 4 +} MonitorState_t; + +#endif /* TYPES_H */ diff --git a/examples/wondrous_forest/src/UartDriver.c b/examples/wondrous_forest/src/UartDriver.c new file mode 100644 index 000000000..cf583de7b --- /dev/null +++ b/examples/wondrous_forest/src/UartDriver.c @@ -0,0 +1,55 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#include "Types.h" +#include "UartDriver.h" + +/* In test builds, substitute static variables for hardware register addresses. + * TX-ready status is pre-set so UartDriver_SendByte() does not spin. */ +#ifdef TEST +#define UART_STATUS_TX_READY (1u << 7) +static uint32 s_uart_status = (1u << 7); +static uint8 s_uart_data = 0u; +static uint32 s_uart_baud = 0u; + +#define UART_STATUS_REG s_uart_status +#define UART_DATA_REG s_uart_data +#define UART_BAUD_REG s_uart_baud +#else +#define UART_BASE_ADDR (0x40011000UL) +#define UART_STATUS_REG (*((volatile uint32*)(UART_BASE_ADDR + 0x00u))) +#define UART_DATA_REG (*((volatile uint8*) (UART_BASE_ADDR + 0x04u))) +#define UART_BAUD_REG (*((volatile uint32*)(UART_BASE_ADDR + 0x08u))) +#define UART_STATUS_TX_READY (1u << 7) +#endif + +void UartDriver_Init(uint32 baud_rate) +{ + UART_BAUD_REG = baud_rate; + UART_STATUS_REG = UART_STATUS_TX_READY; +} + +bool UartDriver_IsTxReady(void) +{ + return (UART_STATUS_REG & UART_STATUS_TX_READY) != 0u; +} + +void UartDriver_SendByte(uint8 byte) +{ + while (!UartDriver_IsTxReady()) { /* spin */ } + UART_DATA_REG = byte; +} + +void UartDriver_SendString(const char* str) +{ + if (str == NULL) { return; } + while (*str != '\0') + { + UartDriver_SendByte((uint8)*str); + str++; + } +} diff --git a/examples/wondrous_forest/src/UartDriver.h b/examples/wondrous_forest/src/UartDriver.h new file mode 100644 index 000000000..a03265e73 --- /dev/null +++ b/examples/wondrous_forest/src/UartDriver.h @@ -0,0 +1,18 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +#ifndef UART_DRIVER_H +#define UART_DRIVER_H + +#include "Types.h" + +void UartDriver_Init(uint32 baud_rate); +void UartDriver_SendByte(uint8 byte); +void UartDriver_SendString(const char* str); +bool UartDriver_IsTxReady(void); + +#endif /* UART_DRIVER_H */ diff --git a/examples/wondrous_forest/test/TestAlertManager.c b/examples/wondrous_forest/test/TestAlertManager.c new file mode 100644 index 000000000..cb9a0f451 --- /dev/null +++ b/examples/wondrous_forest/test/TestAlertManager.c @@ -0,0 +1,105 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_ALL_MODULE + traditional mock coexistence + * Tests all public AND private functions: AlertManager__FindFreeSlot(), + * AlertManager__RaiseAlert(), AlertManager__TempSeverity(). + * MockUartDriver is a traditional mock that coexists with Partials - the + * private AlertManager__RaiseAlert() calls UartDriver_SendByte(), which is + * intercepted by the traditional mock in the same test file. */ + +#include "unity.h" +#include "ceedling.h" +#include "MockUartDriver.h" + +#include TEST_PARTIAL_ALL_MODULE(AlertManager) + +#include "Types.h" + +void setUp(void) +{ + AlertManager_Init(); +} + +void tearDown(void) +{ +} + +void test_TempSeverity_AboveHighThresholdReturnsHigh(void) +{ + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_HIGH, AlertManager__TempSeverity(40001)); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_HIGH, AlertManager__TempSeverity(50000)); +} + +void test_TempSeverity_BelowLowThresholdReturnsMedium(void) +{ + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_MEDIUM, AlertManager__TempSeverity(-10001)); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_MEDIUM, AlertManager__TempSeverity(-20000)); +} + +void test_TempSeverity_WithinRangeReturnsNone(void) +{ + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_NONE, AlertManager__TempSeverity(25000)); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_NONE, AlertManager__TempSeverity(40000)); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_NONE, AlertManager__TempSeverity(-10000)); +} + +void test_FindFreeSlot_ReturnsZeroOnEmptyTable(void) +{ + TEST_ASSERT_EQUAL_INT8(0, AlertManager__FindFreeSlot()); +} + +void test_FindFreeSlot_ReturnsNegativeOneWhenTableFull(void) +{ + uint8 i; + for (i = 0u; i < ALERT_MANAGER_MAX_ALERTS; i++) + { + UartDriver_SendByte_Expect('^'); + AlertManager__RaiseAlert(ALERT_SEVERITY_HIGH, EVENT_TEMP_HIGH); + } + TEST_ASSERT_EQUAL_INT8(-1, AlertManager__FindFreeSlot()); +} + +void test_RaiseAlert_CriticalSendsExclamation(void) +{ + UartDriver_SendByte_Expect('!'); + AlertManager__RaiseAlert(ALERT_SEVERITY_CRITICAL, EVENT_TEMP_HIGH); + TEST_ASSERT_EQUAL_UINT8(1u, AlertManager_GetActiveAlertCount()); +} + +void test_RaiseAlert_MediumSendsTilde(void) +{ + UartDriver_SendByte_Expect('~'); + AlertManager__RaiseAlert(ALERT_SEVERITY_MEDIUM, EVENT_TEMP_LOW); + TEST_ASSERT_EQUAL_UINT8(1u, AlertManager_GetActiveAlertCount()); +} + +void test_EvaluateTemperature_NoAlertWithinNormalRange(void) +{ + AlertManager_EvaluateTemperature(25000); + TEST_ASSERT_EQUAL_UINT8(0u, AlertManager_GetActiveAlertCount()); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_NONE, AlertManager_GetHighestSeverity()); +} + +void test_EvaluateTemperature_AlertWhenTooHot(void) +{ + UartDriver_SendByte_Expect('^'); + AlertManager_EvaluateTemperature(45000); + TEST_ASSERT_EQUAL_UINT8(1u, AlertManager_GetActiveAlertCount()); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_HIGH, AlertManager_GetHighestSeverity()); +} + +void test_ClearAll_ResetsAlertCount(void) +{ + UartDriver_SendByte_Expect('^'); + AlertManager_EvaluateTemperature(45000); + TEST_ASSERT_EQUAL_UINT8(1u, AlertManager_GetActiveAlertCount()); + + AlertManager_ClearAll(); + TEST_ASSERT_EQUAL_UINT8(0u, AlertManager_GetActiveAlertCount()); + TEST_ASSERT_EQUAL_INT(ALERT_SEVERITY_NONE, AlertManager_GetHighestSeverity()); +} diff --git a/examples/wondrous_forest/test/TestEventQueue.c b/examples/wondrous_forest/test/TestEventQueue.c new file mode 100644 index 000000000..16146378d --- /dev/null +++ b/examples/wondrous_forest/test/TestEventQueue.c @@ -0,0 +1,117 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_ALL_MODULE + * Tests private static inline helpers EventQueue__AdvanceIndex(), + * EventQueue__IsEmpty(), and EventQueue__IsFull() directly. + * Also accesses file-scoped statics s_head, s_tail, s_count as extern + * to verify internal state after push/pop operations. + * Uses ALL variant so both public API and private internals are available. */ + +#include "unity.h" +#include "ceedling.h" + +#include TEST_PARTIAL_ALL_MODULE(EventQueue) + +#include "Types.h" + +void setUp(void) +{ + EventQueue_Init(); +} + +void tearDown(void) +{ +} + +void test_AdvanceIndex_NormalIncrement(void) +{ + TEST_ASSERT_EQUAL_UINT8(1u, EventQueue__AdvanceIndex(0u)); + TEST_ASSERT_EQUAL_UINT8(7u, EventQueue__AdvanceIndex(6u)); +} + +void test_AdvanceIndex_WrapsAroundAtCapacity(void) +{ + TEST_ASSERT_EQUAL_UINT8(0u, EventQueue__AdvanceIndex((uint8)(EVENT_QUEUE_CAPACITY - 1u))); +} + +void test_IsEmptyHelper_TrueWhenCountIsZero(void) +{ + TEST_ASSERT_TRUE(EventQueue__IsEmpty(0u)); +} + +void test_IsEmptyHelper_FalseWhenCountNonZero(void) +{ + TEST_ASSERT_FALSE(EventQueue__IsEmpty(1u)); + TEST_ASSERT_FALSE(EventQueue__IsEmpty(EVENT_QUEUE_CAPACITY)); +} + +void test_IsFullHelper_TrueAtCapacity(void) +{ + TEST_ASSERT_TRUE(EventQueue__IsFull(EVENT_QUEUE_CAPACITY)); + TEST_ASSERT_TRUE(EventQueue__IsFull(EVENT_QUEUE_CAPACITY + 1u)); +} + +void test_IsFullHelper_FalseWhenSpaceRemains(void) +{ + TEST_ASSERT_FALSE(EventQueue__IsFull(0u)); + TEST_ASSERT_FALSE(EventQueue__IsFull(EVENT_QUEUE_CAPACITY - 1u)); +} + +void test_Init_SetsHeadTailCountToZero(void) +{ + /* s_head, s_tail, s_count are file-scoped statics exposed as extern by Partials */ + EventQueue_Init(); + TEST_ASSERT_EQUAL_UINT8(0u, s_head); + TEST_ASSERT_EQUAL_UINT8(0u, s_tail); + TEST_ASSERT_EQUAL_UINT8(0u, s_count); +} + +void test_Push_AdvancesTailAndIncreasesCount(void) +{ + ForestEvent_t evt = { EVENT_TEMP_HIGH, 12345u, 42000 }; + EventQueue_Push(&evt); + + TEST_ASSERT_EQUAL_UINT8(0u, s_head); + TEST_ASSERT_EQUAL_UINT8(1u, s_tail); + TEST_ASSERT_EQUAL_UINT8(1u, s_count); +} + +void test_Pop_AdvancesHeadAndDecreasesCount(void) +{ + ForestEvent_t evt_in = { EVENT_HUMIDITY_HIGH, 99u, 75 }; + ForestEvent_t evt_out = { EVENT_NONE, 0u, 0 }; + + EventQueue_Push(&evt_in); + EventQueue_Pop(&evt_out); + + TEST_ASSERT_EQUAL_UINT8(0u, s_count); + TEST_ASSERT_EQUAL_INT(EVENT_HUMIDITY_HIGH, evt_out.type); + TEST_ASSERT_EQUAL_UINT32(99u, evt_out.timestamp_ms); +} + +void test_Queue_FillAndDrainCycle(void) +{ + ForestEvent_t evt = { EVENT_SOIL_DRY, 1u, 10 }; + uint8 i; + + for (i = 0u; i < EVENT_QUEUE_CAPACITY; i++) + { + evt.value = (int32)i; + TEST_ASSERT_TRUE(EventQueue_Push(&evt)); + } + TEST_ASSERT_TRUE(EventQueue_IsFull()); + TEST_ASSERT_FALSE(EventQueue_Push(&evt)); /* overfill rejected */ + + for (i = 0u; i < EVENT_QUEUE_CAPACITY; i++) + { + ForestEvent_t out; + TEST_ASSERT_TRUE(EventQueue_Pop(&out)); + TEST_ASSERT_EQUAL_INT32((int32)i, out.value); + } + TEST_ASSERT_TRUE(EventQueue_IsEmpty()); +} diff --git a/examples/wondrous_forest/test/TestForestMonitor.c b/examples/wondrous_forest/test/TestForestMonitor.c new file mode 100644 index 000000000..5cdebf01c --- /dev/null +++ b/examples/wondrous_forest/test/TestForestMonitor.c @@ -0,0 +1,114 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_PUBLIC_MODULE + MOCK_PARTIAL_PRIVATE_MODULE + * Tests the public state machine interface while mocking the private + * ForestMonitor__NextState() transition function. + * The file-scoped static s_current_state is exposed as extern by Partials, + * allowing direct state injection to test each state branch in isolation. + * Six traditional mocks coexist alongside the Partial mock. */ + +#include "unity.h" +#include "ceedling.h" +#include "MockTemperatureSensor.h" +#include "MockHumiditySensor.h" +#include "MockSoilMoisture.h" +#include "MockAlertManager.h" +#include "MockEventQueue.h" +#include "MockUartDriver.h" + +#include TEST_PARTIAL_PUBLIC_MODULE(ForestMonitor) +#include MOCK_PARTIAL_PRIVATE_MODULE(ForestMonitor) + +#include "Types.h" + +void setUp(void) +{ + AlertManager_Init_Expect(); + EventQueue_Init_Expect(); + ForestMonitor_Init(); +} + +void tearDown(void) +{ +} + +void test_Init_SetsStateToIdle(void) +{ + TEST_ASSERT_EQUAL_INT(MONITOR_STATE_IDLE, ForestMonitor_GetState()); +} + +void test_Tick_InIdleStateTransitionsViaNextState(void) +{ + ForestMonitor__NextState_ExpectAndReturn(MONITOR_STATE_IDLE, MONITOR_STATE_SAMPLING); + ForestMonitor_Tick(); + TEST_ASSERT_EQUAL_INT(MONITOR_STATE_SAMPLING, ForestMonitor_GetState()); +} + +void test_Tick_InSamplingStateSamplesAllSensors(void) +{ + /* s_current_state is a file-scoped static exposed as extern by TEST_PARTIAL_PUBLIC_MODULE */ + s_current_state = MONITOR_STATE_SAMPLING; + + TemperatureSensor_Sample_ExpectAndReturn(true); + HumiditySensor_Sample_ExpectAndReturn(true); + SoilMoisture_Sample_ExpectAndReturn(true); + ForestMonitor__NextState_ExpectAndReturn(MONITOR_STATE_SAMPLING, MONITOR_STATE_EVALUATING); + + ForestMonitor_Tick(); + TEST_ASSERT_EQUAL_INT(MONITOR_STATE_EVALUATING, ForestMonitor_GetState()); +} + +void test_Tick_InEvaluatingStateCallsAlertEvaluations(void) +{ + s_current_state = MONITOR_STATE_EVALUATING; + + /* Strict ordering: C evaluates each argument before its enclosing call, + * so GetMilliCelsius is called before EvaluateTemperature, etc. */ + TemperatureSensor_GetMilliCelsius_ExpectAndReturn(25000); + AlertManager_EvaluateTemperature_Expect(25000); + HumiditySensor_GetPercent_ExpectAndReturn(60u); + AlertManager_EvaluateHumidity_Expect(60u); + SoilMoisture_GetPercent_ExpectAndReturn(55u); + AlertManager_EvaluateSoilMoisture_Expect(55u); + ForestMonitor__NextState_ExpectAndReturn(MONITOR_STATE_EVALUATING, MONITOR_STATE_REPORTING); + + ForestMonitor_Tick(); + TEST_ASSERT_EQUAL_INT(MONITOR_STATE_REPORTING, ForestMonitor_GetState()); +} + +void test_Tick_InAlertingStateSendsAlertString(void) +{ + s_current_state = MONITOR_STATE_ALERTING; + + UartDriver_SendString_Expect("ALERT\r\n"); + ForestMonitor__NextState_ExpectAndReturn(MONITOR_STATE_ALERTING, MONITOR_STATE_REPORTING); + + ForestMonitor_Tick(); + TEST_ASSERT_EQUAL_INT(MONITOR_STATE_REPORTING, ForestMonitor_GetState()); +} + +void test_Tick_InReportingStateSendsOkAndClearsAlerts(void) +{ + s_current_state = MONITOR_STATE_REPORTING; + + UartDriver_SendString_Expect("OK\r\n"); + AlertManager_ClearAll_Expect(); + ForestMonitor__NextState_ExpectAndReturn(MONITOR_STATE_REPORTING, MONITOR_STATE_IDLE); + + ForestMonitor_Tick(); + TEST_ASSERT_EQUAL_INT(MONITOR_STATE_IDLE, ForestMonitor_GetState()); +} + +void test_HasPendingAlerts_ReflectsAlertManagerCount(void) +{ + AlertManager_GetActiveAlertCount_ExpectAndReturn(3u); + TEST_ASSERT_TRUE(ForestMonitor_HasPendingAlerts()); + + AlertManager_GetActiveAlertCount_ExpectAndReturn(0u); + TEST_ASSERT_FALSE(ForestMonitor_HasPendingAlerts()); +} diff --git a/examples/wondrous_forest/test/TestHumiditySensor.c b/examples/wondrous_forest/test/TestHumiditySensor.c new file mode 100644 index 000000000..67b349578 --- /dev/null +++ b/examples/wondrous_forest/test/TestHumiditySensor.c @@ -0,0 +1,85 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_ALL_MODULE + * Tests the private static inline HumiditySensor__ClampPercent() and + * private static HumiditySensor__RawToPercent() directly, while also + * driving state through the public API. SensorHal is mocked traditionally. + * Uses PARTIAL_LOCAL_VAR() to access the function-scoped static s_rolling_sum + * inside HumiditySensor_Sample(). */ + +#include "unity.h" +#include "ceedling.h" +#include "MockSensorHal.h" + +#include TEST_PARTIAL_ALL_MODULE(HumiditySensor) + +#include "Types.h" + +void setUp(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_HUMIDITY); + HumiditySensor_Init(); +} + +void tearDown(void) +{ +} + +void test_ClampPercent_BelowMinClampsToZero(void) +{ + TEST_ASSERT_EQUAL_UINT8(0u, HumiditySensor__ClampPercent(-1)); + TEST_ASSERT_EQUAL_UINT8(0u, HumiditySensor__ClampPercent(-100)); +} + +void test_ClampPercent_AboveMaxClampsTo100(void) +{ + TEST_ASSERT_EQUAL_UINT8(100u, HumiditySensor__ClampPercent(101)); + TEST_ASSERT_EQUAL_UINT8(100u, HumiditySensor__ClampPercent(200)); +} + +void test_ClampPercent_WithinRangePassesThrough(void) +{ + TEST_ASSERT_EQUAL_UINT8(0u, HumiditySensor__ClampPercent(0)); + TEST_ASSERT_EQUAL_UINT8(50u, HumiditySensor__ClampPercent(50)); + TEST_ASSERT_EQUAL_UINT8(100u, HumiditySensor__ClampPercent(100)); +} + +void test_RawToPercent_ZeroCountsGivesZeroPercent(void) +{ + TEST_ASSERT_EQUAL_UINT8(0u, HumiditySensor__RawToPercent(0u)); +} + +void test_RawToPercent_FullScaleGives100Percent(void) +{ + TEST_ASSERT_EQUAL_UINT8(100u, HumiditySensor__RawToPercent(4095u)); +} + +void test_RawToPercent_MidScaleGivesApproximately50Percent(void) +{ + /* (2048 * 100) / 4095 = 50 */ + TEST_ASSERT_UINT8_WITHIN(2u, 50u, HumiditySensor__RawToPercent(2048u)); +} + +void test_RollingSum_AccumulatesAcrossSamples(void) +{ + /* Delta-based assertion: robust against accumulated state from previous tests + * since s_rolling_sum is a function-scoped static that persists. */ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_HUMIDITY, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_HUMIDITY, 2048u); /* ~= 50% */ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_HUMIDITY); + HumiditySensor_Sample(); + uint32 sum_after_first = PARTIAL_LOCAL_VAR(HumiditySensor_Sample, s_rolling_sum); + + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_HUMIDITY, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_HUMIDITY, 4095u); /* 100% */ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_HUMIDITY); + HumiditySensor_Sample(); + uint32 sum_after_second = PARTIAL_LOCAL_VAR(HumiditySensor_Sample, s_rolling_sum); + + TEST_ASSERT_EQUAL_UINT32(sum_after_first + 100u, sum_after_second); +} diff --git a/examples/wondrous_forest/test/TestLightSensor.c b/examples/wondrous_forest/test/TestLightSensor.c new file mode 100644 index 000000000..33a9dee4e --- /dev/null +++ b/examples/wondrous_forest/test/TestLightSensor.c @@ -0,0 +1,79 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_PUBLIC_MODULE + MOCK_PARTIAL_PRIVATE_MODULE + * Tests the public interface (LightSensor_Sample, GetLux, IsNighttime) + * while mocking the private helpers LightSensor__ConvertRawToLux() and + * LightSensor__IsNighttime(). SensorHal is mocked traditionally. + * This demonstrates testing public behavior while isolating private logic. */ + +#include "unity.h" +#include "ceedling.h" +#include "MockSensorHal.h" + +#include TEST_PARTIAL_PUBLIC_MODULE(LightSensor) +#include MOCK_PARTIAL_PRIVATE_MODULE(LightSensor) + +#include "Types.h" + +void setUp(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_LIGHT); + LightSensor_Init(500u); +} + +void tearDown(void) +{ +} + +void test_GetLux_ReturnsZeroBeforeAnySample(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_LIGHT); + LightSensor_Init(1000u); + TEST_ASSERT_EQUAL_UINT32(0u, LightSensor_GetLux()); +} + +void test_Sample_ReturnsFalseWhenChannelNotReady(void) +{ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, false); + TEST_ASSERT_FALSE(LightSensor_Sample()); +} + +void test_Sample_CallsConvertHelperAndStoresResult(void) +{ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, 2000u); + LightSensor__ConvertRawToLux_ExpectAndReturn(2000u, 48840u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_LIGHT); + + TEST_ASSERT_TRUE(LightSensor_Sample()); + TEST_ASSERT_EQUAL_UINT32(48840u, LightSensor_GetLux()); +} + +void test_IsNighttime_ReturnsTrueWhenPrivateHelperReturnsTrue(void) +{ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, 10u); + LightSensor__ConvertRawToLux_ExpectAndReturn(10u, 244u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_LIGHT); + LightSensor_Sample(); + + LightSensor__IsNighttime_ExpectAndReturn(244u, true); + TEST_ASSERT_TRUE(LightSensor_IsNighttime()); +} + +void test_IsNighttime_ReturnsFalseWhenPrivateHelperReturnsFalse(void) +{ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_LIGHT, 3000u); + LightSensor__ConvertRawToLux_ExpectAndReturn(3000u, 73260u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_LIGHT); + LightSensor_Sample(); + + LightSensor__IsNighttime_ExpectAndReturn(73260u, false); + TEST_ASSERT_FALSE(LightSensor_IsNighttime()); +} diff --git a/examples/wondrous_forest/test/TestSensorHal.c b/examples/wondrous_forest/test/TestSensorHal.c new file mode 100644 index 000000000..033fb6402 --- /dev/null +++ b/examples/wondrous_forest/test/TestSensorHal.c @@ -0,0 +1,58 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Traditional test - no Partials needed. + * SensorHal has no private static functions; it is a thin hardware wrapper. + * The #ifdef TEST guards in SensorHal.c substitute zero-initialized static + * arrays for memory-mapped registers, making this code safe to run on a host. + * Tests verify the observable behavior given that known initial register state. */ + +#include "unity.h" +#include "Types.h" +#include "SensorHal.h" + +void setUp(void) +{ + SensorHal_Init(); +} + +void tearDown(void) +{ +} + +void test_IsChannelReady_ReturnsFalseWhenStatusRegisterClear(void) +{ + /* In test builds, status array initializes to 0 (not ready). */ + TEST_ASSERT_FALSE(SensorHal_IsChannelReady(SENSOR_CHANNEL_TEMP)); + TEST_ASSERT_FALSE(SensorHal_IsChannelReady(SENSOR_CHANNEL_HUMIDITY)); +} + +void test_ReadChannel_ReturnsZeroWhenDataRegisterClear(void) +{ + TEST_ASSERT_EQUAL_UINT16(0u, SensorHal_ReadChannel(SENSOR_CHANNEL_TEMP)); +} + +void test_ReadChannel_AlwaysWithinAdcRange(void) +{ + uint16 val = SensorHal_ReadChannel(SENSOR_CHANNEL_LIGHT); + TEST_ASSERT_LESS_OR_EQUAL_UINT16(ADC_MAX_COUNTS, val); +} + +void test_StartConversion_IsCallableForAllChannels(void) +{ + SensorHal_StartConversion(SENSOR_CHANNEL_TEMP); + SensorHal_StartConversion(SENSOR_CHANNEL_HUMIDITY); + SensorHal_StartConversion(SENSOR_CHANNEL_LIGHT); + SensorHal_StartConversion(SENSOR_CHANNEL_SOIL); + TEST_PASS(); +} + +void test_GetTimestampMs_ReturnsZeroWhenTickRegisterClear(void) +{ + /* In test builds, the sys tick register substitute initializes to 0. */ + TEST_ASSERT_EQUAL_UINT32(0u, SensorHal_GetTimestampMs()); +} diff --git a/examples/wondrous_forest/test/TestSoilMoisture.c b/examples/wondrous_forest/test/TestSoilMoisture.c new file mode 100644 index 000000000..aac25ba05 --- /dev/null +++ b/examples/wondrous_forest/test/TestSoilMoisture.c @@ -0,0 +1,96 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_ALL_MODULE + * Tests private static SoilMoisture__RawToPercent() directly, while also + * driving state through the public API. SensorHal is mocked traditionally. + * Uses PARTIAL_LOCAL_VAR() to access and verify the function-scoped static + * s_raw_accumulator inside SoilMoisture_Sample(). */ + +#include "unity.h" +#include "ceedling.h" +#include "MockSensorHal.h" + +#include TEST_PARTIAL_ALL_MODULE(SoilMoisture) + +#include "Types.h" + +void setUp(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_SOIL); + SoilMoisture_Init(); +} + +void tearDown(void) +{ +} + +void test_RawToPercent_ZeroCountsGives100PercentWet(void) +{ + /* 0 counts -> inverted: 4095; (4095 * 100) / 4095 = 100% */ + TEST_ASSERT_EQUAL_UINT8(100u, SoilMoisture__RawToPercent(0u)); +} + +void test_RawToPercent_FullScaleCountsGives0Percent(void) +{ + /* 4095 counts -> inverted: 0; 0% */ + TEST_ASSERT_EQUAL_UINT8(0u, SoilMoisture__RawToPercent(4095u)); +} + +void test_RawToPercent_MidScaleGivesApproximately50Percent(void) +{ + /* 2048 counts -> inverted: 2047; (2047 * 100) / 4095 ~= 50% */ + TEST_ASSERT_UINT8_WITHIN(2u, 50u, SoilMoisture__RawToPercent(2048u)); +} + +void test_RawToPercent_QuarterScaleGivesApproximately75Percent(void) +{ + /* 1024 counts -> inverted: 3071; (3071 * 100) / 4095 ~= 75% */ + TEST_ASSERT_UINT8_WITHIN(2u, 75u, SoilMoisture__RawToPercent(1024u)); +} + +void test_RawAccumulator_IncreasesWithEachSample(void) +{ + /* Delta-based: robust against accumulated state from previous tests + * since s_raw_accumulator is a function-scoped static that persists. */ + uint32 acc_before = PARTIAL_LOCAL_VAR(SoilMoisture_Sample, s_raw_accumulator); + + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_SOIL, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_SOIL, 1000u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_SOIL); + SoilMoisture_Sample(); + uint32 acc_after_first = PARTIAL_LOCAL_VAR(SoilMoisture_Sample, s_raw_accumulator); + TEST_ASSERT_EQUAL_UINT32(acc_before + 1000u, acc_after_first); + + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_SOIL, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_SOIL, 2000u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_SOIL); + SoilMoisture_Sample(); + uint32 acc_after_second = PARTIAL_LOCAL_VAR(SoilMoisture_Sample, s_raw_accumulator); + TEST_ASSERT_EQUAL_UINT32(acc_after_first + 2000u, acc_after_second); +} + +void test_IsDry_TrueWhenMoistureBelow20Percent(void) +{ + /* 3500 counts -> inverted: 595; (595 * 100) / 4095 ~= 14% -> dry */ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_SOIL, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_SOIL, 3500u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_SOIL); + SoilMoisture_Sample(); + + TEST_ASSERT_TRUE(SoilMoisture_IsDry()); +} + +void test_IsDry_FalseWhenMoistureAtMidScale(void) +{ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_SOIL, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_SOIL, 2048u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_SOIL); + SoilMoisture_Sample(); + + TEST_ASSERT_FALSE(SoilMoisture_IsDry()); +} diff --git a/examples/wondrous_forest/test/TestTemperatureSensor.c b/examples/wondrous_forest/test/TestTemperatureSensor.c new file mode 100644 index 000000000..31c5a391c --- /dev/null +++ b/examples/wondrous_forest/test/TestTemperatureSensor.c @@ -0,0 +1,100 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Partials pattern: TEST_PARTIAL_ALL_MODULE + * Tests both public functions AND the private static helpers + * TemperatureSensor__RawToMilliCelsius() and TemperatureSensor__IsInRange(). + * Also accesses the file-scoped static s_calibration_offset directly. + * SensorHal is mocked traditionally since it has no private statics. */ + +#include "unity.h" +#include "ceedling.h" +#include "MockSensorHal.h" + +#include TEST_PARTIAL_ALL_MODULE(TemperatureSensor) + +#include "Types.h" + +void setUp(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_TEMP); + TemperatureSensor_Init(0.0f); +} + +void tearDown(void) +{ +} + +void test_RawToMilliCelsius_ZeroCountsGivesZero(void) +{ + TEST_ASSERT_EQUAL_INT32(0, TemperatureSensor__RawToMilliCelsius(0u)); +} + +void test_RawToMilliCelsius_FullScaleGives85000(void) +{ + TEST_ASSERT_EQUAL_INT32(85000, TemperatureSensor__RawToMilliCelsius(4095u)); +} + +void test_RawToMilliCelsius_MidScaleGivesApproximately42500(void) +{ + /* (2048 * 85000) / 4095 = 42502 */ + TEST_ASSERT_INT32_WITHIN(100, 42500, TemperatureSensor__RawToMilliCelsius(2048u)); +} + +void test_IsInRange_ValidTemperaturesReturnTrue(void) +{ + TEST_ASSERT_TRUE(TemperatureSensor__IsInRange(25000)); + TEST_ASSERT_TRUE(TemperatureSensor__IsInRange(0)); + TEST_ASSERT_TRUE(TemperatureSensor__IsInRange(85000)); + TEST_ASSERT_TRUE(TemperatureSensor__IsInRange(-40000)); +} + +void test_IsInRange_OutOfRangeReturnsFalse(void) +{ + TEST_ASSERT_FALSE(TemperatureSensor__IsInRange(85001)); + TEST_ASSERT_FALSE(TemperatureSensor__IsInRange(-40001)); +} + +void test_CalibrationOffsetIsStoredOnInit(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_TEMP); + TemperatureSensor_Init(2.5f); + /* s_calibration_offset is a file-scoped static exposed as extern by Partials */ + TEST_ASSERT_FLOAT_WITHIN(0.001f, 2.5f, s_calibration_offset); +} + +void test_Sample_ReturnsFalseWhenChannelNotReady(void) +{ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_TEMP, false); + TEST_ASSERT_FALSE(TemperatureSensor_Sample()); +} + +void test_Sample_ReturnsTrueAndStoresReadingWhenReady(void) +{ + /* 1638 counts: (1638 * 85000) / 4095 ~= 34000 milli-C (34 C), in range */ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_TEMP, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_TEMP, 1638u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_TEMP); + + TEST_ASSERT_TRUE(TemperatureSensor_Sample()); + TEST_ASSERT_TRUE(TemperatureSensor_IsValid()); + TEST_ASSERT_INT32_WITHIN(100, 34000, TemperatureSensor_GetMilliCelsius()); +} + +void test_CalibrationOffsetShiftsReading(void) +{ + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_TEMP); + TemperatureSensor_Init(1.0f); + + /* 2048 counts ~= 42502 milli-C; with +1.0 C offset -> ~= 43502 */ + SensorHal_IsChannelReady_ExpectAndReturn(SENSOR_CHANNEL_TEMP, true); + SensorHal_ReadChannel_ExpectAndReturn(SENSOR_CHANNEL_TEMP, 2048u); + SensorHal_StartConversion_Expect(SENSOR_CHANNEL_TEMP); + + TemperatureSensor_Sample(); + TEST_ASSERT_INT32_WITHIN(200, 43502, TemperatureSensor_GetMilliCelsius()); +} diff --git a/examples/wondrous_forest/test/TestUartDriver.c b/examples/wondrous_forest/test/TestUartDriver.c new file mode 100644 index 000000000..f6c0d6666 --- /dev/null +++ b/examples/wondrous_forest/test/TestUartDriver.c @@ -0,0 +1,55 @@ +/* ========================================================================= + Ceedling - Test-Centered Build System for C + ThrowTheSwitch.org + Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams + SPDX-License-Identifier: MIT +========================================================================= */ + +/* Traditional test - no Partials needed. + * UartDriver has no private static functions; it is a thin hardware wrapper. + * The #ifdef TEST guard in UartDriver.c pre-sets the TX-ready status bit, + * so UartDriver_SendByte() does not spin waiting for hardware. */ + +#include "unity.h" +#include "Types.h" +#include "UartDriver.h" + +void setUp(void) +{ + UartDriver_Init(115200u); +} + +void tearDown(void) +{ +} + +void test_IsTxReady_ReturnsTrueAfterInit(void) +{ + /* UartDriver_Init sets UART_STATUS_REG = UART_STATUS_TX_READY. */ + TEST_ASSERT_TRUE(UartDriver_IsTxReady()); +} + +void test_SendByte_DoesNotHangWhenTxReady(void) +{ + /* TX-ready status ensures no spin; call must return without blocking. */ + UartDriver_SendByte((uint8)'H'); + TEST_PASS(); +} + +void test_SendString_TransmitsEachCharacter(void) +{ + UartDriver_SendString("Hi"); + TEST_PASS(); +} + +void test_SendString_DoesNotCrashOnEmptyString(void) +{ + UartDriver_SendString(""); + TEST_PASS(); +} + +void test_SendString_GuardsAgainstNullPointer(void) +{ + UartDriver_SendString(NULL); + TEST_PASS(); +} diff --git a/lib/ceedling/array_patches.rb b/lib/ceedling/array_patches.rb new file mode 100644 index 000000000..af71851b1 --- /dev/null +++ b/lib/ceedling/array_patches.rb @@ -0,0 +1,28 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +module ArrayPatches + # Add Array#intersect? for Ruby 3.0 but use built-in provided by 3.1+ + # Query if any elements are in common. + unless Array.method_defined?(:intersect?) + Array.class_eval do + def intersect?(other) + !(self & other).empty? + end + end + end + + Array.class_eval do + # Query if all elements are in common. + def overlap?(other) + return (self & other).size() == other.size() + end + end +end + +# Auto-load patches +ArrayPatches \ No newline at end of file diff --git a/lib/ceedling/c_extractor/c_extractor.rb b/lib/ceedling/c_extractor/c_extractor.rb new file mode 100644 index 000000000..1bff85ce7 --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor.rb @@ -0,0 +1,340 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'strscan' +require 'stringio' +require 'ceedling/exceptions' +require 'ceedling/c_extractor/c_extractor_constants' +require 'ceedling/c_extractor/c_extractor_preprocessing' +require 'ceedling/c_extractor/c_extractor_definitions' +require 'ceedling/c_extractor/c_extractor_types' + +class CExtractor + + include CExtractorConstants + include CExtractorTypes + + constructor :c_extractor_code_text, :c_extractor_functions, :c_extractor_declarations, :c_extractor_preprocessing, :c_extractor_definitions + + attr_writer :chunk_size, :max_buffer_length + + def setup() + # Aliases + @code_text = @c_extractor_code_text + @functions = @c_extractor_functions + @declarations = @c_extractor_declarations + @preprocessing = @c_extractor_preprocessing + @definitions = @c_extractor_definitions + + @chunk_size = DEFAULT_CHUNK_SIZE + @max_buffer_length = DEFAULT_MAX_FUNCTION_LENGTH + end + + # Extract C module contents from a source file on disk. + # + # Parameters: + # filepath: String path to the C source file to extract from + # + # Returns: CModule struct containing all features extracted. + # + # Raises: + # CeedlingException: If file cannot be opened (permissions, doesn't exist, etc.) + def from_file(filepath) + begin + File.open(filepath, 'r') do |file| + return extract_contents( file, filepath ) + end + rescue => ex + raise CeedlingException.new("Error opening file for C contents extraction `#{filepath}` ⏩️ #{ex.message}") + end + end + + # Extract C module contents from an in-memory string. + # + # Parameters: + # content: String containing C source code to extract from + # chunk_size: (Optional) Size of chunks to read at a time (default: 16 KB) + # max_buffer_length: (Optional) Maximum allowed function size (default: 5 MB) + # max_line_length: (Optional) Maximum allowed line length (default: 1000 chars) + # + # Returns: CModule struct containing all features extracted. + def from_string( + content:, + chunk_size: DEFAULT_CHUNK_SIZE, + max_buffer_length: DEFAULT_MAX_FUNCTION_LENGTH, + max_line_length: DEFAULT_MAX_LINE_LENGTH + ) + @chunk_size = chunk_size + @max_buffer_length = max_buffer_length + @functions.max_line_length = max_line_length + @declarations.max_line_length = max_line_length + + return extract_contents( StringIO.new( content ), nil ) + end + + private + + # Extracts all C code features from the given IO source. + # + # Parameters: + # io: Ruby IO object (File or StringIO) to read C source from + # filepath: String path to the original source file (may be nil for string input) + # + # Returns: CModule struct containing all features extracted. + # + # Raises: + # CeedlingException: If a feature exceeds max_buffer_length during extraction + def extract_contents(io, filepath) + function_definitions = [] + function_declarations = [] + variable_declarations = [] + macro_definitions = [] + type_definitions = [] + aggregate_definitions = [] + sequence = [] + cumulative_newlines = 0 + + # Ensure we're at the start of buffer + io.rewind + + until io.eof? + # Record IO position once per outer iteration. + # All extractors that fail rewind IO back to this position. + call_start = io.pos + + # First: preprocessing directives — '#' is the most syntactically unique leading character. + # All directives are consumed; filter_directive selects only those collected for storage. + directive, dir_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @preprocessing.method(:try_extract_directive) + ) + if directive + line_num, cumulative_newlines = + _compute_line_info(io, call_start, dir_start, cumulative_newlines) + macro_def = @preprocessing.filter_directive(directive, CExtractorPreprocessing::MACRO_DEFINITION) + if macro_def + stmt = CStatement.new(text: macro_def, line_num: line_num) + macro_definitions << stmt + sequence << stmt + end + next + end + + # Second: typedef declarations — 'typedef' is as syntactically unique as '#', + # so handle it early before any heuristic-based feature detectors. + typedef_def, td_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @definitions.method(:try_extract_typedef) + ) + if typedef_def + line_num, cumulative_newlines = + _compute_line_info(io, call_start, td_start, cumulative_newlines) + stmt = CStatement.new(text: typedef_def, line_num: line_num) + type_definitions << stmt + sequence << stmt + next + end + + # Third: static assertions — C11 _Static_assert / C23 static_assert. + # Keyword-led and syntactically unambiguous; consumed but not collected. + static_assert, sa_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @preprocessing.method(:try_extract_static_assert) + ) + if static_assert + _line_num, cumulative_newlines = + _compute_line_info(io, call_start, sa_start, cumulative_newlines) + next + end + + # Fourth: non-typedef struct/enum/union type definitions. + # Keyword-led and syntactically unambiguous at the brace level; + # collected into aggregate_definitions. + agg_def, agg_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @definitions.method(:try_extract_aggregate_definition) + ) + if agg_def + line_num, cumulative_newlines = + _compute_line_info(io, call_start, agg_start, cumulative_newlines) + stmt = CStatement.new(text: agg_def, line_num: line_num) + aggregate_definitions << stmt + sequence << stmt + next + end + + # Extract a function definition (most unique non-preprocessor feature) + func, func_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @functions.method(:try_extract_function_definition), + params: [filepath] + ) + if func + line_num, cumulative_newlines = + _compute_line_info(io, call_start, func_start, cumulative_newlines) + func.line_num = line_num + function_definitions << func + sequence << func + next + end + + # Extract a function forward declaration (next most unique feature) + func, func_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @functions.method(:try_extract_function_declaration) + ) + if func + line_num, cumulative_newlines = + _compute_line_info(io, call_start, func_start, cumulative_newlines) + func.line_num = line_num + function_declarations << func + sequence << func + next + end + + # Extract variable declarations as array + # NOTE: A compound variable declaration (e.g. `int x, y`) yields multiple declarations + vars, vars_start = extract_next_feature( + io: io, + max_length: @max_buffer_length, + extractor: @declarations.method(:try_extract_variable) + ) + if vars + line_num, cumulative_newlines = + _compute_line_info(io, call_start, vars_start, cumulative_newlines) + vars.each { |v| v.line_num = line_num } + variable_declarations.concat(vars) + sequence.concat(vars) + next + end + + # If no features found, we are either at EOF or stuck on unrecognized text. + # In either case, break out of the loop to avoid infinite looping and return the accumulated results. + break + end + + return CModule.new( + function_definitions: function_definitions, + function_declarations: function_declarations, + variable_declarations: variable_declarations, + macro_definitions: macro_definitions, + type_definitions: type_definitions, + aggregate_definitions: aggregate_definitions, + element_sequence: sequence + ) + ensure + io.close + end + + # Generic chunked buffer extraction routine + # Reads IO in chunks, building a buffer until the provided extractor successfully extracts a feature + # + # Parameters: + # io: IO object to read from + # max_length: Maximum buffer size before raising an error + # extractor: Method/Proc that takes a StringScanner and returns [success, extracted_data] + # The extractor should advance the scanner position past the extracted feature on success + # + # Returns: The extracted data on success, nil if EOF reached without finding a complete feature + # + # Side effects: + # On success: Advance IO position to immediately after the extracted feature. + # On failure: Rewind IO position to the start of the current buffer. + def extract_next_feature(io:, max_length:, extractor:, params: []) + buffer = "" + chunk_start_pos = io.pos + + # Incrementally attempt feature extraction with repeated attempts and a growing buffer. + # + # Return on successful finding of a complete feature. + # Exit the method with failure ([nil, nil]) and rewind IO: + # 1. If we reach end of IO. + # 2. Exceed maximum buffer length. + # 3. Find no feature. + # + # Loop: + # 1. Advance in attempting to extract a feature in the current buffer. + # 2. If we find nothing, expand the buffer with another chunk. + # 3. Go back to (1). + loop do + # Read next chunk + chunk = io.read(@chunk_size) + + # Break out of the loop if we've reached the end of IO + break unless chunk # EOF + + # Expand the buffer with the new chunk + buffer << chunk + + # Safety check -- don't let buffer grow indefinitely + if buffer.length > max_length + raise CeedlingException.new("Feature extraction exceeded maximum length of #{max_length} characters") + end + + # Create a new scanner for the current buffer + scanner = StringScanner.new(buffer) + + # Skip any deadspace + @code_text.skip_deadspace(scanner) + + # If reached end of string having found no feature -- restart loop to containing loop to grow buffer + next if scanner.eos? + + # Capture absolute IO position of feature start (after deadspace) before calling extractor + feature_start_pos = chunk_start_pos + scanner.pos + + # Try extract complete feature using provided extractor + success, feature = extractor.call(scanner, *params) + + if success + # Consume any trailing semicolons that may follow the extracted feature. + # This handles cases like "int a;;" or "void foo() {};" where legal but + # unnecessary semicolons could break subsequent feature extraction. + @code_text.skip_semicolons(scanner) + + # Rewind IO buffer to position after this feature for next extraction attempt + io.seek(chunk_start_pos + scanner.pos) + return [feature, feature_start_pos] + end + end + + # Reached IO EOF without finding complete feature -- rewind IO buffer for next extraction attempt + io.seek(chunk_start_pos) + return [nil, nil] + end + + # Compute the 1-based line number of a feature in the source file and advance the + # cumulative newline counter past all bytes consumed in this extraction cycle. + # + # Parameters: + # io: IO object (File or StringIO), currently positioned at end_pos + # call_start: IO byte position at the start of the extract_next_feature call + # feature_start: IO byte position where the feature begins (after leading deadspace) + # cumulative_newlines: Running count of newlines consumed before call_start + # + # Returns: [feature_line, new_cumulative_newlines] + # feature_line: 1-based line number in source file where the feature starts + # new_cumulative_newlines: Updated counter including all bytes consumed this cycle + def _compute_line_info(io, call_start, feature_start, cumulative_newlines) + end_pos = io.pos + io.seek(call_start) + consumed = io.read(end_pos - call_start) + io.seek(end_pos) + + gap_len = feature_start - call_start + feature_line = 1 + cumulative_newlines + consumed[0...gap_len].count("\n") + new_cumulative = cumulative_newlines + consumed.count("\n") + + [feature_line, new_cumulative] + end +end diff --git a/lib/ceedling/c_extractor/c_extractor_code_text.rb b/lib/ceedling/c_extractor/c_extractor_code_text.rb new file mode 100644 index 000000000..279108c7a --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_code_text.rb @@ -0,0 +1,283 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'strscan' +require 'ceedling/c_extractor/c_extractor_constants' + +class CExtractorCodeText + + include CExtractorConstants + + # Collect the full text of a balanced delimiter pair starting AT open_char. + # Nested pairs, string literals (verbatim), and comments (replaced with a + # single space) are handled correctly. + # Returns [true, text_including_delimiters] or [false, nil] on unbalanced input + # or EOS before the matching close delimiter is found. + # + # Works for any single-character delimiter pair: '{}', '()', or '[]'. + # + # @param scanner [StringScanner] positioned at open_char + # @param open_char [String] single-character opening delimiter + # @param close_char [String] single-character closing delimiter + # @return [Array(Boolean, String|nil)] + def collect_balanced(scanner, open_char, close_char) + return [false, nil] unless scanner.peek(1) == open_char + + text = +scanner.getch # consume and record open_char + depth = 1 + + until scanner.eos? + ch = scanner.peek(1) + + if ch == '"' || ch == "'" + before = scanner.pos + skip_c_string(scanner, ch) + text << scanner.string[before...scanner.pos] + elsif scanner.check(%r{/[/*]}) + skip_comment(scanner) + text << ' ' + elsif ch == open_char + depth += 1 + text << scanner.getch + elsif ch == close_char + depth -= 1 + text << scanner.getch + return [true, text] if depth == 0 + else + text << scanner.getch + end + end + + [false, nil] + end + + # Extract a balanced block of braces from the scanner. + # Delegates to collect_balanced() — see its documentation for full details. + # + # @param scanner [StringScanner] positioned at the opening '{' + # @return [Array(Boolean, String|nil)] + def extract_balanced_braces(scanner) + collect_balanced(scanner, '{', '}') + end + + # Skip a C string or character literal + # Handles escape sequences to avoid false termination on escaped quotes + # + # Parameters: + # scanner: StringScanner positioned at the opening quote + # quote: The quote character (either '"' for strings or "'" for characters) + # + # Returns: Number of bytes skipped (including opening and closing quotes) + # + # Side effects: Advances scanner position past the closing quote (or to end of string if unterminated) + # + # Examples: + # "hello" -> skips 7 bytes + # 'a' -> skips 3 bytes + # "say \"hi\"" -> skips 11 bytes (handles escaped quotes) + # "path\\file" -> skips 11 bytes (handles escaped backslashes) + def skip_c_string(scanner, quote) + start_pos = scanner.pos + scanner.getch # Opening quote + + until scanner.eos? + if scanner.scan(/\\/) + scanner.getch + elsif scanner.getch == quote + break + end + end + + return (scanner.pos - start_pos) + end + + # Skip consecutive semicolons and any intervening deadspace + # + # This method handles cases where multiple semicolons appear in sequence, + # potentially separated by whitespace or comments. + # This is valid C syntax (null statements) and can occur due to: + # - Macro expansions + # - Code generation + # - Coding mistakes that don't cause compilation errors + # + # The method repeatedly: + # 1. Skips any deadspace (whitespace and comments) + # 2. Checks for a semicolon + # 3. If found, consumes it and continues + # 4. If not found, restores position and exits + # + # Parameters: + # scanner: StringScanner positioned at potential semicolons/deadspace + # + # Returns: Nothing (void method) + # + # Side effects: Advances scanner position past all consecutive semicolons and deadspace + # + # Examples: + # ";;;" -> skips all three semicolons + # "; ; ;" -> skips semicolons and spaces + # "; /* comment */ ;" -> skips semicolons and comment + # "; code" -> skips first semicolon, stops at "code" + # "code" -> skips nothing + def skip_semicolons(scanner) + while !scanner.eos? + start_pos = scanner.pos + skip_deadspace(scanner) + break if scanner.eos? + + # If we find a semicolon, consume it and continue + if scanner.scan(/;/) + next + else + # Not a semicolon, restore position and break + scanner.pos = start_pos + break + end + end + end + + # Skip "deadspace" - non-code elements that should be ignored during extraction + # Deadspace includes: + # - Whitespace (spaces, tabs, newlines, carriage returns) + # - Comments (both single-line // and multi-line /* */) + # + # NOTE: Preprocessing directives (lines starting with #) are NOT deadspace — + # they are first-class features handled by CExtractorPreprocessing. + # + # This method repeatedly scans for and skips these elements until no more are found, + # ensuring all consecutive deadspace is consumed in a single call. + # + # Parameters: + # scanner: StringScanner positioned at potential deadspace + # + # Returns: Number of bytes skipped + # + # Side effects: Advances scanner position past all consecutive deadspace + # + # Examples: + # " \n// comment\ncode" -> skips to "code" + # "/* block */ \t\ncode" -> skips to "code" + # "code" -> skips 0 bytes (no deadspace) + def skip_deadspace(scanner) + start_pos = scanner.pos + + loop do + initial = scanner.pos + + # Skip whitespace + scanner.skip(/\s+/) + + # Skip comments + skip_comment(scanner) if scanner.check(%r{/[/*]}) + + # If nothing was skipped, we're done + break if scanner.pos == initial + end + + return (scanner.pos - start_pos) + end + + def skip_comment(scanner) + # Single line comment + if scanner.scan(%r{//}) + scanner.skip_until(/\n/) || scanner.terminate + # Multiline comment + elsif scanner.scan(%r{/\*}) + scanner.skip_until(%r{\*/}) || scanner.terminate + end + end + + # Skip a single compiler extension at the current scanner position. + # Uses collect_balanced() for paren matching so all nesting depths are handled. + # Returns true and advances the scanner if an extension is found; returns false without + # moving if the current position is not the start of a known compiler extension. + # + # Handles: + # __word__(…) — any double-underscore attribute form, e.g. __attribute__((…)) + # __declspec(…) — MSVC declaration specifier, including nested forms + # Bare MSVC calling-convention keywords (__cdecl, __stdcall, etc.) + # + # Does NOT skip __int64, __int32, or any non-extension __ identifiers. + def skip_compiler_extension(scanner) + if scanner.check(/__\w+__\s*\(/) + scanner.skip(/__\w+__/) + skip_deadspace(scanner) + collect_balanced(scanner, '(', ')') if scanner.peek(1) == '(' + return true + end + + if scanner.check(/__declspec\s*\(/) + scanner.skip(/__declspec/) + skip_deadspace(scanner) + collect_balanced(scanner, '(', ')') if scanner.peek(1) == '(' + return true + end + + MSVC_CALLING_CONVENTIONS.each do |kw| + if scanner.check(/#{Regexp.escape(kw)}\b/) + scanner.skip(/#{Regexp.escape(kw)}/) + return true + end + end + + false + end + + # Strip all compiler extensions from a string and return the cleaned result. + # Uses an internal StringScanner plus collect_balanced() so all paren nesting depths + # are handled correctly (e.g. __declspec(align(8)), __attribute__((format(printf,1,2)))). + # Whitelist-based: only known forms are stripped; __int64, __int32, and any other + # non-extension __ identifiers are preserved verbatim. + # Whitespace is normalized to single spaces and the result is stripped. + # + # Handles: + # __word__(…) — any double-underscore attribute form + # __declspec(…) — MSVC declaration specifier (including nested parens) + # Bare MSVC calling conventions, MSVC inline hints, C11 specifier keywords + def strip_compiler_extensions(text) + scanner = StringScanner.new(text) + result = +"" + + bare_strip = MSVC_CALLING_CONVENTIONS + + ['__forceinline', '__inline__', '__inline'] + + C11_SPECIFIER_KEYWORDS + + until scanner.eos? + # __word__(…) — any double-underscore attribute form including __attribute__((…)) + if scanner.check(/__\w+__\s*\(/) + scanner.skip(/__\w+__/) + skip_deadspace(scanner) + collect_balanced(scanner, '(', ')') if scanner.peek(1) == '(' + next + end + + # __declspec(…) — handles nested forms like __declspec(align(8)) + if scanner.check(/__declspec\s*\(/) + scanner.skip(/__declspec/) + skip_deadspace(scanner) + collect_balanced(scanner, '(', ')') if scanner.peek(1) == '(' + next + end + + # Whitelisted bare keywords (calling conventions, inline hints, C11 specifiers) + stripped = bare_strip.any? do |kw| + if scanner.check(/#{Regexp.escape(kw)}\b/) + scanner.skip(/#{Regexp.escape(kw)}/) + true + end + end + next if stripped + + result << scanner.getch + end + + result.gsub!(/\s+/, ' ') + result.strip! + result + end + +end \ No newline at end of file diff --git a/lib/ceedling/c_extractor/c_extractor_constants.rb b/lib/ceedling/c_extractor/c_extractor_constants.rb new file mode 100644 index 000000000..6a443ad6e --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_constants.rb @@ -0,0 +1,38 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +module CExtractorConstants + # 1000 character safety limit + DEFAULT_MAX_LINE_LENGTH = 1000 + + # 16 KB -- enough for most functions + DEFAULT_CHUNK_SIZE = (16 * 1024) + + # 5 MB mega-length safety limit + DEFAULT_MAX_FUNCTION_LENGTH = (5 * 1024 * 1024) + + # C function decorators that indicate private (file-local) visibility + PRIVATE_KEYWORDS = ['static', 'inline', '__inline', '__inline__', '__forceinline'].freeze + + # MSVC calling-convention keywords (appear between return type and function name) + MSVC_CALLING_CONVENTIONS = ['__cdecl', '__stdcall', '__fastcall', '__thiscall', '__vectorcall'].freeze + + # C11/C23 bare specifier keywords (no argument list) + C11_SPECIFIER_KEYWORDS = ['_Noreturn', '_Thread_local', '_Atomic', '_Bool', '_Complex', '_Imaginary'].freeze + + # Common type keywords that are part of return type, not decorators + TYPE_KEYWORDS = ['unsigned', 'signed', 'long', 'short', 'struct', 'union', 'enum'].freeze + + # C type qualifiers + TYPE_QUALIFIER_KEYWORDS = ['const', 'volatile', 'restrict'].freeze + + # C function modifier keywords + MODIFIER_KEYWORDS = (['extern'] + TYPE_QUALIFIER_KEYWORDS).freeze + + # Keywords stripped when producing clean `declaration` / `signature_stripped` fields + DECORATOR_KEYWORDS = (PRIVATE_KEYWORDS + TYPE_QUALIFIER_KEYWORDS + ['extern']).freeze +end \ No newline at end of file diff --git a/lib/ceedling/c_extractor/c_extractor_declarations.rb b/lib/ceedling/c_extractor/c_extractor_declarations.rb new file mode 100644 index 000000000..0a6b96331 --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_declarations.rb @@ -0,0 +1,415 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/exceptions' +require 'ceedling/c_extractor/c_extractor_constants' +require 'ceedling/c_extractor/c_extractor_types' + +class CExtractorDeclarations + + include CExtractorConstants + include CExtractorTypes + + constructor :c_extractor_code_text + + attr_writer :max_line_length + + def setup() + # Aliases + @code_text = @c_extractor_code_text + @max_line_length = DEFAULT_MAX_LINE_LENGTH + end + + # Attempts to extract a complete variable declaration from the scanner + # + # Scans forward from the current scanner position looking for a complete C variable + # declaration terminated by a semicolon. Handles complex declaration syntax including: + # - Simple variables: `int x;` + # - Pointers: `char* ptr;`, `int** buffer;` + # - Arrays: `int arr[10];`, `char matrix[3][4];` + # - Initializers: `int x = 5;`, `int arr[] = {1, 2, 3};` + # - String literals: `char* str = "hello";` + # - Qualifiers: `const int MAX;`, `static volatile int flag;` + # - Function pointers: `void (*callback)(int);` + # - Complex nested structures with balanced parentheses, brackets, and braces + # - Compound declarations: `int x, y;` expanded to one struct per variable + # + # The extraction process: + # 1. Tracks nesting depth of (), [], and {} to handle complex declarations + # 2. Properly handles string literals (both " and ') including escape sequences + # 3. Skips comments (both // line comments and /* block comments */) + # 4. Stops at the first semicolon found at depth 0 (outside all nesting) + # 5. Validates the extracted text looks like a valid declaration + # 6. Expands compound declarations and parses each into a CVariableDeclaration struct + # + # Parameters: + # scanner: StringScanner positioned at potential start of variable declaration + # + # Returns: Array of [success, declarations] + # - success: Boolean indicating if a valid declaration was found + # - declarations: Array of CVariableDeclaration structs (nil if not found) + # + # Side effects: + # On success: Advances scanner position past the semicolon + # On failure: Resets scanner position to starting position + # + # Safety: + # Enforces max_line_length limit to prevent infinite loops on malformed input + def try_extract_variable(scanner) + start_pos = scanner.pos + + # Tracks paren, bracket, and brace depth simultaneously with inline string state machine and max_line_length guard — not suitable for collect_balanced() + paren_depth = 0 + bracket_depth = 0 + brace_depth = 0 + in_string = false + string_char = nil + + # Scan until we find a semicolon at depth 0. + # If we reach the end of string scanner, we failed to find something. + until scanner.eos? + char = scanner.peek(1) + + # Safety check -- prevent infinite loops on malformed input + if (scanner.pos - start_pos) > @max_line_length + scanner.pos = start_pos + return [false, nil] + end + + # Handle string literals + if in_string + if char == '\\' + scanner.getch + scanner.getch unless scanner.eos? + next + elsif char == string_char + scanner.getch + in_string = false + string_char = nil + next + else + scanner.getch + next + end + end + + case char + when '"', "'" + in_string = true + string_char = char + scanner.getch + when '/' + # Handle comments + if scanner.peek(2) =~ %r{^(/[/*])} + if scanner.peek(2) == '//' + # Line comment -- skip to end of line + scanner.scan_until(/\n/) || scanner.terminate + elsif scanner.peek(2) == '/*' + # Block comment -- skip to closing */ + scanner.pos += 2 + scanner.scan_until(%r{\*/}) + else + scanner.getch + end + else + scanner.getch + end + when '=' + # Track assignment for initializer detection + scanner.getch + when '(' + paren_depth += 1 + scanner.getch + when ')' + paren_depth -= 1 + scanner.getch + # Unbalanced parentheses -- not a valid declaration + if paren_depth < 0 + scanner.pos = start_pos + return [false, nil] + end + when '[' + bracket_depth += 1 + scanner.getch + when ']' + bracket_depth -= 1 + scanner.getch + # Unbalanced brackets -- not a valid declaration + if bracket_depth < 0 + scanner.pos = start_pos + return [false, nil] + end + when '{' + # Braces after '=' are initializers, not code blocks + brace_depth += 1 + scanner.getch + when '}' + brace_depth -= 1 + scanner.getch + # Unbalanced braces -- not a valid declaration + if brace_depth < 0 + scanner.pos = start_pos + return [false, nil] + end + when ';' + # Found semicolon - check if it's at depth 0 + if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 + # This is the end of a declaration + scanner.getch # Consume the semicolon + + # Extract the declaration + declaration = scanner.string[start_pos...scanner.pos] + + # Verify this looks like a valid declaration + # Must have at least a type and identifier + # Can end with: word character, ], ), }, or " (for string initializers) + if declaration =~ /\w+.*[\w\]\)\}"']\s*;$/ + return [true, expand_and_parse(declaration)] + else + scanner.pos = start_pos + return [false, nil] + end + else + # Semicolon inside parens, brackets, or braces -- keep scanning + scanner.getch + end + else + scanner.getch + end + end + + # Reached end without finding a complete declaration + scanner.pos = start_pos + [false, nil] + end + + private + + # Expand compound declarations and parse each into CVariableDeclaration structs + # + # Parameters: + # raw_text: String containing a complete C declaration (e.g., "static int x, y;") + # + # Returns: Array<CVariableDeclaration> + def expand_and_parse(raw_text) + expand_compound(raw_text).map { |individual| parse_declaration(individual, raw_text) } + end + + # Detect and split compound declarations (e.g., "int x, y;" => ["int x;", "int y;"]) + # + # Splits at depth-0 commas that appear before any initializer (=). + # Preserves pointer specifiers (*) from the base type prefix. + # + # Parameters: + # raw_text: Complete declaration string including trailing semicolon + # + # Returns: Array<String> of individual declaration strings + def expand_compound(raw_text) + # Determine the text before any initializer to look for commas + pre_init = raw_text.split('=', 2).first || raw_text + + # Combined depth across (), [], {} for comma-splitting — not suitable for collect_balanced() + depth = 0 + comma_positions = [] + pre_init.each_char.with_index do |ch, i| + case ch + when '(', '[', '{' then depth += 1 + when ')', ']', '}' then depth -= 1 + when ',' then comma_positions << i if depth == 0 + end + end + + return [raw_text] if comma_positions.empty? + + # Extract the base type prefix (everything before the first declarator) + # The prefix is everything up to the first declarator token after the last type keyword + first_comma = comma_positions.first + prefix_with_first = raw_text[0...first_comma].strip + + # Determine the type prefix: everything before the last identifier/pointer declarator + # Split the pre-comma text to identify the base type + base_type = extract_base_type_prefix(prefix_with_first) + + # Collect all declarators: split by depth-0 commas, then handle the last one (before ;) + # Build the full raw text without the semicolon + text_body = raw_text.rstrip.chomp(';').strip + + declarators = [] + depth = 0 + current = '' + text_body.each_char do |ch| + case ch + when '(', '[', '{' then depth += 1; current << ch + when ')', ']', '}' then depth -= 1; current << ch + when ',' + if depth == 0 + declarators << current.strip + current = '' + else + current << ch + end + else + current << ch + end + end + declarators << current.strip unless current.strip.empty? + + # Reconstruct individual declarations + declarators.map do |declarator| + # Strip leading type tokens from subsequent declarators (they already have the type from base_type) + # The first declarator already includes the full type; subsequent ones just have the name/pointer + if declarator == declarators.first + "#{declarator};" + else + # Subsequent declarators: prepend base type (without trailing pointer specifiers) + clean_base = base_type.gsub(/\*+\s*$/, '').strip + # Preserve pointer specifiers on the declarator name itself + "#{clean_base} #{declarator};" + end + end + end + + # Extract the base type prefix from the first declarator in a compound declaration + # e.g., "static int *p" => "static int" + # "const char *s1" => "const char" + # "unsigned long x" => "unsigned long" + def extract_base_type_prefix(first_declarator_text) + # Remove leading/trailing whitespace + text = first_declarator_text.strip + + # Remove pointer specifiers and the identifier at the end + # The identifier is the last word token; pointers are * before it + text_no_ptr = text.gsub(/\*+\s*\w+\s*$/, '').strip + if text_no_ptr.empty? || text_no_ptr == text + # No pointer -- remove just the trailing identifier + text.gsub(/\s*\w+\s*$/, '').strip + else + text_no_ptr + end + end + + # Parse a single (non-compound) declaration string into a CVariableDeclaration struct + # + # Parameters: + # individual_text: Single declaration string (e.g., "static int x;") + # original_text: The original (possibly compound) declaration text + # + # Returns: CVariableDeclaration + def parse_declaration(individual_text, original_text) + decorators = extract_decorators(individual_text) + clean_text = strip_decorators(individual_text, decorators) + # Strip compiler extensions for name/type/array_suffix extraction only; + # clean_text (stored as .text) retains attributes for correct compilation. + extraction_text = @code_text.strip_compiler_extensions(clean_text) + name = extract_name(extraction_text) + type = extract_type(extraction_text, name) + array_suffix = extract_array_suffix(extraction_text) + + CVariableDeclaration.new( + original: original_text, + name: name, + type: type, + array_suffix: array_suffix, + decorators: decorators, + text: clean_text + ) + end + + # Scan leading whole-word tokens against DECORATOR_KEYWORDS + # + # Returns: Array<String> ordered decorator keywords found at start of text + def extract_decorators(text) + decorators = [] + remaining = text.strip + loop do + matched = false + DECORATOR_KEYWORDS.each do |kw| + if remaining =~ /\A#{Regexp.escape(kw)}\b(.*)/m + decorators << kw + remaining = $1.strip + matched = true + break + end + end + break unless matched + end + decorators + end + + # Remove decorator keywords from text and normalize whitespace + # + # Returns: String with decorators removed, whitespace normalized, semicolon retained + def strip_decorators(text, decorators) + result = text.dup + decorators.each do |kw| + result.gsub!(/\b#{Regexp.escape(kw)}\b\s*/, '') + end + # Normalize whitespace but preserve semicolon + result.gsub!(/\r\n|\r|\n|\t/, ' ') + result.gsub!(/\s+/, ' ') + result.strip! + # Ensure ends with semicolon (may have been trimmed) + result << ';' unless result.end_with?(';') + result + end + + # Extract the variable name from a clean (decorator-stripped) declaration + # + # Returns: String or nil + def extract_name(clean_text) + # Function pointers: void (*callback)(int); + if clean_text =~ /\(\s*\*(\w+)\s*\)/ + return $1 + end + + # Strip only the trailing semicolon (not inner semicolons in e.g. struct bodies) + text = clean_text.sub(/\s*;\s*$/, '') + # Strip initializer (from first = onward) + text = text.sub(/\s*=.*/, '') + # Strip array subscripts + text = text.gsub(/\[.*?\]/, '') + # Strip trailing whitespace and return last word token + text.strip =~ /(\w+)\s*$/ ? $1 : nil + end + + # Extract array subscript suffix from a clean (decorator/extension-stripped) declaration. + # Returns the complete subscript string (e.g., "[8]", "[M][N]") or "" for scalars. + def extract_array_suffix(clean_text) + text = clean_text.sub(/\s*;\s*$/, '').sub(/\s*=.*/, '') + return '' if text =~ /\(\s*\*/ # function pointer -- no array suffix + text =~ /\w+(\s*(?:\[[^\]]*\])+)\s*$/ ? $1.strip : '' + end + + # Extract the type from a clean (decorator-stripped) declaration + # + # Returns: String or nil + def extract_type(clean_text, name) + return nil if name.nil? + + # Find last occurrence of name in the text (before ; or [) + # Strip only trailing semicolon to avoid matching inner semicolons (e.g., in struct bodies) + text = clean_text.sub(/\s*;\s*$/, '').sub(/\s*=.*/, '').gsub(/\[.*?\]/, '').strip + + # For function pointers like "void (*callback)(int)", type is everything before (*name) + if text =~ /^(.*?)\(\s*\*#{Regexp.escape(name)}\s*\)/ + return $1.strip + end + + # Find the name in the text and take everything before it (including pointer specifiers) + idx = text.rindex(name) + return nil if idx.nil? + + type_part = text[0...idx] + # Include any pointer specifiers attached to the name + if text[idx..] =~ /^#{Regexp.escape(name)}/ + # Look for * immediately before name (with optional space) + type_part = type_part.rstrip + end + type_part.strip + end + +end diff --git a/lib/ceedling/c_extractor/c_extractor_definitions.rb b/lib/ceedling/c_extractor/c_extractor_definitions.rb new file mode 100644 index 000000000..6f001242a --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_definitions.rb @@ -0,0 +1,157 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +class CExtractorDefinitions + + constructor :c_extractor_code_text + + # Tracks brace depth but terminates on ';' rather than '}' — not suitable for collect_balanced() + # Try to extract a C typedef declaration from the scanner. + # Called as a feature extractor by CExtractor#extract_next_feature. + # Collects everything from the `typedef` keyword through the terminating `;` + # (handling nested braces for struct/union/enum bodies, string literals, + # and comments) and returns it as a raw string including any trailing newline. + # Comments are replaced with a single space; string literals are verbatim. + # + # @param scanner [StringScanner] positioned at the start of potential typedef + # @return [Array(Boolean, String)] + # [true, "typedef struct { int x; } Point;\n"] — full typedef text + # [false, nil ] — no typedef keyword here; nothing consumed + def try_extract_typedef(scanner) + return [false, nil] unless scanner.check(/typedef\b/) + + text = +'' + depth = 0 # brace nesting — typedef body terminates only at depth == 0 + + until scanner.eos? + ch = scanner.peek(1) + + if ch == '"' || ch == "'" + # Capture string/char literals verbatim — a ';' inside must not terminate + before = scanner.pos + @c_extractor_code_text.skip_c_string(scanner, ch) + text << scanner.string[before...scanner.pos] + + elsif scanner.check(%r{/[/*]}) + # Replace comment with a single space — a ';' inside must not terminate + @c_extractor_code_text.skip_comment(scanner) + text << ' ' + + elsif scanner.scan(/\{/) + depth += 1 + text << '{' + + elsif scanner.scan(/\}/) + depth -= 1 + text << '}' + + elsif depth == 0 && scanner.scan(/;/) + text << ';' + scanner.scan(/[ \t]*\n/) # absorb optional trailing newline + return [true, text.rstrip] + + else + text << scanner.getch + end + end + + [false, nil] # EOF without finding ';' + end + + # Tracks brace depth and uses post-body lookahead to distinguish type definitions from + # variable declarations — not suitable for collect_balanced() + # + # Try to extract a file-scope struct, enum, or union type definition (non-typedef form). + # Called as a feature extractor by CExtractor#extract_next_feature. + # Collects standalone aggregate type definitions (body required, ';' follows '}' directly + # with only optional whitespace/comments between) as raw text. + # Comments are replaced with a single space; string literals are verbatim. + # + # Handles: + # struct [tag] { member-list }; + # enum [tag] { enumerator-list }; + # union [tag] { member-list }; + # + # Does NOT handle (returns [false, nil], scanner unchanged): + # struct/enum/union { ... } declarator; — variable declaration; falls to variable extractor + # struct/enum/union tag; — forward declaration without body; not collected + # + # @param scanner [StringScanner] positioned at potential aggregate definition + # @return [Array(Boolean, String|nil)] + # [true, "struct Foo { int x; };\n"] — full verbatim aggregate definition text + # [false, nil ] — not a standalone aggregate definition; scanner unchanged + def try_extract_aggregate_definition(scanner) + return [false, nil] unless scanner.check(/(?:struct|enum|union)\b/) + + start_pos = scanner.pos + text = +'' + depth = 0 + + until scanner.eos? + ch = scanner.peek(1) + + if ch == '"' || ch == "'" + # Capture string/char literals verbatim — ';' or '{'/'}' inside must not affect state + before = scanner.pos + @c_extractor_code_text.skip_c_string(scanner, ch) + text << scanner.string[before...scanner.pos] + + elsif scanner.check(%r{/[/*]}) + # Replace comment with a single space — ';' inside must not terminate + @c_extractor_code_text.skip_comment(scanner) + text << ' ' + + elsif scanner.scan(/\{/) + depth += 1 + text << '{' + + elsif scanner.scan(/\}/) + depth -= 1 + text << '}' + + if depth == 0 + # Body closed. Speculatively advance past whitespace/comments to peek at next char. + # Do NOT commit whitespace to text yet — we may need to rollback entirely. + ws_start = scanner.pos + loop do + init = scanner.pos + scanner.skip(/\s+/) + @c_extractor_code_text.skip_comment(scanner) if scanner.check(%r{/[/*]}) + break if scanner.pos == init + end + + if scanner.peek(1) == ';' + # Standalone type definition — commit + text << scanner.string[ws_start...scanner.pos] # include whitespace before ';' + text << scanner.scan(/;/) + scanner.scan(/[ \t]*\n/) # absorb optional trailing newline + return [true, text.rstrip] + else + # Declarator present (variable name, '*', '[', etc.) — not a standalone type definition. + # Rollback entirely so the variable extractor sees the full text. + scanner.pos = start_pos + return [false, nil] + end + end + + elsif depth == 0 && scanner.scan(/;/) + # Hit ';' at depth 0 before any '{' — forward declaration or variable declaration. + # Neither is a standalone aggregate type definition; rollback. + scanner.pos = start_pos + return [false, nil] + + else + text << scanner.getch + end + end + + # EOF without completing extraction — rollback + scanner.pos = start_pos + [false, nil] + end + +end diff --git a/lib/ceedling/c_extractor/c_extractor_functions.rb b/lib/ceedling/c_extractor/c_extractor_functions.rb new file mode 100644 index 000000000..9b65bf384 --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_functions.rb @@ -0,0 +1,402 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/c_extractor/c_extractor_constants' +require 'ceedling/c_extractor/c_extractor_types' + +class CExtractorFunctions + + include CExtractorConstants + include CExtractorTypes + + constructor :c_extractor_code_text + + attr_writer :max_line_length + + def setup() + # Aliases + @code_text = @c_extractor_code_text + + @max_line_length = DEFAULT_MAX_LINE_LENGTH + end + + def try_extract_function_declaration(scanner) + # Look for function signature + signature = extract_function_signature(scanner, :declaration) + return [false, nil] unless signature + + name = extract_function_name(signature) + decorators, signature_stripped = parse_decorators_and_strip(signature, name) + return [true, CFunctionDeclaration.new(name: name, signature: signature, decorators: decorators, signature_stripped: signature_stripped)] + end + + # Try to extract a complete function from the scanner + # Returns [success, function_data] where: + # - success: boolean indicating if extraction was successful + # - function_data: CFunctionDefinition with as much info as available (may be partial on failure) + def try_extract_function_definition(scanner, filepath) + start_pos = scanner.pos + + # Look for function signature + signature = extract_function_signature(scanner, :definition) + return [false, CFunctionDefinition.new] unless signature + + @code_text.skip_deadspace(scanner) + + unless scanner.peek(1) == '{' + return [false, CFunctionDefinition.new( + name: extract_function_name(signature), + signature: signature, + filepath: filepath + )] + end + + # Extract function body + success, braced_body = @code_text.extract_balanced_braces(scanner) + unless success + return [false, CFunctionDefinition.new( + name: extract_function_name(signature), + signature: signature, + source_filepath: filepath, + code_block: scanner.string[start_pos...scanner.pos] + )] + end + + # Extract full function definition + code_block = scanner.string[start_pos...scanner.pos] + + # Fill out function data class + name = extract_function_name(signature) + decorators, signature_stripped = parse_decorators_and_strip(signature, name) + + func = CFunctionDefinition.new( + name: name, + signature: signature, + source_filepath: filepath, + body: braced_body, + code_block: code_block, + line_count: code_block.count("\n") + 1, + decorators: decorators, + signature_stripped: signature_stripped + ) + + return [true, func] + end + + private + + # Extract a function signature from the scanner + # + # This method attempts to extract either a function declaration or definition signature + # from the current scanner position. It distinguishes between functions and variables by + # analyzing the structure of the extracted code. + # + # @param scanner [StringScanner] The scanner positioned at potential function start + # @param type [Symbol] Either :declaration or :definition + # - :declaration expects pattern: type name(...); + # - :definition expects pattern: type name(...) { ... } + # + # @return The extracted and cleaned signature string, or nil if: + # - No valid signature found + # - Unbalanced parentheses detected + # - Variable declaration detected (not a function) + # - Type mismatch (e.g., semicolon found when expecting definition) + # + # Variable declarations are rejected based on these patterns: + # - Simple variables: int x; + # - Arrays: int arr[10]; + # - Function pointers: int (*ptr)(int); + # - Initialized variables: int x = 42; + # + # The method handles: + # - String literals (both single and double quoted) + # - C-style comments (// and /* */) + # - Nested parentheses in function parameters + # - Whitespace and newlines + # + # On failure, the scanner position is reset to the starting position. + # On success, the scanner is positioned after the signature: + # - After ';' for declarations + # - Before '{' for definitions) + # + # Safety: + # Enforces max_line_length limit to prevent infinite loops on malformed input + def extract_function_signature(scanner, type) + start_pos = scanner.pos + # Tracks paren depth while accumulating candidate end-positions — not suitable for collect_balanced() + paren_depth = 0 + in_string = false + string_char = nil + signature_candidates = [] # Track positions where paren_depth returns to 0 + found_opening_paren = false # Track if we've seen an opening paren + + until scanner.eos? + char = scanner.peek(1) + + # Safety check + if (scanner.pos - start_pos) > @max_line_length + scanner.pos = start_pos + return nil + end + + # Handle string literals + if in_string + if char == '\\' + scanner.getch + scanner.getch unless scanner.eos? + next + elsif char == string_char + scanner.getch + in_string = false + string_char = nil + next + else + scanner.getch + next + end + end + + case char + when '"', "'" + in_string = true + string_char = char + scanner.getch + when '/' + if scanner.peek(2) =~ %r{^(/[/*])} + @code_text.skip_comment(scanner) + else + scanner.getch + end + when '(' + paren_depth += 1 + found_opening_paren = true + scanner.getch + when ')' + paren_depth -= 1 + scanner.getch + + # When we return to depth 0, this could be the end of the function's parameter list + if paren_depth == 0 + signature_candidates << scanner.pos + elsif paren_depth < 0 + # Unbalanced parentheses - not a valid function signature + scanner.pos = start_pos + return nil + end + when '{' + # Found opening brace + if type == :declaration + scanner.pos = start_pos + return nil + else # :definition + # Check if any of our candidates is valid + if signature_candidates.empty? + # No balanced parens found before brace - not a function + scanner.pos = start_pos + return nil + end + + # The last candidate (outermost closing paren) should be the function parameter list + signature_end_pos = signature_candidates.last + + # Extract and clean the signature + signature = scanner.string[start_pos...signature_end_pos] + return clean_signature(signature) + end + when ';' + # Found semicolon - this is a declaration, not a definition + + if type == :declaration + # Before accepting this as a function declaration, verify it has parentheses + # and doesn't look like a variable declaration + unless found_opening_paren + # No parentheses found - this is a variable declaration + scanner.pos = start_pos + return nil + end + + scanner.getch + signature = scanner.string[start_pos...scanner.pos] + + # Validate this looks like a function declaration, not a variable + # Function declarations should have: type name(...) ; + # NOT: type (*name)(...) ; (function pointer variable) + # NOT: type name[...] ; (array variable) + # NOT: type name = ... ; (variable with initializer) + cleaned = clean_declaration(signature) + + # Check if this looks like a function declaration + # Pattern: ends with identifier followed by (...) and semicolon + # NOT: contains (*identifier) pattern (function pointer variable) + if cleaned =~ /\(\s*\*\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\)/ + # This is a function pointer variable, not a function declaration + scanner.pos = start_pos + return nil + end + + # Additional check: ensure the pattern is identifier(...); not identifier[...]; or identifier; + # Strip compiler extensions (e.g. trailing __attribute__((...)) or __declspec(...)) before + # checking the closing paren — otherwise a trailing attribute's ')' fools the check into + # accepting a variable declaration like `int x __attribute__((aligned(16)));` as a function. + content_before_semicolon = @code_text.strip_compiler_extensions(cleaned.gsub(/\s*;$/, '')) + unless content_before_semicolon.end_with?(')') + # Doesn't end with closing paren - likely a variable declaration + scanner.pos = start_pos + return nil + end + + return cleaned + else # :definition + scanner.pos = start_pos + return nil + end + when '[' + # Found opening bracket - this could be an array declaration + # If we haven't found any opening parenthesis yet, this is likely a variable + unless found_opening_paren + scanner.pos = start_pos + return nil + end + scanner.getch + when '=' + # Found assignment operator - this is a variable with initializer + # Only reject if we're at depth 0 (not inside function parameters) + if paren_depth == 0 + scanner.pos = start_pos + return nil + end + scanner.getch + else + scanner.getch + end + end + + # Reached end without finding complete signature + scanner.pos = start_pos + return nil + end + + def clean_signature(signature) + # Remove C-style line comments (in multiline signatures) + _signature = signature.gsub(/\/\/.*$/, '') + # Remove newlines and tabs + _signature.gsub!(/\r\n|\r|\n|\t/, ' ') + # Remove C-style block comments + _signature.gsub!(/\/\*.*?\*\//m, '') + # Collapse consecutive whitespace + _signature.gsub!(/\s+/, ' ') + # Tidy up leadinga and trailing whitespace + _signature.strip!() + return _signature + end + + def clean_declaration(declaration) + _declaration = clean_signature(declaration) + # Removes any whitespace before final semicolon + _declaration.gsub!(/\s*;$/, ';') + return _declaration + end + + # Extracts decorator keywords from a C function signature and returns the shortened signature + # Migrated from PartializerParser#parse_signature_decorators + # + # Parameters: + # signature: Complete function signature string + # name: Function name string (used to split the signature) + # + # Returns: [decorators_array, shortened_signature_string] + # Example: "static inline int foo(void)" with name "foo" + # => [["static", "inline"], "int foo(void)"] + def parse_decorators_and_strip(signature, name) + return [[], signature] if name.nil? + + # Find the function name in the signature + name_index = signature.index(name) + return [[], signature] if name_index.nil? + + # Extract everything before the function name + prefix = signature[0...name_index] + + # Extract everything from the function name onwards + remainder = signature[name_index..-1] + + # Split prefix by whitespace to get tokens + tokens = prefix.split(/\s+/).reject(&:empty?) + + return [[], signature] if tokens.empty? + + # Find where decorators end and return type begins + decorator_end_index = 0 + tokens.each_with_index do |token, idx| + if TYPE_KEYWORDS.include?(token) || + !PRIVATE_KEYWORDS.any? { |kw| token == kw.downcase } && + !MODIFIER_KEYWORDS.include?(token) + decorator_end_index = idx + break + end + end + + # If all tokens are decorators (shouldn't happen in valid C), treat last as return type + decorator_end_index = tokens.length - 1 if decorator_end_index == 0 && tokens.length > 1 + + decorators = tokens[0...decorator_end_index] + return_type_tokens = tokens[decorator_end_index..-1] + + return [[], signature] if decorators.empty? + + # Find where the first return type token appears in the prefix + first_return_type_token = return_type_tokens.first + return_type_start_index = prefix.index(first_return_type_token) + + # Everything from first return type token onwards is the return type portion + return_type_portion = prefix[return_type_start_index..-1] + + # Build shortened signature: return_type_portion + remainder + shortened_signature = "#{return_type_portion}#{remainder}" + + return decorators, shortened_signature + end + + def extract_function_name(signature) + # Paren depth for balance validation only, no text collection — not suitable for collect_balanced() + paren_depth = 0 + signature.each_char do |char| + case char + when '(' + paren_depth += 1 + when ')' + paren_depth -= 1 + return nil if paren_depth < 0 # Unbalanced - closing before opening + end + end + + # If parentheses aren't balanced, return nil + return nil unless paren_depth == 0 + + # Strategy: Find the main parameter list by looking for the pattern: + # identifier followed by '(' that represents the function's parameter list + + # First, handle function pointer return types: int (*name(params))(params) + # Pattern: (*identifier(...)) + if signature =~ /\(\s*\*\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/ + return $1 + end + + cleaned = @code_text.strip_compiler_extensions(signature.dup) + + # Now find the function name in the cleaned signature + # Look for: identifier followed by '(' + # The identifier should be preceded by whitespace or * (for pointer returns) + if cleaned =~ /(?:^|[\s*])([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/ + return $1 + end + + # Fallback: no valid function name found + nil + end + +end \ No newline at end of file diff --git a/lib/ceedling/c_extractor/c_extractor_preprocessing.rb b/lib/ceedling/c_extractor/c_extractor_preprocessing.rb new file mode 100644 index 000000000..e21c376d3 --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_preprocessing.rb @@ -0,0 +1,262 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +class CExtractorPreprocessing + + # Directive type symbols for use with filter_directive() + MACRO_DEFINITION = :macro_definition unless defined?(MACRO_DEFINITION) + + constructor :c_extractor_code_text + + # Scan `scanner` for calls to any macro in `macro_names` and return them as a + # flat Array of cleaned strings in order of appearance. Each string is the full + # macro call with its argument list on a single line, runs of whitespace + # (including embedded newlines) collapsed to a single space. + # + # - Macro calls inside C line or block comments are skipped. + # - String literals (including those containing commas, parens, or a macro name) + # are collected verbatim and do not cause false positives or broken extraction. + # + # @param scanner [StringScanner] positioned anywhere in the source text + # @param macro_names [Array<String>] macro names to search for + # @return [Array<String>] cleaned macro call strings + def try_extract_macro_calls(scanner, macro_names) + results = [] + pattern = _build_pattern(macro_names) + + until scanner.eos? + # Skip comments — macro names inside are not extracted + if scanner.check( %r{/[/*]} ) + @c_extractor_code_text.skip_comment(scanner) + + # Skip string/char literals — macro names inside are not extracted + elsif (ch = scanner.peek(1)) == '"' || ch == "'" + @c_extractor_code_text.skip_c_string(scanner, ch) + + # Found a macro name followed by '(' — collect its argument list. + # Guard against matching a suffix of a longer identifier (e.g., FOO inside NOTFOO). + # Note: `\b` and lookbehinds are unreliable in StringScanner because the engine + # only sees the remaining suffix; we inspect the original string manually instead. + elsif scanner.scan(pattern) + macro_name = scanner[1] + match_start = scanner.pos - scanner.matched.length + if match_start > 0 && scanner.string[match_start - 1] =~ /\w/ + _collect_balanced_args(scanner) # consume and discard — part of a longer identifier + else + args = _collect_balanced_args(scanner) + results << _clean_whitespace("#{macro_name}(#{args})") if args + end + + else + scanner.getch + end + end + + return results + end + + # Parse a single macro call string (as returned by `try_extract_macro_calls`) into its + # macro name and an array of individual parameter strings. + # + # Top-level commas (not nested inside `()`, `[]`, `{}`, or string literals) are + # treated as argument separators. Each returned parameter is trimmed of leading + # and trailing whitespace. + # + # @param call_str [String] a cleaned macro call string, e.g. "FOO(a, b, [c, d])" + # @return [Array(String, Array<String>)] two-element array of [macro_name, params] + # where macro_name is nil and params is [] if the string is malformed + def parse_macro_call(call_str) + scanner = StringScanner.new( call_str ) + + # Extract macro name — everything before the opening '(' + macro_name = scanner.scan( /[^(]+/ )&.strip + return [nil, []] if macro_name.nil? || !scanner.scan( /\(/ ) + + return [macro_name, _split_params(scanner)] + end + + # Try to extract a C preprocessing directive from the scanner. + # Called as a feature extractor by CExtractor#extract_next_feature. + # Returns every directive found as raw text — callers filter by type as needed. + # + # @param scanner [StringScanner] positioned at the start of potential directive + # @return [Array(Boolean, String)] + # [true, '#define FOO 42\n'] — directive text (any directive type) + # [false, nil ] — no # at current position; nothing consumed + def try_extract_directive(scanner) + text = _collect_directive(scanner) + return [false, nil] if text.nil? + + [true, text] + end + + # Filter a directive string by type, returning the text only if it matches the requested type. + # This allows callers to selectively collect specific directive types while still ensuring + # all directives are consumed from the input. + # + # @param directive [String] raw directive text (as returned by try_extract_directive) + # @param type [Symbol] the directive type to match; see MACRO_DEFINITION and other constants + # @return [String, nil] the directive text if it matches the requested type, nil otherwise + def filter_directive(directive, type) + case type + when MACRO_DEFINITION + directive.match?(/\A#\s*define\b/) ? directive : nil + end + end + + # Try to extract a C11 _Static_assert or C23 static_assert statement from the scanner. + # Called as a feature extractor by CExtractor#extract_next_feature. + # The statement is consumed and the full text returned, but callers discard it — + # static asserts are not collected into CModule. + # + # Handles all three forms: + # _Static_assert(expr, "message"); # C11 — message required + # static_assert(expr); # C23 — message optional + # static_assert(expr, "message"); # C23 — with message + # + # The expression argument may contain arbitrarily nested parentheses + # (e.g. sizeof(struct S) == 8) which are handled by collect_balanced(). + # + # @param scanner [StringScanner] positioned at potential static assert + # @return [Array(Boolean, String|nil)] + # [true, '_Static_assert(sizeof(S) == 4, "msg");\n'] — full statement text + # [false, nil ] — not a static assert; scanner unchanged + def try_extract_static_assert(scanner) + return [false, nil] unless scanner.check(/(?:_Static_assert|static_assert)\b/) + + text = +'' + + # Consume keyword + # Pattern (?:_Static_assert|static_assert) ensures that a longer identifier (e.g. `not_static_assert`) does not match + text << scanner.scan(/(?:_Static_assert|static_assert)/) + + # Consume optional whitespace between keyword and '(' + text << (scanner.scan(/[ \t]*/) || '') + + # Consume the balanced argument list — handles all nested parens, strings, comments + success, args = @c_extractor_code_text.collect_balanced(scanner, '(', ')') + return [false, nil] unless success + text << args + + # Consume optional whitespace before ';' + text << (scanner.scan(/[ \t]*/) || '') + + # Consume the required terminating ';' + return [false, nil] unless scanner.scan(/;/) + text << ';' + + # Absorb optional trailing newline (mirrors try_extract_typedef convention) + text << (scanner.scan(/[ \t]*\n/) || '') + + [true, text] + end + + ### Private ### + + private + + # Build a Regexp matching any of the given macro names followed by optional + # whitespace and '('. Capture group 1 = matched name. + # Whether the match starts inside a longer identifier is checked in the caller + # by inspecting the original string, since StringScanner lookbehinds and `\b` + # only see the remaining suffix and cannot inspect characters before scanner.pos. + def _build_pattern(macro_names) + escaped = macro_names.map { |n| Regexp.escape(n) } + Regexp.new("(#{escaped.join('|')})\\s*\\(") + end + + # Collect argument text of a macro call whose opening '(' has already been + # consumed by the scan pattern in try_extract_macro_calls. Steps back one + # position so collect_balanced() can start at '(' and strips the outer parens + # from the result. Returns nil on malformed (unbalanced) input. + def _collect_balanced_args(scanner) + # The outer scan pattern consumed the opening '(' — step back one position + # so collect_balanced() can start at it. + scanner.pos -= 1 + success, text = @c_extractor_code_text.collect_balanced(scanner, '(', ')') + success ? text[1..-2] : nil # strip outer parens; nil on unbalanced input + end + + # Tracks paren, bracket, and brace depth simultaneously for comma-splitting — not suitable for collect_balanced() + # Split parameter text of a macro call whose opening '(' has already been consumed. + # Splits on top-level commas only — commas inside `()`, `[]`, `{}`, or string + # literals are not treated as separators. Returns an array of trimmed parameters. + def _split_params(scanner) + params = [] + buffer = +'' + d_paren = 0 + d_bracket = 0 + d_brace = 0 + + until scanner.eos? + ch = scanner.peek(1) + + # String/char literals are captured verbatim — commas and delimiters inside + # must not affect depth tracking or param splitting + if ch == '"' || ch == "'" + before = scanner.pos + @c_extractor_code_text.skip_c_string( scanner, ch ) + buffer << scanner.string[before...scanner.pos] + + elsif scanner.scan( /\(/ ) ; d_paren += 1 ; buffer << '(' + elsif scanner.scan( /\[/ ) ; d_bracket += 1 ; buffer << '[' + elsif scanner.scan( /\{/ ) ; d_brace += 1 ; buffer << '{' + elsif scanner.scan( /\]/ ) ; d_bracket -= 1 ; buffer << ']' + elsif scanner.scan( /\}/ ) ; d_brace -= 1 ; buffer << '}' + + elsif scanner.scan( /\)/ ) + if d_paren == 0 + # Closing outer paren — end of argument list + params << buffer.strip unless buffer.strip.empty? + break + end + d_paren -= 1 + buffer << ')' + + elsif d_paren == 0 && d_bracket == 0 && d_brace == 0 && scanner.scan( /,/ ) + params << buffer.strip + buffer = +'' + + else + buffer << scanner.getch + end + end + + return params + end + + # Collect and return the full text of a preprocessing directive starting at '#'. + # Returns nil if not positioned at '#'. Handles backslash-newline continuations. + # Trailing whitespace and newlines are stripped from the returned text. + def _collect_directive(scanner) + return nil unless scanner.check(/#/) + + text = scanner.scan(/#/) + + loop do + text += scanner.scan(/[^\n\\]*/) || '' + + if scanner.scan(/\\\n/) + text += "\\\n" + elsif scanner.scan(/\n/) + text += "\n" + break + else + break # EOS — no trailing newline + end + end + + text.rstrip + end + + # Collapse any run of whitespace (spaces, tabs, newlines) to a single space + # and strip leading/trailing whitespace. + def _clean_whitespace(text) + text.gsub( /\s+/, ' ' ).strip + end + +end diff --git a/lib/ceedling/c_extractor/c_extractor_types.rb b/lib/ceedling/c_extractor/c_extractor_types.rb new file mode 100644 index 000000000..7ac45dfd0 --- /dev/null +++ b/lib/ceedling/c_extractor/c_extractor_types.rb @@ -0,0 +1,122 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +module CExtractorTypes + + # Pairs a raw C statement string with its 1-based source line number. + # Used for macro_definitions, type_definitions, and aggregate_definitions in CModule. + CStatement = Struct.new( + :text, # String — raw extracted statement text (comments replaced with spaces) + :line_num, # Integer — 1-based line number where the statement begins in the source file + keyword_init: true + ) do + def initialize(text: nil, line_num: nil) + super + end + end + + # Data class representing all extracted content of C module + CModule = Struct.new( + :variable_declarations, # Array of CVariableDeclaration structs (each with :line_num) + :function_definitions, # Array of CFunctionDefinition structs (each with :line_num) + :function_declarations, # Array of CFunctionDeclaration structs (each with :line_num) + :macro_definitions, # Array of CStatement — raw #define text with source line number + :type_definitions, # Array of CStatement — raw typedef text with source line number + :aggregate_definitions, # Array of CStatement — raw non-typedef struct/enum/union text with source line number + :element_sequence, # Array of references to all items above in extraction order (cross-type ordering index) + keyword_init: true + ) do + # Constructor to set unassigned fields to empty arrays for convenience + def initialize( + variable_declarations: [], + function_definitions: [], + function_declarations: [], + macro_definitions: [], + type_definitions: [], + aggregate_definitions: [], + element_sequence: [] + ) + super + end + + # Concatenate two CModule instances. + # element_sequence preserves first operand's items before second operand's items, + # maintaining source-before-header ordering regardless of line numbers. + def +(other) + CModule.new( + variable_declarations: (self.variable_declarations + other.variable_declarations), + function_definitions: (self.function_definitions + other.function_definitions), + function_declarations: (self.function_declarations + other.function_declarations), + macro_definitions: (self.macro_definitions + other.macro_definitions), + type_definitions: (self.type_definitions + other.type_definitions), + aggregate_definitions: (self.aggregate_definitions + other.aggregate_definitions), + element_sequence: (self.element_sequence + other.element_sequence) + ) + end + end + + # Data class representing an extracted C function declaration + CFunctionDeclaration = Struct.new( + :name, # Function name (e.g., "foo") + :signature, # Full signature with decorators (e.g., "static int foo(void);") + :decorators, # Array of decorator strings (e.g., ["static"]) + :signature_stripped, # Signature without decorators (e.g., "int foo(void);") + :line_num, # Integer — 1-based line number in source file where declaration begins + keyword_init: true + ) do + def initialize(name: nil, signature: nil, decorators: [], signature_stripped: nil, line_num: nil) + super + end + end + + # Data class representing an extracted C function definition + CFunctionDefinition = Struct.new( + :name, # Function name only (e.g., "foo") + :signature, # Function signature (e.g., "int foo(void)") + :body, # Function body including containing braces + :code_block, # Complete function text (signature + body) + :line_count, # Total number of lines in code_block + :source_filepath, # Source C filepath + :line_num, # Line number in source C file + :decorators, # Array of decorator strings (e.g., ["static"]) + :signature_stripped, # Signature without decorators (e.g., "int foo(void)") + keyword_init: true + ) do + # Constructor to set unassigned fields to nil for convenience + def initialize( + name: nil, + signature: nil, + body: nil, + code_block: nil, + line_count: 0, + source_filepath: nil, + line_num: nil, + decorators: [], + signature_stripped: nil + ) + super + end + end + + # Struct representing a single parsed C variable declaration + CVariableDeclaration = Struct.new( + :original, # Full original C text (e.g., "static int x, y;") -- shared by all Structs + # created from a single compound declaration. + :name, # Variable name (e.g., "x") -- array subscripts stripped + :type, # Type without decorator keywords (e.g., "int", "char*") -- array subscripts stripped + :array_suffix, # Array subscript string (e.g., "[8]", "[M][N]", "" for scalars) + :decorators, # Array of decorator keyword strings (e.g., ["static", "const"]) + :text, # Cleaned declaration without decorators, whitespace normalized (e.g., "int x;") + :line_num, # Integer — 1-based line number in source file where declaration begins + keyword_init: true + ) do + def initialize(original: nil, name: nil, type: nil, array_suffix: '', decorators: [], text: nil, line_num: nil) + super + end + end + +end diff --git a/lib/ceedling/ceedling.h b/lib/ceedling/ceedling.h new file mode 100644 index 000000000..3d6701749 --- /dev/null +++ b/lib/ceedling/ceedling.h @@ -0,0 +1,33 @@ +#ifndef _CEEDLING_SUPPORT_H_ +#define _CEEDLING_SUPPORT_H_ + +// Stringification and tokenization helper macros +#define __PARTIALS_STRINGIFY(x) __PARTIALS_STR(x) +#define __PARTIALS_STR(x) #x +#define __PARTIALS_EXPAND(x) x + +// Create a unique namespaced variable name +#define PARTIAL_LOCAL_VAR(namespace, var) partial_##namespace##_##var + +// +// NOTE: These macros expect symbols CMOCK_MOCK_PREFIX and CEEDLING_PARTIALS_PREFIX to be defined at compilation +// + +// Partials directive macros encoding gross configuration +#define TEST_PARTIAL_PUBLIC_MODULE(module) __PARTIALS_STRINGIFY(__PARTIALS_EXPAND(CEEDLING_PARTIALS_PREFIX)__PARTIALS_EXPAND(module)__PARTIALS_EXPAND(_impl.h)) +#define TEST_PARTIAL_PRIVATE_MODULE(module) TEST_PARTIAL_PUBLIC_MODULE(module) // Deduplicate macro definition +#define TEST_PARTIAL_ALL_MODULE(module) TEST_PARTIAL_PUBLIC_MODULE(module) // Deduplicate macro definition +#define MOCK_PARTIAL_PUBLIC_MODULE(module) __PARTIALS_STRINGIFY(__PARTIALS_EXPAND(CMOCK_MOCK_PREFIX)__PARTIALS_EXPAND(CEEDLING_PARTIALS_PREFIX)__PARTIALS_EXPAND(module)__PARTIALS_EXPAND(_interface.h)) +#define MOCK_PARTIAL_PRIVATE_MODULE(module) MOCK_PARTIAL_PUBLIC_MODULE(module) // Deduplicate macro definition +#define MOCK_PARTIAL_ALL_MODULE(module) MOCK_PARTIAL_PUBLIC_MODULE(module) // Deduplicate macro definition + +// Partials directive macros requiring configuration +#define TEST_PARTIAL_MODULE(module) TEST_PARTIAL_PUBLIC_MODULE(module) // Deduplicate macro definition +#define MOCK_PARTIAL_MODULE(module) MOCK_PARTIAL_PUBLIC_MODULE(module) // Deduplicate macro definition + +// Partials configuration macros +// The parameter construction ensures at least two arguments +#define TEST_PARTIAL_CONFIG(module, func1, ...) +#define MOCK_PARTIAL_CONFIG(module, func1, ...) + +#endif /* _CEEDLING_SUPPORT_H_ */ diff --git a/lib/ceedling/config_matchinator.rb b/lib/ceedling/config/config_matchinator.rb similarity index 100% rename from lib/ceedling/config_matchinator.rb rename to lib/ceedling/config/config_matchinator.rb diff --git a/lib/ceedling/config_walkinator.rb b/lib/ceedling/config/config_walkinator.rb similarity index 100% rename from lib/ceedling/config_walkinator.rb rename to lib/ceedling/config/config_walkinator.rb diff --git a/lib/ceedling/configurator.rb b/lib/ceedling/config/configurator.rb similarity index 95% rename from lib/ceedling/configurator.rb rename to lib/ceedling/config/configurator.rb index b6fac6f14..56fa73620 100644 --- a/lib/ceedling/configurator.rb +++ b/lib/ceedling/config/configurator.rb @@ -67,6 +67,29 @@ def set_verbosity(config) end + def set_partials_derived_config(config) + return if !config[:project][:use_partials] + + # If partials enabled, enable mocking + config[:project][:use_mocks] = true + @loginator.log( " > Enabled mocking." ) + + # If partials enabled, enable full test preprocessing + config[:project][:use_test_preprocessor] = :all + @loginator.log( " > Enabled preprocessing." ) + + # If partials enabled and CMock's `:treat_inlines` is enabled, quietly disable. + # Enabling Partials and this feature causes extra processing for Ceedling and CMock that is never used. + if config[:cmock][:treat_inlines] == :include + config[:cmock][:treat_inlines] = :exclude + @loginator.log( "Reverted :cmock ↳ :treat_inlines to :exclude because this CMock feature is superseded by Partials.", Verbosity::COMPLAIN, LogLabels::NOTICE ) + end + + # If partials enabled, inject partials name prefix symbols to all test compilation + config[:defines][:test] << "CEEDLING_PARTIALS_PREFIX=#{PARTIAL_FILENAME_PREFIX}" + end + + # The default tools (eg. DEFAULT_TOOLS_TEST) are merged into default config hash def merge_tools_defaults(config, default_config) @loginator.lazy( Verbosity::OBNOXIOUS ) do @@ -250,6 +273,9 @@ def populate_cmock_config(config) cmock[:includes].uniq! + # Add mocking prefix symbol for all test compilation + cmock[:defines] << "CMOCK_MOCK_PREFIX=#{cmock[:mock_prefix]}" + @loginator.lazy( Verbosity::DEBUG ) do "CMock configuration >> #{cmock}" end @@ -626,7 +652,6 @@ def validate_final(config, app_cfg) blotter &= @configurator_setup.validate_defines( config ) blotter &= @configurator_setup.validate_flags( config ) blotter &= @configurator_setup.validate_test_preprocessor( config ) - blotter &= @configurator_setup.validate_deep_preprocessor( config ) blotter &= @configurator_setup.validate_backtrace( config ) blotter &= @configurator_setup.validate_threads( config ) blotter &= @configurator_setup.validate_plugins( config ) @@ -671,7 +696,7 @@ def redefine_element(elem, value) # Ensure element already exists if not @project_config_hash.include?(elem) - error = "Could not rederine #{elem} in configurator--element does not exist" + error = "Could not redefine #{elem} in configurator ⏩️ Element does not exist" raise CeedlingException.new(error) end diff --git a/lib/ceedling/configurator_builder.rb b/lib/ceedling/config/configurator_builder.rb similarity index 96% rename from lib/ceedling/configurator_builder.rb rename to lib/ceedling/config/configurator_builder.rb index 306f3e1ab..00c35c71d 100644 --- a/lib/ceedling/configurator_builder.rb +++ b/lib/ceedling/config/configurator_builder.rb @@ -105,7 +105,8 @@ def populate_with_defaults(config, defaults) def cleanup(in_hash) # Ensure that include files inserted into test runners have file extensions & proper ones at that - in_hash[:test_runner_includes].map!{|include| include.ext(in_hash[:extension_header])} + # TODO: Remove once we know it can be removed + # in_hash[:test_runner_includes].map!{|include| include.ext(in_hash[:extension_header])} end @@ -131,6 +132,8 @@ def set_build_paths(in_hash, logging_path) [:project_test_dependencies_path, File.join(project_build_tests_root, 'dependencies'), true ], [:project_build_vendor_unity_path, File.join(project_build_vendor_root, 'unity', 'src'), true ], + # Always include a Ceedling path (even if empty) as we need a search path present for certain preprocessing steps + [:project_build_vendor_ceedling_path, File.join(project_build_vendor_root, 'ceedling'), true ], [:project_build_vendor_cmock_path, File.join(project_build_vendor_root, 'cmock', 'src'), in_hash[:project_use_mocks] ], [:project_build_vendor_cexception_path, File.join(project_build_vendor_root, 'c_exception', 'lib'), in_hash[:project_use_exceptions] ], @@ -143,6 +146,8 @@ def set_build_paths(in_hash, logging_path) [:project_test_preprocess_includes_path, File.join(project_build_tests_root, 'preprocess/includes'), (in_hash[:project_use_test_preprocessor] != :none) ], [:project_test_preprocess_files_path, File.join(project_build_tests_root, 'preprocess/files'), (in_hash[:project_use_test_preprocessor] != :none) ], + + [:project_test_partials_path, File.join(project_build_tests_root, 'partials'), in_hash[:project_use_partials] ], ] out_hash[:project_build_paths] = [] @@ -226,7 +231,6 @@ def set_build_thread_counts(in_hash) } end - def set_test_preprocessor_accessors(in_hash) accessors = {} @@ -288,6 +292,7 @@ def collect_source_and_include_paths(in_hash) def collect_source_include_vendor_paths(in_hash) extra_paths = [] extra_paths << in_hash[:project_build_vendor_cexception_path] if (in_hash[:project_use_exceptions]) + extra_paths << in_hash[:project_build_vendor_ceedling_path] if (in_hash[:project_use_partials]) return { :collection_paths_source_include_vendor => @@ -551,6 +556,7 @@ def get_vendor_paths(in_hash) vendor_paths << in_hash[:project_build_vendor_unity_path] vendor_paths << in_hash[:project_build_vendor_cmock_path] if (in_hash[:project_use_mocks]) vendor_paths << in_hash[:project_build_vendor_cexception_path] if (in_hash[:project_use_exceptions]) + vendor_paths << in_hash[:project_build_vendor_ceedling_path] if (in_hash[:project_use_partials]) return vendor_paths end diff --git a/lib/ceedling/configurator_plugins.rb b/lib/ceedling/config/configurator_plugins.rb similarity index 100% rename from lib/ceedling/configurator_plugins.rb rename to lib/ceedling/config/configurator_plugins.rb diff --git a/lib/ceedling/configurator_setup.rb b/lib/ceedling/config/configurator_setup.rb similarity index 95% rename from lib/ceedling/configurator_setup.rb rename to lib/ceedling/config/configurator_setup.rb index 4df184351..1f24328d0 100644 --- a/lib/ceedling/configurator_setup.rb +++ b/lib/ceedling/config/configurator_setup.rb @@ -87,6 +87,12 @@ def vendor_frameworks_and_support_files(ceedling_lib_path, flattened_config) File.join( ceedling_lib_path, BACKTRACE_GDB_SCRIPT_FILE ), flattened_config[:project_build_tests_root] ) if flattened_config[:project_use_backtrace] == :gdb + + # Copy supporting partials code into build/vendor directory structure + @file_wrapper.cp_r( + File.join( ceedling_lib_path, CEEDLING_HEADER_FILEPATH ), + flattened_config[:project_build_vendor_ceedling_path] + ) if flattened_config[:project_use_partials] end def build_project_collections(flattened_config) @@ -583,24 +589,6 @@ def validate_test_preprocessor(config) end - def validate_deep_preprocessor(config) - valid = true - - options = [:none, :mocks] - - use_deep_preprocessor = config[:project][:use_deep_preprocessor] - - if !options.include?( use_deep_preprocessor ) - walk = @reportinator.generate_config_walk( [:project, :use_deep_preprocessor] ) - msg = "#{walk} is ':#{use_deep_preprocessor}' but must be one of {#{options.map{|o| ':' + o.to_s()}.join(', ')}}" - @loginator.log( msg, Verbosity::ERRORS ) - valid = false - end - - return valid - end - - def validate_environment_vars(config) environment = config[:environment] @@ -766,7 +754,6 @@ def validate_plugins(config) def warnings_for_problematic_configs(config) warning_for_parameterized_tests_with_preprocessing( config ) - warning_for_deep_preprocessor_without_preprocessing( config ) end ### Private @@ -789,19 +776,4 @@ def warning_for_parameterized_tests_with_preprocessing(config) end end - def warning_for_deep_preprocessor_without_preprocessing(config) - # :use_deep_preprocessor set without :use_test_preprocessor for mocks - mock_preprocessing = - (config[:project][:use_test_preprocessor] == :mocks) || - (config[:project][:use_test_preprocessor] == :all) - - if ((config[:project][:use_deep_preprocessor] == :mocks) and !mock_preprocessing) - msg = "The deep dependencies preprocessor configuration setting [:project ↳ :use_deep_preprocessor => :mocks] " \ - "is only useful when Ceedling's test preprocessing feature is also enabled and configured for mocks. " \ - "See docs for more." - @loginator.log( msg, Verbosity::COMPLAIN ) - end - end - - end diff --git a/lib/ceedling/configurator_validator.rb b/lib/ceedling/config/configurator_validator.rb similarity index 100% rename from lib/ceedling/configurator_validator.rb rename to lib/ceedling/config/configurator_validator.rb diff --git a/lib/ceedling/constants.rb b/lib/ceedling/constants.rb index 9bbb40d3e..cb1d5f1df 100644 --- a/lib/ceedling/constants.rb +++ b/lib/ceedling/constants.rb @@ -68,13 +68,32 @@ class StdErrRedirect TCSH = :tcsh end +EXTENSION_WIN_EXE = '.exe' +EXTENSION_NONWIN_EXE = '.out' + +# Vendor frameworks, generated mocks, generated runners are always .c files +EXTENSION_CORE_HEADER = '.h' +EXTENSION_CORE_SOURCE = '.c' + +CEEDLING_HEADER_FILENAME = 'ceedling.h' +CEEDLING_HEADER_FILEPATH = CEEDLING_HEADER_FILENAME # lib/ceedling/ +PARTIAL_FILENAME_PREFIX = 'ceedling_partial_' + class PATTERNS GLOB = /[\*\?\{\}\[\]]/ + RUBY_STRING_REPLACEMENT = /#\{.+\}/ TOOL_EXECUTOR_ARGUMENT_REPLACEMENT = /(\$\{(\d+)\})/ + TEST_STDOUT_STATISTICS = /\n-+\s*(\d+)\s+Tests\s+(\d+)\s+Failures\s+(\d+)\s+Ignored\s+(OK|FAIL)\s*/i + + USER_INCLUDE_DIRECTIVE_FILENAME = /#\s*include\s+\"\s*([\/\w\.\-]+)\s*\"/ + SYSTEM_INCLUDE_DIRECTIVE_FILENAME = /#\s*include\s+<\s*([\/\w\.\-]+)\s*>/ + TEST_SOURCE_FILE = /TEST_SOURCE_FILE\s*\(\s*\"\s*([^"]+)\s*\"\s*\)/ TEST_INCLUDE_PATH = /TEST_INCLUDE_PATH\s*\(\s*\"\s*([^"]+)\s*\"\s*\)/ + + PARTIAL_IMPL_FILENAME = /\A#{PARTIAL_FILENAME_PREFIX}.+_impl#{Regexp.escape(EXTENSION_CORE_SOURCE)}\z/ end GIT_COMMIT_SHA_FILENAME = 'GIT_COMMIT_SHA' @@ -85,12 +104,12 @@ class PATTERNS DEFAULT_PROJECT_FILENAME = 'project.yml' DEFAULT_BUILD_LOGS_PATH = 'logs' +DOCS_SITE_LOCAL_PATH = 'site-local' + GENERATED_DIR_PATH = [['vendor', 'ceedling'], 'src', "test", ['test', 'support'], 'build'].each{|p| File.join(*p)} -EXTENSION_WIN_EXE = '.exe' -EXTENSION_NONWIN_EXE = '.out' -# Vendor frameworks, generated mocks, generated runners are always .c files -EXTENSION_CORE_SOURCE = '.c' +# String used in generated include guards +CEEDLING_GENERATED = 'CEEDLING_GENERATED' PREPROCESS_SYM = :preprocess @@ -139,15 +158,17 @@ class PATTERNS OPERATION_ASSEMBLE_SYM = :assemble unless defined?(OPERATION_ASSEMBLE_SYM) OPERATION_LINK_SYM = :link unless defined?(OPERATION_LINK_SYM) +PREPROCESS_STANDINS_DIR = 'standins' PREPROCESS_FULL_EXPANSION_DIR = 'full_expansion' PREPROCESS_DIRECTIVES_ONLY_DIR = 'directives_only' +PREPROCESS_RAW_DIRECTIVES_ONLY_DIR = 'directives_only/raw' NULL_FILE_PATH = '/dev/null' TESTS_BASE_PATH = TEST_ROOT_NAME RELEASE_BASE_PATH = RELEASE_ROOT_NAME -VENDORS_FILES = %w(unity UnityHelper cmock CException).freeze +VENDORS_FILES = %w(unity UnityHelper cmock CException ceedling).freeze # Ruby Here UNITY_TEST_RESULTS_TEMPLATE = <<~UNITY_TEST_RESULTS diff --git a/lib/ceedling/defaults.rb b/lib/ceedling/defaults.rb index 8ab015773..d7d4e7117 100644 --- a/lib/ceedling/defaults.rb +++ b/lib/ceedling/defaults.rb @@ -73,15 +73,19 @@ ].freeze } -DEFAULT_TEST_SHALLOW_INCLUDES_PREPROCESSOR_TOOL = { + +# Extracts all dependencies as make-style rules. +# We use this to extract a bare list includes (user + system) from files. +DEFAULT_TEST_BARE_INCLUDES_PREPROCESSOR_TOOL = { :executable => FilePathUtils.os_executable_ext('gcc').freeze, - :name => 'default_test_shallow_includes_preprocessor'.freeze, + :name => 'default_test_bare_includes_preprocessor'.freeze, :optional => false.freeze, :arguments => [ '-E'.freeze, # Run only through preprocessor stage with its output - '-MM'.freeze, # Output make rule + suppress header files found in system header directories + '-M'.freeze, # Output make rule of dependencies including system includes '-MG'.freeze, # Assume missing header files are generated files (do not discard) '-MP'.freeze, # Create make "phony" rules for each include dependency + "-I\"${4}\"".freeze, # Per-test shallow includes essential search paths (e.g. Ceedling vendor path) "-D\"${2}\"".freeze, # Per-test executable defines "-DGNU_COMPILER".freeze, # OSX clang '-nostdinc'.freeze, # Ignore standard include paths @@ -90,24 +94,8 @@ ].freeze } -DEFAULT_TEST_NESTED_INCLUDES_PREPROCESSOR_TOOL = { - :executable => FilePathUtils.os_executable_ext('gcc').freeze, - :name => 'default_test_nested_includes_preprocessor'.freeze, - :optional => false.freeze, - :arguments => [ - '-E'.freeze, # Run only through preprocessor stage with its output - '-MM'.freeze, # Output make rule + suppress header files found in system header directories - '-MG'.freeze, # Assume missing header files are generated files (do not discard) - '-H'.freeze, # Also output #include list with depth - "-I\"${2}\"".freeze, # Per-test executable search paths - "-D\"${3}\"".freeze, # Per-test executable defines - "-DGNU_COMPILER".freeze, # OSX clang - '-nostdinc'.freeze, # Ignore standard include paths - "-x c".freeze, # Force C language - "\"${1}\"".freeze - ].freeze - } - +# Fully expands a given file through the preprocessor +# Processes #ifdefs, expands macros, etc. DEFAULT_TEST_FILE_FULL_PREPROCESSOR_TOOL = { :executable => FilePathUtils.os_executable_ext('gcc').freeze, :name => 'default_test_file_full_preprocessor'.freeze, @@ -124,6 +112,8 @@ ].freeze } +# Expands a given file through the preprocessor, preserving directives (macros, includes, etc.). +# Can be used to extract includes (differentiating system and user indludes) and non-code directives. DEFAULT_TEST_FILE_DIRECTIVES_ONLY_PREPROCESSOR_TOOL = { :executable => FilePathUtils.os_executable_ext('gcc').freeze, :name => 'default_test_file_directives_only_preprocessor'.freeze, @@ -249,10 +239,12 @@ DEFAULT_TOOLS_TEST_PREPROCESSORS = { :tools => { - :test_shallow_includes_preprocessor => DEFAULT_TEST_SHALLOW_INCLUDES_PREPROCESSOR_TOOL, - :test_nested_includes_preprocessor => DEFAULT_TEST_NESTED_INCLUDES_PREPROCESSOR_TOOL, - :test_file_full_preprocessor => DEFAULT_TEST_FILE_FULL_PREPROCESSOR_TOOL, - :test_file_directives_only_preprocessor => DEFAULT_TEST_FILE_DIRECTIVES_ONLY_PREPROCESSOR_TOOL, + # Extracts include directives as make-style depenedencies + :test_bare_includes_preprocessor => DEFAULT_TEST_BARE_INCLUDES_PREPROCESSOR_TOOL, + # Fully expands a given file through the preprocessor + :test_file_full_preprocessor => DEFAULT_TEST_FILE_FULL_PREPROCESSOR_TOOL, + # Expands a given file through the preprocessor, preserving directives (macros, includes, etc.) + :test_file_directives_only_preprocessor => DEFAULT_TEST_FILE_DIRECTIVES_ONLY_PREPROCESSOR_TOOL, } } @@ -281,12 +273,13 @@ DEFAULT_CEEDLING_PROJECT_CONFIG = { :project => { # :build_root must be set by user + :name => '', :use_mocks => true, :use_exceptions => false, + :use_partials => false, + :use_test_preprocessor => :none, :compile_threads => 1, :test_threads => 1, - :use_test_preprocessor => :none, - :use_deep_preprocessor => :none, :test_file_prefix => 'test_', :release_build => false, :use_backtrace => :simple diff --git a/lib/ceedling/exceptions.rb b/lib/ceedling/exceptions.rb index ad96b1c1d..e1f722d39 100644 --- a/lib/ceedling/exceptions.rb +++ b/lib/ceedling/exceptions.rb @@ -33,7 +33,7 @@ def initialize(shell_result:{}, name:, message:'') _message = "#{name} terminated with exit code [#{shell_result[:exit_code]}]" if !shell_result[:output].empty? - _message += " and output >>\n#{shell_result[:output].strip()}" + _message += " and output ⏩️\n#{shell_result[:output].strip()}" end if !message.empty? @@ -42,7 +42,7 @@ def initialize(shell_result:{}, name:, message:'') # Otherwise, just report the exception message else - _message = "#{name} encountered an error with output >>\n#{message}" + _message = "#{name} encountered an error with output ⏩️\n#{message}" end # Hand the message off to parent Exception diff --git a/lib/ceedling/file_finder.rb b/lib/ceedling/file_finder.rb index f93d7347f..3e4c1c021 100644 --- a/lib/ceedling/file_finder.rb +++ b/lib/ceedling/file_finder.rb @@ -14,15 +14,20 @@ class FileFinder constructor :configurator, :file_finder_helper, :cacheinator, :file_path_utils, :file_wrapper, :yaml_wrapper - def find_header_input_for_mock(mock_name) - # Mock name => <mock prefix><header filename without extension> - # Examples: 'Mockfoo' or 'mock_Bar' + def find_header_input_for_mock(mock) + # Mock name => <mock prefix><header filename (.h)> + # Examples: 'Mockfoo.h' or 'mock_Bar.h' # Note: In some rare cases, a mock name may include a dot (ex. Sensor.44) because of versioning file naming convention # Be careful about assuming the end of the name has any sort of file extension - header = mock_name.sub(/#{@configurator.cmock_mock_prefix}/, '') + @configurator.extension_header + header = mock.delete_prefix(@configurator.cmock_mock_prefix) - found_path = @file_finder_helper.find_file_in_collection(header, @configurator.collection_all_headers, :error, mock_name) + found_path = @file_finder_helper.find_file_in_collection( + header, + @configurator.collection_all_headers, + :error, + header.ext() + ) return found_path end @@ -52,7 +57,7 @@ def find_build_input_file(filepath:, complain: :error, context:) found_file = nil - # Strip off file extension + # Extract filename without file extension source_file = File.basename(filepath).ext('') # We only collect files that already exist when we start up. @@ -65,7 +70,8 @@ def find_build_input_file(filepath:, complain: :error, context:) # If we use .ext() below we'll clobber the dotted portion of the filename # Generated test runners - if (!release) and (source_file =~ /^#{@configurator.project_test_file_prefix}.+#{@configurator.test_runner_file_suffix}$/) + if (!release) and + (source_file =~ /^#{@configurator.project_test_file_prefix}.+#{@configurator.test_runner_file_suffix}$/) _source_file = source_file + EXTENSION_CORE_SOURCE found_file = @file_finder_helper.find_file_in_collection( @@ -75,12 +81,30 @@ def find_build_input_file(filepath:, complain: :error, context:) filepath) # Generated mocks - elsif (!release) and (source_file =~ /^#{@configurator.cmock_mock_prefix}/) + elsif (!release) and + (source_file.start_with?( @configurator.cmock_mock_prefix )) _source_file = source_file + EXTENSION_CORE_SOURCE found_file = @file_finder_helper.find_file_in_collection( _source_file, - @file_wrapper.directory_listing( File.join(@configurator.cmock_mock_path, '**/*') ), + @file_wrapper.directory_listing( + File.join(@configurator.cmock_mock_path, + ('**/*' + EXTENSION_CORE_SOURCE)) + ), + complain, + filepath) + + # Generated partials + elsif (!release) and + (source_file.start_with?( PARTIAL_FILENAME_PREFIX )) + _source_file = source_file + EXTENSION_CORE_SOURCE + found_file = + @file_finder_helper.find_file_in_collection( + _source_file, + @file_wrapper.directory_listing( + File.join(@configurator.project_test_partials_path, + ('**/*' + EXTENSION_CORE_SOURCE)) + ), complain, filepath) @@ -165,6 +189,11 @@ def find_build_input_file(filepath:, complain: :error, context:) end + def find_header_file(filepath, complain = :error) + header_file = File.basename(filepath).ext(@configurator.extension_header) + return @file_finder_helper.find_file_in_collection(header_file, @configurator.collection_all_headers, complain, filepath) + end + def find_source_file(filepath, complain = :error) source_file = File.basename(filepath).ext(@configurator.extension_source) return @file_finder_helper.find_file_in_collection(source_file, @configurator.collection_all_source, complain, filepath) diff --git a/lib/ceedling/file_path_utils.rb b/lib/ceedling/file_path_utils.rb index 2e61e36c4..94f99bbbb 100644 --- a/lib/ceedling/file_path_utils.rb +++ b/lib/ceedling/file_path_utils.rb @@ -8,6 +8,7 @@ require 'rubygems' require 'rake' # for ext() require 'fileutils' +require 'ceedling/exceptions' require 'ceedling/system_wrapper' require 'ceedling/constants' @@ -91,7 +92,7 @@ def self.reform_subdirectory_glob(path) return path end - ######### instance methods ########## + ######### Instance methods ########## ### release ### def form_release_build_cache_path(filepath) @@ -164,7 +165,11 @@ def form_preprocessed_file_full_expansion_filepath(filepath, subdir) return File.join( @configurator.project_test_preprocess_files_path, subdir, PREPROCESS_FULL_EXPANSION_DIR, File.basename(filepath) ) end - def form_preprocessed_file_directives_only_filepath(filepath, subdir) + def form_preprocessed_file_raw_directives_only_filepath(filepath, subdir) + return File.join( @configurator.project_test_preprocess_files_path, subdir, PREPROCESS_RAW_DIRECTIVES_ONLY_DIR, File.basename(filepath) ) + end + + def form_preprocessed_file_compacted_directives_only_filepath(filepath, subdir) return File.join( @configurator.project_test_preprocess_files_path, subdir, PREPROCESS_DIRECTIVES_ONLY_DIR, File.basename(filepath) ) end @@ -172,9 +177,37 @@ def form_test_build_objects_filelist(path, sources) return (@file_wrapper.instantiate_file_list(sources)).pathmap("#{path}/%n#{@configurator.extension_object}") end + def form_mock_header_filepath(subdir, filename) + # @configurator.cmock_mock_path accessor only exists if mocks are enabled + raise CeedlingException.new('Mocks are not enabled, but an internal feature dependent on them was accessed.') unless @configurator.project_use_mocks + return File.join(@configurator.cmock_mock_path, subdir, filename.ext(EXTENSION_CORE_HEADER)) + end + def form_mocks_source_filelist(path, mocks) list = (@file_wrapper.instantiate_file_list(mocks)) - return list.map{ |file| File.join(path, File.basename(file).ext(@configurator.extension_source)) } + return list.map{ |file| File.join(path, File.basename(file).ext(EXTENSION_CORE_SOURCE)) } + end + + def form_partial_header_filepath(subdir, filename) + # @configurator.project_test_partials_path accessor only exists if partials are enabled + raise CeedlingException.new('Partials are not enabled, but an internal feature dependent on them was accessed.') unless @configurator.project_use_partials + return File.join( @configurator.project_test_partials_path, subdir, filename.ext(EXTENSION_CORE_HEADER) ) + end + + def form_partial_interface_header_filename(_module) + return PARTIAL_FILENAME_PREFIX + _module + '_interface' + EXTENSION_CORE_HEADER + end + + def form_mock_partial_interface_header_filename(_module) + return @configurator.cmock_mock_prefix + PARTIAL_FILENAME_PREFIX + _module + '_interface' + EXTENSION_CORE_HEADER + end + + def form_partial_implementation_header_filename(_module) + return PARTIAL_FILENAME_PREFIX + _module + '_impl' + EXTENSION_CORE_HEADER + end + + def form_partial_implementation_source_filename(_module) + return PARTIAL_FILENAME_PREFIX + _module + '_impl' + EXTENSION_CORE_SOURCE end def form_test_dependencies_filelist(files) diff --git a/lib/ceedling/file_wrapper.rb b/lib/ceedling/file_wrapper.rb index cf0473e5f..c451650ff 100644 --- a/lib/ceedling/file_wrapper.rb +++ b/lib/ceedling/file_wrapper.rb @@ -14,6 +14,13 @@ class FileWrapper + def self.generate_include_guard(name) + # abc-XYZ.h --> _ABC_XYZ_H_ + base = File.basename(name, '.*') # Remove any extension + guard = '__' + CEEDLING_GENERATED + '_' + base.gsub(/\W/, '_').upcase + '_H__' + return guard + end + def get_expanded_path(path) return File.expand_path(path) end @@ -101,6 +108,12 @@ def touch(filepath, options={}) FileUtils.touch(filepath, **options) end + def write_blank_file(filepath) + File.open(filepath, 'w') do |file| + file.write("// Ceedling intentionally blank file\n\n") + end + end + def write(filepath, contents, flags='w') File.open(filepath, flags) do |file| file.write(contents) diff --git a/lib/ceedling/generator.rb b/lib/ceedling/generators/generator.rb similarity index 82% rename from lib/ceedling/generator.rb rename to lib/ceedling/generators/generator.rb index ceb3d22a4..bd0ed2160 100644 --- a/lib/ceedling/generator.rb +++ b/lib/ceedling/generators/generator.rb @@ -7,6 +7,7 @@ require 'ceedling/constants' require 'ceedling/exceptions' +require 'ceedling/includes/includes' require 'ceedling/file_path_utils' require 'rake' @@ -14,10 +15,10 @@ class Generator constructor :configurator, :generator_helper, - :preprocessinator, :generator_mocks, :generator_test_results, :generator_test_results_backtrace, + :generator_partials, :test_context_extractor, :tool_executor, :file_finder, @@ -25,7 +26,6 @@ class Generator :reportinator, :loginator, :plugin_manager, - :file_wrapper, :test_runner_manager @@ -35,6 +35,57 @@ def setup() @backtrace = @generator_test_results_backtrace end + def generate_partial_interface(test:, partial:, function_declarations:, includes:, c_module:, input_filepath:, output_path:) + msg = @reportinator.generate_module_progress( + operation: "Generating Partial mockable interface for", + module_name: test, + filename: partial # Partial module name, not filename + ) + @loginator.log( msg ) + + arg_hash = { + :test => test, + :name => partial, + :function_declarations => function_declarations, + :includes => includes, + :c_module => c_module, + :output_path => output_path + } + + return @generator_partials.generate_interface( **arg_hash ) + end + + def generate_partial_implementation( + test:, + partial:, + function_definitions:, + source_includes:, + header_includes:, + c_module:, + input_filepath:, + output_path: + ) + + msg = @reportinator.generate_module_progress( + operation: "Generating Partial implementation for", + module_name: test, + filename: partial # Partial module name, not filename + ) + @loginator.log( msg ) + + arg_hash = { + :function_definitions => function_definitions, + :test => test, + :name => partial, + :source_includes => source_includes, + :header_includes => header_includes, + :c_module => c_module, + :output_path => output_path + } + + return @generator_partials.generate_implementation( **arg_hash ) + end + def generate_mock(context:, mock:, test:, input_filepath:, output_path:) arg_hash = { :header_file => input_filepath, @@ -74,7 +125,9 @@ def generate_mock(context:, mock:, test:, input_filepath:, output_path:) end end - def generate_test_runner(context:, mock_list:, includes_list:, test_filepath:, input_filepath:, runner_filepath:) + # @param mocks: Array of Mocks to include in the runner + # @param includes: Array of Includes without mocks to include in the runner + def generate_test_runner(context:, mocks:, includes:, test_filepath:, input_filepath:, runner_filepath:) arg_hash = { :context => context, :test_file => test_filepath, @@ -97,14 +150,18 @@ def generate_test_runner(context:, mock_list:, includes_list:, test_filepath:, i raise CeedlingException.new( msg ) end + # Further filter others to remove vendor files + others = includes.reject do |include| + !VENDORS_FILES.include?( include.filename.ext() ) + end + # Build runner file begin unity_test_runner_generator.generate( module_name: module_name, runner_filepath: runner_filepath, - mock_list: mock_list, - test_file_includes: includes_list, - header_extension: @configurator.extension_header + mocks: mocks, + includes: others ) rescue StandardError => ex # Re-raise execption but decorate it to better identify it in Ceedling output @@ -156,7 +213,9 @@ def generate_object_file_c( command = @tool_executor.build_command_line( arg_hash[:tool], + # Extra arguments arg_hash[:flags], + # Argument replacement arg_hash[:source], arg_hash[:object], arg_hash[:list], @@ -219,7 +278,9 @@ def generate_object_file_asm( command = @tool_executor.build_command_line( arg_hash[:tool], + # Extra arguments arg_hash[:flags], + # Argument replacement arg_hash[:source], arg_hash[:object], arg_hash[:search_paths], @@ -260,7 +321,9 @@ def generate_executable_file(tool, context, objects, flags, executable, map='', command = @tool_executor.build_command_line( arg_hash[:tool], + # Extra arguments arg_hash[:flags], + # Argument replacement arg_hash[:objects], arg_hash[:executable], arg_hash[:map], @@ -301,8 +364,9 @@ def generate_test_results(tool:, context:, test_name:, test_filepath:, executabl command = @tool_executor.build_command_line( arg_hash[:tool], - # Apply additional test case filters + # Extra arguments: Additional test case filters @test_runner_manager.collect_cmdline_args(), + # Argument replacement arg_hash[:executable] ) diff --git a/lib/ceedling/generator_helper.rb b/lib/ceedling/generators/generator_helper.rb similarity index 100% rename from lib/ceedling/generator_helper.rb rename to lib/ceedling/generators/generator_helper.rb diff --git a/lib/ceedling/generator_mocks.rb b/lib/ceedling/generators/generator_mocks.rb similarity index 100% rename from lib/ceedling/generator_mocks.rb rename to lib/ceedling/generators/generator_mocks.rb diff --git a/lib/ceedling/generators/generator_partials.rb b/lib/ceedling/generators/generator_partials.rb new file mode 100644 index 000000000..5404c8baa --- /dev/null +++ b/lib/ceedling/generators/generator_partials.rb @@ -0,0 +1,177 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/file_wrapper' +require 'ceedling/partials/partials' +require 'ceedling/c_extractor/c_extractor_types' + +class GeneratorPartials + + constructor :file_wrapper, :file_path_utils, :loginator + + def generate_implementation( + test:, + name:, + function_definitions:, + source_includes:, + header_includes:, + c_module:, + output_path: + ) + source = @file_path_utils.form_partial_implementation_source_filename(name) + header = @file_path_utils.form_partial_implementation_header_filename(name) + + header_filepath = File.join(output_path, header) + source_filepath = File.join(output_path, source) + + @file_wrapper.open(header_filepath, 'w') do |file| + generate_header(file, header, header_includes, function_definitions, c_module, true) + end + + @file_wrapper.open(source_filepath, 'w') do |file| + generate_source(file, source_includes, function_definitions, c_module) + end + + return source_filepath + end + + def generate_interface(test:, name:, function_declarations:, includes:, c_module:, output_path:) + header = @file_path_utils.form_partial_interface_header_filename(name) + filepath = File.join(output_path, header) + + @file_wrapper.open(filepath, 'w') do |file| + generate_header(file, header, includes, function_declarations, c_module, false) + end + + return filepath + end + + private + + # Emit a partial header file. + # + # Iterates c_module.element_sequence to emit non-function items (macros, typedefs, + # aggregates, and optionally variable extern declarations) in their original extraction + # order. Function items in element_sequence are matched by name against function_list + # (pre-filtered Partials::FunctionDeclaration or Partials::FunctionDefinition objects) + # and emitted at their natural position. Any function_list entries not found in + # element_sequence (e.g., added from a different module) are emitted afterward. + # + # @param io [IO] Output file handle + # @param name [String] Header filename (used for include guard) + # @param includes [Array] Include directives + # @param function_list [Array] Pre-filtered Partials function objects (respond to :name and :signature) + # @param c_module [CExtractorTypes::CModule] Merged module with element_sequence + # @param include_variables [Boolean] True for implementation header (emits extern vars); false for interface + def generate_header(io, name, includes, function_list, c_module, include_variables) + guard = FileWrapper.generate_include_guard( name ) + + io << "#ifndef #{guard}\n" + io << "#define #{guard}\n\n" + + includes.each do |include| + io << "#{include}\n" + end + + io << "\n" if !includes.empty? + + func_by_name = function_list.to_h { |f| [f.name, f] } + emitted_funcs = {} + last_was_func = false + anything_emitted = false + + emit_func = lambda do |func| + # Blank line before a function when preceded by a non-function item + io << "\n" if anything_emitted && !last_was_func + io << func.signature << ";\n\n" + emitted_funcs[func.name] = true + last_was_func = true + anything_emitted = true + end + + c_module.element_sequence.each do |item| + case item + when CExtractorTypes::CStatement + io << item.text << "\n" + last_was_func = false + anything_emitted = true + when CExtractorTypes::CVariableDeclaration + next unless include_variables + # If there is no array involved, array_suffix collapses to an empty string + io << "extern #{item.type} #{item.name}#{item.array_suffix};\n" + last_was_func = false + anything_emitted = true + when CExtractorTypes::CFunctionDefinition, CExtractorTypes::CFunctionDeclaration + func = func_by_name[item.name] + next unless func && !emitted_funcs[item.name] + emit_func.call(func) + end + end + + # Non-function items end with \n; add one more for a blank line before #endif. + # Function items already end with \n\n, so no extra newline needed. + io << "\n" if anything_emitted && !last_was_func + + io << "#endif // #{guard}\n\n" + end + + # Emit a partial source file. + # + # Iterates c_module.element_sequence to emit CVariableDeclaration and + # CFunctionDefinition items in their original extraction order. CStatement and + # CFunctionDeclaration items are skipped (they belong in headers). Function items + # are matched by name against function_definitions (pre-filtered + # Partials::FunctionDefinition objects). Any entries not found in element_sequence + # are emitted afterward. + # + # @param io [IO] Output file handle + # @param includes [Array] Include directives + # @param function_definitions [Array] Pre-filtered Partials::FunctionDefinition objects + # @param c_module [CExtractorTypes::CModule] Merged module with element_sequence + def generate_source(io, includes, function_definitions, c_module) + io << "// Ceeding generated file\n" + includes.each do |include| + io << "#{include}\n" + end + + io << "\n" + + func_by_name = function_definitions.to_h { |f| [f.name, f] } + emitted_funcs = {} + last_was_func = false + anything_emitted = false + + emit_func = lambda do |func| + # Blank line before a function when preceded by a non-function item + io << "\n" if anything_emitted && !last_was_func + if func.line_num and func.source_filepath + io << "#line #{func.line_num} \"#{func.source_filepath}\"\n" + end + io << func.code_block << "\n\n" + emitted_funcs[func.name] = true + last_was_func = true + anything_emitted = true + end + + c_module.element_sequence.each do |item| + case item + when CExtractorTypes::CVariableDeclaration + io << "#{item.text}\n" + last_was_func = false + anything_emitted = true + when CExtractorTypes::CFunctionDefinition + func = func_by_name[item.name] + next unless func && !emitted_funcs[item.name] + emit_func.call(func) + end + # CStatement and CFunctionDeclaration items are skipped in source + end + + end + + +end diff --git a/lib/ceedling/generator_test_results.rb b/lib/ceedling/generators/generator_test_results.rb similarity index 99% rename from lib/ceedling/generator_test_results.rb rename to lib/ceedling/generators/generator_test_results.rb index 1fbfaaac2..d23e494a4 100644 --- a/lib/ceedling/generator_test_results.rb +++ b/lib/ceedling/generators/generator_test_results.rb @@ -114,7 +114,7 @@ def process_and_write_results(unity_shell_result) results[:counts][:ignored] = $3.to_i results[:counts][:passed] = (results[:counts][:total] - results[:counts][:failed] - results[:counts][:ignored]) else - raise CeedlingException.new( "Could not parse output for `#{executable}`: \"#{unity_shell_result[:output]}\"" ) + raise CeedlingException.new( "Could not parse output for `#{executable}` ⏩️ \"#{unity_shell_result[:output]}\"" ) end # Remove test statistics lines diff --git a/lib/ceedling/generator_test_results_backtrace.rb b/lib/ceedling/generators/generator_test_results_backtrace.rb similarity index 100% rename from lib/ceedling/generator_test_results_backtrace.rb rename to lib/ceedling/generators/generator_test_results_backtrace.rb diff --git a/lib/ceedling/generator_test_results_sanity_checker.rb b/lib/ceedling/generators/generator_test_results_sanity_checker.rb similarity index 100% rename from lib/ceedling/generator_test_results_sanity_checker.rb rename to lib/ceedling/generators/generator_test_results_sanity_checker.rb diff --git a/lib/ceedling/generator_test_runner.rb b/lib/ceedling/generators/generator_test_runner.rb similarity index 91% rename from lib/ceedling/generator_test_runner.rb rename to lib/ceedling/generators/generator_test_runner.rb index 894d4d9b4..b8deb3bb9 100644 --- a/lib/ceedling/generator_test_runner.rb +++ b/lib/ceedling/generators/generator_test_runner.rb @@ -16,7 +16,7 @@ class GeneratorTestRunner # It is instantiated on demand for each test file processed in a build. # - def initialize(config:, test_file_contents:, preprocessed_file_contents:nil) + def initialize(config:, test_file_contents:, preprocessed_file_contents: nil) @unity_runner_generator = UnityTestRunnerGenerator.new( config ) # Reduced information set @@ -28,14 +28,15 @@ def initialize(config:, test_file_contents:, preprocessed_file_contents:nil) parse_test_file( test_file_contents, preprocessed_file_contents ) end - def generate(module_name:, runner_filepath:, mock_list:, test_file_includes:, header_extension:) + def generate(module_name:, runner_filepath:, mocks:, includes:) # Actually build the test runner using Unity's test runner generator. @unity_runner_generator.generate( module_name, runner_filepath, @test_cases_internal, - mock_list.map{ |mock| mock + header_extension }, - test_file_includes.map{|f| File.basename(f,'.*') + header_extension} + # Small hack for mock subdirectory support until include paths fully supported + mocks.map{ |include| include.filepath }, + includes.map{ |include| include.filename } ) end diff --git a/lib/ceedling/includes/include_factory.rb b/lib/ceedling/includes/include_factory.rb new file mode 100644 index 000000000..3407423b9 --- /dev/null +++ b/lib/ceedling/includes/include_factory.rb @@ -0,0 +1,43 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# =========================================================================' + +require 'ceedling/constants' +require 'ceedling/includes/includes' + +class IncludeFactory + + constructor :configurator + + def user_include_from_directive(directive) + results = directive.match(PATTERNS::USER_INCLUDE_DIRECTIVE_FILENAME) + return user_include_from_filepath( results[1] ) if !results.nil? + return nil + end + + def user_include_from_filepath(filepath) + if File.basename(filepath).start_with?( @configurator.cmock_mock_prefix ) + # Remove any build directory path that snuck into mock handling. + # This can happen from discovering an empty mock stand-in or previously generated mock files. + # This regex matches the base build mocks directory and any test name subdirectory beneath it. + _filepath = filepath.sub( /^#{Regexp.escape( @configurator.cmock_mock_path )}\/[^\/]+\//, '' ) + return MockInclude.new(_filepath) + end + return UserInclude.new(filepath) + end + + def system_include_from_directive(directive) + results = directive.match(PATTERNS::SYSTEM_INCLUDE_DIRECTIVE_FILENAME) + return system_include_from_filepath( results[1] ) if !results.nil? + return nil + end + + def system_include_from_filepath(filepath) + # Just a light wrapper anticipating more complexities later on + return SystemInclude.new(filepath) + end + +end \ No newline at end of file diff --git a/lib/ceedling/include_pathinator.rb b/lib/ceedling/includes/include_pathinator.rb similarity index 100% rename from lib/ceedling/include_pathinator.rb rename to lib/ceedling/includes/include_pathinator.rb diff --git a/lib/ceedling/includes/includes.rb b/lib/ceedling/includes/includes.rb new file mode 100644 index 000000000..e9c239f0c --- /dev/null +++ b/lib/ceedling/includes/includes.rb @@ -0,0 +1,341 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# =========================================================================' + +class Includes + # Class method to convert mixed list of Include objects into an order-preserving list of hashes + # + # @param includes [Array<Include>] List of UserInclude and SystemInclude objects + # @return [Array<Hash>] Array of hashes, each with 'type' and 'filepath' keys + # @example + # includes = [ + # UserInclude.new("header.h"), + # SystemInclude.new("stdio.h"), + # UserInclude.new("module.h") + # ] + # Include.to_hash(includes) + # # => [ + # # { 'type' => 'user', 'filepath' => 'header.h' }, + # # { 'type' => 'system', 'filepath' => 'stdio.h' }, + # # { 'type' => 'user', 'filepath' => 'module.h' } + # # ] + def self.to_hashes(includes) + return includes.map do |include| + type = + case include + when MockInclude then 'mock' + when UserInclude then 'user' + when SystemInclude then 'system' + else raise ArgumentError, "Unknown Include type: #{include.class}" + end + + { + 'type' => type, + 'filepath' => include.filepath, + } + end + end + + # Class method to convert a list of hashes back into Include objects + # + # @param hashes [Array<Hash>] Array of hashes with 'type' and 'filepath' keys + # @return [Array<Include>] List of UserInclude and SystemInclude objects + # @raise [ArgumentError] If hash is missing required keys or has invalid type + # @example + # hashes = [ + # { 'type' => 'user', 'filepath' => 'header.h' }, + # { 'type' => 'system', 'filepath' => 'stdio.h' }, + # { 'type' => 'user', 'filepath' => 'module.h' } + # ] + # Include.from_hashes(hashes) + # # => [ + # # UserInclude.new("header.h"), + # # SystemInclude.new("stdio.h"), + # # UserInclude.new("module.h") + # # ] + def self.from_hashes(hashes) + return hashes.map do |hash| + raise ArgumentError, "Hash missing 'type' key" unless hash.key?('type') + raise ArgumentError, "Hash missing 'filepath' key" unless hash.key?('filepath') + + case hash['type'] + when 'user' + UserInclude.new(hash['filepath']) + when 'mock' + MockInclude.new(hash['filepath']) + when 'system' + SystemInclude.new(hash['filepath']) + else + raise ArgumentError, "Invalid include type: #{hash['type']}. Must be 'user' or 'system'" + end + end + end + + # Class method to extract all matching includes by filename pattern + def self.filter(includes, pattern) + includes.select { |include| include.filename =~ pattern } + end + + # Class method to extract all system includes + def self.system(includes) + includes.select { |include| include.is_a?(SystemInclude) } + end + + # Class method to extract all user includes + def self.user(includes) + includes.select { |include| include.is_a?(UserInclude) } + end + + # Class method to check for a filename in the collection + def self.contains?(includes, filename) + includes.any? { |include| include.filename == filename } + end + + # Class method for non-mutating sanitize + # + # @param includes [Array<Include>] List of includes to sanitize + # @param block [Proc] Optional block passed to reject! for custom filtering + # @yield [include] Each include object for custom rejection logic + # @return [Array<Include>] New sanitized list + # @example Basic usage + # Includes.sanitize(includes) + # @example Custom rejection + # Includes.sanitize(includes) { |include, all| ... } + def self.sanitize(includes, &block) + _includes = includes.clone + self.sanitize!(_includes, &block) + return _includes + end + + # Class method for mutating sanitize + # + # @param includes [Array<Include>] List of includes to sanitize in place + # @param block [Proc] Optional block passed to reject! for custom filtering + # @yield [include] Each include object for custom rejection logic + # @return [Array<Include>] The modified includes list + # @example Basic usage + # Includes.sanitize!(includes) + # @example Custom rejection + # Includes.sanitize!(includes) { |include, all| ... } + def self.sanitize!(includes, &block) + # Remove any duplicates + includes.uniq! + + # Apply custom rejection with access to full list if block provided + if block_given? + includes.reject! { |include| block.call(include, includes) } + end + + # Ensure system includes come first + self.sort!(includes) + + return includes + end + + # Class method to reconcile bare, user, and system includes returning a list of + # reconciled user and system includes. + # + # Purpose + # ------- + # Bare include preprocessing extracts user and system includes, but there's no way + # to explicitly differentiate these. Meanwhile, by necessity, user and system include + # extraction can identify too many includes. This class method uses the knowledeg of + # the different types of extraction to reconcile the two lists. It accomplishes: + # 1. Paring down system includes to the include directives used in original file. + # 2. Paring down user includes to the include directives used in original file. + # 3. Reconciling a list of user & system includes properly distinguished. + # + # Method + # ------ + # Compares bare includes against user and system includes and applies the following rules: + # 1. Intersection of bare includes and system includes. + # 2. Intersection of bare includes and user includes. + def self.reconcile(bare:, user:, system:) + # Validate input types + + # `bare` can only be base Include objects, no sub-classes. + unless bare.is_a?(Array) && bare.all? { |include| include.class == Include } + raise ArgumentError, "`bare` must be an Array of Include objects" + end + + # Ensure `user` is an array of UserInclude objects or sub-classes + unless user.is_a?(Array) && user.all? { |include| include.is_a?(UserInclude) } + raise ArgumentError, "`user` must be an Array of UserInclude objects" + end + + # Ensure `system` is an array of SystemInclude objects or sub-classes + unless system.is_a?(Array) && system.all? { |include| include.is_a?(SystemInclude) } + raise ArgumentError, "`system` must be an Array of SystemInclude objects" + end + + return [] if bare.empty? + + system_includes = [] + user_includes = [] + + # Create set of bare include filenames for O(1) lookup + bare_filenames = Set.new(bare.map(&:filename)) + + # Intersect system includes with bare includes based on filename. + # Keep system includes that have matching filenames in bare list. + system_includes = system.select do |include| + bare_filenames.include?(include.filename) + end + + # Intersect user includes with bare includes based on filename. + # Keep user includes (including subclasses) that have matching filenames in bare list. + user_includes = user.select do |include| + bare_filenames.include?(include.filename) + end + + # Construct reconciled list of includes with reconciled results. + # Always system includes first (C best practice). + return (system_includes + user_includes) + end + + # Sort list so system includes are at the beginning + # (Best practice) + def self.sort(includes) + _includes = includes.clone + self.sort!(_includes) + return _includes + end + + def self.sort!(includes) + includes.sort_by! { |include| include.is_a?(SystemInclude) ? 0 : 1 } + return includes + end +end + + +# Base class for C header includes +class Include + attr_reader :filepath + attr_reader :filename + attr_reader :path + + # Initialize an Include object from a C include statement or simple filepath. + # + # @param statement [String] A C include statement. Examples: + # - #include "header.h" + # - #include <stdio.h> + # - A quoted/bracketed filepath (e.g., '"header.h"' or <stdio.h>') + # - A plain filepath (e.g., 'path/to/header.h') + # @param use_path [Boolean] (default: false) + # - If true, use the full filepath in the include directive + # - If false, use only the filename + # @raise [ArgumentError] If the statement is empty or becomes empty after cleaning + def initialize(statement, use_path: false) + @filepath = clean(statement) + + raise ArgumentError, "Empty include statement" if @filepath.empty? + + @filename = File.basename(@filepath) + @path = File.dirname(@filepath) + @use_path = use_path + end + + # Method specialized by subclasses + def to_s() + # Simple string representation of class contents with no additional formatting or #include decoration + return @filename + end + + def to_str() + # Coerce to string for implicit conversions (e.g., string interpolation, concatenation) + # For instance, Rake#FileList needs this to treat Include objects as strings when extending with #pathmap + return @filename + end + + # Equality operator -- for Include objects and strings + def ==(other) + case other + when String + include == other + when UserInclude, MockInclude + if self.is_a?(SystemInclude) + false + else + include == other.include + end + when SystemInclude + if self.is_a?(UserInclude) + false + else + include == other.include + end + when Include + include == other.include + else + false + end + end + + # Matching operator for pattern matching on filename + def =~(pattern) + @filename =~ pattern + end + + # Not matching operator for pattern matching on filename + def !~(pattern) + @filename !~ pattern + end + + # Hash method for use in sets and as hash keys + def hash() + include.hash + end + + # Alias for == to support case equality + alias eql? == + + # Returns the configured entry to use in the include directive + def include() + @use_path ? @filepath : @filename + end + + private + + def clean(line) + # Remove any initial `#include` statement + _line = line.gsub(/#\s*include/, '') + + # Remove any quotation marks from an extracted user include directive + _line.gsub!(/"/, '') + + # Remove any angle brackets from an extracted system include directive + _line.gsub!(/</, '') + _line.gsub!(/>/, '') + + # Whitespace cleanup + _line.strip! + + return _line + end +end + +# UserInclude generates #include "header.h" (with quotes) +class UserInclude < Include + def to_s() + "#include \"#{include}\"" + end +end + +# MockInclude generates #include "<subdir>/header.h" (with quotes) +# Specialization to support include directive paths for mocks before path are supported everywhere +class MockInclude < UserInclude + def to_s() + "#include \"#{filepath}\"" + end +end + + +# SystemInclude generates #include <header.h> (with brackets) +class SystemInclude < Include + def to_s() + "#include <#{include}>" + end +end diff --git a/lib/ceedling/loginator.rb b/lib/ceedling/loginator.rb index 744578051..5288a85ff 100644 --- a/lib/ceedling/loginator.rb +++ b/lib/ceedling/loginator.rb @@ -38,9 +38,17 @@ def setup() @replace = { # Problematic characters pattern => Simple characters - /↳/ => '>>', # Config sub-entry notation - /•/ => '*', # Bulleted lists - /➡️/ => '>>', # Right arrow + + # Config sub-entry notation + /↳/ => '>>', + # Bulleted lists + /•/ => '*', + # Triangle + /▶️/ => '>', + # Right arrow + /➡️/ => '->', + # Double right arrow + /⏩️/ => '>>', } @project_logging = false @@ -122,6 +130,7 @@ def set_logfile( log_filepath ) # Write the given string to an optional log file and to the console # - Logging statements to a file are always at the highest verbosity # - Console logging is controlled by the verbosity level + # - Ensure at least one newline at the end of each message but collapse multiple newlines as two (for extra whitespace) # # For default label of LogLabels::AUTO # - If verbosity ERRORS, add ERROR: heading @@ -151,8 +160,8 @@ def log(message="\n", verbosity=Verbosity::NORMAL, label=LogLabels::AUTO, stream # Flatten if needed message = message.flatten.join("\n") if (message.class == Array) - # Message contatenated with "\n" (unless it aready ends with a newline) - message += "\n" unless message.end_with?( "\n" ) + # Ensure at least one newline but no more than two newlines at the end + message = message.rstrip + (message.rstrip != message.chomp ? "\n\n" : "\n") # Add item to the queue item = { @@ -164,6 +173,18 @@ def log(message="\n", verbosity=Verbosity::NORMAL, label=LogLabels::AUTO, stream @queue << item end + def log_list(list, header='', verbosity=Verbosity::NORMAL, label=LogLabels::AUTO, stream=nil) + msg = (header.nil? or header.empty?) ? '' : header + ':' + + if list.nil? or list.empty? + msg += ' ' if !msg.empty? + msg += "<empty>" + else + list.each { |item| msg += "\n - #{item}" } + end + + log(msg + "\n\n", verbosity, label, stream) + end # This is a version of the log function which performs lazy evaluation of the message itself. # The purpose of this version is to improve performance by only building strings that are needed @@ -201,13 +222,32 @@ def lazy(verbosity=Verbosity::NORMAL, label=LogLabels::AUTO, stream=nil, &block) def log_debug_backtrace(exception) # Send backtrace to debug logging, formatted almost identically to how Ruby does it. # Don't log the exception message itself in the first `log()` call as it will already be logged elsewhere - lazy( Verbosity::DEBUG ) do + lazy( Verbosity::DEBUG ) do "\nDebug Backtrace ==>\n#{exception.backtrace.first}: (#{exception.class})" + exception.backtrace.drop(1).map{|s| "\t#{s}"}.join("\n") end end + # Write directly to $stdout, bypassing the queue and all verbosity filtering. + # Applies emoji decorators (if enabled) but never text labels (INFO:, WARNING:, etc.). + # Applies the same character stripping as log() when decorators are disabled. + def console(message="\n", label=LogLabels::AUTO) + # Flatten if needed + message = message.flatten.join("\n") if (message.class == Array) + + # Ensure at least one newline but no more than two newlines at the end + message = message.rstrip + (message.rstrip != message.chomp ? "\n\n" : "\n") + + # Add emoji decorator if enabled; skip AUTO (no verbosity context) and NONE + prepend = '' + prepend = decorate( '', label ) if @decorators && label != LogLabels::AUTO && label != LogLabels::NONE + + # Write directly to stdout — no queue, no verbosity check, no text labels + $stdout.print( sanitize( insert_prepend(prepend, message), @decorators ) ) + end + + def decorate(str, label=LogLabels::NONE) return str if !@decorators @@ -260,6 +300,12 @@ def sanitize(string, decorate=nil) private + def insert_prepend(prepend, string) + leading, rest = string.match(/\A(\n*)(.*)\z/m).captures + return leading + prepend + rest + end + + def get_stream(verbosity, stream) # If no stream has been specified, choose one based on the verbosity level of the prompt if stream.nil? @@ -318,7 +364,7 @@ def format(string, verbosity, label, decorate) # Otherwise no headings for decorator-only messages end - return prepend + string + return insert_prepend( prepend, string ) end diff --git a/lib/ceedling/objects.yml b/lib/ceedling/objects.yml index be52bcfe8..ecf6a93a9 100644 --- a/lib/ceedling/objects.yml +++ b/lib/ceedling/objects.yml @@ -148,7 +148,6 @@ plugin_reportinator_helper: - file_wrapper verbosinator: - # compose: configurator file_finder: compose: @@ -164,10 +163,17 @@ file_finder_helper: parsing_parcels: +include_factory: + compose: + - configurator + test_context_extractor: compose: - configurator - file_wrapper + - include_factory + - partializer_config + - file_path_utils - loginator - parsing_parcels @@ -207,7 +213,7 @@ generator: compose: - configurator - generator_helper - - preprocessinator + - generator_partials - generator_mocks - generator_test_results - test_context_extractor @@ -217,7 +223,6 @@ generator: - reportinator - loginator - plugin_manager - - file_wrapper - test_runner_manager - generator_test_results_backtrace @@ -246,6 +251,17 @@ generator_mocks: compose: - configurator +generator_partials: + compose: + - file_path_utils + - file_wrapper + - loginator + +batchinator: + compose: + - batchinator_helper + - task_invoker + dependinator: compose: - configurator @@ -254,44 +270,109 @@ dependinator: - rake_wrapper - file_wrapper +preprocessinator_line_marker_includes_extractor: + compose: + - include_factory + +c_comment_scanner: + +preprocessinator_comment_stripper: + compose: + - c_comment_scanner + +preprocessinator_reconstructor: + compose: + - parsing_parcels + preprocessinator: compose: - preprocessinator_includes_handler - - preprocessinator_file_handler - - task_invoker - - file_finder + - preprocessinator_comment_stripper + - preprocessinator_file_assembler + - preprocessinator_reconstructor - file_path_utils + - tool_executor - file_wrapper - - yaml_wrapper - plugin_manager - configurator - - test_context_extractor - loginator - reportinator - - rake_wrapper preprocessinator_includes_handler: compose: - configurator + - preprocessinator_line_marker_includes_extractor + - include_factory - tool_executor - - test_context_extractor + - parsing_parcels - file_wrapper - yaml_wrapper - loginator - reportinator -preprocessinator_file_handler: +preprocessinator_file_assembler: compose: - - preprocessinator_extractor + - preprocessinator_reconstructor - configurator - tool_executor - file_path_utils - file_wrapper - loginator + - reportinator -preprocessinator_extractor: +preprocessinator_code_finder: + +c_extractor_code_text: + +c_extractor_functions: compose: - - parsing_parcels + - c_extractor_code_text + +c_extractor_declarations: + compose: + - c_extractor_code_text + +c_extractor_preprocessing: + compose: + - c_extractor_code_text + +c_extractor_definitions: + compose: + - c_extractor_code_text + +c_extractor: + compose: + - c_extractor_code_text + - c_extractor_declarations + - c_extractor_functions + - c_extractor_preprocessing + - c_extractor_definitions + +partializer_config: + compose: + - c_extractor_preprocessing + +partializer_utils: + compose: + - preprocessinator_code_finder + - loginator + +partializer_helper: + compose: + - partializer_utils + - c_extractor + - c_extractor_declarations + - file_path_utils + - loginator + +partializer: + compose: + - partializer_helper + - file_finder + - c_extractor + - file_path_utils + - reportinator + - loginator batchinator: compose: @@ -303,36 +384,56 @@ test_invoker: compose: - application - configurator - - test_invoker_helper + - test_build_setup + - test_build_planner + - test_build_executor - plugin_manager - batchinator - - reportinator - loginator - - preprocessinator - - task_invoker - - generator - - test_context_extractor - - file_path_utils - - file_finder - - file_wrapper - verbosinator -test_invoker_helper: +test_build_setup: compose: - configurator - loginator + - reportinator - batchinator - - task_invoker - test_context_extractor - include_pathinator - preprocessinator - defineinator - flaginator + - file_wrapper + - file_path_utils + - test_runner_manager + +test_build_planner: + compose: + - configurator + - loginator + - reportinator + - batchinator + - test_context_extractor + - partializer - file_finder - file_path_utils - file_wrapper + - plugin_manager + +test_build_executor: + compose: + - configurator + - loginator + - reportinator + - batchinator + - preprocessinator + - partializer - generator - - test_runner_manager + - test_context_extractor + - plugin_manager + - file_path_utils + - file_finder + - file_wrapper release_invoker: compose: diff --git a/lib/ceedling/parsing_parcels.rb b/lib/ceedling/parsing_parcels.rb index 0d451ddc5..2ea5204df 100644 --- a/lib/ceedling/parsing_parcels.rb +++ b/lib/ceedling/parsing_parcels.rb @@ -14,20 +14,44 @@ class ParsingParcels # lines to the block (one line at a time) for further analysis. It analyzes a single line at a time, # which is far more memory efficient and faster for large files. However, this requires it to also # handle backslash line continuations as a single line at this point. + # @param input [IO, File, String] The input source to parse line by line + # @yield [line] Gives each cleaned line to the block + # @yieldparam line [String] The cleaned code line def code_lines(input) + code_lines_with_num(input) { |line, _line_num| yield(line) } + end + + # This parser accepts a collection of lines which it will sweep through and tidy, giving the purified + # lines to the block (one line at a time) for further analysis along with the line number. It analyzes + # a single line at a time, which is far more memory efficient and faster for large files. However, this + # requires it to also handle backslash line continuations as a single line at this point. + # + # @param input [IO, File, String] The input source to parse line by line + # @yield [line, line_num] Gives each cleaned line and its line number to the block + # @yieldparam line [String] The cleaned code line + # @yieldparam line_num [Integer] The line number (1-indexed) where this line appears in the input. + # For continuation lines (lines ending with backslash), the line number of the first line in the + # continuation sequence is provided. + def code_lines_with_num(input) comment_block = false full_line = '' + line_num = 0 + continuation_start_line = 0 + input.each_line do |line| + line_num += 1 m = line.clean_encoding.match /(.*)\\\s*$/ if (!m.nil?) - full_line += m[1] + full_line += m[1] + continuation_start_line = line_num if full_line == m[1] elsif full_line.empty? _line, comment_block = clean_code_line( line, comment_block ) - yield( _line ) + yield( _line, line_num ) else _line, comment_block = clean_code_line( full_line + line, comment_block ) - yield( _line ) + yield( _line, continuation_start_line ) full_line = '' + continuation_start_line = 0 end end end diff --git a/lib/ceedling/partials/partializer.rb b/lib/ceedling/partials/partializer.rb new file mode 100644 index 000000000..1812ab3c2 --- /dev/null +++ b/lib/ceedling/partials/partializer.rb @@ -0,0 +1,444 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'set' +require 'rake' # .ext() +require 'ceedling/includes/includes' +require 'ceedling/partials/partials' +require 'ceedling/partials/partializer_runtime' +require 'ceedling/c_extractor/c_extractor' +require 'ceedling/c_extractor/c_extractor_constants' +require 'ceedling/c_extractor/c_extractor_types' +require 'ceedling/constants' + +class Partializer + + include Partials + + constructor :partializer_helper, :file_finder, :c_extractor, :file_path_utils, :reportinator, :loginator + + def setup() + # Alias + @helper = @partializer_helper + end + + def validate_config(c_module:, config:, name:) + msg = @reportinator.generate_progress("Validating Partial config for '#{name}'") + @loginator.log(msg, Verbosity::DEBUG) + @helper.validate_function_names_exist(c_module, config, name) + @helper.validate_no_additions_subtractions_overlap(config, name) + @helper.validate_additions_subtractions_visibility(c_module, config, name) + end + + def sanitize(c_module) + # Remove macro definitions that contain the CEEDLING_GENERATED sentinel string. + # These are include-guard and boilerplate macros injected into Ceedling-generated header files. + removed = c_module.macro_definitions.select { |m| m.text.include?(CEEDLING_GENERATED) } + # Remove from both the macro_definitions list and the element_sequence that references it + c_module.macro_definitions.reject! { |m| m.text.include?(CEEDLING_GENERATED) } + c_module.element_sequence.reject! { |e| removed.include?(e) } + end + + def validate_extracted_functions(name:, partial:, impl:, interface:) + # Validation is only meaningful and possible if both references are non-nil. + return if impl.nil? || interface.nil? + + # Validation is only meaningful if both lists have content. + return if impl.empty? || interface.empty? + + impl_names = Set.new(impl.map(&:name)) + interface_names = Set.new(interface.map(&:name)) + + msg = @reportinator.generate_module_progress( + module_name: name, + filename: partial, + operation: 'Validating Partial functions for' + ) + @loginator.log(msg, Verbosity::DEBUG) + + overlap = impl_names & interface_names + overlap.each do |func_name| + raise CeedlingException.new( + "#{name}: Partial '#{partial}' ⏩️ Function '#{func_name}' cannot be both testable and mockable" + ) + end + end + + def populate_filepaths(configs) + configs.each do |_module, config| + # Every partial involves processing header files + config.header.filepath = @file_finder.find_header_file(_module, :ignore) + + # Source file not needed only when mocking public functions exclusively + unless !config.tests.present? && config.mocks.type == PUBLIC + config.source.filepath = @file_finder.find_source_file(_module, :ignore) + end + end + + return configs + end + + # When `test:` is provided, logs the resulting includes at OBNOXIOUS. + def remap_implementation_header_includes(name:, includes:, partials:, test: nil) + _includes = includes.clone() + + # Get list of all partialized module names + partialized_modules = partials.keys + + # Remove includes for all partialized modules + # Remove our own orginal name as well + _includes = remove_matching_includes( + includes: _includes, + modules: ([name] + partialized_modules) + ) + + # Remove any duplicates + Includes.sanitize!(_includes) + + @loginator.log_list( + _includes, + "Header includes to inject for testable Partial #{test}::#{name}", + Verbosity::OBNOXIOUS + ) if test + + return _includes + end + + # When `test:` is provided, logs the resulting includes at OBNOXIOUS. + def remap_implementation_source_includes(name:, includes:, partials:, test: nil) + _includes = includes.clone() + + # Add implementation header + _includes << UserInclude.new( + @file_path_utils.form_partial_implementation_header_filename(name) + ) + + mockable_modules = [] + + partials.each do |_module, config| + # Remap mockable interface headers that will be injected into generated partial implementation + if includes.any? { |include| include.filename.ext().downcase() == _module.downcase() } + if [PUBLIC, PRIVATE].include?( config.mocks.type ) + # Insert mockable interface header from remapping of module name + _includes << UserInclude.new( + @file_path_utils.form_partial_interface_header_filename(_module) + ) + # Remember the module for later removal of original header + mockable_modules << _module + end + end + end + + # Remove the original module header now that it's remapped to mockable interface + # Remove our own orginal name as well + _includes = remove_matching_includes( + includes: _includes, + modules: ([name] + mockable_modules) + ) + + # Remove any duplicates + Includes.sanitize!(_includes) + + @loginator.log_list( + _includes, + "Source includes to inject for testable Partial #{test}::#{name}", + Verbosity::OBNOXIOUS + ) if test + + return _includes + end + + # When `test:` is provided, logs the resulting includes at OBNOXIOUS. + def remap_interface_header_includes(name:, includes:, partials:, test: nil) + _includes = includes.clone() + + # Get list of all partialized module names + partialized_modules = partials.keys + + # Remove includes for all partialized modules + # Remove our own orginal name as well + _includes = remove_matching_includes( + includes: _includes, + modules: ([name] + partialized_modules) + ) + + # Remove any duplicates + Includes.sanitize!(_includes) + + @loginator.log_list( + _includes, + "Header includes to inject for mockable Partial #{test}::#{name}", + Verbosity::OBNOXIOUS + ) if test + + return _includes + end + + # Extracts and combines C code contents from header and source files + # + # This method uses CExtractor to parse C files and extract their contents including + # function definitions, function declarations, and variable declarations. If both + # header and source files are provided, their contents are merged into a single + # CModule structure. + # + # @param header_filepath [String, nil] Path to the header file to extract from. + # If nil, no header content is extracted. + # @param source_filepath [String, nil] Path to the source file to extract from. + # If nil, no source content is extracted. + # + # @return [CExtractorTypes::CModule] A merged CModule containing all extracted contents + # from both files. The structure includes: + # - function_definitions: Array of function definitions with full implementations + # - function_declarations: Array of function declarations (prototypes) + # - variable_declarations: Array of variable declarations + # + # @note The method always starts with an empty CModule and merges in contents + # from any provided files using the CModule's + operator for combining structures. + def extract_module_contents(name, config, fallback) + # Array for CModule structs + contents = [CExtractorTypes::CModule.new()] + + # Process the C module source and/or header associated with the Partial config + [config.header, config.source].zip(['header', 'source']).each do |c_file, file_type| + # Do nothing if there's no directives-only preprocessed filepath (e.g. no source only header for a Partial mock) + next unless c_file.directives_only_filepath + + c_module = @c_extractor.from_file( c_file.directives_only_filepath ) + + _log_module_contents(name, config.module, file_type, c_module) + + # Update function signatures from fully preprocessed output when available. + # Replaces signature/decorators/signature_stripped (but NOT code_block) so that + # macros wrapping `static` and `inline` are resolved before visibility filtering. + if c_file.full_expansion_filepath + @helper.update_signatures_from_full_expansion( + funcs: c_module.function_definitions, + full_expansion_filepath: c_file.full_expansion_filepath, + name: name, + module_name: config.module, + file_type: file_type + ) + end + + # Align extracted function definitions with line markers in preprocessor output. + # This perfectly remaps functions found in expanded preprocessor output with + # original source location. + # This routine depends on original, unaltered function definitions. + @helper.associate_function_line_numbers( + name: name, + funcs: c_module.function_definitions, + filepath: c_file.filepath, + fallback: fallback + ) + + # 1. Find any function-scope static variable declarations. + # 2. Replace them in function definitions with no-ops (for proper coverage reporting). + # 3. Promote the function-scoped variables to be module-level variables. + decls = @helper.extract_function_scope_static_vars( + c_module.function_definitions, + name: name, module_name: config.module, file_type: file_type + ) + c_module.variable_declarations.concat(decls) + c_module.element_sequence.concat(decls) unless decls.empty? + + contents << c_module + end + + # Use `+` operator for CModule to merge everything + contents = contents.reduce(&:+) + + return contents + end + + # Returns Array<Partials::FunctionDefinition> for the testable partial implementation. + # + # Processes the `tests` PartialFunctions config against extracted C function definitions: + # PUBLIC -- initial list is all non-private functions; additions inject named private functions + # PRIVATE -- initial list is all private functions; additions inject named public functions + # ACCUMULATE -- initial list is empty; additions fill it entirely + # nil -- returns nil + # Subtractions remove named functions from the assembled list. + # Any functions in mocks.additions are also removed from the final result. + # Parameters are expected to be pre-validated (no unknown names, no overlap, etc.). + # + # @param test [String] Test file name (for log messages) + # @param partial [String] Partial module name (for log messages) + # @param definitions [Array<CFunctionDefinition>] Extracted function definitions + # @param config [PartializerConfig::Config] Full partial config for the module + # @return [Array<Partials::FunctionDefinition>] + def extract_implementation_functions(test:, partial:, definitions:, config:) + pf = config.tests + return nil if pf.type.nil? + + @loginator.log( + "Extracting testable Partial functions for #{test}::#{partial}: " \ + "type=#{pf.type} additions=#{pf.additions} subtractions=#{pf.subtractions}", + Verbosity::DEBUG + ) + + # Build initial list by visibility; ACCUMULATE yields [] + funcs = @helper.filter_and_transform_funcs(definitions, pf.type, :impl) + + # Additions: only search definitions — code_block required for impl transform + pf.additions.each do |name| + next if funcs.any? { |f| f.name == name } + func = @helper.find_and_transform_func( + name: name, + primary_funcs: definitions, + secondary_funcs: [], + output_type: :impl + ) + funcs << func if func + end + + # Subtractions: remove named functions from list + result = @helper.subtract_funcs(funcs: funcs, names: pf.subtractions) + if !funcs.empty? && result.empty? + @loginator.log( + "Partial #{test}::#{partial} ⏩️ Subtractions left no testable functions", + Verbosity::COMPLAIN, + LogLabels::NOTICE + ) + end + + # Remove any functions explicitly claimed by the mock side + result = @helper.subtract_funcs(funcs: result, names: config.mocks.additions) + + _log_impl_functions(test, partial, result) + + return result + end + + # Returns Array<Partials::FunctionDeclaration> for the mockable partial interface. + # + # Processes the `mocks` PartialFunctions config against extracted C functions: + # PUBLIC -- initial list is all non-private functions; additions inject named private functions + # PRIVATE -- initial list is all private functions; additions inject named public functions + # ACCUMULATE -- initial list is empty; additions fill it entirely + # nil -- returns nil + # Subtractions remove named functions from the assembled list. + # Any functions in tests.additions are also removed from the final result. + # Parameters are expected to be pre-validated (no unknown names, no overlap, etc.). + # Additions search definitions first, then declarations; only the first match is used. + # + # @param test [String] Test file name (for log messages) + # @param partial [String] Partial module name (for log messages) + # @param definitions [Array<CFunctionDefinition>] Extracted function definitions + # @param declarations [Array<CFunctionDeclaration>] Extracted function declarations + # @param config [PartializerConfig::Config] Full partial config for the module + # @return [Array<Partials::FunctionDeclaration>] + def extract_interface_functions(test:, partial:, definitions:, declarations:, config:) + pf = config.mocks + return nil if pf.type.nil? + + @loginator.log( + "Extracting mockable Partial functions for #{test}::#{partial}: " \ + "type=#{pf.type} additions=#{pf.additions} subtractions=#{pf.subtractions}", + Verbosity::DEBUG + ) + + # Build initial list by visibility; ACCUMULATE yields [] + funcs = @helper.filter_and_transform_funcs(definitions, pf.type, :interface) + + # Additions: search definitions first, then declarations + pf.additions.each do |name| + next if funcs.any? { |f| f.name == name } + func = @helper.find_and_transform_func( + name: name, + primary_funcs: definitions, + secondary_funcs: declarations, + output_type: :interface + ) + funcs << func if func + end + + # Subtractions: remove named functions from list + result = @helper.subtract_funcs(funcs: funcs, names: pf.subtractions) + if !funcs.empty? && result.empty? + @loginator.log( + "Partial #{test}::#{partial} ⏩️ Subtractions left no mockable signatures", + Verbosity::COMPLAIN, + LogLabels::NOTICE + ) + end + + # Remove any functions explicitly claimed by the test side + result = @helper.subtract_funcs(funcs: result, names: config.tests.additions) + + _log_interface_functions(test, partial, result) + + return result + end + + private + + # Log all user-defined (non-function) C content extracted from a module's source/header at OBNOXIOUS level. + # Covers the four categories that are injected into generated Partial files: + # variable declarations, type definitions, macro definitions, and aggregate definitions + # (structs, unions, enums not wrapped in a typedef). + def _log_module_contents(name, module_name, source, contents) + _vars = contents.variable_declarations.map { |v| "`#{v.text}`" } + @loginator.log_list( + _vars, + "Variable declarations for Partial #{name}::#{module_name} from #{source}", + Verbosity::OBNOXIOUS + ) + + _types = contents.type_definitions.map { |t| "`#{t.text}`" } + @loginator.log_list( + _types, + "Type definitions for Partial #{name}::#{module_name} from #{source}", + Verbosity::OBNOXIOUS + ) + + _macros = contents.macro_definitions.map { |m| "`#{m.text}`" } + @loginator.log_list( + _macros, + "Macro definitions for Partial #{name}::#{module_name} from #{source}", + Verbosity::OBNOXIOUS + ) + + _aggregates = contents.aggregate_definitions.map { |a| "`#{a.text}`" } + @loginator.log_list( + _aggregates, + "Aggregate definitions (structs/unions/enums) for Partial #{name}::#{module_name} from #{source}", + Verbosity::OBNOXIOUS + ) + end + + # Log testable (implementation) functions at OBNOXIOUS level. + def _log_impl_functions(test, partial, funcs) + _funcs = funcs.nil? ? [] : funcs.map { |f| "`#{f.signature}`" } + @loginator.log_list( + _funcs, + "Testable functions for Partial #{test}::#{partial}", + Verbosity::OBNOXIOUS + ) + end + + # Log mockable (interface) functions at OBNOXIOUS level. + def _log_interface_functions(test, partial, funcs) + _funcs = funcs.nil? ? [] : funcs.map { |f| "`#{f.signature}`" } + @loginator.log_list( + _funcs, + "Mockable functions for Partial #{test}::#{partial}", + Verbosity::OBNOXIOUS + ) + end + + # Remove includes that match the given module names (case-insensitive) + # Returns a new array with matching includes removed + def remove_matching_includes(includes:, modules:) + # Normalize module names to lowercase for comparison + normalized_modules = modules.map(&:downcase) + + # Filter out includes (minus extension) that match any module name + return includes.reject do |include| + normalized_modules.include?(include.filename.ext().downcase()) + end + end +end \ No newline at end of file diff --git a/lib/ceedling/partials/partializer_config.rb b/lib/ceedling/partials/partializer_config.rb new file mode 100644 index 000000000..9bb51f7aa --- /dev/null +++ b/lib/ceedling/partials/partializer_config.rb @@ -0,0 +1,217 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'stringio' +require 'strscan' +require 'ceedling/partials/partials' +require 'ceedling/exceptions' +require 'ceedling/c_extractor/c_extractor_preprocessing' + +class PartializerConfig + + include Partials + + constructor :c_extractor_preprocessing + + # Macro names for all partial configuration macros + MACRO_NAMES = [ + 'TEST_PARTIAL_PUBLIC_MODULE', + 'TEST_PARTIAL_PRIVATE_MODULE', + 'MOCK_PARTIAL_PUBLIC_MODULE', + 'MOCK_PARTIAL_PRIVATE_MODULE', + 'TEST_PARTIAL_MODULE', + 'MOCK_PARTIAL_MODULE', + 'TEST_PARTIAL_ALL_MODULE', + 'MOCK_PARTIAL_ALL_MODULE', + 'TEST_PARTIAL_CONFIG', + 'MOCK_PARTIAL_CONFIG', + ].freeze + + # Holds function-level extraction config for tests or mocks within a Partial. + # type -- :public, :private, or :accumulate (additions-driven); nil if unset + # additions -- function names to explicitly include + # subtractions -- function names to explicitly exclude (illegal with ACCUMULATE) + PartialFunctions = Struct.new(:type, :additions, :subtractions, keyword_init: true) do + def initialize(type: nil, additions: [], subtractions: []) + super + end + + # Returns true if this PartialFunctions entry has enough configuration to be processed. + # Used to gate downstream work: a nil type means the feature is disabled; an ACCUMULATE + # type with no additions has no functions to include; all other cases are meaningful. + def present? + # Feature is disabled for this test/mock side of the module + return false if type.nil? + # ACCUMULATE starts empty and relies entirely on explicit additions; without any, + # there is nothing to include, so the config has no effect + return false if type == ACCUMULATE && additions.empty? + # PUBLIC, PRIVATE: always have a base set of functions to filter + # DEDUCT: starts with all functions; zero subtractions is valid ("include everything") + return true + end + end + + # Top-level Partial configuration for a single C module. + Config = Struct.new(:module, :tests, :mocks, :header, :source, keyword_init: true) do + def initialize(module:, + tests: PartializerConfig::PartialFunctions.new, + mocks: PartializerConfig::PartialFunctions.new, + header: Partials::ConfigFileInfo.new, + source: Partials::ConfigFileInfo.new) + super + end + end + + # Extract partial configuration macros from a string. + # Returns a hash of module_name => Config. + def extract_configs_from_string(string) + extract_configs( StringIO.new(string) ) + end + + # Extract partial configuration macros from a file. + # Returns a hash of module_name => Config. + def extract_configs_from_file(filepath) + File.open(filepath) { |f| extract_configs(f) } + end + + # Core three-pass extraction: + # Pass 1 — MODULE macros: build Config entries, set types + # Pass 2 — CONFIG macros: populate additions/subtractions + # Pass 3 — Validation: raise if any Config has no meaningful content + def extract_configs(io) + content = io.read + scanner = StringScanner.new(content) + calls = @c_extractor_preprocessing.try_extract_macro_calls(scanner, MACRO_NAMES) + + configs = {} # module_name => Config + config_calls = [] # deferred: [macro_name, params] for CONFIG macros + + # --- Pass 1: MODULE macros --- + calls.each do |call_str| + macro_name, params = @c_extractor_preprocessing.parse_macro_call(call_str) + next if macro_name.nil? + + if macro_name.end_with?('_CONFIG') + config_calls << [macro_name, params] + next + end + + mod = _strip_quotes(params[0]) + configs[mod] ||= Config.new(module: mod) + + case macro_name + when 'TEST_PARTIAL_PUBLIC_MODULE' + _check_type_unset!(configs[mod].tests, mod, macro_name) + configs[mod].tests.type = PUBLIC + when 'TEST_PARTIAL_PRIVATE_MODULE' + _check_type_unset!(configs[mod].tests, mod, macro_name) + configs[mod].tests.type = PRIVATE + when 'MOCK_PARTIAL_PUBLIC_MODULE' + _check_type_unset!(configs[mod].mocks, mod, macro_name) + configs[mod].mocks.type = PUBLIC + when 'MOCK_PARTIAL_PRIVATE_MODULE' + _check_type_unset!(configs[mod].mocks, mod, macro_name) + configs[mod].mocks.type = PRIVATE + when 'TEST_PARTIAL_MODULE' + _check_type_unset!(configs[mod].tests, mod, macro_name) + configs[mod].tests.type = ACCUMULATE + when 'MOCK_PARTIAL_MODULE' + _check_type_unset!(configs[mod].mocks, mod, macro_name) + configs[mod].mocks.type = ACCUMULATE + when 'TEST_PARTIAL_ALL_MODULE' + _check_type_unset!(configs[mod].tests, mod, macro_name) + configs[mod].tests.type = DEDUCT + when 'MOCK_PARTIAL_ALL_MODULE' + _check_type_unset!(configs[mod].mocks, mod, macro_name) + configs[mod].mocks.type = DEDUCT + end + end + + # --- Pass 2: CONFIG macros --- + config_calls.each do |macro_name, params| + mod = _strip_quotes(params[0]) + unless configs.key?(mod) + raise CeedlingException.new( + "#{macro_name} references module '#{mod}' but no corresponding MODULE Partial macro directive for that module was found" + ) + end + + target = macro_name.start_with?('TEST_') ? configs[mod].tests : configs[mod].mocks + + params[1..].each do |raw| + name = _strip_quotes(raw) + if name.start_with?('-') + target.subtractions << name[1..] + else + target.additions << name.delete_prefix('+') + end + end + + target.subtractions.uniq! + target.additions.uniq! + end + + # --- Pass 3: Validation --- + configs.each do |mod, config| + if config.tests.type == ACCUMULATE && config.tests.additions.empty? + raise CeedlingException.new( + "TEST Partial for module '#{mod}' uses TEST_PARTIAL_MODULE() but no function additions were specified — " \ + "add at least one function name via TEST_PARTIAL_CONFIG()" + ) + end + + if config.mocks.type == ACCUMULATE && config.mocks.additions.empty? + raise CeedlingException.new( + "MOCK Partial for module '#{mod}' uses MOCK_PARTIAL_MODULE() but no function additions were specified — " \ + "add at least one function name via MOCK_PARTIAL_CONFIG()" + ) + end + + # Rule 1: subtractions are illegal with ACCUMULATE + [[:tests, 'TEST'], [:mocks, 'MOCK']].each do |field, label| + pf = config.send(field) + if pf.type == ACCUMULATE && !pf.subtractions.empty? + raise CeedlingException.new( + "#{label} configuration for '#{mod}' Partial cannot contain subtractions because only additions are available with PARTIAL_#{label}_MODULE()" + ) + end + end + + # Rule 2: additions are illegal with DEDUCT + [[:tests, 'TEST'], [:mocks, 'MOCK']].each do |field, label| + pf = config.send(field) + if pf.type == DEDUCT && !pf.additions.empty? + raise CeedlingException.new( + "#{label} configuration for '#{mod}' Partial cannot contain additions because only subtractions are available with #{label}_PARTIAL_ALL_MODULE()" + ) + end + end + end + + return configs + end + + ### Private ### + + private + + # Raise if partial_functions.type is already set -— indicates duplicate MODULE macro. + def _check_type_unset!(partial_functions, mod, macro_name) + return if partial_functions.type.nil? + raise CeedlingException.new( + "Partial for module '#{mod}' was declared with '#{macro_name}', but it was already declared and can only be declared once." + ) + end + + # Strip a matched pair of double-quotes from str. + # Returns str unchanged if it is not double-quoted. + def _strip_quotes(str) + return str unless str.length >= 2 && str.start_with?('"') && str.end_with?('"') + str[1..-2] + end + +end diff --git a/lib/ceedling/partials/partializer_helper.rb b/lib/ceedling/partials/partializer_helper.rb new file mode 100644 index 000000000..4ab288c8d --- /dev/null +++ b/lib/ceedling/partials/partializer_helper.rb @@ -0,0 +1,344 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'set' +require 'ceedling/exceptions' +require 'ceedling/array_patches' # Redundant `require` to ensure patching in test cases +require 'ceedling/partials/partials' +require 'ceedling/c_extractor/c_extractor_declarations' +require 'ceedling/c_extractor/c_extractor_constants' +require 'strscan' + +class PartializerHelper + + include Partials + + constructor( + :partializer_utils, + :c_extractor, + :c_extractor_declarations, + :file_path_utils, + :loginator + ) + + def setup() + # Aliases + @utils = @partializer_utils + @declaration_extractor = @c_extractor_declarations + end + + # 1. Filter functions by visibility (:private | :public | :deduct) or seed an empty list (:accumulate) + # 2. Transform functions to appropriate container (:impl | :interface) → `FunctionDefinition[]` or `FunctionDeclaration[]` + def filter_and_transform_funcs(funcs, visibility, output_type) + # DEDUCT starts with all functions regardless of visibility; subtractions are applied later by the caller + if visibility == DEDUCT + return funcs.filter_map { |func| @utils.transform_function(func, func.signature_stripped, output_type) } + end + + # ACCUMULATE starts with an empty list; the caller then injects explicitly named additions + return [] unless [PRIVATE, PUBLIC].include?(visibility) + + # PUBLIC or PRIVATE: filter to matching visibility, then transform + funcs.filter_map do |func| + next unless @utils.matches_visibility?(func.decorators, visibility) + + @utils.transform_function(func, func.signature_stripped, output_type) + end + end + + # Find a function by name, searching primary_funcs first then secondary_funcs. + # Returns the first match transformed for the given output_type, or nil if not found. + # For :impl output_type, pass secondary_funcs: [] — declarations have no code_block. + def find_and_transform_func(name:, primary_funcs:, secondary_funcs:, output_type:) + func = primary_funcs.find { |f| f.name == name } + return @utils.transform_function(func, func.signature_stripped, output_type) if func + + func = secondary_funcs.find { |f| f.name == name } + return @utils.transform_function(func, func.signature_stripped, output_type) if func + + nil + end + + # Return funcs with any function whose name is in names removed. + def subtract_funcs(funcs:, names:) + return funcs if names.empty? + name_set = Set.new(names) + funcs.reject { |f| name_set.include?(f.name) } + end + + # Associate each FunctionDefinition with its line number in the original source file. + # + # C source files are run through the GCC preprocessor before extraction. The + # resulting fully expanded output file retains GCC line markers of the form: + # # <linenum> "<filename>" [flags] + # These markers preserve the correspondence between preprocessed text and the + # original source lines, even after macro expansion has altered the content. + # + # For each function in funcs, this method searches the preprocessor expansion + # for an exact match of the function's `code_block`` text. When a match is + # found, the GCC line marker immediately preceding the match is used to + # calculate the 1-indexed source line number. This line number is then written + # back into the `FunctionDefinition` struct alongside the originating filepath. + # + # `FunctionDefinition` entries whose `code_block` cannot be located in the + # preprocessor output are skipped -- `line_num` fields remain unset. + # + # `source_filepath` is always updated. + # + # @param name [String]Name of the containing test, used to construct the path to + # the preprocessor expansion file for that test context. + # @param funcs [Array<FunctionDefinition>] Function definitions whose source + # locations are to be resolved. Matched entries are mutated in place. + # @param filepath [String] Path to the original C source file that was + # preprocessed, written into each matched FunctionDefinition as source_filepath. + # @param fallback [bool] Whether to immediately use simple source file scanning + # instead of preprocessed output (because preprocessed output is not available) + def associate_function_line_numbers(name:, funcs:, filepath:, fallback:) + # File path of directives-only preprocessor output + preprocessed_filepath = @file_path_utils.form_preprocessed_file_raw_directives_only_filepath( filepath, name ) + + @utils.stamp_source_filepaths( funcs, filepath ) + + if fallback + msg = "Using fallback C function location search for #{filepath}" + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + funcs.each do |func| + func.line_num = @utils.locate_function_in_source( + code_block: func.code_block, + filepath: filepath + ) + end + + else + funcs.each do |func| + # Uses `locate_function_in_source` as an automatic fallback + func.line_num = @utils.locate_function_via_preprocessed( + code_block: func.code_block, + filepath: filepath, + preprocessed_filepath: preprocessed_filepath + ) + end + end + + funcs.each do |func| + if func.line_num.nil? + msg = "Could not locate function #{func.name}() in #{filepath} ➡️ Any test coverage reporting will be incomplete." + @loginator.log( msg, Verbosity::COMPLAIN ) + end + end + + header = "Found functions at line numbers in #{filepath}" + @loginator.log_list( @utils.format_line_number_list( funcs ), header, Verbosity::DEBUG ) + end + + # Excise function-scoped static variable declarations from function bodies (to be + # promoted to module-scope). + # + # C functions may contain local `static` variable declarations. These variables have + # file-level storage duration but function-level scope. When generating partials, they + # must be lifted out of function bodies and treated as module-level variables so that + # linker and coverage tooling see them correctly. + # + # For each function in `funcs`, this method: + # 1. Scans the function body for variable declarations bearing a private keyword + # (i.e. any keyword in `CExtractorConstants::PRIVATE_KEYWORDS`, e.g. `static`). + # 2. Replaces each such declaration in the function's `code_block` and `body` with a + # no-op expression of the form `(void)0; /* <original text> */` so that coverage + # line mappings remain valid without re-declaring the variable inside the body. + # 3. Renames each private function-scoped declaration to be prepended with the + # containing function name to prevent name collisions at module-scope. + # 4. Collects all promoted declarations and returns them for inclusion at module scope. + # + # @param funcs [Array<CFunctionDefinition>] Function definitions to scan. Each matched + # function's `code_block` and `body` fields are mutated in place. + # @param name [String] Test name, used in log messages. + # @param module_name [String] Module name, used in log messages. + # @param file_type [String] "source" or "header", used in log messages. + # + # @return [Array<CVariableDeclaration>] All function-scoped static variable declarations + # found across all supplied functions, suitable for emission at module scope. + def extract_function_scope_static_vars(funcs, name:, module_name:, file_type:) + decls = [] + + # Process each function definition looking for function-scoped static variables. + # If found, collect them and remove from function `body` and `code_block`. + funcs.each do |func| + # Remove containing brackets of function body + func_body = func.body.dup + func_body.delete_prefix!( '{' ) + func_body.delete_suffix!( '}' ) + + scanner = StringScanner.new( func_body ) + _decls = [] + + loop do + # `try_extract_variable` returns an array of declarations. + # A compound declaration (e.g. int x, y) yields multiple declaration Structs + success, var_decls = @declaration_extractor.try_extract_variable( scanner ) + break unless success + var_decls.each do |var| + if var.decorators.any? { |d| CExtractorConstants::PRIVATE_KEYWORDS.include?(d) } + _decls << var + end + end + end + + # Group declarations by original statement. + # Simple declarations (one var per unique original) and + # compound declarations (multiple vars sharing the same original, e.g. `static int a, b;`) + # require different strategies to prevent the restored comment text from being found and + # corrupted by a subsequent replace call. + groups = _decls.group_by { |var| var.original.strip } + + groups.each do |_original, vars| + # Pre-compute old/new names before any mutation of var.name + old_names = vars.map(&:name) + new_names = old_names.map { |n| "partial_#{func.name}_#{n}" } + + # Single placeholder per group — keyed on the first variable's name + placeholder = "__CEEDLING_NOOP_#{func.name.upcase}_#{old_names.first.upcase}__" + + # Replace original declaration: one no-op per variable, single comment with placeholder. + # Defer placeholder restoration until after ALL renames are complete so that + # restored comment text cannot be found and re-processed by a subsequent replace. + if vars.size > 1 + func.code_block = @utils.replace_compound_declaration_with_noops( func.code_block, _original, placeholder, vars.size ) + func.body = @utils.replace_compound_declaration_with_noops( func.body, _original, placeholder, vars.size ) + else + func.code_block = @utils.replace_declaration_with_noop( func.code_block, _original, placeholder ) + func.body = @utils.replace_declaration_with_noop( func.body, _original, placeholder ) + end + + # Rename all variables' token-bounded references (shared logic for both cases) + vars.zip( old_names, new_names ).each do |var, old_name, new_name| + var.text = @utils.rename_c_identifier( var.text, old_name, new_name ) + var.name = new_name + func.code_block = @utils.rename_c_identifier( func.code_block, old_name, new_name ) + func.body = @utils.rename_c_identifier( func.body, old_name, new_name ) + end + + # Restore original declaration text in the single placeholder comment + func.code_block = func.code_block.sub(placeholder) { _original } + func.body = func.body.sub(placeholder) { _original } + end + + _decls_log = _decls.map { |d| "`#{d.original.strip}`" } + @loginator.log_list( + _decls_log, + "Function-scope static variables in #{func.name}() in #{file_type} to be promoted for Partial #{name}::#{module_name}", + Verbosity::OBNOXIOUS + ) unless _decls_log.empty? + + decls += _decls + end + + return decls + end + + # For each function definition in funcs, find the matching function by name in the fully + # preprocessed expansion file and replace the three signature-related fields with their + # macro-expanded equivalents. All other fields — especially code_block — are preserved + # from the directives-only extraction so that line number mapping stays valid. + # + # @param funcs [Array<CFunctionDefinition>] Definitions to update in place. + # @param full_expansion_filepath [String] Path to the assembled full expansion file + # produced by preprocess_partial_{header,source}_expand_macros(). + # @param name [String] Test name, used in log messages. + # @param module_name [String] Partial module name, used in log messages. + # @param file_type [String] "source" or "header", used in log messages. + def update_signatures_from_full_expansion(funcs:, full_expansion_filepath:, name:, module_name:, file_type:) + expanded_module = @c_extractor.from_file( full_expansion_filepath ) + expanded_by_name = expanded_module.function_definitions.each_with_object({}) { |f, h| h[f.name] = f } + + updated = [] + funcs.each do |func| + expanded = expanded_by_name[func.name] + next unless expanded + + func.signature = expanded.signature + func.decorators = expanded.decorators + func.signature_stripped = expanded.signature_stripped + updated << func.name + end + + @loginator.log_list( + updated.map { |n| "`#{n}`" }, + "Updated signatures from full expansion for Partial #{name}::#{module_name} #{file_type}", + Verbosity::DEBUG + ) unless updated.empty? + end + + # Validate that every function name in additions and subtractions exists in c_module. + # Case-sensitive match; if a name matches case-insensitively but not exactly, raise + # a specific case-mismatch exception. + def validate_function_names_exist(c_module, config, name) + known_exact = Set.new( c_module.function_definitions.map(&:name) ) + known_lower = Set.new( c_module.function_definitions.map { |f| f.name.downcase } ) + mod = config.module + + {TEST: config.tests, MOCK: config.mocks}.each do |label, pf| + (pf.additions + pf.subtractions).each do |func_name| + next if known_exact.include?(func_name) + + if known_lower.include?(func_name.downcase) + raise CeedlingException.new( + "#{name}: #{label} Partial configuration for module '#{mod}' references function '#{func_name}' " \ + "which differs only by case from a real function name" + ) + else + raise CeedlingException.new( + "#{name}: #{label} Partial configuration for module '#{mod}' references function '#{func_name}' which does not exist" + ) + end + end + end + end + + # Validate that no function name appears in both additions and subtractions of the + # same tests or mocks entry. + def validate_no_additions_subtractions_overlap(config, name) + mod = config.module + + {TEST: config.tests, MOCK: config.mocks}.each do |label, pf| + overlap = Set.new(pf.additions) & Set.new(pf.subtractions) + overlap.each do |func_name| + raise CeedlingException.new( + "#{name}: #{label} Partial configuration for module '#{mod}' ⏩️ Function '#{func_name}' should not be both added and subtracted" + ) + end + end + end + + # Validate that subtractions match their type's own visibility classification. + # For PUBLIC type: subtractions must be public functions. + # For PRIVATE type: subtractions must be private functions. + # Additions are not validated (same-visibility additions are redundant but harmless). + # ACCUMULATE is skipped (no subtractions allowed, already enforced by extract_configs). + def validate_additions_subtractions_visibility(c_module, config, name) + func_map = c_module.function_definitions.each_with_object({}) { |f, h| h[f.name] = f } + mod = config.module + + {tests: config.tests, mocks: config.mocks}.each do |label, pf| + next unless pf.type == PUBLIC || pf.type == PRIVATE + + subtraction_required_visibility = pf.type + + pf.subtractions.each do |func_name| + func = func_map[func_name] + unless @utils.matches_visibility?(func.decorators, subtraction_required_visibility) + raise CeedlingException.new( + "#{name}: Partial configuration for module '#{mod}': #{label} type is #{pf.type}, " \ + "so subtractions must be #{subtraction_required_visibility} functions, " \ + "but '#{func_name}' is not #{subtraction_required_visibility}" + ) + end + end + end + end + +end \ No newline at end of file diff --git a/lib/ceedling/partials/partializer_runtime.rb b/lib/ceedling/partials/partializer_runtime.rb new file mode 100644 index 000000000..df49ba626 --- /dev/null +++ b/lib/ceedling/partials/partializer_runtime.rb @@ -0,0 +1,29 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +module PartializerRuntime + + # Format a mistaken configuration parameter for error messages + # @param option [Object] The mistaken value to format + # @return [String] Formatted string representation + def self.raise_on_option(option) + str = '' + + case option + when Symbol + str = " :#{option}" + when NilClass, nil + str = ': nil' + when String + str = ": \"#{option}\"" + else + str = ": #{option}" + end + + raise ArgumentError, "Invalid internal option#{str}" + end +end \ No newline at end of file diff --git a/lib/ceedling/partials/partializer_utils.rb b/lib/ceedling/partials/partializer_utils.rb new file mode 100644 index 000000000..aeaa6d109 --- /dev/null +++ b/lib/ceedling/partials/partializer_utils.rb @@ -0,0 +1,222 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/partials/partials' +require 'ceedling/partials/partializer_runtime' +require 'ceedling/c_extractor/c_extractor_constants' + +class PartializerUtils + + include Partials + + constructor :preprocessinator_code_finder, :loginator + + def setup() + # Aliases + @code_finder = @preprocessinator_code_finder + end + + # Check if function decorators match the desired visibility + def matches_visibility?(decorators, visibility) + case visibility + when PUBLIC + return !is_function_private?(decorators) + when PRIVATE + return is_function_private?(decorators) + else + PartializerRuntime.raise_on_option(visibility) + end + end + + # Transform function to appropriate output format `FunctionDefinition` or `FunctionDeclaration` + def transform_function(func, signature, output_type) + case output_type + when :impl + # Strip decorators from the front of code_block and count how many newlines were removed. + # Adjust line_num upward so that emitted #line directives remain accurate. + new_code_block, stripped_newlines = extract_code_block(func.code_block, func.decorators) + Partials.manufacture_function_definition( + name: func.name, + signature: signature, + source_filepath: func.source_filepath, + line_num: adjust_line_num(func.line_num, stripped_newlines), + code_block: new_code_block + ) + when :interface + Partials.manufacture_function_declaration( + name: func.name, + signature: signature + ) + else + PartializerRuntime.raise_on_option(output_type) + end + end + + # Replace a C variable declaration in a text block with a no-op expression. + # + # Substitutes the first occurrence of `original_decl` in `text` with: + # (void)0; /* <placeholder> */ + # The caller supplies a unique `placeholder` token for the comment slot. This + # allows subsequent rename operations (which use simple token-bounded substitution + # with no comment awareness) to run without touching the comment. After all renames + # are complete, the caller restores `original_decl` by replacing the placeholder. + # + # Block form of `sub` is used so that `\` and `&` in the declaration are not + # interpreted as regex replacement backreferences. + # + # @param text [String] Code text to modify (e.g., a function code_block or body) + # @param original_decl [String] Declaration text to replace (should be stripped of surrounding whitespace) + # @param placeholder [String] Opaque token to embed in the comment (caller replaces it afterwards) + # @return [String] Modified text with declaration replaced by a no-op expression + def replace_declaration_with_noop(text, original_decl, placeholder) + replace_compound_declaration_with_noops(text, original_decl, placeholder, 1) + end + + # Replace a C compound variable declaration with N no-op expressions and a single comment. + # + # Generates `count` `(void)0;` expressions where the final one carries a comment + # containing `placeholder`. The result replaces the first occurrence of `original_decl` + # in `text`. The caller supplies `count` = number of variables in the compound statement + # and a single unique `placeholder` token. After all variable renames are complete, the + # caller restores `original_decl` by replacing the placeholder with the original text. + # + # Block form of `sub` is used so that `\` and `&` in the declaration are not interpreted + # as regex replacement backreferences. + # + # @param text [String] Code text to modify (e.g., a function code_block or body) + # @param original_decl [String] Declaration text to replace (should be stripped of surrounding whitespace) + # @param placeholder [String] Opaque token to embed in the single trailing comment + # @param count [Integer] Number of no-op expressions to insert (one per variable) + # @return [String] Modified text + def replace_compound_declaration_with_noops(text, original_decl, placeholder, count) + noops = "(void)0; " * (count - 1) + comment = "(void)0; /* `#{placeholder}` replaced with no-op plus variable renamed & promoted to module-scope */" + text.sub(original_decl) { noops + comment } + end + + # Rename a C identifier throughout a text block with token-bounded substitution. + # + # Replaces all occurrences of `old_name` that are bounded by C identifier boundaries + # with `new_name`. Token boundaries use Ruby `\b` (word boundary between + # `\w` = `[a-zA-Z0-9_]` and `\W`), which exactly matches C identifier boundaries. + # For example, renaming `count`: + # - Matches: `count = 0`, `(count)`, `count==5`, `*count`, `count[0]` + # - No match: `count_down`, `up_count`, `recount` + # + # Note: This method has no comment-awareness. Callers that need comment content + # preserved verbatim (e.g., no-op placeholders) should use `replace_declaration_with_noop` + # with an opaque placeholder and restore the original text after renaming. + # + # @param text [String] Code text to process + # @param old_name [String] Identifier to replace + # @param new_name [String] Replacement identifier + # @return [String] Modified text with all token-bounded occurrences renamed + def rename_c_identifier(text, old_name, new_name) + text.gsub(/\b#{Regexp.escape(old_name)}\b/, new_name) + end + + # Stamp the originating source filepath onto each function in a collection. + # + # Mutates each element of `funcs` in place by assigning `filepath` to its + # `source_filepath` field. Called before any line-number search so the field + # is always populated regardless of whether a line number is ultimately found. + # + # @param funcs [Array<CFunctionDefinition>] Functions to annotate + # @param filepath [String] Path to the originating C source file + def stamp_source_filepaths(funcs, filepath) + funcs.each { |func| func.source_filepath = filepath } + end + + # Locate a function's line number by searching the original C source file. + # + # Used when the global fallback mode is active — i.e., preprocessed output is + # unavailable for all functions in the current context. Delegates directly to + # `code_finder.find_in_c_file`. + # + # @param code_block [String] Function definition text to search for + # @param filepath [String] Path to the C source file to search + # @return [Integer, nil] 1-indexed source line number, or nil if not found + def locate_function_in_source(code_block:, filepath:) + @code_finder.find_in_c_file(filepath, code_block) + end + + # Locate a function's line number using preprocessed output with C source fallback. + # + # Two-strategy lookup for a single function: + # 1. Try the GCC-preprocessed directives-only file — exact match preserving line markers. + # 2. If that yields nil, fall back to the original C source file. + # + # Returns line number or nil if neither approach succeeds. + # + # @param code_block [String] Function definition text to search for + # @param filepath [String] Path to the original C source file (fallback target) + # @param preprocessed_filepath [String] Path to the preprocessed directives-only file + # @return [Integer, nil] 1-indexed source line number, or nil if not found + def locate_function_via_preprocessed(code_block:, filepath:, preprocessed_filepath:) + line_num = @code_finder.find_in_preprpocessed_file(preprocessed_filepath, code_block) + return line_num unless line_num.nil? + + msg = "Using fallback C function location search for #{filepath}" + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + return @code_finder.find_in_c_file(filepath, code_block) + end + + # Format per-function line-number results as a list of human-readable strings. + # + # Produces one entry per function in the form `"name(): <line_num>"`. + # Functions with no resolved line number are rendered with `'N/A'`. + # The returned array is passed directly to `@loginator.log_list` for debug output. + # + # @param funcs [Array<CFunctionDefinition>] Functions with `name` and `line_num` fields + # @return [Array<String>] + def format_line_number_list(funcs) + funcs.map do |func| + "#{func.name}(): #{func.line_num.nil? ? 'N/A' : func.line_num.to_s()}" + end + end + + private + + # Does any decorator in a list matche any private keyword (case-insensitive) + def is_function_private?(decorators) + return decorators.any? do |decorator| + CExtractorConstants::PRIVATE_KEYWORDS.any? { |keyword| decorator.downcase == keyword.downcase } + end + end + + # Strip decorator keywords from the front of code_block and count leading newlines removed. + # + # Removes the first occurrence of each decorator string, then strips any leading whitespace + # (including newlines). Returns the trimmed code block and a count of the newlines removed + # from the leading portion — used by the caller to adjust line_num so that emitted #line + # directives in generated source remain accurate. + # + # @param code_block [String] Raw function source text (decorators + signature + body) + # @param decorators [Array<String>] Decorator keywords from CFunctionDefinition#decorators + # @return [Array(String, Integer)] [trimmed_code_block, leading_newline_count] + def extract_code_block(code_block, decorators) + stripped = code_block.dup + decorators.each { |d| stripped.sub!(d, '') } + leading = stripped[/\A\s*/] + stripped.lstrip! + return stripped, leading.count("\n") + end + + # Offset a source line number by the count of newlines stripped from the front of a code block. + # + # When decorator keywords occupy one or more lines before the function's return type, those + # lines are removed from the emitted code_block. The #line directive must be bumped forward + # by the same count so it still points at the return type in the original source file. + # + # Returns nil unchanged when line_num was not resolved. + def adjust_line_num(line_num, stripped_newlines) + return line_num if line_num.nil? + line_num + stripped_newlines + end + +end \ No newline at end of file diff --git a/lib/ceedling/partials/partials.rb b/lib/ceedling/partials/partials.rb new file mode 100644 index 000000000..137cbd78b --- /dev/null +++ b/lib/ceedling/partials/partials.rb @@ -0,0 +1,83 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + + +module Partials + # Constants + PUBLIC = :public unless const_defined?(:PUBLIC) + PRIVATE = :private unless const_defined?(:PRIVATE) + ACCUMULATE = :accumulate unless const_defined?(:ACCUMULATE) + DEDUCT = :deduct unless const_defined?(:DEDUCT) + + TEST_PUBLIC = :test_public unless const_defined?(:TEST_PUBLIC) + TEST_PRIVATE = :test_private unless const_defined?(:TEST_PRIVATE) + MOCK_PUBLIC = :mock_public unless const_defined?(:MOCK_PUBLIC) + MOCK_PRIVATE = :mock_private unless const_defined?(:MOCK_PRIVATE) + + # Data class representing a source or header file to be partialized + ConfigFileInfo = Struct.new(:filepath, :directives_only_filepath, :full_expansion_filepath, :includes, keyword_init: true) do + def initialize(filepath: nil, directives_only_filepath: nil, full_expansion_filepath: nil, includes: []) + super + end + end + + # Data class representing a C partial to be generated + Config = Struct.new(:module, :types, :header, :source, keyword_init: true) do + def initialize(module:, types: [], header: ConfigFileInfo.new, source: ConfigFileInfo.new) + super + end + end + + # Data class representing a C function signature + FunctionDeclaration = Struct.new( + :name, # Function name (e.g., "foo") + :return_type, # Return type (e.g., "int") + :signature, # FunctionDefinition signature (e.g., "int foo(void)") + :source_filepath, # Path to source file + :line_num, # Line number in source file + keyword_init: true + ) do + # Constructor to set unassigned fields to nil for convenience + def initialize(name: nil, signature: nil, source_filepath: nil, line_num: nil) + super + end + end + + # Data class representing a C function with intentionally duplicated fields + FunctionDefinition = Struct.new( + :name, # Function name (e.g., "foo") + :signature, # FunctionDefinition signature (e.g., "int foo(void)") + :code_block, # Complete function text (signature + body) + :source_filepath, # Path to source file + :line_num, # Line number in source file + keyword_init: true + ) do + # Constructor to set unassigned fields to nil for convenience + def initialize(name: nil, signature: nil, code_block: nil, source_filepath: nil, line_num: nil) + super + end + end + + def self.manufacture_function_declaration(line_num: nil, source_filepath: nil, name:, signature:) + return FunctionDeclaration.new( + name: name, + signature: signature, + source_filepath: source_filepath, + line_num: line_num + ) + end + + def self.manufacture_function_definition(line_num: nil, source_filepath: nil, name:, signature:, code_block:) + return FunctionDefinition.new( + name: name, + signature: signature, + code_block: code_block, + source_filepath: source_filepath, + line_num: line_num + ) + end +end \ No newline at end of file diff --git a/lib/ceedling/preprocess/README.md b/lib/ceedling/preprocess/README.md new file mode 100644 index 000000000..36dc2c5a2 --- /dev/null +++ b/lib/ceedling/preprocess/README.md @@ -0,0 +1,184 @@ +# Preprocessing in Ceedling + +## Summary + +Ceedling’s preprocessing system provides the basis for C code extraction and transformation capabilities for test builds. It operates in two primary modes utilitizing GCC’s preprpocessor output: + +1. **Includes Extraction** — Extracts and categorizes `#include` directives (user vs. system headers). +2. **Code Expansion** — Fully expands macros and preprocessor directives to generate simplified C files (both test and source files). Reconstructing C code from this expansion is dependent on (1). + +Ceedling’s preprocessing system runs GCC’s preprocessor in multiple modes to extract includes and expand C code. It employs cacheing and conditional strategies to minimize preprocessing tool execution across test runs. + +--- + +## Includes Extraction + +### Overview + +Includes extraction identifies all `#include` directives in a C file and categorizes them as either user includes (`#include "header.h"`) or system includes (`#include <header.h>`). This process can operate independently of code expansion for use in Ceedling build steps. The same process is relied upon to provide the include directives needed for reconstructing expanded C files. + +### Three-Way Intersection Technique + +Ceedling uses a three-way intersection approach to accurately extract and categorize includes: + +#### 1. Bare Includes Extraction + +The preprocessor runs in **dependencies mode** (`-MM -MG -MP`) with: +- All project symbols defined. +- **Only** the Ceedling vendor path in search paths (no project paths). This ensures no header files are opened apart from Ceedling’s internal _partials.h_. + +This configuration causes the preprocessor to: +- Conditionally evaluate all `#ifdef`, `#ifndef`, and `#if defined()` directives. +- Assume any unresolved includes will be generated (via `-MG` flag). This encompasses mocks but also avoids any include guard complications since no headers are actually opened. +- Extract all includes that would be processed given the current symbol definitions. + +**Result:** A complete list of all includes that would be processed but without distinguishing user vs. system includes. + +#### 2. User Includes Extraction + +The preprocessor runs in **directives-only mode** (`-E -dD -fdirectives-only`) with full symbols and search paths, which: +- Outputs only preprocessor directives and line markers. +- Preserves the original `#include` statements. +- Generates line markers showing file entry/exit points. + +Ceedling parses the line markers to identify: +- All user includes via tracing which headers were entered (flag `1` in line markers). +- Which files are user headers (_absence_ of flag `3` in line markers). + +**Result:** A list of all user includes associated with the processed file. Note that because of nesting includes and include guards this list cannot be used to determine the top-level (i.e. depth 0) includes in the way bare includes extraction can. + +**Note:** The directives-only mode requires that all files referenced in `#include` directives exist in search paths. Ceedling addresses this need by generating blank “stand-in” files for mocks and partials to allow the preprocessor to succeed. These files are replaced by the actual generated content in later build steps. + +#### 3. System Includes Extraction + +Using the same directives-only preprocessor output as used for user includes, Ceedling identifies: +- Includes marked with the system header flag (`3`). +- Includes limited to a practical depth (typically 5 levels) to avoid excessive noise from deeply nested internal system includes. + +**Result:** A list of system includes associated with the processed file. Note that because of nesting includes and include guards this list cannot be used to determine the top-level (i.e. depth 0) includes in the way bare includes extraction can. + +#### 4. Intersection and Reconciliation + +The three lists are reconciled using `Includes.reconcile()`: +- **Bare includes** provide the authoritative list of the top-level (i.e. depth 0) includes but with no distinction of user and system includes. +- **User includes** from line markers distinguishes user headers. +- **System includes** from line markers distinguishes system headers. +- Any include appearing in bare includes but not in user/system lists is ignored. + +The final list is sanitized to: +- Remove self-references (a file referencing itself). +- Remove any includes that have been mocked (e.g., `mock_header.h` supersedes `header.h`). +- Sort such that system includes are first in the resulting list (a C best practice). + +### Benefits of This Approach + +- **Conditional accuracy:** Respects `#ifdef` and other conditional compilation directives. +- **No include guard issues:** Never opens actual header files during bare includes extraction, ensuring a list of top-level (i.e. depth 0) include directives. +- **Handles generated files:** Assumes missing files will be generated (mocks, etc.). +- **Proper categorization:** Distinguishes user vs. system includes for correct build ordering. + +--- + +## Code Expansion + +### Overview + +Code expansion transforms C source and header files by fully expanding all preprocessor directives, macros, and conditional compilation statements. This produces simplified files suitable for extracting test case names, C function definitions (for Partials), and more as needed by Ceedling’s advanced features. + +Code expansion via the preprocessor is “too good.” It expands all include directives, macros, etc. These details are needed by various build steps and text extraction. As such, after code expansion, Ceedling reconstructs the expanded code file to inject include directives and certain macros. + +### Full Preprocessing Mode + +The preprocessor runs in **full expansion mode** (`-E`) with: +- All project symbols defined. +- Complete search paths (project, vendor, system). +- All header files opened and processed. + +This generates output where: +- All macros are expanded to their final values. +- All `#ifdef`/`#ifndef` blocks are resolved. +- All `#include` directives are replaced with file contents. +- Line markers indicate the source of each line. + +### File Reconstruction + +Expanded files are reconstructed to maintain a usable structure: + +#### 1. Header Reconstruction + +For each expanded header file: +1. Extract the original includes list (using the includes extraction process discussed in preceding sections). +2. Create a new file with: + - Original `#include` directives at the top (user and system headers). + - Fully expanded macros, function declarations, and function definitions. + +#### 2. Source File Reconstruction + +For each expanded source file: +1. Extract the original includes list. +2. Create a new file with: + - Original `#include` directives at the top. + - Fully expanded code with all macros resolved. + - All conditional compilation resolved. + +### Directives-Only Output Usage + +The directives-only preprocessor output serves multiple purposes in reconstruction: + +1. **Include:** Used by includes extraction to inject includes into reconstructed files. +2. **Macro Preservation (Optional):** Can extract `#define` directives for inclusion in reconstructed C files. Key “marker” macros like `TEST_SOURCE_FILE()` must be preserved for text scanning steps that provide the details of the marker needed in later build steps. + +### Dependency on Includes Extraction + +Code expansion **requires** includes extraction because: +- Reconstructed files need original `#include` directives at the top, but these are expanded inline during preprocessing. +- Mock generation requires knowing which mocks a test author referenced in a test file. + +Without accurate includes extraction, reconstructed files would lack proper header dependencies and fail to compile or provide necessary build details to later build steps. + +--- + +## Efficiencies and Caching + +### Shared Directives-Only Output + +**Problem:** Multiple preprocessing steps need the same directives-only preprocessor output. + +**Solution:** Generate the directives-only output once and pass its filepath to all consumers: + +**Benefits:** +- Reduces preprocessor invocations from N to 1 per file. +- Eliminates redundant file I/O. +- Ensures consistency across extraction steps. + +### Shared Directives-Only Output + +**Problem:** Preprocessing is expensive and often repeated unnecessarily. + +**Solution:** Cache extracted includes lists as YAML files and rely on file timestamps to determine if cached includes are up to date and can be used in place of running the preprocessor multiple times. + +**Benefits:** +- Skips expensive preprocessing on unchanged files. +- Preserves full includes information across runs. + +--- + +## Fallback + +If executing the preprocessor fails for any reason — a mode not supported by the toolchain available in the environment or some oddball quirk of symbols and paths — automatic fallback options are executed. + +In fallback modes, in place of relying on the preprocessor, Ceedling relies on simple text scanning of the original file. Of course, this cannot be resilient to conditional compilation, etc. that preprocessing handles. But, this can often be good enough or bring a test build to a sufficient point of completion to allow a test author to more easily determine the failure scenario at hand. + +--- + +## Implementation Details + +**Key Classes** +- `Preprocessinator` - Main preprocessing orchestrator. +- `PreprocessinatorIncludesHandler` - Manages includes extraction workflows. +- `PreprocessinatorLineMarkerIncludesExtractor` - Parses line markers from directives-only output. +- `PreprocessinatorBareIncludesExtractor` - Parses make-style dependency output. +- `PreprocessinatorReconstructor` — Recreates C code files from fully expanded output, include lists, and optionally preserved directives. +- `Include` objects and derivatives — Encapsulates include directive details. +- `Includes` - Various utilities for processing includes lists. +- `IncludeFactory` - Manufactures `UserInclude` and `SystemInclude` objects. diff --git a/lib/ceedling/preprocess/c_comment_scanner.rb b/lib/ceedling/preprocess/c_comment_scanner.rb new file mode 100644 index 000000000..0f31c642d --- /dev/null +++ b/lib/ceedling/preprocess/c_comment_scanner.rb @@ -0,0 +1,175 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'stringio' +require 'strscan' + +class CCommentScanner + + PRESERVE_LINES = :preserve_lines unless const_defined?( :PRESERVE_LINES ) + COMPACT = :compact unless const_defined?( :COMPACT ) + + # Describes a single C comment found within source or preprocessor text. + # + # position - Byte offset of the first comment character (the leading /) + # length - Byte count of the complete comment text + # lines_removed - Number of \n characters within the comment text; equals the + # number of source lines eliminated when the comment is replaced + # by a single space (e.g. a // comment with no continuation → 0; + # a /* ... */ comment spanning 3 physical lines → 2) + CommentInfo = Struct.new(:position, :length, :lines_removed, keyword_init: true) do + def initialize(position: nil, length: nil, lines_removed: 0) + super + end + end + + + # Given the Array<CommentInfo> from scan, return a copy of + # content with every comment replaced according to mode: + # + # :compact (default) — every comment replaced by a single space character. + # :preserve_lines — single-line comments (lines_removed == 0) replaced by a + # single space; multi-line comments replaced by + # lines_removed newlines so the total line count is unchanged. + # + # Array<CommentInfo> is processed in descending position order (i.e. backwards) + # to ensure each comment removal does not disturb earlier comments in the content + # with respect to `CommentInfo` details. + def remove(content, comment_infos, mode: COMPACT) + result = content.dup + # Process in descending position order so earlier byte positions remain valid + comment_infos.sort_by { |info| -info.position }.each do |info| + replacement = ' ' + if (mode == PRESERVE_LINES && info.lines_removed > 0) + replacement = "\n" * info.lines_removed + end + + result[info.position, info.length] = replacement + end + return result + end + + # Scan an IO stream (File or StringIO) and return all C comments found. + # Returns an Array<CommentInfo> in ascending position order. + # + # Uses StringScanner for a single-pass scan. Recognises string/character + # literals and prevents comment detection inside them. + # + # Records: + # - // single-line comments (with optional backslash continuation lines) + # - /* ... */ block comments (including multiline; unterminated at EOF) + def scan(io:) + content = io.read + return [] if content.nil? || content.empty? + + scanner = StringScanner.new(content) + comments = [] + + until scanner.eos? + ch = scanner.peek(1) + + case ch + when '"', "'" + # Skip string or character literal -- any // or /* */ inside is not a comment + skip_string_literal(scanner, ch) + + when '/' + two = scanner.peek(2) + + if two == '//' + start = scanner.pos + scan_line_comment(scanner) + len = scanner.pos - start + comments << CommentInfo.new( + position: start, + length: len, + lines_removed: content[start, len].count("\n") + ) + + elsif two == '/*' + start = scanner.pos + scan_block_comment(scanner) + len = scanner.pos - start + comments << CommentInfo.new( + position: start, + length: len, + lines_removed: content[start, len].count("\n") + ) + + else + scanner.getch + end + + else + scanner.getch + end + end + + return comments + end + + + private + + # Advance the scanner past a string or character literal. + # Called when the scanner is positioned at the opening quote character. + # Handles escape sequences so an escaped quote does not close the literal. + def skip_string_literal(scanner, quote) + scanner.getch # consume opening quote + until scanner.eos? + ch = scanner.getch + if ch == '\\' + scanner.getch unless scanner.eos? # skip one escaped character + elsif ch == quote + break + end + end + end + + + # Advance the scanner past a // line comment. + # + # A backslash followed by optional spaces or tabs and then a newline is a line + # continuation -- the comment extends onto the next physical line. A bare + # newline (without a preceding backslash) ends the comment; that newline is left + # unconsumed so that the caller's line-number tracking remains correct. + # + # // inside a /* */ block is not a line-comment start -- this method is only + # called when we are in :normal state. + def scan_line_comment(scanner) + scanner.skip(/\/\//) # consume // + + loop do + # Skip bulk of non-special characters efficiently + scanner.skip(/[^\\\n]+/) + break if scanner.eos? + + if scanner.scan(/\\[ \t]*\n/) + # Backslash + optional whitespace + newline: line continuation + next + elsif scanner.check(/\n/) + # Bare newline: end of comment; leave it unconsumed + break + else + # Lone backslash (not before whitespace+newline) or any other character + scanner.getch + end + end + end + + + # Advance the scanner past a /* ... */ block comment. + # Handles unterminated comments by advancing to the end of the stream. + # // sequences inside a block comment are not line-comment starts. + def scan_block_comment(scanner) + scanner.skip(/\/\*/) # consume /* + unless scanner.skip_until(%r{\*/}) + scanner.terminate # unterminated block comment: consume to EOF + end + end + +end diff --git a/lib/ceedling/preprocess/preprocessinator.rb b/lib/ceedling/preprocess/preprocessinator.rb new file mode 100644 index 000000000..84890acf7 --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator.rb @@ -0,0 +1,596 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/includes/includes' +require 'ceedling/exceptions' + +class Preprocessinator + + constructor( + :preprocessinator_includes_handler, + :preprocessinator_comment_stripper, + :preprocessinator_file_assembler, + :preprocessinator_reconstructor, + :file_path_utils, + :tool_executor, + :file_wrapper, + :plugin_manager, + :configurator, + :loginator, + :reportinator + ) + + def setup + # Aliases + @includes_handler = @preprocessinator_includes_handler + @file_assembler = @preprocessinator_file_assembler + @comment_stripper = @preprocessinator_comment_stripper + @reconstructor = @preprocessinator_reconstructor + + # Thread-safe per-file locking for YAML cache operations + # Key: includes list filepath (String), Value: Mutex + @file_locks = {} + @file_locks_mutex = Mutex.new + + @directives_only_available = true + end + + def directives_only_available? + return @directives_only_available + end + + # Extract bare includes (does not differentiate user/system) from a file. + # Called externally. + def preprocess_bare_includes(filepath:, test:, search_paths:, flags:, defines:) + # Pass-through + return @includes_handler.extract_bare_includes( + filepath: filepath, + test: test, + flags: flags, + search_paths: search_paths, + defines: defines + ) + end + + def generate_directives_only_output(filepath:, test:, flags:, include_paths:, vendor_paths:, defines:) + raw_preprocessed_filepath = + @file_path_utils.form_preprocessed_file_raw_directives_only_filepath( filepath, test ) + + compacted_preprocessed_fileapth = + @file_path_utils.form_preprocessed_file_compacted_directives_only_filepath( filepath, test ) + + # Run GCC with directives-only preprocessor expansion + command = @tool_executor.build_command_line( + @configurator.tools_test_file_directives_only_preprocessor, + # Additional arguments + flags, + # Argument replacement + filepath, + raw_preprocessed_filepath, + defines, + (include_paths + vendor_paths) + ) + command[:options][:boom] = false + results = @tool_executor.exec( command ) + + # Handle warning from preprocessor saying that clang can't handle directives-only (common with older clang) + if results[:output].match /warning[^\n]+-fdirectives-only/ + msg = "Your C preprocessor lacks support for directives-only output that Ceedling relies upon." + @directives_only_available = false + raise CeedlingException.new( msg ) + end + + # Preprocessor did not succeed + if results[:exit_code] != 0 + msg = "Failed to generate directive-only preprocessor output (fallback methods will be used) for #{filepath}" + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::ERROR ) + return nil + end + + # Remove comments from directives-only file in filesystem. + # Directives-only output keeps our most essential details (include directives & macros) and handles #ifdefs, etc. + # However, it does not strip out comments. + @comment_stripper.strip_file( raw_preprocessed_filepath ) + + # Collect all code from between line markers into a clean file + @reconstructor.compact_file_from_expansion( + input_filepath: raw_preprocessed_filepath, + source_filepath: filepath, + output_filepath: compacted_preprocessed_fileapth + ) + + return raw_preprocessed_filepath + end + + # Extract user includes from a file using directives-only output (or text-only fallback). + # Called externally and internally by `preprocess_common`. + def preprocess_user_includes(name:, filepath:, directives_only_filepath:, fallback: false) + includes = [] + + if !fallback + includes = @includes_handler.extract_user_includes_preprocess( + name: name, + filepath: filepath, + preprocessed_filepath: directives_only_filepath + ) + else + includes = @includes_handler.extract_user_includes_from_text( + name: name, + filepath: filepath + ) + end + + header = "Extracted user #includes from #{filepath}" + @loginator.log_list( includes, header, Verbosity::DEBUG ) + + return includes + end + + # Extract system includes from a file using directives-only output (or text-only fallback). + # Called externally and internally by `preprocess_common`. + def preprocess_system_includes(name:, filepath:, directives_only_filepath:, fallback: false) + includes = [] + + if !fallback + includes = @includes_handler.extract_system_includes_preprocess( + name: name, + filepath: filepath, + preprocessed_filepath: directives_only_filepath + ) + else + includes = @includes_handler.extract_system_includes_from_text( + name: name, + filepath: filepath + ) + end + + header = "Extracted system #includes from #{filepath}" + @loginator.log_list( includes, header, Verbosity::DEBUG ) + + return includes + end + + def store_includes_list(test:, filepath:, includes:) + _filepath = @file_path_utils.form_preprocessed_includes_list_filepath( filepath, test ) + + # Get or create a mutex for this specific cache file + file_lock = @file_locks_mutex.synchronize do + @file_locks[_filepath] ||= Mutex.new + end + + file_lock.synchronize do + @includes_handler.write_includes_list( _filepath, includes ) + end + end + + def cached_includes_list?(test:, filepath:) + _filepath = @file_path_utils.form_preprocessed_includes_list_filepath( filepath, test ) + + # Get or create a mutex for this specific cache file + file_lock = @file_locks_mutex.synchronize do + @file_locks[_filepath] ||= Mutex.new + end + + file_lock.synchronize do + # If existing YAML file of includes is newer than the file we're processing, skip preprocessing + return @file_wrapper.newer?( _filepath, filepath ) + end + end + + def load_includes_list(test:, filepath:) + includes = [] + + _filepath = @file_path_utils.form_preprocessed_includes_list_filepath( filepath, test ) + + # Get or create a mutex for this specific cache file + file_lock = @file_locks_mutex.synchronize do + @file_locks[_filepath] ||= Mutex.new + end + + file_lock.synchronize do + # If existing YAML file of includes is newer than the file we're processing, skip preprocessing + if @file_wrapper.newer?( _filepath, filepath ) + msg = @reportinator.generate_module_progress( + operation: "Loading #include statement listing file for", + module_name: test, + filename: File.basename(filepath) + ) + @loginator.log( msg, Verbosity::OBNOXIOUS ) + + includes = @includes_handler.load_includes_list( _filepath ) + + header = "Loaded existing #include list from #{_filepath}" + @loginator.log_list( includes, header, Verbosity::DEBUG ) + end + end + + return !includes.empty?, includes + end + + def preprocess_mockable_header_file( + test:, + filepath:, + directives_only_filepath:, + fallback:, + flags:, + include_paths:, + vendor_paths:, + defines:, + extras: false + ) + msg = @reportinator.generate_module_progress( + operation: 'Preprocessing header file for follow-on mock handling', + module_name: test, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_filepath( filepath, test ) + + plugin_arg_hash = { + header_file: filepath, + preprocessed_header_file: preprocessed_filepath, + test: test, + flags: flags, + include_paths: include_paths, + defines: defines + } + + # Trigger pre_mock_preprocessing plugin hook + @plugin_manager.pre_mock_preprocess( plugin_arg_hash ) + + arg_hash = { + test: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback, + flags: flags, + include_paths: include_paths, + vendor_paths: vendor_paths, + defines: defines + } + + # Extract includes & log progress and details + includes = preprocess_file_includes_common( **arg_hash ) + + header = "Discovered #includes for mockable header from #{filepath}" + @loginator.log_list( includes, header, Verbosity::OBNOXIOUS ) + + arg_hash = { + test: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback, + flags: flags, + include_paths: include_paths, + defines: defines, + extras: extras + } + + # `contents` & `extras` are arrays of text strings to be assembled in generating a new header file. + # `extras` are macro definitions, pragmas, etc. needed for the special case of mocking `inline` function declarations. + # `extras` are empty for any cases other than mocking `inline` function declarations + # (We don't want to increase our chances of a badly generated file--extracting extras could fail in complex files.) + contents, extras = @file_assembler.collect_mockable_header_file_contents( **arg_hash ) + + arg_hash = { + filename: File.basename( filepath ), + preprocessed_filepath: preprocessed_filepath, + contents: contents, + extras: extras, + includes: includes + } + + # Create a reconstituted header file from preprocessing expansion and preserving any extras + @file_assembler.assemble_preprocessed_header_file( **arg_hash ) + + # Trigger post_mock_preprocessing plugin hook + @plugin_manager.post_mock_preprocess( plugin_arg_hash ) + + return preprocessed_filepath + end + + def preprocess_partial_header_file_preserve_macros( + test:, + filepath:, + directives_only_filepath:, + fallback:, + flags:, + include_paths:, + vendor_paths:, + defines: + ) + msg = @reportinator.generate_module_progress( + operation: 'Preprocessing header file for follow-on Partials handling', + module_name: test, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_filepath( filepath, test ) + + arg_hash = { + test: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback, + flags: flags, + include_paths: include_paths, + vendor_paths: vendor_paths, + defines: defines + } + + # Extract includes & log progress and details + includes = preprocess_file_includes_common( **arg_hash ) + + header = "Discovered #includes for Partial header from #{filepath}" + @loginator.log_list( includes, header, Verbosity::OBNOXIOUS ) + + contents = @file_assembler.collect_file_contents_from_directives_only_preprocessing( source_filepath: filepath, test: test ) + + arg_hash = { + filename: File.basename( filepath ), + preprocessed_filepath: preprocessed_filepath, + contents: contents, + extras: [], + includes: includes + } + + # Create a reconstituted header file + @file_assembler.assemble_preprocessed_header_file( **arg_hash ) + + return preprocessed_filepath, includes + end + + def preprocess_partial_source_file_preserve_macros( + test:, + filepath:, + directives_only_filepath:, + fallback:, + flags:, + include_paths:, + vendor_paths:, + defines: + ) + msg = @reportinator.generate_module_progress( + operation: 'Preprocessing source file for follow-on Partials handling', + module_name: test, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_filepath( filepath, test ) + + arg_hash = { + test: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback, + flags: flags, + include_paths: include_paths, + vendor_paths: vendor_paths, + defines: defines + } + + # Extract includes & log progress and info + includes = preprocess_file_includes_common( **arg_hash ) + + header = "Discovered #includes for Partial source from #{filepath}" + @loginator.log_list( includes, header, Verbosity::OBNOXIOUS ) + + contents = @file_assembler.collect_file_contents_from_directives_only_preprocessing( source_filepath: filepath, test: test ) + + arg_hash = { + filename: File.basename( filepath ), + preprocessed_filepath: preprocessed_filepath, + contents: contents, + extras: [], + includes: includes + } + + # Create a reconstituted source file + @file_assembler.assemble_preprocessed_code_file( **arg_hash ) + + return preprocessed_filepath, includes + end + + def preprocess_test_file( + test:, + filepath:, + directives_only_filepath:, + fallback:, + includes:, + flags:, + include_paths:, + vendor_paths:, + defines: + ) + msg = @reportinator.generate_module_progress( + operation: 'Preprocessing test file', + module_name: test, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_filepath( filepath, test ) + + plugin_arg_hash = { + test_file: filepath, + preprocessed_test_file: preprocessed_filepath, + test: test, + flags: flags, + include_paths: include_paths, + defines: defines + } + + # Trigger pre_test_preprocess plugin hook + @plugin_manager.pre_test_preprocess( plugin_arg_hash ) + + # NOTE: No call to `preprocess_file_includes_common()` because we already have includes + + arg_hash = { + test: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback, + flags: flags, + include_paths: include_paths, + defines: defines + } + + # `contents` & `extras` are arrays of text strings to be assembled in generating a new test file. + # `extras` are test build directives TEST_SOURCE_FILE() and TEST_INCLUDE_PATH(). + contents, extras = @file_assembler.collect_test_file_contents( **arg_hash ) + + arg_hash = { + filename: File.basename( filepath ), + preprocessed_filepath: preprocessed_filepath, + contents: contents, + extras: extras, + includes: includes + } + + # Create a reconstituted test file from preprocessing expansion and preserving any extras + @file_assembler.assemble_preprocessed_code_file( **arg_hash ) + + # Trigger post_test_preprocess plugin hook + @plugin_manager.post_test_preprocess( plugin_arg_hash ) + + return preprocessed_filepath + end + + def preprocess_partial_header_expand_macros(filepath:, test:, flags:, include_paths:, vendor_paths:, defines:) + _preprocess_partial_expand_macros( + filepath: filepath, + test: test, + flags: flags, + include_paths: include_paths, + vendor_paths: vendor_paths, + defines: defines + ) + end + + def preprocess_partial_source_expand_macros(filepath:, test:, flags:, include_paths:, vendor_paths:, defines:) + _preprocess_partial_expand_macros( + filepath: filepath, + test: test, + flags: flags, + include_paths: include_paths, + vendor_paths: vendor_paths, + defines: defines + ) + end + + ### Private ### + private + + def _preprocess_partial_expand_macros(filepath:, test:, flags:, include_paths:, vendor_paths:, defines:) + msg = @reportinator.generate_module_progress( + operation: 'Full-preprocessing for expanded Partial signature extraction', + module_name: test, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + full_expansion_filepath = @file_path_utils.form_preprocessed_file_full_expansion_filepath( filepath, test ) + + command = @tool_executor.build_command_line( + @configurator.tools_test_file_full_preprocessor, + flags, + filepath, + full_expansion_filepath, + defines, + (include_paths + vendor_paths) + ) + result = @tool_executor.exec( command ) + + if result[:exit_code] != 0 + msg = "Failed to generate full expansion for Partial signature extraction (directives-only signatures will be used) for #{filepath}" + @loginator.log( msg, Verbosity::COMPLAIN ) + return nil + end + + contents = @file_assembler.collect_file_contents_from_full_expansion( source_filepath: filepath, test: test ) + + @file_assembler.assemble_preprocessed_code_file( + filename: File.basename( filepath ), + preprocessed_filepath: full_expansion_filepath, + contents: contents, + extras: [], + includes: [] + ) + + return full_expansion_filepath + end + + def preprocess_file_includes_common( + test:, + filepath:, + directives_only_filepath:, + fallback:, + flags:, + include_paths:, + vendor_paths:, + defines: + ) + msg = @reportinator.generate_module_progress( + operation: "Extracting includes", + module_name: test, + filename: File.basename(filepath) + ) + @loginator.log( msg, Verbosity::OBNOXIOUS ) + + includes = [] + success, includes = load_includes_list( test: test, filepath: filepath ) + + if !success + # Full preprocessing-based #include extraction with saving to YAML file + + # Extract bare includes + bare_includes = @includes_handler.extract_bare_includes( + filepath: filepath, + test: test, + flags: flags, + search_paths: vendor_paths, + defines: defines + ) + + # Extract user includes + user_includes = preprocess_user_includes( + name: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback + ) + + # Extract system includes + system_includes = preprocess_system_includes( + name: test, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback + ) + + # Reconcile includes with overlapping information + includes = Includes.reconcile( + bare: bare_includes, + user: user_includes, + system: system_includes + ) + + # Sanitize the final list and remove any includes that have been mocked + Includes.sanitize!(includes) do |include, all| + all.include?( "#{@configurator.cmock_mock_prefix}#{include.filename}" ) + end + + store_includes_list( filepath: filepath, test: test, includes: includes ) + end + + return includes + end + +end diff --git a/lib/ceedling/preprocess/preprocessinator_bare_includes_extractor.rb b/lib/ceedling/preprocess/preprocessinator_bare_includes_extractor.rb new file mode 100644 index 000000000..79306e713 --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator_bare_includes_extractor.rb @@ -0,0 +1,69 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/includes/includes' + +## +## Bare Includes Preprocessor Make Rule Parsing +## ============================================ +## +## Format: +## - First line is .o file followed by colon and dependencies (on one or more lines). +## - "Phony" make rules follow that conveniently list each #include, one per line. +## +## Notes: +## - A file with no includes will create the first line with self-referential .h file path. +## - Make rule formation assumes any files not found in a search path will be generated. +## - Since we're not using search paths, the preprocessor largely assumes all #include +## files are generated (and include no paths). +## - The exception is #include files that exist in the same directory as the file +## being processed. +## +## Approach: +## 1. Disable exceptions for tool execution as errors are likely. +## - We may still have usable output. +## - We do not want to stop execution on fatal error; instead use a fallback method. +## 2. The only true error is no make rule present--check for this first. +## - A make rule may be present but not depedencies if the file has no #includes. +## 3. Extract includes from "phony" make rules that follow opening rule line. +## - These may be .h or .c files. +## +## Example output follows +## ----------------------------------------------------------------------------------------- +## os.o: ../../src/app/task/os/os.h fstd_types.h FreeRTOS.h queue.h +## fstd_types.h: +## FreeRTOS.h: +## queue.h: +## ../../src/app/task/os/os.h:72:21: error: no include path in which to search for stdbool.h +## 72 | #include <stdbool.h> +## | ^ +## ../../src/app/task/os/os.h:73:20: error: no include path in which to search for stdint.h +## 73 | #include <stdint.h> +## | ^ +## + +# Parse GCC preprocessor make-rule dependencies output to extract user include directives +class PreprocessinatorBareIncludesExtractor + + # Matcher for the first line of the make rule output + MAKE_RULE_MATCHER = /^\S+\.o:\s+.+$/ # <characters>.o: <characters> + + # Matcher for the “phony“ make rule output lines for each #include dependency (.h, .c, etc.) + # Capture file name before the colon + INCLUDE_MATCHER = /^(\S+\.\S+):\s*$/ # <characters>.<extension>: + + def self.extract_includes(make_rules) + # Extract the #include dependencies from the "phony" make rules, one per line + includes = make_rules.scan( INCLUDE_MATCHER ) + includes.flatten! # Regex results can be nested arrays becuase of paren captures + includes.uniq! + + # Convert list of fileapth strings to list of bare Include objects + return includes.map { |_include| Include.new(_include) } + end +end + diff --git a/lib/ceedling/preprocess/preprocessinator_code_finder.rb b/lib/ceedling/preprocess/preprocessinator_code_finder.rb new file mode 100644 index 000000000..5379262a3 --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator_code_finder.rb @@ -0,0 +1,150 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'stringio' + +class PreprocessinatorCodeFinder + + LINE_MARKER_REGEX = /^#\s+(\d+)\s+"[^"]+"[^\n]*\n/ unless const_defined?(:LINE_MARKER_REGEX) + + # Regex-special-character-immune string + WHITESPACE_MARKER = '<!@_ws_!@>' unless const_defined?(:WHITESPACE_MARKER) + + # Open a GCC preprocessor output file and search it for code. + # Returns the 1-indexed source line number of the match, or nil if not found. + # Intended for production use where preprocessor output resides on disk. + def find_in_preprpocessed_file(filepath, code) + File.open( filepath, 'r' ) do |file| + return find_in_preprocessed_content( io: file, search: code ) + end + end + + + # Wrap a GCC preprocessor output string in a StringIO and search it for code. + # Returns the 1-indexed source line number of the match, or nil if not found. + # Intended for test use so that specs require no temporary files. + def find_in_preprpocessed_string(content, code) + buffer = StringIO.new( content ) + return find_in_preprocessed_content( io: buffer, search: code ) + end + + # Open a C source file and search it for code. + # Returns the 1-indexed source line number of the match, or nil if not found. + # Intended for production use where C file resides on disk. + def find_in_c_file(filepath, code) + File.open( filepath, 'r' ) do |file| + return find_in_c_code( io: file, search: code ) + end + end + + + # Wrap a C file content string in a StringIO and search it for code. + # Returns the 1-indexed source line number of the match, or nil if not found. + # Intended for test use so that specs require no temporary files. + def find_in_c_string(content, code) + buffer = StringIO.new( content ) + return find_in_c_code( io: buffer, search: code ) + end + + private + + # Search a GCC preprocessor output IO stream for an exact match of search. + # + # GCC preprocessor output intersperses the expanded source text with line + # markers of the form: + # # <linenum> "<filename>" [flags] + # + # Each marker declares that the source line immediately following it + # corresponds to line linenum in the named file. Subsequent non-marker lines + # increment the source line count by one each. + # + # The method locates `search`` as a substring of the full stream content, then + # walks backwards through the line markers that precede the match to find the + # most recent one. The source line number is computed as: + # last_marker_linenum + (newlines between marker end and match start) + # + # Returns nil when search is not present in the stream or no line marker + # precedes the match (which indicates malformed preprocessor output). + def find_in_preprocessed_content(io:, search:) + content = io.read + return nil if content.nil? || content.empty? + + # Locate the search string within the preprocessor output + match_pos = whitespace_insensitive_search( content, search ) + return nil if match_pos.nil? + + # Examine only the content before the match for GCC line markers. + # Line markers have the form: # <linenum> "<filename>" [optional flags] + # The linenum in each marker refers to the source line immediately following it. + prefix = content[0, match_pos] + + last_marker_num = nil + # Byte position in content after the last marker's newline + last_marker_end = 0 + + prefix.scan( LINE_MARKER_REGEX ) do |captures| + last_marker_num = captures[0].to_i + last_marker_end = $~.end(0) + end + + if last_marker_num.nil? + # No line marker precedes the match -- something is wrong + return nil + end + + # Each newline between the end of the last line marker line and the match start + # advances the source line by one. The marker's linenum is the base. + newlines_after_marker = content[last_marker_end, match_pos - last_marker_end].count("\n") + + # Return line number of found code (mostly for test validation) + return last_marker_num + newlines_after_marker + end + + # Search a C code IO stream for an exact match of search. + # + # The method locates `search`` as a substring of the full stream content, then + # identifies the source line number. Returns nil when search is not present in + # the stream. + def find_in_c_code(io:, search:) + content = io.read + return nil if content.nil? || content.empty? + + # Locate the search string within the preprocessor output + match_pos = whitespace_insensitive_search( content, search ) + return nil if match_pos.nil? + + return (content[0, match_pos].count("\n")) + 1 + end + + private + + def whitespace_insensitive_search(content, code) + # Collapse whitespace to a unique token + _code = code.gsub(/\s+/, WHITESPACE_MARKER) + + # Escape the code block to search for. + # Note: Regexp#escape escapes each piece of whitespace and, so, we must take the preparation step above. + _code = Regexp.escape( _code ) + + # Replace the whitespace token with regex whitespace-insenstive matcher + _code.gsub!(/#{WHITESPACE_MARKER}/, '\s+') + + # Create the regex we'll use + pattern = Regexp.new( _code ) + + # Try to find the code block + matched = pattern.match( content ) + + # Failed to match + return matched if matched.nil? + + # Return position of the match within `content` + return matched.begin( 0 ) + end + + +end diff --git a/lib/ceedling/preprocess/preprocessinator_comment_stripper.rb b/lib/ceedling/preprocess/preprocessinator_comment_stripper.rb new file mode 100644 index 000000000..e73a4ebf3 --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator_comment_stripper.rb @@ -0,0 +1,78 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'stringio' +require 'ceedling/exceptions' + +class PreprocessinatorCommentStripper + + constructor :c_comment_scanner + + + # Strip all C comments from a file. The file is unchanged if no comments + # are found. The routine returns `true` if changes are made, `false` otherwise. + # + # Every single-line comment is replaced by a single space. Every multi-line + # comment is replaced by an equivalent number of newlines. + def strip_file(filepath) + stripped = '' + changed = false + + begin + File.open(filepath) do |buffer| + stripped = strip(buffer) + changed = !stripped.nil? + end + rescue => e + raise CeedlingException.new("Failed to read '#{filepath}' for comment stripping ⏩️ #{e}") + end + + # No change + return changed unless changed + + begin + File.write(filepath, stripped) + rescue => e + raise CeedlingException.new("Failed to rewrite '#{filepath}' after comment stripping ⏩️ #{e}") + end + + return true + end + + # Strip all C comments from a string content and return the cleaned content + # as a String. The string is unchanged if no comments are found. + # + # Every single-line comment is replaced by a single space. Every multi-line + # comment is replaced by an equivalent number of newlines. + def strip_string(content) + buffer = StringIO.new(content) + stripped = strip(buffer) + + return content if stripped.nil? + + return stripped + end + + private + + def strip(buffer) + # Search buffer for comments + comment_infos = @c_comment_scanner.scan(io: buffer) + return nil if comment_infos.empty? + + # Reset buffer + buffer.rewind + + # Remove comments from content of buffer + return @c_comment_scanner.remove( + buffer.read, + comment_infos, + mode: CCommentScanner::PRESERVE_LINES + ) + end + +end diff --git a/lib/ceedling/preprocess/preprocessinator_file_assembler.rb b/lib/ceedling/preprocess/preprocessinator_file_assembler.rb new file mode 100644 index 000000000..d50d8f74e --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator_file_assembler.rb @@ -0,0 +1,255 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'rake' # for ext() method +require 'ceedling/file_wrapper' + +class PreprocessinatorFileAssembler + + constructor( + :preprocessinator_reconstructor, + :configurator, + :tool_executor, + :file_path_utils, + :file_wrapper, + :loginator, + :reportinator + ) + + def collect_mockable_header_file_contents( + test:, + filepath:, + directives_only_filepath:, + fallback:, + flags:, + defines:, + include_paths:, + extras: + ) + contents = [] + + # Our extra file content to be preserved + # Leave these empty if :extras is false + pragmas = [] + macro_defs = [] + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_full_expansion_filepath( filepath, test ) + + # Run GCC with full preprocessor expansion + command = @tool_executor.build_command_line( + @configurator.tools_test_file_full_preprocessor, + # Additional arguments + flags, + # Argument replacement + filepath, + preprocessed_filepath, + defines, + include_paths + ) + @tool_executor.exec( command ) + + @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| + contents = @preprocessinator_reconstructor.extract_file_as_array_from_expansion( file, filepath ) + end + + # Bail out if no extras are required + return contents, [] if !extras + + # Try to find an #include guard in the first 2k of the file text. + # An #include guard is one macro from the original file we don't want to preserve if we can help it. + # We create our own #include guard in the header file we create. + # It's possible preserving the macro from the original file's #include guard could trip something up. + # Of course, it's also possible some header conditional compilation feature is dependent on it. + # ¯\_(ツ)_/¯ + include_guard = @preprocessinator_reconstructor.extract_include_guard( @file_wrapper.read( filepath, 2048 ) ) + + if fallback + msg = @reportinator.generate_module_progress( + operation: "Using fallback method to extract pragmas and macros from", + module_name: test, + filename: File.basename(filepath) + ) + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + @file_wrapper.open( filepath, 'r' ) do |file| + # Get code contents of original source file as a string + # TODO: Modify to process line-at-a-time for memory savings & performance boost + _contents = file.read + + # Extract pragmas and macros from + pragmas = @preprocessinator_reconstructor.extract_pragmas( _contents ) + macro_defs = @preprocessinator_reconstructor.extract_macro_defs( _contents, include_guard ) + end + else + @file_wrapper.open( directives_only_filepath, 'r' ) do |file| + # Get code contents of preprocessed directives-only file as a string + # TODO: Modify to process line-at-a-time for memory savings & performance boost + _contents = @preprocessinator_reconstructor.extract_file_as_string_from_expansion( file, filepath ) + + # Extract pragmas and macros from + pragmas = @preprocessinator_reconstructor.extract_pragmas( _contents ) + macro_defs = @preprocessinator_reconstructor.extract_macro_defs( _contents, include_guard ) + end + end + + return contents, (pragmas + macro_defs) + end + + + def collect_file_contents_from_directives_only_preprocessing(source_filepath:, test:) + contents = [] + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_raw_directives_only_filepath( source_filepath, test ) + + @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| + contents = @preprocessinator_reconstructor.extract_file_as_array_from_expansion( file, source_filepath ) + end + + return contents + end + + + def collect_file_contents_from_full_expansion(source_filepath:, test:) + contents = [] + + full_expansion_filepath = @file_path_utils.form_preprocessed_file_full_expansion_filepath( source_filepath, test ) + + @file_wrapper.open( full_expansion_filepath, 'r' ) do |file| + contents = @preprocessinator_reconstructor.extract_file_as_array_from_expansion( file, source_filepath ) + end + + return contents + end + + + def assemble_preprocessed_header_file(filename:, preprocessed_filepath:, contents:, extras:, includes:) + # Generate #include guard name for header files + guardname = FileWrapper.generate_include_guard( filename ) + + # Write contents of final preprocessed file a line at a time + # ---------------------------------------------------------- + @file_wrapper.open( preprocessed_filepath, 'w' ) do |file| + # Add include guards and extra blank lines to beginning of file contents + file << "#ifndef #{guardname}\n" + file << "#define #{guardname}\n\n" + + # Reinsert #include statements into stripped down file + # Rely on Include object stringification for formatting of incudes + includes.each { |include| file << "#{include}\n" } + + # Blank line + file << "\n" unless includes.empty? + + # Add in any macro defintions or prgamas + extras.each do |ex| + if ex.class == String + file << ex + "\n" + + elsif ex.class == Array + ex.each { |line| file << line + "\n" } + end + + # Blank line + file << "\n" + end + + # Add extracted contents from preprocessed file + contents.each { |line| file << line + "\n" } + + # Add final rear guard with extra blank lines + file << "\n#endif // #{guardname}\n" + end + end + + + def collect_test_file_contents( + test:, + filepath:, + directives_only_filepath:, + fallback:, + flags:, + defines:, + include_paths: + ) + contents = [] + # TEST_SOURCE_FILE() and TEST_INCLUDE_PATH() + test_directives = [] + + preprocessed_filepath = @file_path_utils.form_preprocessed_file_full_expansion_filepath( filepath, test ) + + # Run GCC with full preprocessor expansion + command = @tool_executor.build_command_line( + @configurator.tools_test_file_full_preprocessor, + # Additional arguments + flags, + # Argument replacement + filepath, + preprocessed_filepath, + defines, + include_paths + ) + @tool_executor.exec( command ) + + @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| + contents = @preprocessinator_reconstructor.extract_file_as_array_from_expansion( file, filepath ) + end + + if fallback + msg = @reportinator.generate_module_progress( + operation: "Using fallback method to extract test directive macros from", + module_name: test, + filename: File.basename(filepath) + ) + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + @file_wrapper.open( filepath, 'r' ) do |file| + # Get code contents of original source file as a string + _contents = file.read + + # Extract TEST_SOURCE_FILE() and TEST_INCLUDE_PATH() + test_directives = @preprocessinator_reconstructor.extract_test_directive_macro_calls( _contents ) + end + else + @file_wrapper.open( directives_only_filepath, 'r' ) do |file| + # Get code contents of preprocessed directives-only file as a string + _contents = @preprocessinator_reconstructor.extract_file_as_string_from_expansion( file, filepath ) + + # Extract TEST_SOURCE_FILE() and TEST_INCLUDE_PATH() + test_directives = @preprocessinator_reconstructor.extract_test_directive_macro_calls( _contents ) + end + end + + return contents, test_directives + end + + + def assemble_preprocessed_code_file(filename:, preprocessed_filepath:, contents:, extras:, includes:) + # Write contents of final preprocessed file a line at a time + # ---------------------------------------------------------- + @file_wrapper.open( preprocessed_filepath, 'w' ) do |file| + # Reinsert #include statements into stripped down file + # Rely on Include object stringification for formatting of incudes + includes.each { |include| file << "#{include}\n" } + + # Blank line + file << "\n" unless includes.empty? + + # Add in any extras like test directive macros + extras.each { |ex| file << ex + "\n" } + + # Blank line + file << "\n" unless extras.empty? + + # Add extracted contents from preprocessed file + contents.each { |line| file << line + "\n" } + + # Blank line + file << "\n" unless contents.empty? + end + end + +end diff --git a/lib/ceedling/preprocess/preprocessinator_includes_handler.rb b/lib/ceedling/preprocess/preprocessinator_includes_handler.rb new file mode 100644 index 000000000..554f01361 --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator_includes_handler.rb @@ -0,0 +1,203 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/includes/includes' +require 'ceedling/preprocess/preprocessinator_bare_includes_extractor' +require 'ceedling/preprocess/preprocessinator_line_marker_includes_extractor' + +class PreprocessinatorIncludesHandler + + constructor( + :configurator, + :preprocessinator_line_marker_includes_extractor, + :include_factory, + :tool_executor, + :file_wrapper, + :yaml_wrapper, + :parsing_parcels, + :loginator, + :reportinator + ) + + def setup() + # Aliases + @line_marker_includes_extractor = @preprocessinator_line_marker_includes_extractor + end + + def extract_bare_includes(test:, filepath:, search_paths:, flags:, defines:) + filename = File.basename(filepath) + + msg = @reportinator.generate_module_progress( + operation: "Extracting bare #includes via preprocessing from", + module_name: test, + filename: filename + ) + @loginator.log( msg, Verbosity::OBNOXIOUS ) + + # Creation: + # - This output is created with the -MM -MG -MP command line options. + # - Limited search paths are used towards shallow extracting of only the user #include statements of the file. + # This preprocessor mode assumes any includes discovered outside of a search path will be generated. + # + # Notes: + # - This approach can have gaps with advacnced user-level macros like `#include <MACRO>`. + # By including Ceedling's vendor search path, we support Partials macros of this sort. + # - Gaps can be minimized with proper defines in the project file. However, needed, complex macros + # located in other header files could still gum up the works. + # - Many errors can occur but may not necessarily prevent usable results. + command = + @tool_executor.build_command_line( + @configurator.tools_test_bare_includes_preprocessor, + # No additional arguments + [], + # Argument replacement + filepath, + defines, + flags, + search_paths + ) + + # Assume possible errors so we have best shot at extracting results from preprocessing. + # Full code compilation will catch any breaking code errors + command[:options][:boom] = false + shell_result = @tool_executor.exec( command ) + + make_rules = shell_result[:output] + + # Do not check exit code for success. In some error conditions we still get usable output. + # Look for the first line of the make rule output. + if not make_rules =~ PreprocessinatorBareIncludesExtractor::MAKE_RULE_MATCHER + @loginator.lazy( Verbosity::DEBUG ) do + "Preprocessor bare #include extraction failed: #{shell_result[:output]}" + end + return [] + end + + includes = PreprocessinatorBareIncludesExtractor.extract_includes( make_rules ) + includes = clean_self_reference(filepath, includes) + + header = "Extracted bare #includes from #{filepath}" + @loginator.log_list( includes, header, Verbosity::DEBUG ) + + return includes + end + + def extract_user_includes_preprocess(name:, filepath:, preprocessed_filepath:) + includes = [] + + filename = File.basename(filepath) + + msg = @reportinator.generate_module_progress( + operation: "Extracting user #includes from preprocessed output", + module_name: name, + filename: filename + ) + @loginator.log(msg, Verbosity::OBNOXIOUS) + + includes = + @line_marker_includes_extractor.extract_includes_from_file( + preprocessed_filepath, + PreprocessinatorLineMarkerIncludesExtractor::USER + # Note: No limit to max depth to search for user includes + ) + + return clean_self_reference( filepath, includes ) + end + + def extract_user_includes_from_text(name:, filepath:) + includes = [] + + filename = File.basename(filepath) + + msg = @reportinator.generate_module_progress( + operation: "Extracting user #includes from original file using fallback method", + module_name: name, + filename: filename + ) + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + @file_wrapper.open(filepath, 'r') do |input| + @parsing_parcels.code_lines( input ) do |line| + _include = @include_factory.user_include_from_directive( line ) + includes << _include if !_include.nil? + end + end + + return clean_self_reference( filepath, includes ) + end + + def extract_system_includes_preprocess(name:, filepath:, preprocessed_filepath:) + includes = [] + + filename = File.basename(filepath) + + msg = @reportinator.generate_module_progress( + operation: "Extracting system #includes from preprocessed output", + module_name: name, + filename: filename + ) + @loginator.log(msg, Verbosity::OBNOXIOUS) + + includes = + @line_marker_includes_extractor.extract_includes_from_file( + preprocessed_filepath, + PreprocessinatorLineMarkerIncludesExtractor::SYSTEM, + 5 # Practical max depth limit for system headers (to avoid noisy length) + ) + + return clean_self_reference( filepath, includes ) + end + + def extract_system_includes_from_text(name:, filepath:) + includes = [] + + filename = File.basename(filepath) + + msg = @reportinator.generate_module_progress( + operation: "Extracting system #includes from original file using fallback method", + module_name: name, + filename: filename + ) + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + @file_wrapper.open(filepath, 'r') do |input| + @parsing_parcels.code_lines( input ) do |line| + _include = @include_factory.system_include_from_directive( line ) + includes << _include if !_include.nil? + end + end + + return clean_self_reference( filepath, includes ) + end + + # Write to disk a yaml representation of a list of includes + def write_includes_list(filepath, list) + @yaml_wrapper.dump(filepath, Includes.to_hashes(list)) + end + + def load_includes_list(filepath) + return Includes.from_hashes( + # Note: It's possible empty YAML content returns nil so ensure empty list + @yaml_wrapper.load( filepath ) || [] + ) + end + + ### Private ### + private + + # Remove any filepath in the includes list that is identical to the filepath being processed. + # We want to prevent an includes list containing an unnecessary self-reference. + # Use normalized paths for comparison to handle variations (relative vs absolute, different separators, etc.) + def clean_self_reference(filepath, includes) + _filepath = File.expand_path(filepath) + Includes.sanitize!(includes) do |include, _| + _filepath == File.expand_path(include.filepath) + end + return includes + end + +end diff --git a/lib/ceedling/preprocess/preprocessinator_line_marker_includes_extractor.rb b/lib/ceedling/preprocess/preprocessinator_line_marker_includes_extractor.rb new file mode 100644 index 000000000..f36f12fc4 --- /dev/null +++ b/lib/ceedling/preprocess/preprocessinator_line_marker_includes_extractor.rb @@ -0,0 +1,196 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/exceptions' +require 'set' + +## +## System Includes Directives-only Preprocessor Output Parsing +## =========================================================== +## +## Format: +## - File content excerpted between line markers nesting the expansion of files. +## - Line marker: `# linenum filename flags` +## - Flags: +## - 1 = Start of new file +## - 2 = Returning to a file +## - 3 = System header +## - 4 = Implicit extern C +## +## Notes: +## - The initial line marker is "# 1" followed by the source file being preprocessed +## - `# 0` at the top of the output is reserved for internal macros, command line macros +## and other symbols not from #include directives. +## - Because of the preprocessor's behavior around #include guards, the order of #include +## directives can mask the header filenames. That is, in the following examples, +## <stdint.h> is at depth two. +## +## #include "ecmsi_bar.h" +## #include "ecmsi_foo.h" // Includes <stdint.h> +## #include <stdint.h> // Does not appear in preprocessor output. +## // Transitive <stdint.h> from preceding user include at depth 2. +## +## Example output follows +## (Edited for length) +## ----------------------------------------------------------------------------------------- +## # 0 "<command-line>" 2 +## # 1 "src/external_calls_multi_static_inline/ecmsi_bar.c" +## +## /**************************************************************************************** +## * Includes +## ***************************************************************************************/ +## # 1 "src/external_calls_multi_static_inline/ecmsi_bar.h" 1 +## +## #define ECMSI_BAR_H +## +## /**************************************************************************************** +## * Public function prototypes +## ***************************************************************************************/ +## extern void ecmsi_bar_init(void); +## +## # 6 "src/external_calls_multi_static_inline/ecmsi_bar.c" 2 +## # 1 "src/external_calls_multi_static_inline/ecmsi_foo.h" 1 +## +## #define ECMSI_FOO_H +## +## /**************************************************************************************** +## * Includes +## ***************************************************************************************/ +## # 1 "/usr/lib/gcc/x86_64-linux-gnu/12/include/stdint.h" 1 3 4 +## +## +## # 1 "/usr/include/stdint.h" 1 3 4 +## /* Copyright (C) 1997-2022 Free Software Foundation, Inc. +## This file is part of the GNU C Library. +## */ +## +## #define _STDINT_H 1 +## +## #define __GLIBC_INTERNAL_STARTING_HEADER_IMPLEMENTATION +## +## # 318 "/usr/include/stdint.h" 3 4 +## +## # 10 "/usr/lib/gcc/x86_64-linux-gnu/12/include/stdint.h" 2 3 4 +## +## #define _GCC_WRAP_STDINT_H +## # 9 "src/external_calls_multi_static_inline/ecmsi_foo.h" 2 +## + +# Parse GCC preprocessor output (from -fdirectives-only) to extract system include directives +class PreprocessinatorLineMarkerIncludesExtractor + LINE_MARKER_REGEX = /^#\s+(\d+)\s+"([^"]+)"(?:\s+(\d+(?:\s+\d+)*))?$/ unless const_defined?(:LINE_MARKER_REGEX) + + SYSTEM = :system unless const_defined?(:SYSTEM) + USER = :user unless const_defined?(:USER) + + constructor :include_factory + + # Parse preprocessor output from a file (production use) + # @param filepath [String] Path to the preprocessor output file + # @return [Array<UserInclude, SystemInclude>] + def extract_includes_from_file(filepath, type, max_depth=nil) + validate_type_argument( type ) + includes = [] + begin + File.open(filepath, 'r') do |file| + includes = extract_includes(io: file, filepath: filepath, type: type, max_depth: max_depth) + end + rescue StandardError => e + raise CeedlingException.new("Failed to extract #{type} includes from preprocessor output file '#{filepath}' ⏩️ #{e.message}") + end + return includes + end + + # Parse preprocessor output from a string (testing use) + # @param content [String] Preprocessor output as a string + # @return [Array<UserInclude, SystemInclude>] + def extract_includes_from_string(content, filepath, type, max_depth=nil) + validate_type_argument( type ) + require 'stringio' + io = StringIO.new(content) + return extract_includes(io: io, filepath: filepath, type: type, max_depth: max_depth) + end + + private + + def validate_type_argument(type) + unless [SYSTEM, USER].include?(type) + raise CeedlingException.new("Invalid type argument: #{type.inspect}. Must be :#{SYSTEM} or :#{USER}") + end + end + + # Extracts includes from directives-only preprocessor output + # Returns an array of Include-derived objects + def extract_includes(io:, filepath:, type:, max_depth:) + includes = [] + seen_paths = Set.new + initial_file_seen = false + depth = 0 + + # Extract just the filename from full path + source_filename = File.basename(filepath) + + io.each_line do |line| + # Match GCC line markers + if (match = LINE_MARKER_REGEX.match(line)) + # String filename + filepath = match[2] + + # Skip special markers like "<built-in>" and "<command-line>" + next if filepath.start_with?('<') + + # Integer line number + line_number = match[1].to_i + + # Array of flag integers + flags = match[3] ? match[3].split.map(&:to_i) : [] + + # Look for `# 1 "<filename>"` + if !initial_file_seen + if (line_number == 1) && (File.basename(filepath) == source_filename) + initial_file_seen = true + depth = 1 + end + next + end + + # Flag 1 means entering a new file + if flags.include?(1) + depth += 1 + + # Skip if we've already seen this path + next if seen_paths.include?( filepath ) + + # Skip if max depth is defined and we've exceeded it. + # If max depth is not defined, do not limit the depth. + next if (max_depth && (depth > max_depth)) + + seen_paths.add(filepath) + + # Extract system includes + if type == SYSTEM + # Flag 3 indicates a system header + if flags.include?(3) + includes << @include_factory.system_include_from_filepath( filepath ) + end + # Extract user includes + elsif type == USER + unless flags.include?(3) + includes << @include_factory.user_include_from_filepath( filepath ) + end + end + # Flag 2 means returning to a previous file + elsif flags.include?(2) + depth -= 1 if depth > 0 + end + end + end + + return includes + end +end + diff --git a/lib/ceedling/preprocessinator_extractor.rb b/lib/ceedling/preprocess/preprocessinator_reconstructor.rb similarity index 66% rename from lib/ceedling/preprocessinator_extractor.rb rename to lib/ceedling/preprocess/preprocessinator_reconstructor.rb index c95ee9048..c4a9a507d 100644 --- a/lib/ceedling/preprocessinator_extractor.rb +++ b/lib/ceedling/preprocess/preprocessinator_reconstructor.rb @@ -9,7 +9,7 @@ require 'ceedling/encodinator' require 'ceedling/parsing_parcels' -class PreprocessinatorExtractor +class PreprocessinatorReconstructor constructor :parsing_parcels @@ -80,68 +80,8 @@ class PreprocessinatorExtractor # `input` must have the interface of IO -- StringIO for testing or File in typical use def extract_file_as_array_from_expansion(input, filepath) - - ## - ## Iterate through all lines and alternate between extract and ignore modes. - ## All lines between a '#' line containing the filepath to extract (a line marker) and the next '#' line should be extracted. - ## - ## GCC preprocessor output line marker format: `# <linenum> "<filename>" <flags>` - ## - ## Documentation on line markers in GCC preprocessor output: - ## https://gcc.gnu.org/onlinedocs/gcc-3.0.2/cpp_9.html - ## - ## Notes: - ## 1. Successive blocks can all be from the same source text file without a different, intervening '#' line. - ## Multiple back-to-back blocks could all begin with '# 99 "path/file.c"'. - ## 2. The first line of the file could start a text block we care about. - ## 3. End of file could end a text block. - ## 4. Usually, the first line marker contains no trailing flag. - ## 5. Different preprocessors conforming to the GCC output standard may use different trailiing flags. - ## 6. Our simple ping-pong-between-line-markers extraction technique does not require decoding flags. - ## - - # Expand filepath under inspection to ensure proper match - extaction_filepath = File.expand_path( filepath ) - # Preprocessor directive blocks generally take the form of '# <digits> <text> [optional digits]' - directive = /^# \d+ \"/ - # Line markers have the specific form of '# <digits> "path/filename.ext" [optional digits]' (see above) - line_marker = /^#\s\d+\s\"(.+)\"/ - # Boolean to ping pong between line-by-line extract/ignore - extract = false - - # Collection of extracted lines lines = [] - - # Use `each_line()` instead of `readlines()` (chomp removes newlines). - # `each_line()` processes the IO buffer one line at a time instead of ingesting lines in an array. - # At large buffer sizes needed for potentially lengthy preprocessor output this is far more memory efficient and faster. - input.each_line( chomp:true ) do |line| - - # Clean up any oddball characters in an otherwise ASCII document - line = line.clean_encoding - - # Handle expansion extraction if the line is not a preprocessor directive - if extract and not line =~ directive - # Strip a line so we can omit useless blank lines - _line = line.strip() - # Restore text with left-side whitespace if previous stripping left some text - _line = line.rstrip() if !_line.empty? - # Collect extracted lines - lines << _line - - # Otherwise the line contained a preprocessor directive; drop out of extract mode - else - extract = false - end - - # Enter extract mode if the line is a preprocessor line marker with filepath of interest - matches = line.match( line_marker ) - if matches and matches.size() > 1 - filepath = File.expand_path( matches[1].strip() ) - extract = true if extaction_filepath == filepath - end - end - + _scan_expansion_for_file(input, filepath) { |line| lines << line } return lines end @@ -152,6 +92,26 @@ def extract_file_as_string_from_expansion(input, filepath) end + # Writes only C code from `input` preprocessor expansion belonging to `filepath` + # to `output` IO object incrementally (one logical line at a time) without building + # an intermediate array. `output` must respond to `puts` (e.g. File or StringIO). + def compact_from_expansion(input:, filepath:, output:) + _scan_expansion_for_file(input, filepath) { |line| output.puts(line) } + end + + + # File-based convenience wrapper around `compact_from_expansion`. + # Opens `input_filepath` for reading and `output_filepath` for writing, + # then delegates to `compact_from_expansion` with the resulting IO objects. + def compact_file_from_expansion(input_filepath:, source_filepath:, output_filepath:) + File.open( input_filepath, 'r' ) do |input| + File.open( output_filepath, 'w' ) do |output| + compact_from_expansion( input: input, filepath: source_filepath, output: output ) + end + end + end + + # Extract all test directive macros as a list from a file as string def extract_test_directive_macro_calls(file_contents) # Look for TEST_SOURCE_FILE("...") and TEST_INCLUDE_PATH("...") in a string (i.e. a file's contents as a string) @@ -205,6 +165,109 @@ def extract_macro_defs(file_contents, include_guard) private + ## + ## Iterate through all lines and alternate between extract and ignore modes. + ## All lines between a '#' line containing the filepath to extract (a line marker) and the next '#' line should be extracted. + ## + ## GCC preprocessor output line marker format: `# <linenum> "<filename>" <flags>` + ## + ## Documentation on line markers in GCC preprocessor output: + ## https://gcc.gnu.org/onlinedocs/gcc-3.0.2/cpp_9.html + ## + ## Notes: + ## 1. Successive blocks can all be from the same source text file without a different, intervening '#' line. + ## Multiple back-to-back blocks could all begin with '# 99 "path/file.c"'. + ## 2. The first line of the file could start a text block we care about. + ## 3. End of file could end a text block. + ## 4. Usually, the first line marker contains no trailing flag. + ## 5. Different preprocessors conforming to the GCC output standard may use different trailiing flags. + ## 6. Our simple ping-pong-between-line-markers extraction technique does not require decoding flags. + ## + ## Yields one complete logical line at a time to the given block. + ## A single `pending_line` buffer is held to allow aggregation of preprocessor-wrapped + ## expansions (multiple physical lines at the same logical line number) before yielding. + ## + def _scan_expansion_for_file(input, filepath, &block) + # Expand filepath under inspection to ensure proper match + extraction_filepath = File.expand_path( filepath ) + # Preprocessor directive blocks generally take the form of '# <digits> <text> [optional digits]' + directive = /^# \d+ \"/ + # Line markers have the specific form of '# <digits> "path/filename.ext" [optional digits]' (see above) + line_marker = /^#\s(\d+)\s\"(.+)\"/ + # Boolean to ping pong between line-by-line extract/ignore + extract = false + + line_num = 0 + last_line_num = 0 + + # Buffer for the last logical line (may still receive aggregated content) + pending_line = nil + # Whether a blank line should follow pending_line when flushed + pending_blank = false + + # Yields pending_line (and optional trailing blank) then clears the buffer + flush = lambda do + unless pending_line.nil? + block.call( pending_line ) + block.call( '' ) if pending_blank + pending_line = nil + pending_blank = false + end + end + + # Use `each_line()` instead of `readlines()` (chomp removes newlines). + # `each_line()` processes the IO buffer one line at a time instead of ingesting lines in an array. + # At large buffer sizes needed for potentially lengthy preprocessor output this is far more memory efficient and faster. + input.each_line( chomp:true ) do |line| + + # Clean up any oddball characters in an otherwise ASCII document + line = line.clean_encoding + + # Handle expansion extraction if the line is not a preprocessor directive + if extract and not line =~ directive + line_num += 1 + + # Strip a line so we can omit useless blank lines + _line = line.strip() + + # Skip processing blank lines, but mark a pending blank unless we already have one + if _line.empty? + pending_blank = true if !pending_line.nil? && !pending_line.empty? + next + end + + # If the linemarker line number hasn't advanced, aggregate the expanded line into pending + if (last_line_num == line_num) and !pending_line.nil? + # Append the stripped line to the pending line + # Include a space in the concatenation unless it's a semicolon or pending_line is blank + pending_line = (_line == ';' or pending_line.empty?) ? (pending_line + _line) : (pending_line + ' ' + _line) + else + # Flush previous pending line before starting a new one + flush.call() + # Collect a left-whitespace-preserved version of the line + pending_line = line.rstrip() + end + + # Otherwise the line contained a preprocessor directive; drop out of extract mode + else + extract = false + end + + # Enter extract mode if the line is a preprocessor line marker with filepath of interest + matches = line.match( line_marker ) + if matches and matches.size() > 2 + last_line_num = line_num + line_num = (matches[1].to_i - 1) + fp = File.expand_path( matches[2].strip() ) + extract = true if extraction_filepath == fp + end + end + + # Yield any remaining buffered line at end of input + flush.call() + end + + def extract_multiline_directives(file_contents, directive) results = [] diff --git a/lib/ceedling/preprocessinator.rb b/lib/ceedling/preprocessinator.rb deleted file mode 100644 index a241fea78..000000000 --- a/lib/ceedling/preprocessinator.rb +++ /dev/null @@ -1,253 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -class Preprocessinator - - constructor :preprocessinator_includes_handler, - :preprocessinator_file_handler, - :task_invoker, - :file_finder, - :file_path_utils, - :file_wrapper, - :yaml_wrapper, - :plugin_manager, - :configurator, - :test_context_extractor, - :loginator, - :reportinator, - :rake_wrapper - - - def setup - # Aliases - @includes_handler = @preprocessinator_includes_handler - @file_handler = @preprocessinator_file_handler - - # Thread-safe per-file locking for YAML cache operations - # Key: includes_list_filepath (String), Value: Mutex - @file_locks = {} - @file_locks_mutex = Mutex.new - end - - - def preprocess_includes(filepath:, test:, flags:, include_paths:, defines:, deep: false) - includes_list_filepath = @file_path_utils.form_preprocessed_includes_list_filepath( filepath, test ) - - # Get or create a mutex for this specific cache file - file_lock = @file_locks_mutex.synchronize do - @file_locks[includes_list_filepath] ||= Mutex.new - end - - includes = [] - - # Wrap the entire check-read-or-extract-write operation in a mutex - # This prevents race conditions when multiple threads process the same file - file_lock.synchronize do - # If existing YAML file of includes is newer than the file we're processing, skip preprocessing - if @file_wrapper.newer?( includes_list_filepath, filepath ) - msg = @reportinator.generate_module_progress( - operation: "Loading #include statement listing file for", - module_name: test, - filename: File.basename(filepath) - ) - @loginator.log( msg, Verbosity::NORMAL ) - - # Note: It's possible empty YAML content returns nil - includes = @yaml_wrapper.load( includes_list_filepath ) - - msg = "Loaded existing #include list from #{includes_list_filepath}:" - - if includes.nil? or includes.empty? - # Ensure includes defaults to emtpy array to prevent external iteration problems - includes = [] - msg += ' <empty>' - else - includes.each { |include| msg += "\n - #{include}" } - end - - @loginator.log( msg, Verbosity::DEBUG ) - @loginator.log( '', Verbosity::DEBUG ) - - # Full preprocessing-based #include extraction with saving to YAML file - else - includes = @includes_handler.extract_includes( - filepath: filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines, - deep: deep - ) - - msg = "Extracted #include list from #{filepath}:" - - if includes.nil? or includes.empty? - # Ensure includes defaults to emtpy array to prevent external iteration problems - includes = [] - msg += ' <empty>' - else - includes.each { |include| msg += "\n - #{include}" } - end - - @loginator.log( msg, Verbosity::DEBUG ) - @loginator.log( '', Verbosity::DEBUG ) - - @includes_handler.write_includes_list( includes_list_filepath, includes ) - end - end - - return includes - end - - - def preprocess_mockable_header_file(filepath:, test:, flags:, include_paths:, defines:) - preprocessed_filepath = @file_path_utils.form_preprocessed_file_filepath( filepath, test ) - - # Check if we're using deep define processing for mocks - preprocess_deep = !@configurator.project_use_deep_preprocessor.nil? && [:mocks, :all].include?(@configurator.project_use_deep_preprocessor) - - plugin_arg_hash = { - header_file: filepath, - preprocessed_header_file: preprocessed_filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines - } - - # Trigger pre_mock_preprocessing plugin hook - @plugin_manager.pre_mock_preprocess( plugin_arg_hash ) - - arg_hash = { - filepath: filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines, - deep: preprocess_deep - } - - # Extract includes & log progress and details - includes = preprocess_file_common( **arg_hash ) - - arg_hash = { - source_filepath: filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines, - extras: (@configurator.cmock_treat_inlines == :include) - } - - # `contents` & `extras` are arrays of text strings to be assembled in generating a new header file. - # `extras` are macro definitions, pragmas, etc. needed for the special case of mocking `inline` function declarations. - # `extras` are empty for any cases other than mocking `inline` function declarations - # (We don't want to increase our chances of a badly generated file--extracting extras could fail in complex files.) - contents, extras = @file_handler.collect_header_file_contents( **arg_hash ) - - arg_hash = { - filename: File.basename( filepath ), - preprocessed_filepath: preprocessed_filepath, - contents: contents, - extras: extras, - includes: includes - } - - # Create a reconstituted header file from preprocessing expansion and preserving any extras - @file_handler.assemble_preprocessed_header_file( **arg_hash ) - - # Trigger post_mock_preprocessing plugin hook - @plugin_manager.post_mock_preprocess( plugin_arg_hash ) - - return preprocessed_filepath - end - - - def preprocess_test_file(filepath:, test:, flags:, include_paths:, defines:) - preprocessed_filepath = @file_path_utils.form_preprocessed_file_filepath( filepath, test ) - - # Check if we're using deep define processing for mocks - preprocess_deep = !@configurator.project_use_deep_preprocessor.nil? && [:tests, :all].include?(@configurator.project_use_deep_preprocessor) - - plugin_arg_hash = { - test_file: filepath, - preprocessed_test_file: preprocessed_filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines - } - - # Trigger pre_test_preprocess plugin hook - @plugin_manager.pre_test_preprocess( plugin_arg_hash ) - - arg_hash = { - filepath: filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines, - deep: preprocess_deep - } - - # Extract includes & log progress and info - includes = preprocess_file_common( **arg_hash ) - - arg_hash = { - source_filepath: filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines - } - - # `contents` & `extras` are arrays of text strings to be assembled in generating a new test file. - # `extras` are test build directives TEST_SOURCE_FILE() and TEST_INCLUDE_PATH(). - contents, extras = @file_handler.collect_test_file_contents( **arg_hash ) - - arg_hash = { - filename: File.basename( filepath ), - preprocessed_filepath: preprocessed_filepath, - contents: contents, - extras: extras, - includes: includes - } - - # Create a reconstituted test file from preprocessing expansion and preserving any extras - @file_handler.assemble_preprocessed_test_file( **arg_hash ) - - # Trigger pre_mock_preprocessing plugin hook - @plugin_manager.post_test_preprocess( plugin_arg_hash ) - - return preprocessed_filepath - end - - ### Private ### - private - - def preprocess_file_common(filepath:, test:, flags:, include_paths:, defines:, deep: false) - msg = @reportinator.generate_module_progress( - operation: "Preprocessing", - module_name: test, - filename: File.basename(filepath) - ) - - @loginator.log( msg, Verbosity::NORMAL ) - - # Extract includes - includes = preprocess_includes( - filepath: filepath, - test: test, - flags: flags, - include_paths: include_paths, - defines: defines, - deep: deep) - - return includes - end - -end diff --git a/lib/ceedling/preprocessinator_file_handler.rb b/lib/ceedling/preprocessinator_file_handler.rb deleted file mode 100644 index de49c9838..000000000 --- a/lib/ceedling/preprocessinator_file_handler.rb +++ /dev/null @@ -1,256 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -require 'rake' # for ext() method - -class PreprocessinatorFileHandler - - constructor :preprocessinator_extractor, :configurator, :tool_executor, :file_path_utils, :file_wrapper, :loginator - - def collect_header_file_contents(source_filepath:, test:, flags:, defines:, include_paths:, extras:) - contents = [] - - # Our extra file content to be preserved - # Leave these empty if :extras is false - pragmas = [] - macro_defs = [] - - preprocessed_filepath = @file_path_utils.form_preprocessed_file_full_expansion_filepath( source_filepath, test ) - - # Run GCC with full preprocessor expansion - command = @tool_executor.build_command_line( - @configurator.tools_test_file_full_preprocessor, - flags, - source_filepath, - preprocessed_filepath, - defines, - include_paths - ) - @tool_executor.exec( command ) - - @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| - contents = @preprocessinator_extractor.extract_file_as_array_from_expansion( file, source_filepath ) - end - - # Bail out, skipping directives-only preprocessing if no extras are required - return contents, (pragmas + macro_defs) if !extras - - preprocessed_filepath = @file_path_utils.form_preprocessed_file_directives_only_filepath( source_filepath, test ) - - # Run GCC with directives-only preprocessor expansion - command = @tool_executor.build_command_line( - @configurator.tools_test_file_directives_only_preprocessor, - flags, - source_filepath, - preprocessed_filepath, - defines, - include_paths - ) - results = @tool_executor.exec( command ) - - # Try to find an #include guard in the first 2k of the file text. - # An #include guard is one macro from the original file we don't want to preserve if we can help it. - # We create our own #include guard in the header file we create. - # It's possible preserving the macro from the original file's #include guard could trip something up. - # Of course, it's also possible some header conditional compilation feature is dependent on it. - # ¯\_(ツ)_/¯ - include_guard = @preprocessinator_extractor.extract_include_guard( @file_wrapper.read( source_filepath, 2048 ) ) - - # If we received a warning from preprocessor saying that clang can't handle directives-only (common with older clang) - # then we need to attempt to extract the information directly from the source file instead - if results[:output].match /warning[^\n]+-fdirectives-only/ - @file_wrapper.open( source_filepath, 'r' ) do |file| - # Get code contents of original source file as a string - # TODO: Modify to process line-at-a-time for memory savings & performance boost - _contents = file.read - - # Extract pragmas and macros from - pragmas = @preprocessinator_extractor.extract_pragmas( _contents ) - macro_defs = @preprocessinator_extractor.extract_macro_defs( _contents, include_guard ) - end - else - @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| - # Get code contents of preprocessed directives-only file as a string - # TODO: Modify to process line-at-a-time for memory savings & performance boost - _contents = @preprocessinator_extractor.extract_file_as_string_from_expansion( file, source_filepath ) - - # Extract pragmas and macros from - pragmas = @preprocessinator_extractor.extract_pragmas( _contents ) - macro_defs = @preprocessinator_extractor.extract_macro_defs( _contents, include_guard ) - end - end - - return contents, (pragmas + macro_defs) - end - - - def assemble_preprocessed_header_file(filename:, preprocessed_filepath:, contents:, extras:, includes:) - _contents = [] - - # Add #include guards for header files - # Note: These aren't truly needed as preprocessed header files are only ingested by CMock. - # They're created for sake of completeness and just in case... - # ---------------------------------------------------- - # abc-XYZ.h --> _ABC_XYZ_H_ - guardname = '_' + filename.gsub(/\W/, '_').upcase + '_' - - forward_guards = [ - "#ifndef #{guardname} // Ceedling-generated include guard", - "#define #{guardname}", - '' - ] - - # Insert Ceedling notice - # ---------------------------------------------------- - comment = "// CEEDLING NOTICE: This generated file only to be consumed by CMock" - _contents += [comment, ''] - - # Add guards to beginning of file contents - _contents += forward_guards - - # Blank line - _contents << '' - - # Reinsert #include statements into stripped down file - includes.each{ |include| _contents << "#include \"#{include}\"" } - - # Blank line - _contents << '' - - # Add in any macro defintions or prgamas - extras.each do |ex| - if ex.class == String - _contents << ex - - elsif ex.class == Array - _contents += ex - end - - # Blank line - _contents << '' - end - - _contents += contents - - _contents += ['', "#endif // #{guardname}", ''] # Rear guard - - # Write file, collapsing any repeated blank lines - # ---------------------------------------------------- - _contents = _contents.join("\n") - _contents.gsub!( /(\h*\n){3,}/, "\n\n" ) - - # Remove paths from expanded #include directives - # ---------------------------------------------------- - # - We rely on search paths at compilation rather than explicit #include paths - # - Match (#include ")((path/)+)(file") and reassemble string using first and last matching groups - _contents.gsub!( /(#include\s+")(?:(?:[^"\/]+\/)+)([^"\/]*")/, '\1\2' ) - - # Write contents of final preprocessed file - @file_wrapper.write( preprocessed_filepath, _contents ) - end - - - def collect_test_file_contents(source_filepath:, test:, flags:, defines:, include_paths:) - contents = [] - # TEST_SOURCE_FILE() and TEST_INCLUDE_PATH() - test_directives = [] - - preprocessed_filepath = @file_path_utils.form_preprocessed_file_full_expansion_filepath( source_filepath, test ) - - # Run GCC with full preprocessor expansion - command = @tool_executor.build_command_line( - @configurator.tools_test_file_full_preprocessor, - flags, - source_filepath, - preprocessed_filepath, - defines, - include_paths - ) - @tool_executor.exec( command ) - - @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| - contents = @preprocessinator_extractor.extract_file_as_array_from_expansion( file, source_filepath ) - end - - preprocessed_filepath = @file_path_utils.form_preprocessed_file_directives_only_filepath( source_filepath, test ) - - # Run GCC with directives-only preprocessor expansion - command = @tool_executor.build_command_line( - @configurator.tools_test_file_directives_only_preprocessor, - flags, - source_filepath, - preprocessed_filepath, - defines, - include_paths - ) - results = @tool_executor.exec( command ) - - # If we receive a warning saying that clang can't handle directives-only (common with older clang) - # then we fall back to using the original source file to detect all TEST_SOURCE_FILE and TEST_INCLUDE_PATH macros - if results[:output].match /warning[^\n]+-fdirectives-only/ - @file_wrapper.open( source_filepath, 'r' ) do |file| - # Get code contents of original source file as a string - # TODO: Modify to process line-at-a-time for memory savings & performance boost - _contents = file.read - - # Extract TEST_SOURCE_FILE() and TEST_INCLUDE_PATH() - test_directives = @preprocessinator_extractor.extract_test_directive_macro_calls( _contents ) - end - else - @file_wrapper.open( preprocessed_filepath, 'r' ) do |file| - # Get code contents of preprocessed directives-only file as a string - # TODO: Modify to process line-at-a-time for memory savings & performance boost - _contents = @preprocessinator_extractor.extract_file_as_string_from_expansion( file, source_filepath ) - - # Extract TEST_SOURCE_FILE() and TEST_INCLUDE_PATH() - test_directives = @preprocessinator_extractor.extract_test_directive_macro_calls( _contents ) - end - end - - return contents, test_directives - end - - - def assemble_preprocessed_test_file(filename:, preprocessed_filepath:, contents:, extras:, includes:) - _contents = [] - - # Insert Ceedling notice - # ---------------------------------------------------- - comment = "// CEEDLING NOTICE: This generated file only to be consumed for test runner creation" - _contents += [comment, ''] - - # Blank line - _contents << '' - - # Reinsert #include statements into stripped down file - includes.each{ |include| _contents << "#include \"#{include}\"" } - - # Blank line - _contents << '' - - # Add in test directive macro calls - extras.each {|ex| _contents << ex} - - # Blank line - _contents << '' - - _contents += contents - - # Write file, doing some prettyifying along the way - # ---------------------------------------------------- - _contents = _contents.join("\n") - _contents.gsub!( /^\s*;/, '' ) # Drop blank lines with semicolons left over from macro expansion + trailing semicolon - _contents.gsub!( /\)\s+\{/, ")\n{" ) # Collapse any unnecessary white space between closing argument paren and opening function bracket - _contents.gsub!( /\{(\n){2,}/, "{\n" ) # Collapse any unnecessary white space between opening function bracket and code - _contents.gsub!( /(\n){2,}\}/, "\n}" ) # Collapse any unnecessary white space between code and closing function bracket - _contents.gsub!( /(\h*\n){3,}/, "\n\n" ) # Collapse repeated blank lines - - # Write contents of final preprocessed file - @file_wrapper.write( preprocessed_filepath, _contents ) - end - -end diff --git a/lib/ceedling/preprocessinator_includes_handler.rb b/lib/ceedling/preprocessinator_includes_handler.rb deleted file mode 100644 index 1bb27af9f..000000000 --- a/lib/ceedling/preprocessinator_includes_handler.rb +++ /dev/null @@ -1,366 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -class PreprocessinatorIncludesHandler - - constructor :configurator, :tool_executor, :test_context_extractor, :file_wrapper, :yaml_wrapper, :loginator, :reportinator - - ## - ## Includes Extraction Overview - ## ============================ - ## - ## BACKGROUND - ## -------- - ## #include extraction is hard to do. In simple cases a regex approach suffices, but nested header files, - ## clever macros, and conditional preprocessing statements easily introduce high complexity. - ## - ## Unfortunately, there's no readily available cross-platform C parsing tool that provides a simple means - ## to extract the #include statements directly embedded in a given file. Even the gcc preprocessor itself - ## only comes close to providing this information externally. - ## - ## APPROACH - ## -------- - ## (Full details including fallback options are in the extensive code comments among the methods below.) - ## - ## Sadly, we can't preprocess a file with full search paths and defines and ask for the #include statements - ## embedded in a file. We get far more #includes than we want with no way to discern which are at the depth - ## of the file being processed. - ## - ## Instead, we try our best to use some educated guessing to get as close as possible to the desired list. - ## - ## I. Try to extract shallow defines with no crawling out into other header files. This conservative approach - ## gives us a reference point on possible directly included files. The results may be incomplete, though. - ## They also may mistakenly list #includes that should not be in the list--because of #ifndef defaults or - ## because of system headers or #include <...> statements and differences among gcc implementations. - ## - ## II. Extract a full list of #includes by spidering out into nested headers and processing all macros, etc. - ## This is the greedy approach. - ## - ## III. Find #includes common to (I) and (II). The results of (I) should limit the potentially lengthy - ## results of (II). The complete and accurate list of (II) should cut out any mistaken entries in (I). - ## - ## IV. I–III are not foolproof. A purely greedy approach or a purely conservative approach will cause symbol - ## conflicts, missing symbols, etc. The blended and balanced approach should come quite close to an - ## accurate list of shallow includes. Edge cases and gaps will cause trouble. Other Ceedling features - ## should provide the tools to intervene. - ## - - def extract_includes(filepath:, test:, flags:, include_paths:, defines:, deep: false) - msg = @reportinator.generate_module_progress( - operation: "Extracting #include statements via preprocessor from", - module_name: test, - filename: File.basename(filepath) - ) - @loginator.log(msg, Verbosity::NORMAL) - - # Extract shallow includes with fallback regex - fallback = extract_shallow_includes_regex( - test: test, - filepath: filepath, - flags: flags, - defines: defines - ) - - # Extract shallow includes with preprocessor - shallow_success, shallow = - extract_shallow_includes_preprocessor( - test: test, - filepath: filepath, - flags: flags, - defines: defines - ) - shallow = fallback unless shallow_success - - # Extract nested includes but optionally act in fallback mode - nested = extract_nested_includes( - filepath: filepath, - include_paths: include_paths, - flags: flags, - defines: defines, - # If no shallow results, fall back to only depth 1 results of nested discovery - shallow: shallow.empty? - ) - - # Combine shallow and nested include knowledge of mocks - mocks = combine_mocks(shallow, nested) - - # Redefine shallow and nested results without any mocks - shallow = remove_mocks( shallow ) - nested = remove_mocks( nested ) - - # Return - # - Includes common to shallow and nested results, with paths from nested - # - Add mocks back in (may be empty if mocking not enabled) - return common_includes(shallow:shallow, nested:nested, explicit:fallback, deep:deep) + mocks - end - - # Write to disk a yaml representation of a list of includes - def write_includes_list(filepath, list) - @yaml_wrapper.dump(filepath, list) - end - - ### Private ### - private - - def extract_shallow_includes_preprocessor(test:, filepath:, flags:, defines:) - ## - ## Preprocessor Make Rule Handling - ## =============================== - ## - ## Creation: - ## - This output is created with the -MM -MG -MP command line options. - ## - No search paths are used towards extracting only the #include statements of the file. - ## The intent is to minimize the list of .h -> .c module matches to, in turn, minimize - ## unnecessary compilation when extracting includes from a test file. - ## - Note: This approach can have gaps with complex macros / conditional statements. - ## Gaps can be minimized with proper defines in the project file. - ## However, needed / complex macros located in other header files can still gum - ## up the works. - ## - ## Format: - ## - First line is .o file followed by colon and dependencies (on one or more lines). - ## - "Phony" make rules follow that conveniently list each #include, one per line. - ## - ## Notes: - ## - Many errors can occur but may not necessarily prevent usable results. - ## - A file with no includes will create the first line with self-referential .h file path. - ## - Make rule formation assumes any files not found in a search path will be generated. - ## - Since we're not using search paths, the preprocessor largely assumes all #include - ## files are generated (and include no paths). - ## - The exception is #include files that exist in the same directory as the file - ## being processed. - ## - ## Approach: - ## 1. Disable exceptions for tool execution as errors are likely. - ## - We may still have usable output. - ## - We do not want to stop execution on fatal error; instead use a fallback method. - ## 2. The only true error is no make rule present--check for this first. - ## - A make rule may be present but not depedencies if the file has no #includes. - ## 3. Extract includes from "phony" make rules that follow opening rule line. - ## - These may be .h or .c files. - ## - ## Example output follows - ## ----------------------------------------------------------------------------------------- - ## os.o: ../../src/app/task/os/os.h fstd_types.h FreeRTOS.h queue.h - ## fstd_types.h: - ## FreeRTOS.h: - ## queue.h: - ## ../../src/app/task/os/os.h:72:21: error: no include path in which to search for stdbool.h - ## 72 | #include <stdbool.h> - ## | ^ - ## ../../src/app/task/os/os.h:73:20: error: no include path in which to search for stdint.h - ## 73 | #include <stdint.h> - ## | ^ - ## - - # Matcher for the first line of the make rule output - make_rule_matcher = /^\S+\.o:\s+.+$/ # <characters>.o: <characters> - - # Matcher for the “phony“ make rule output lines for each #include dependency (.h, .c, etc.) - # Capture file name before the colon - include_matcher = /^(\S+\.\S+):\s*$/ # <characters>.<extension>: - - command = - @tool_executor.build_command_line( - @configurator.tools_test_shallow_includes_preprocessor, - flags, - filepath, - defines - ) - - # Assume possible errors so we have best shot at extracting results from preprocessing. - # Full code compilation will catch any breaking code errors - command[:options][:boom] = false - shell_result = @tool_executor.exec( command ) - - make_rules = shell_result[:output] - - # Do not check exit code for success. In some error conditions we still get usable output. - # Look for the first line of the make rule output. - if not make_rules =~ make_rule_matcher - @loginator.lazy( Verbosity::DEBUG ) do - "Preprocessor #include extraction failed: #{shell_result[:output]}" - end - - return false, [] - end - - includes = [] - - # Extract the #include dependencies from the "phony" make rules, one per line - includes = make_rules.scan( include_matcher ) - includes.flatten! # Regex results can be nested arrays becuase of paren captures - - return true, includes.uniq - end - - def extract_shallow_includes_regex(test:, filepath:, flags:, defines:) - msg = @reportinator.generate_module_progress( - operation: "Using fallback regex #include extraction for", - module_name: test, - filename: File.basename( filepath ) - ) - @loginator.log(msg, Verbosity::NORMAL) - - # Use abilities of @test_context_extractor to extract the #includes via regex on the file - includes = [] - @file_wrapper.open( filepath, 'r' ) do |file| - includes = @test_context_extractor.extract_includes( file ) - end - - return includes - end - - def extract_nested_includes(filepath:, include_paths:, flags:, defines:, shallow:false) - ## - ## Preprocessor Header File Listing Handling - ## ========================================= - ## - ## Creation: - ## - This output is created with the -MM -MG -H command line options. - ## - -MM -MG generates unused make rule that significantly reduces overall output. - ## - -H creates the header file output listing we actually want. - ## - Search paths are provided towards fully preprocessing all macros / conditionals and - ## symbols. (This produces a rich list of #includes far greater than we need.) - ## - ## Format (ignoring throwaway make rule): - ## - Each included filepath is listed per line. - ## - The depth of the #include nesting is signified by precending '.'s. - ## - Files directly #include'd in the file being preprocessed are at depth 1 ('.') - ## - ## Notes: - ## - Because search paths and defines are provided, error-free execution is assumed. - ## If the preprocessor fails, issues exist that will cause full compilation to fail. - ## - Unfortuantely, because of ordering and nesting effects, a file directly #include'd may - ## not be listed at depth 1 ('.'). Instead, it may end up listed at greater depth beneath - ## another #include'd file if both files reference it. That is, there is no way - ## to give the preprocessor full context and ask for only the files directly - ## #include'd in the file being processed. - ## - The preprocessor outputs the -H #include listing to STDERR. ToolExecutor does this - ## by default in creating the shell result output. - ## - Since we're using search paths, all #included files will include paths. Depending on - ## circumstances, this could yield a list with generated mocks with full build paths. - ## - ## Approach: - ## - Match on each listing line a filepath preceeded by its depth - ## - One mode of using this preprocessor approach is as a fallback / double-check method - ## if the simpler, earler shallow preprocessing produces no #include results. When used - ## this way we match only #include'd files at depth 1 ('.'), hoping we extract an - ## appropriate, usable list of #includes. - ## - ## Example output follows - ## ----------------------------------------------------------------------------------------- - ## . build/vendor/unity/src/unity.h - ## .. build/vendor/unity/src/unity_internals.h - ## . src/Types.h - ## . src/Model.h - ## . src/TimerModel.h - ## .. src/Testing.h - ## TestModel.o: test/TestModel.c build/vendor/unity/src/unity.h \ - ## build/vendor/unity/src/unity_internals.h setjmp.h math.h stddef.h \ - ## stdint.h limits.h stdio.h src/Types.h src/Model.h src/TimerModel.h \ - ## src/Testing.h MockTaskScheduler.h MockTemperatureFilter.h - ## - - command = - @tool_executor.build_command_line( - @configurator.tools_test_nested_includes_preprocessor, - flags, - filepath, - include_paths, - defines - ) - - # Let the preprocessor do as much as possible - # We'll extract nothing if a catastrophic error, but we'll see it in debug logging - # Any real problems will be flagged by actual compilation step - command[:options][:boom] = false - - shell_result = @tool_executor.exec( command ) - - list = shell_result[:output] - - includes = [] - - # Extract entries from #include listing - matches = list.scan(/(\.*\s+([^\s]+\.h))/) - if shallow - # First level of includes in preprocessor output - includes = matches.map {|v| (v[0].match?(/^\.\.+\s+/) ? nil : v[1]) }.compact - else - # All levels of includes in preprocessor output - includes = matches.map {|v| v[1] }.compact - end - - includes.flatten! # Regex results can be nested arrays becuase of paren captures - - return includes.uniq - end - - def combine_mocks(*lists) - # Handle mocks - # - Ensure no build filepaths in mock listings - # - Do not return mocks if mocking is disabled - mocks = [] - - # Bail out early if mocks are not enabled - return [] if !@configurator.project_use_mocks - - # Use some greediness to ensure we get all possible mocks - lists.each { |list| mocks |= extract_mocks( list ) } - - return mocks - end - - # Return a list of mock .h files with no paths - def extract_mocks(includes) - return includes.select { |include| File.basename(include).start_with?( @configurator.cmock_mock_prefix ) } - end - - # Return list of includes with any mocks removed - def remove_mocks(includes) - return includes.reject { |include| File.basename(include).start_with?( @configurator.cmock_mock_prefix ) } - end - - # Return includes common in both lists with the full paths of the nested list - def common_includes(shallow:, nested:, explicit:, deep: false) - return shallow if nested.empty? - return nested if shallow.empty? - - # Notes: - # - We want to preserve filepaths whenever possible. Other areas of Ceedling use or discard the - # filepath as needed. - # - We generally do not have filepaths in the shallow list--except when the #include is in the - # same directory as the file being processed - - # Approach - # 1. Create hashed lists of shallow and nested for easier matching - # 2. Perform appropriate mix of paths - # a. A union if performing a deep include list - # b. An intersection if performing a shallow include list - # 3. Pick the "fullest" path from the lists (assumes nested list has deeper paths) - - # Hash list for Shallow Search - _shallow = {} - shallow.each { |item| _shallow[ File.basename(item) ] = item } - - # Hash list for Nested Search - _nested = {} - nested.each {|item| _nested[ File.basename(item) ] = item } - - # Determine the filenames to include in our list - basenames = if deep - ( _nested.keys.to_set.union( _shallow.keys.to_set ) ) - else - ( _nested.keys.to_set.intersection( _shallow.keys.to_set ) ).intersection( explicit ) #intersection of both arrays PLUS MUST BE IN EXPLICIT call list - end - - # Iterate through the basenames and return the fullest version of each - return basenames.map {|v| _nested[v] || _shallow[v] } - end - -end diff --git a/lib/ceedling/rakefile.rb b/lib/ceedling/rakefile.rb index a88d1e2ba..2896dab6b 100644 --- a/lib/ceedling/rakefile.rb +++ b/lib/ceedling/rakefile.rb @@ -10,6 +10,10 @@ # Add Unity and CMock's Ruby code paths to $LOAD_PATH for runner generation and mocking $LOAD_PATH.unshift( File.join( CEEDLING_APPCFG[:ceedling_vendor_path], 'unity/auto') ) $LOAD_PATH.unshift( File.join( CEEDLING_APPCFG[:ceedling_vendor_path], 'cmock/lib') ) +# Add all subdirectories beneath ceedling_lib_path to $LOAD_PATH to support DIY construction +Dir.glob(File.join(CEEDLING_APPCFG[:ceedling_lib_path], '**/')).each do |dir| + $LOAD_PATH.unshift(dir) +end require 'rake' @@ -29,8 +33,7 @@ def log_runtime(run, start_time_s, end_time_s, enabled) return if duration.empty? - @ceedling[:loginator].log() # Blank line - @ceedling[:loginator].log( "Ceedling #{run} completed in #{duration}", Verbosity::NORMAL) + @ceedling[:loginator].log( "\nCeedling #{run} completed in #{duration}", Verbosity::NORMAL) end start_time = nil # Outside scope of exception handling diff --git a/lib/ceedling/reportinator.rb b/lib/ceedling/reportinator.rb index 5ba47d7b3..5620b56e9 100644 --- a/lib/ceedling/reportinator.rb +++ b/lib/ceedling/reportinator.rb @@ -107,8 +107,11 @@ def generate_progress(message) def generate_module_progress(module_name:, filename:, operation:) # <Operation [module_name::]filename>..." + # Sanitze -- ensure it's a string and strip any filename extension + _module_name = module_name.to_s().ext('') + # If filename is the module name, don't add the module label - label = (File.basename(filename).ext('') == module_name.to_s) ? '' : "#{module_name}::" + label = (File.basename(filename).ext('') == _module_name) ? '' : "#{_module_name}::" return generate_progress("#{operation} #{label}#{filename}") end diff --git a/lib/ceedling/setupinator.rb b/lib/ceedling/setupinator.rb index 76eb3a489..58e9142b1 100644 --- a/lib/ceedling/setupinator.rb +++ b/lib/ceedling/setupinator.rb @@ -57,7 +57,7 @@ def do_setup( app_cfg ) @loginator.set_logfile( app_cfg[:log_filepath] ) @configurator.project_logging = @loginator.project_logging - log_step( 'Validating configuration contains minimum required sections', heading:false ) + log_step( 'Validating configuration contains minimum required sections', heading: false ) # Complain early about anything essential that's missing @configurator.validate_essential( config_hash ) @@ -65,11 +65,17 @@ def do_setup( app_cfg ) # Merge any needed runtime settings into user configuration @configurator.merge_ceedling_runtime_config( config_hash, CEEDLING_RUNTIME_CONFIG.deep_clone ) + if config_hash[:project][:use_partials] + log_step( 'Processing Partials configuration', heading: false, verbosity: Verbosity::NORMAL ) + # Set configuration settings derived from enabling partials + @configurator.set_partials_derived_config( config_hash ) + end + ## ## 2. Handle basic configuration ## - log_step( 'Base configuration handling', heading:false ) + log_step( 'Base configuration handling', heading: false ) # Evaluate environment vars before plugin configurations that might reference with inline Ruby string expansion @configurator.eval_environment_variables( config_hash ) @@ -146,7 +152,7 @@ def do_setup( app_cfg ) ## 6. Validate configuration ## - log_step( 'Validating final project configuration', heading:false ) + log_step( 'Validating final project configuration', heading: false ) @configurator.validate_final( config_hash, app_cfg ) @@ -185,8 +191,8 @@ def do_setup( app_cfg ) private # Neaten up a build step with progress message and some scope encapsulation - def log_step(msg, heading:true) - @loginator.lazy( Verbosity::OBNOXIOUS ) do + def log_step(msg, heading: true, verbosity: Verbosity::OBNOXIOUS ) + @loginator.lazy( verbosity ) do if heading @reportinator.generate_heading( @loginator.decorate( msg, LogLabels::CONSTRUCT ) ) else # Progress message diff --git a/lib/ceedling/test_context_extractor.rb b/lib/ceedling/test_context_extractor.rb index 6101b5653..3acba57e8 100644 --- a/lib/ceedling/test_context_extractor.rb +++ b/lib/ceedling/test_context_extractor.rb @@ -6,23 +6,48 @@ # ========================================================================= require 'ceedling/exceptions' +require 'ceedling/partials/partials' require 'ceedling/file_path_utils' -require 'ceedling/generator_test_runner' # From lib/ not vendor/unity/auto +require 'ceedling/generators/generator_test_runner' # From lib/ not vendor/unity/auto require 'ceedling/encodinator' class TestContextExtractor - constructor :configurator, :file_wrapper, :loginator, :parsing_parcels + # Context extraction options + module Context + BUILD_DIRECTIVE_INCLUDE_PATHS = :build_directive_include_paths unless const_defined?(:BUILD_DIRECTIVE_INCLUDE_PATHS) + BUILD_DIRECTIVE_SOURCE_FILES = :build_directive_source_files unless const_defined?(:BUILD_DIRECTIVE_SOURCE_FILES) + INCLUDES = :includes unless const_defined?(:INCLUDES) + TEST_RUNNER_DETAILS = :test_runner_details unless const_defined?(:TEST_RUNNER_DETAILS) + PARTIALS_CONFIGURATION = :partials_configuration unless const_defined?(:PARTIALS_CONFIGURATION) + + ALL = [ + BUILD_DIRECTIVE_INCLUDE_PATHS, + BUILD_DIRECTIVE_SOURCE_FILES, + INCLUDES, + TEST_RUNNER_DETAILS, + PARTIALS_CONFIGURATION + ].freeze unless const_defined?(:ALL) + end + + constructor( + :configurator, + :parsing_parcels, + :include_factory, + :partializer_config, + :file_path_utils, + :file_wrapper, + :loginator + ) def setup # Per test-file lookup hashes - @all_header_includes = {} # Full list of all headers from test #include statements - @header_includes = {} # List of all headers minus mocks & framework files - @source_includes = {} # List of C files #include'd in a test file - @source_extras = {} # C source files outside of header convention added to test build by TEST_SOURCE_FILE() + @header_includes = {} # Full list of all headers as Include objects from #include statements + @source_includes = {} # List of C files #include'd in a test file as Include objects + @source_extras = {} # List of C source files as strings outside of header convention added to test build by TEST_SOURCE_FILE() @test_runner_details = {} # Test case lists & Unity runner generator instances - @mocks = {} # List of mocks by name without header file extension - @include_paths = {} # Additional search paths added to a test build via TEST_INCLUDE_PATH() + @partials_config = {} # Hash of module_name => PartializerConfig::Config structs per test file + @include_paths = {} # List of additional search paths as strings added to a test build via TEST_INCLUDE_PATH() # Arrays @all_include_paths = [] # List of all search paths added through individual test files using TEST_INCLUDE_PATH() @@ -30,58 +55,77 @@ def setup @lock = Mutex.new end - # `input` must have the interface of IO -- StringIO for testing or File in typical use - def collect_simple_context( filepath, input, *args ) - all_options = [ - :build_directive_include_paths, - :build_directive_source_files, - :includes, - :test_runner_details - ] + # Reads through a file's content line by line to extract relevant information by context. + # `content_filepath` is the file to be read (could be preprocessed version of test file). + # `source_fileapth` is the test filepath to use for results storage and lookup. + # `*args` is a list of context symbols. + # If `source_filepath` is not provided, then `content_filepath` is used for results storage and lookup. + def collect_simple_context_from_file( content_filepath, source_filepath, *args ) + # Load content_filepath + @file_wrapper.open( content_filepath, 'r' ) do |input| + collect_context( + # Use source_filepath if provided, else use content_filepath + (source_filepath.nil? ? content_filepath : source_filepath), + input, + *args + ) + end + end + # Reads through IO interface line by line to extract relevant information by context. + # `*args` is a list of context symbols. + # `input` must have the interface of IO -- StringIO for testing or File in typical use. + def collect_context( filepath, input, *args ) # Code error check--bad context symbol argument args.each do |context| - next if context == :all msg = "Unrecognized test context for collection :#{context}" - raise CeedlingException.new( msg ) if !all_options.include?( context ) + raise CeedlingException.new( msg ) if !Context::ALL.include?( context ) end - # Handle the :all shortcut to redefine list to include all contexts - args = all_options if args.include?( :all ) - include_paths = [] source_extras = [] includes = [] + partials_config = {} @parsing_parcels.code_lines( input ) do |line| - if args.include?( :build_directive_include_paths ) + if args.include?( Context::BUILD_DIRECTIVE_INCLUDE_PATHS ) # Scan for build directives: TEST_INCLUDE_PATH() include_paths += extract_build_directive_include_paths( line ) end - if args.include?( :build_directive_source_files ) + if args.include?( Context::BUILD_DIRECTIVE_SOURCE_FILES ) # Scan for build directives: TEST_SOURCE_FILE() source_extras += extract_build_directive_source_files( line ) end - if args.include?( :includes ) + if args.include?( Context::INCLUDES ) # Scan for contents of #include directives includes += _extract_includes( line ) end end - collect_build_directive_include_paths( filepath, include_paths ) if args.include?( :build_directive_include_paths ) - collect_build_directive_source_files( filepath, source_extras ) if args.include?( :build_directive_source_files ) - collect_includes( filepath, includes ) if args.include?( :includes ) - + collect_build_directive_include_paths( filepath, include_paths ) if !include_paths.empty? + collect_build_directive_source_files( filepath, source_extras ) if !source_extras.empty? + # Different code processing pattern for test runner - if args.include?( :test_runner_details ) + if args.include?( Context::TEST_RUNNER_DETAILS ) # Go back to beginning of IO object for a full string extraction input.rewind() # Ultimately, we rely on Unity's runner generator that processes file contents as a single string _collect_test_runner_details( filepath, input.read() ) end + + if args.include?( Context::PARTIALS_CONFIGURATION ) + # Go back to beginning of IO object for a full string extraction + input.rewind() + + # Scan for Partials configuration directive macros + partials_config = _extract_partials_config( input ) + collect_partials_configuration( filepath, partials_config ) if !partials_config.empty? + end + + collect_includes( filepath, partials_config, includes ) if (!includes.empty? or !partials_config.empty?) end def collect_test_runner_details(test_filepath, input_filepath=nil) @@ -93,28 +137,8 @@ def collect_test_runner_details(test_filepath, input_filepath=nil) ) end - # Scan for all includes. - # Unlike other extract() calls, extract_includes() is public to be called externally. - # `input` must have the interface of IO -- StringIO for testing or File in typical use - def extract_includes(input) - includes = [] - - @parsing_parcels.code_lines( input ) {|line| includes += _extract_includes( line ) } - - return includes.uniq - end - - # All header includes .h of test file - def lookup_full_header_includes_list(filepath) - val = nil - @lock.synchronize do - val = @all_header_includes[form_file_key( filepath )] || [] - end - return val - end - - # Header includes .h (minus mocks & framework headers) in test file - def lookup_header_includes_list(filepath) + # All header includes .h as list of Include objects of test file + def lookup_all_header_includes_list(filepath) val = nil @lock.synchronize do val = @header_includes[form_file_key( filepath )] || [] @@ -131,7 +155,7 @@ def lookup_include_paths_list(filepath) return val end - # Source C includes within test file + # Source C includes as list of Include objects within test file def lookup_source_includes_list(filepath) val = nil @lock.synchronize do @@ -140,7 +164,7 @@ def lookup_source_includes_list(filepath) return val end - # Source extras via TEST_SOURCE_FILE() within test file + # Source extras as list of string via TEST_SOURCE_FILE() within test file def lookup_build_directive_sources_list(filepath) val = nil @lock.synchronize do @@ -149,6 +173,7 @@ def lookup_build_directive_sources_list(filepath) return val end + # Test case names as list of strings def lookup_test_cases(filepath) val = [] @lock.synchronize do @@ -160,6 +185,7 @@ def lookup_test_cases(filepath) return val end + # Fetch Unity runner generator instance for test file def lookup_test_runner_generator(filepath) val = nil @lock.synchronize do @@ -171,16 +197,30 @@ def lookup_test_runner_generator(filepath) return val end - # Mocks within test file with no file extension - def lookup_raw_mock_list(filepath) + # Mocks within test file header includes list as list of MockInclude objects + def lookup_mock_header_includes_list(filepath) + includes = lookup_all_header_includes_list(filepath) + return includes.select { |include| include.is_a?( MockInclude ) } + end + + # Test file header includes list minus mocks as list of Include objects + def lookup_nonmock_header_includes_list(filepath) + includes = lookup_all_header_includes_list(filepath) + return includes.reject { |include| include.is_a?( MockInclude ) } + end + + # List of single item hashes for Partials configuration by test name + # { <Partial type symbol> => <Module name string> } + def lookup_partials_config(filepath) val = nil @lock.synchronize do - val = @mocks[form_file_key( filepath )] || [] + val = @partials_config[form_file_key( filepath )] || {} end return val end - def lookup_all_include_paths + # Full list of all search paths added through individual test files using TEST_INCLUDE_PATH() + def lookup_all_include_paths() val = nil @lock.synchronize do val = @all_include_paths.uniq @@ -188,7 +228,7 @@ def lookup_all_include_paths return val end - def inspect_include_paths + def inspect_include_paths() @lock.synchronize do @include_paths.each { |test, paths| yield test, paths } end @@ -196,54 +236,45 @@ def inspect_include_paths # Unlike other ingest() calls, ingest_includes() can be called externally. def ingest_includes(filepath, includes) - mock_prefix = @configurator.cmock_mock_prefix - file_key = form_file_key( filepath ) + _includes = Includes.sanitize(includes) + + file_key = form_file_key( filepath ) - mocks = [] - all_headers = [] - headers = [] - sources = [] + headers = [] + sources = [] - includes.each do |include| + # Processing list of UserInclude and/or SystemInclude + _includes.each do |include| # <*.h> - if include =~ /#{Regexp.escape(@configurator.extension_header)}$/ - # Check if include is a mock with regex match that extracts only mock name (no .h) - scan_results = include.scan(/([^\s]*\b#{mock_prefix}.+)#{Regexp.escape(@configurator.extension_header)}/) - - if (scan_results.size > 0) - # Collect mock name - mocks << scan_results[0][0] - else - # If not a mock or framework file, collect tailored header filename - headers << include unless VENDORS_FILES.include?( include.ext('') ) - end - + if include.filename =~ /#{Regexp.escape(@configurator.extension_header)}$/ # Add to .h includes list - all_headers << include - # <*.c> - elsif include =~ /#{Regexp.escape(@configurator.extension_source)}$/ + headers << include + elsif include.filename =~ /#{Regexp.escape(@configurator.extension_source)}$/ # Add to .c includes list sources << include end end @lock.synchronize do - @mocks[file_key] = mocks - @all_header_includes[file_key] = all_headers @header_includes[file_key] = headers @source_includes[file_key] = sources end + + return _includes end private ################################# def collect_build_directive_source_files(filepath, files) - ingest_build_directive_source_files( filepath, files.uniq ) + _files = files.compact + _files.uniq! + + ingest_build_directive_source_files( filepath, _files ) debug_log_list( "Extra source files found via TEST_SOURCE_FILE()", filepath, - files + _files ) end @@ -257,9 +288,39 @@ def collect_build_directive_include_paths(filepath, paths) ) end - def collect_includes(filepath, includes) - ingest_includes( filepath, includes.uniq ) - debug_log_list( "#includes found", filepath, includes ) + def collect_includes(filepath, partials_config, includes) + # Squeeze out any nil elements + includes.compact! + + # `partials_config` is a hash of module_name => PartializerConfig::Config. + partials_config.each do |_module, config| + if config.tests.present? + filename = @file_path_utils.form_partial_implementation_header_filename( _module ) + includes << @include_factory.user_include_from_filepath( filename ) + end + + if config.mocks.present? + filename = @file_path_utils.form_mock_partial_interface_header_filename( _module ) + includes << @include_factory.user_include_from_filepath( filename ) + end + end + + # `ingest_includes()` does some housekeeping on the list + _includes = ingest_includes( filepath, includes ) + + debug_log_list( "#includes found", filepath, _includes ) + end + + def collect_partials_configuration(filepath, partials_config) + ingest_partials_configuration(filepath, partials_config) + + debug_lines = partials_config.map do |_module, config| + t = config.tests + m = config.mocks + "#{_module}: tests(type=#{t.type} +#{t.additions} -#{t.subtractions}) " \ + "mocks(type=#{m.type} +#{m.additions} -#{m.subtractions})" + end + debug_log_list( "Partials configurations found", filepath, debug_lines ) end def _collect_test_runner_details(filepath, test_content, input_content=nil) @@ -275,9 +336,9 @@ def _collect_test_runner_details(filepath, test_content, input_content=nil) ) test_cases = unity_test_runner_generator.test_cases - test_cases = test_cases.map {|test_case| "#{test_case[:line_number]}:#{test_case[:test]}()" } + test_cases = test_cases.map {|test_case| "#{test_case[:test]}(): #{test_case[:line_number]}" } - debug_log_list( "Test cases found ", filepath, test_cases ) + debug_log_list( "Test cases with line numbers found", filepath, test_cases ) end def extract_build_directive_source_files(line) @@ -307,13 +368,20 @@ def extract_build_directive_include_paths(line) def _extract_includes(line) includes = [] - # Look for #include statements - results = line.match(/#\s*include\s+\"\s*([\w\.\-]+)\s*\"/) - includes << results[1] if !results.nil? + _include = @include_factory.system_include_from_directive( line ) + includes << _include if !_include.nil? + # All of the UserInclude types + _include = @include_factory.user_include_from_directive( line ) + includes << _include if !_include.nil? + return includes end + def _extract_partials_config(input) + @partializer_config.extract_configs(input) + end + ## ## Data structure management ingest methods ## @@ -353,6 +421,16 @@ def ingest_test_runner_details(filepath:, test_runner_generator:) end end + def ingest_partials_configuration(filepath, partials_config) + return if partials_config.empty? + + key = form_file_key( filepath ) + + @lock.synchronize do + @partials_config[key] = partials_config + end + end + ## ## Utility methods ## @@ -362,17 +440,8 @@ def form_file_key( filepath ) end def debug_log_list(message, filepath, list) - msg = "#{message} in #{filepath}:" - if list.empty? - msg += " <none>" - else - msg += "\n" - list.each do |item| - msg += " - #{item}\n" - end - end - - @loginator.log( "#{msg}\n\n", Verbosity::DEBUG ) + header = "#{message} in #{filepath}" + @loginator.log_list( list, header, Verbosity::DEBUG ) end end diff --git a/lib/ceedling/test_invoker.rb b/lib/ceedling/test_invoker.rb deleted file mode 100644 index 75d01dfd0..000000000 --- a/lib/ceedling/test_invoker.rb +++ /dev/null @@ -1,517 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -require 'ceedling/constants' -require 'fileutils' - -class TestInvoker - - attr_reader :sources, :tests, :mocks - - constructor :application, - :configurator, - :test_invoker_helper, - :plugin_manager, - :reportinator, - :loginator, - :batchinator, - :preprocessinator, - :task_invoker, - :generator, - :test_context_extractor, - :file_path_utils, - :file_wrapper, - :file_finder, - :verbosinator - - def setup - # Master data structure for all test activities - @testables = {} - - # For thread-safe operations on @testables - @lock = Mutex.new - - # Aliases for brevity in code that follows - @helper = @test_invoker_helper - @context_extractor = @test_context_extractor - end - - def setup_and_invoke(tests:, context:TEST_SYM, options:{}) - # Wrap everything in an exception handler - begin - # Begin fleshing out the testables data structure - @batchinator.build_step("Preparing Build Paths", heading: false) do - results_path = File.join( @configurator.project_build_root, context.to_s, 'results' ) - - @batchinator.exec(workload: :compile, things: tests) do |filepath| - filepath = filepath.to_s - key = testable_symbolize(filepath) - name = key.to_s - build_path = File.join( @configurator.project_build_root, context.to_s, 'out', name ) - mocks_path = File.join( @configurator.cmock_mock_path, name ) - - preprocess_includes_path = File.join( @configurator.project_test_preprocess_includes_path, name ) - preprocess_files_path = File.join( @configurator.project_test_preprocess_files_path, name ) - - @lock.synchronize do - @testables[key] = { - :filepath => filepath, - :name => name, - :paths => {} - } - end - - paths = @testables[key][:paths] - paths[:build] = build_path - paths[:results] = results_path - paths[:mocks] = mocks_path if @configurator.project_use_mocks - if @configurator.project_use_test_preprocessor != :none - paths[:preprocess_incudes] = preprocess_includes_path - paths[:preprocess_files] = preprocess_files_path - paths[:preprocess_files_full_expansion] = File.join( preprocess_files_path, PREPROCESS_FULL_EXPANSION_DIR ) - paths[:preprocess_files_directives_only] = File.join( preprocess_files_path, PREPROCESS_DIRECTIVES_ONLY_DIR ) - end - - @testables[key][:paths].each {|_, path| @file_wrapper.mkdir( path ) } - end - - # Remove any left over test results from previous runs - @helper.clean_test_results( results_path, @testables.map{ |_, t| t[:name] } ) - end - - # Collect in-test build directives, #include statements, and test cases from test files. - # (Actions depend on preprocessing configuration) - @batchinator.build_step("Collecting Test Context") do - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - filepath = details[:filepath] - - if @configurator.project_use_test_preprocessor_tests - msg = @reportinator.generate_progress( "Parsing #{File.basename(filepath)} for include path build directive macros" ) - @loginator.log( msg ) - - # Just build directive macro using simple text scanning. - # Other context collected in later steps with help of preprocessing. - @file_wrapper.open( filepath, 'r' ) do |input| - @context_extractor.collect_simple_context( filepath, input, :build_directive_include_paths ) - end - else - msg = @reportinator.generate_progress( "Parsing #{File.basename(filepath)} for build directive macros, #includes, and test case names" ) - @loginator.log( msg ) - - # Collect everything using simple text scanning (no preprocessing involved). - @file_wrapper.open( filepath, 'r' ) do |input| - @context_extractor.collect_simple_context( filepath, input, :all ) - end - end - - end - - # Validate paths via TEST_INCLUDE_PATH() & augment header file collection from the same - @helper.process_project_include_paths() - end - - # Fill out testables data structure with build context - @batchinator.build_step("Ingesting Test Configurations") do - framework_defines = @helper.framework_defines() - runner_defines = @helper.runner_defines() - - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - filepath = details[:filepath] - - search_paths = @helper.search_paths( filepath, details[:name] ) - - compile_flags = @helper.flags( context:context, operation:OPERATION_COMPILE_SYM, filepath:filepath ) - preprocess_flags = @helper.preprocess_flags( context:context, compile_flags:compile_flags, filepath:filepath ) - assembler_flags = @helper.flags( context:context, operation:OPERATION_ASSEMBLE_SYM, filepath:filepath ) - link_flags = @helper.flags( context:context, operation:OPERATION_LINK_SYM, filepath:filepath ) - - compile_defines = @helper.compile_defines( context:context, filepath:filepath ) - preprocess_defines = @helper.preprocess_defines( test_defines: compile_defines, filepath:filepath ) - - msg = @reportinator.generate_module_progress( - operation: 'Collecting search paths, flags, and defines', - module_name: details[:name], - filename: File.basename( details[:filepath] ) - ) - @loginator.log( msg ) - - @lock.synchronize do - details[:search_paths] = search_paths - details[:preprocess_flags] = preprocess_flags - details[:compile_flags] = compile_flags - details[:assembler_flags] = assembler_flags - details[:link_flags] = link_flags - details[:compile_defines] = compile_defines + framework_defines + runner_defines - details[:preprocess_defines] = preprocess_defines + framework_defines - end - end - end - - # Collect include statements & mocks from test files - @batchinator.build_step("Collecting Test Context") do - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - arg_hash = { - filepath: details[:filepath], - test: details[:name], - flags: details[:preprocess_flags], - include_paths: details[:search_paths], - defines: details[:preprocess_defines] - } - - msg = @reportinator.generate_module_progress( - operation: 'Preprocessing #include statements for', - module_name: arg_hash[:test], - filename: File.basename( arg_hash[:filepath] ) - ) - @loginator.log( msg ) - - @helper.extract_include_directives( arg_hash ) - end - end if @configurator.project_use_test_preprocessor_tests - - # Determine Runners & Mocks For All Tests - @batchinator.build_step("Determining Files to be Generated", heading: false) do - @batchinator.exec(workload: :compile, things: @testables) do |test, details| - runner_filepath = @file_path_utils.form_runner_filepath_from_test( details[:filepath] ) - - mocks = {} - mocks_list = @configurator.project_use_mocks ? @context_extractor.lookup_raw_mock_list( details[:filepath] ) : [] - mocks_list.each do |name| - source = @helper.find_header_input_for_mock( name, details[:search_paths] ) - preprocessed_input = @file_path_utils.form_preprocessed_file_filepath( source, details[:name] ) - mocks[name.to_sym] = { - :name => name, - :source => source, - :input => (@configurator.project_use_test_preprocessor_mocks ? preprocessed_input : source) - } - end - - @lock.synchronize do - details[:runner] = { - :output_filepath => runner_filepath, - :input_filepath => details[:filepath] # Default of the test file - } - details[:mocks] = mocks - details[:mock_list] = mocks_list - - # Trigger pre_test plugin hook after having assembled all testing context - @plugin_manager.pre_test( details[:filepath] ) - end - end - end - - # Create inverted/flattened mock lookup list to take advantage of threading - # (Iterating each testable and mock list instead would limit the number of simultaneous mocking threads) - mocks = [] - if @configurator.project_use_mocks - @testables.each do |_, details| - details[:mocks].each do |name, elems| - mocks << {:name => name, :details => elems, :testable => details} - end - end - end - - # Preprocess Header Files - @batchinator.build_step("Preprocessing for Mocks") { - @batchinator.exec(workload: :compile, things: mocks) do |mock| - details = mock[:details] - testable = mock[:testable] - - arg_hash = { - filepath: details[:source], - test: testable[:name], - flags: testable[:preprocess_flags], - include_paths: testable[:search_paths], - defines: testable[:preprocess_defines] - } - - @preprocessinator.preprocess_mockable_header_file( **arg_hash ) - end - } if @configurator.project_use_mocks and @configurator.project_use_test_preprocessor_mocks - - # Generate mocks for all tests - @batchinator.build_step("Mocking") { - @batchinator.exec(workload: :compile, things: mocks) do |mock| - details = mock[:details] - testable = mock[:testable] - output_subpath = @file_wrapper.dirname( mock[:name].to_s ) - output_path = testable[:paths][:mocks] + (output_subpath.empty? ? '' : "/#{output_subpath}") - - arg_hash = { - context: context, - mock: mock[:name], - test: testable[:name], - input_filepath: details[:input], - output_path: output_path - } - - @generator.generate_mock(**arg_hash) - end - } if @configurator.project_use_mocks - - # Preprocess test files - @batchinator.build_step("Preprocessing Test Files") { - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - - arg_hash = { - filepath: details[:filepath], - test: details[:name], - flags: details[:preprocess_flags], - include_paths: details[:search_paths], - defines: details[:preprocess_defines] - } - - filepath = @preprocessinator.preprocess_test_file(**arg_hash) - - # Replace default input with preprocessed file - @lock.synchronize { details[:runner][:input_filepath] = filepath } - - # Collect sources added to test build with TEST_SOURCE_FILE() directive macro - # TEST_SOURCE_FILE() can be within #ifdef's--this retrieves them - @file_wrapper.open( filepath, 'r' ) do |input| - @context_extractor.collect_simple_context( details[:filepath], input, :build_directive_source_files ) - end - - # Validate test build directive source file entries via TEST_SOURCE_FILE() - @testables.each do |_, details| - @helper.validate_build_directive_source_files( test:details[:name], filepath:details[:filepath] ) - end - end - } if @configurator.project_use_test_preprocessor_tests - - # Collect test case names - @batchinator.build_step("Collecting Test Context") { - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - - msg = @reportinator.generate_module_progress( - operation: 'Parsing test case names', - module_name: details[:name], - filename: File.basename( details[:filepath] ) - ) - @loginator.log( msg ) - - @context_extractor.collect_test_runner_details( details[:filepath], details[:runner][:input_filepath] ) - end - } if @configurator.project_use_test_preprocessor_tests - - # Generate runners for all tests - @batchinator.build_step("Test Runners") do - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - arg_hash = { - context: context, - mock_list: details[:mock_list], - includes_list: @test_context_extractor.lookup_header_includes_list( details[:filepath] ), - test_filepath: details[:filepath], - input_filepath: details[:runner][:input_filepath], - runner_filepath: details[:runner][:output_filepath] - } - - @generator.generate_test_runner(**arg_hash) - end - end - - # Determine objects required for each test - @batchinator.build_step("Determining Artifacts to Be Built", heading: false) do - @batchinator.exec(workload: :compile, things: @testables) do |test, details| - # Source files referenced by conventions or specified by build directives in a test file - test_sources = @helper.extract_sources( details[:filepath] ) - test_core = test_sources + @helper.form_mock_filenames( details[:mock_list] ) - - # When we have a mock and an include for the same file, the mock wins - @helper.remove_mock_original_headers( test_core, details[:mock_list] ) - - # CMock + Unity + CException - test_frameworks = @helper.collect_test_framework_sources( !details[:mock_list].empty? ) - - # Extra suport source files (e.g. microcontroller startup code needed by simulator) - test_support = @configurator.collection_all_support - - compilations = [] - compilations << details[:filepath] - compilations += test_core - compilations << details[:runner][:output_filepath] - compilations += test_frameworks - compilations += test_support - compilations.uniq! - - test_objects = @file_path_utils.form_test_build_objects_filelist( details[:paths][:build], compilations ) - - test_executable = @file_path_utils.form_test_executable_filepath( details[:paths][:build], details[:filepath] ) - test_pass = @file_path_utils.form_pass_results_filepath( details[:paths][:results], details[:filepath] ) - test_fail = @file_path_utils.form_fail_results_filepath( details[:paths][:results], details[:filepath] ) - - # Assemble a list of object files from .c files that have been #included in the test file - test_no_link_objects = - @file_path_utils.form_test_build_objects_filelist( - details[:paths][:build], - @helper.fetch_shallow_source_includes( details[:filepath] )) - - # Redefine test_objects, removing any problematic object file that would otherwise get linked into the test executable - test_objects = (test_objects.uniq - test_no_link_objects) - - @lock.synchronize do - details[:sources] = test_sources - details[:frameworks] = test_frameworks - details[:core] = test_core - details[:objects] = test_objects - details[:executable] = test_executable - details[:no_link_objects] = test_no_link_objects - details[:results_pass] = test_pass - details[:results_fail] = test_fail - details[:tool] = TOOLS_TEST_COMPILER - end - end - end - - # Prepare to Parallelize ALL the build objects - objects = @testables.map do |_, details| - details[:objects].map do |obj| - { - tool: details[:tool], - test: details[:name], - msg: details[:msg], - obj: obj - } - end - end.flatten - - # Build All Test objects - @batchinator.build_step("Building Objects") do - @batchinator.exec(workload: :compile, things: objects) do |obj| - src = @file_finder.find_build_input_file(filepath: obj[:obj], context: context) - compile_test_component(tool: obj[:tool], context: context, test: obj[:test], source: src, object: obj[:obj], msg: obj[:msg]) - end - end - - # Create test binary - @batchinator.build_step("Building Test Executables") do - lib_args = @helper.convert_libraries_to_arguments() - lib_paths = @helper.get_library_paths_to_arguments() - @batchinator.exec(workload: :compile, things: @testables) do |_, details| - arg_hash = { - context: context, - build_path: details[:paths][:build], - executable: details[:executable], - objects: details[:objects], - flags: details[:link_flags], - lib_args: lib_args, - lib_paths: lib_paths, - options: options - } - - @helper.generate_executable_now(**arg_hash) - end - end - - # Execute Final Tests - @batchinator.build_step("Executing") { - results = @batchinator.exec(workload: :test, things: @testables) do |_, details| - begin - arg_hash = { - context: context, - test_name: details[:name], - test_filepath: details[:filepath], - executable: details[:executable], - result: details[:results_pass], - options: options - } - - @helper.run_fixture_now(**arg_hash) - - # Handle exceptions so we can ensure post_test() is called. - # A lone `ensure` includes an implicit rescuing of StandardError - # with the exception continuing up the call trace. - ensure - @plugin_manager.post_test( details[:filepath] ) - end - end - } unless options[:build_only] - - # Handle application-level exceptions. - # StandardError is the parent class of all application-level exceptions. - # Runtime errors (parent is Exception) continue on up to be handled by Ruby itself. - rescue StandardError => ex - @application.register_build_failure - - @loginator.log( ex.message, Verbosity::ERRORS, LogLabels::EXCEPTION ) - - # Debug backtrace (only if debug verbosity) - @loginator.log_debug_backtrace( ex ) - end - end - - def each_test_with_sources - @testables.each do |test, details| - yield(test.to_s, lookup_sources(test:test)) - end - end - - def lookup_sources(test:) - _test = test.is_a?(Symbol) ? test : test.to_sym - return (@testables[_test])[:sources] - end - - def compile_test_component(tool:, context:TEST_SYM, test:, source:, object:, msg:nil) - testable = @testables[test.to_sym] - filepath = testable[:filepath] - defines = testable[:compile_defines] - - # Tailor search path: - # 1. Remove duplicates. - # 2. If it's compilations of vendor / support files, reduce paths to only framework & support paths - # (e.g. we don't need all search paths to compile unity.c). - search_paths = @helper.tailor_search_paths(search_paths:testable[:search_paths], filepath:source) - - # C files (user-configured extension or core framework file extensions) - if @file_wrapper.extname(source) != @configurator.extension_assembly - flags = testable[:compile_flags] - - arg_hash = { - tool: tool, - module_name: test, - context: context, - source: source, - object: object, - search_paths: search_paths, - flags: flags, - defines: defines, - list: @file_path_utils.form_test_build_list_filepath( object ), - dependencies: @file_path_utils.form_test_dependencies_filepath( object ), - msg: msg - } - - @generator.generate_object_file_c(**arg_hash) - - # Assembly files - elsif @configurator.test_build_use_assembly - flags = testable[:assembler_flags] - - arg_hash = { - tool: tool, - module_name: test, - context: context, - source: source, - object: object, - search_paths: search_paths, - flags: flags, - defines: defines, # Generally ignored by assemblers - list: @file_path_utils.form_test_build_list_filepath( object ), - dependencies: @file_path_utils.form_test_dependencies_filepath( object ), - msg: msg - } - - @generator.generate_object_file_asm(**arg_hash) - end - end - - private - - def testable_symbolize(filepath) - return (File.basename( filepath ).ext('')).to_sym - end - -end diff --git a/lib/ceedling/test_invoker/test_build_executor.rb b/lib/ceedling/test_invoker/test_build_executor.rb new file mode 100644 index 000000000..6ec1b08aa --- /dev/null +++ b/lib/ceedling/test_invoker/test_build_executor.rb @@ -0,0 +1,653 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/constants' +require 'ceedling/exceptions' +require 'ceedling/test_invoker/test_invoker_types' + +class TestBuildExecutor + + include TestInvokerTypes + + constructor( + :configurator, + :loginator, + :reportinator, + :batchinator, + :preprocessinator, + :partializer, + :generator, + :test_context_extractor, + :plugin_manager, + :file_path_utils, + :file_finder, + :file_wrapper + ) + + def setup() + @context_extractor = @test_context_extractor + end + + # Stage 6: Preprocess partial header files for extract-and-generate pass. + def stage_preprocess_partial_headers(state) + # Generate directive-only preprocessor output if available + @batchinator.exec(workload: :compile, things: state.partials_headers) do |details| + config = details[:config] + testable = details[:testable] + name = testable.name + + arg_hash = { + filepath: config.filepath, + test: name, + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + _filepath = @preprocessinator.generate_directives_only_output( **arg_hash ) + details[:directives_only_filepath] = _filepath + end if @preprocessinator.directives_only_available? + + # Preprocess and assemble header files + @batchinator.exec(workload: :compile, things: state.partials_headers) do |details| + config = details[:config] + testable = details[:testable] + name = testable.name + directives_only_filepath = details[:directives_only_filepath] + + arg_hash = { + test: name, + filepath: config.filepath, + directives_only_filepath: directives_only_filepath, + fallback: (!@preprocessinator.directives_only_available? or directives_only_filepath.nil?), + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + config.directives_only_filepath, config.includes = @preprocessinator.preprocess_partial_header_file_preserve_macros( **arg_hash ) + end + + # Full-preprocess partial header files for expanded signature extraction. + @batchinator.exec(workload: :compile, things: state.partials_headers) do |details| + config = details[:config] + testable = details[:testable] + name = testable.name + + arg_hash = { + filepath: config.filepath, + test: name, + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + config.full_expansion_filepath = @preprocessinator.preprocess_partial_header_expand_macros( **arg_hash ) + end + end + + # Stage 7: Preprocess partial source files for extract-and-generate pass. + def stage_preprocess_partial_sources(state) + # Generate directive-only preprocessor output if available + @batchinator.exec(workload: :compile, things: state.partials_sources) do |details| + config = details[:config] + testable = details[:testable] + name = testable.name + + arg_hash = { + filepath: config.filepath, + test: name, + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + _filepath = @preprocessinator.generate_directives_only_output( **arg_hash ) + details[:directives_only_filepath] = _filepath + end if @preprocessinator.directives_only_available? + + # Preprocess and assemble source files + @batchinator.exec(workload: :compile, things: state.partials_sources) do |details| + config = details[:config] + testable = details[:testable] + name = testable.name + directives_only_filepath = details[:directives_only_filepath] + + arg_hash = { + test: name, + filepath: config.filepath, + directives_only_filepath: directives_only_filepath, + fallback: (!@preprocessinator.directives_only_available? or directives_only_filepath.nil?), + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + config.directives_only_filepath, config.includes = @preprocessinator.preprocess_partial_source_file_preserve_macros( **arg_hash ) + end + + # Full-preprocess partial source files for expanded signature extraction. + @batchinator.exec(workload: :compile, things: state.partials_sources) do |details| + config = details[:config] + testable = details[:testable] + name = testable.name + + arg_hash = { + filepath: config.filepath, + test: name, + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + config.full_expansion_filepath = @preprocessinator.preprocess_partial_source_expand_macros( **arg_hash ) + end + end + + # Stage 8: Extract and generate partial implementation and interface files. + def stage_generate_partials(state) + partials = [] + state.testables.each do |_, testable| + next if testable.partials.configs.empty? + testable.partials.configs.each do |_, config| + partials << { config: config, testable: testable } + end + end + + @batchinator.exec(workload: :compile, things: partials) do |partial| + config = partial[:config] + testable = partial[:testable] + name = testable.name + + module_contents = @partializer.extract_module_contents( + name, + config, + !@preprocessinator.directives_only_available? + ) + + @partializer.validate_config( c_module: module_contents, config: config, name: name ) + + @partializer.sanitize( module_contents ) + + implementation = @partializer.extract_implementation_functions( + test: name, + partial: config.module, + definitions: module_contents.function_definitions, + config: config + ) + + interface = @partializer.extract_interface_functions( + test: name, + partial: config.module, + definitions: module_contents.function_definitions, + declarations: module_contents.function_declarations, + config: config + ) + + @partializer.validate_extracted_functions( + name: name, + partial: config.module, + impl: implementation, + interface: interface + ) + + arg_hash = { + test: name, + partial: config.module, + function_definitions: implementation, + c_module: module_contents, + header_includes: @partializer.remap_implementation_header_includes( + name: config.module, + includes: (config.source.includes + config.header.includes), + partials: testable.partials.configs, + test: name + ), + source_includes: @partializer.remap_implementation_source_includes( + name: config.module, + includes: (config.source.includes + config.header.includes), + partials: testable.partials.configs, + test: name + ), + input_filepath: config.source.filepath, + output_path: testable.paths[:partials] + } + + unless implementation.nil? + @generator.generate_partial_implementation( **arg_hash ) + state.lock.synchronize { testable.partials.tests << config.module } + end + + arg_hash = { + test: name, + partial: config.module, + function_declarations: interface, + includes: @partializer.remap_interface_header_includes( + name: config.module, + includes: (config.source.includes + config.header.includes), + partials: testable.partials.configs, + test: name + ), + c_module: module_contents, + input_filepath: config.header.filepath, + output_path: testable.paths[:partials] + } + + unless interface.nil? + @generator.generate_partial_interface( **arg_hash ) + state.lock.synchronize { testable.partials.mocks << config.module } + end + end + end + + # Stage 9: Preprocess header files to be mocked. + def stage_preprocess_mocks(state) + # Generate directive-only preprocessor output if available + @batchinator.exec(workload: :compile, things: state.mocks_list) do |mock| + details = mock[:details] + testable = mock[:testable] + name = testable.name + filepath = details[:source] + + arg_hash = { + filepath: filepath, + test: name, + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + _filepath = @preprocessinator.generate_directives_only_output( **arg_hash ) + + if _filepath.nil? + msg = "Failed to generate directive-only preprocessor output (fallback methods will be used) for #{filepath}" + @loginator.log( msg, Verbosity::COMPLAIN ) + end + + mock[:directives_only_filepath] = _filepath + end if @preprocessinator.directives_only_available? + + # Preprocess and assemble header files to be mocked + @batchinator.exec(workload: :compile, things: state.mocks_list) do |mock| + details = mock[:details] + testable = mock[:testable] + directives_only_filepath = mock[:directives_only_filepath] + + extras = (@configurator.cmock_treat_inlines == :include) + + arg_hash = { + test: testable.name, + filepath: details[:source], + directives_only_filepath: directives_only_filepath, + fallback: (!@preprocessinator.directives_only_available? or directives_only_filepath.nil?), + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines, + extras: extras + } + + @preprocessinator.preprocess_mockable_header_file( **arg_hash ) + end + end + + # Stage 10: Generate mocks for all tests. + def stage_generate_mocks(state) + @batchinator.exec(workload: :compile, things: state.mocks_list) do |mock| + details = mock[:details] + testable = mock[:testable] + + output_path = File.join( testable.paths[:mocks], details[:path] ) + @file_wrapper.mkdir( output_path ) + + arg_hash = { + context: state.context, + mock: mock[:name], + test: testable.name, + input_filepath: details[:input], + output_path: output_path + } + + @generator.generate_mock( **arg_hash ) + end + end + + # Stage 11: Preprocess test files and extract source build directives. + def stage_preprocess_test_files(state) + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + filepath = testable.filepath + filename = File.basename( filepath ) + name = testable.name + directives_only_filepath = testable.preprocess[:directives_only][:filepath] + + fallback = (!@preprocessinator.directives_only_available? or directives_only_filepath.nil?) + + arg_hash = { + test: name, + filepath: filepath, + directives_only_filepath: directives_only_filepath, + fallback: fallback, + includes: @context_extractor.lookup_all_header_includes_list( testable.filepath ), + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + _filepath = @preprocessinator.preprocess_test_file( **arg_hash ) + + state.lock.synchronize { testable.runner[:input_filepath] = _filepath } + + msg = @reportinator.generate_progress( "Parsing #{filename} for test source directive macros" ) + @loginator.log( msg ) + + if fallback + _filepath = filepath + else + _filepath = @file_path_utils.form_preprocessed_file_compacted_directives_only_filepath( filepath, name ) + end + + @context_extractor.collect_simple_context_from_file( + _filepath, + filepath, + TestContextExtractor::Context::BUILD_DIRECTIVE_SOURCE_FILES + ) + + state.testables.each do |_, t| + validate_build_directive_source_files( test: name, filepath: t.filepath ) + end + end + end + + # Stage 12: Collect test runner details (test case names) from preprocessed test files. + def stage_collect_runner_details(state) + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + msg = @reportinator.generate_module_progress( + operation: 'Parsing test case names', + module_name: testable.name, + filename: File.basename( testable.filepath ) + ) + @loginator.log( msg ) + + @context_extractor.collect_test_runner_details( testable.filepath, testable.runner[:input_filepath] ) + end + end + + # Stage 13: Generate test runner files. + def stage_generate_runners(state) + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + arg_hash = { + context: state.context, + mocks: @context_extractor.lookup_mock_header_includes_list( testable.filepath ), + includes: @context_extractor.lookup_nonmock_header_includes_list( testable.filepath ), + test_filepath: testable.filepath, + input_filepath: testable.runner[:input_filepath], + runner_filepath: testable.runner[:output_filepath] + } + + @generator.generate_test_runner( **arg_hash ) + end + end + + # Stage 15: Compile all test build objects in parallel. + def stage_build_objects(state) + @batchinator.exec(workload: :compile, things: state.objects_list) do |obj| + src = @file_finder.find_build_input_file( filepath: obj[:obj], context: state.context ) + compile_test_component( + tool: obj[:tool], + context: state.context, + test: obj[:test], + source: src, + object: obj[:obj], + state: state + ) + end + end + + # Stage 16: Link test executables. + def stage_build_executables(state) + lib_args = convert_libraries_to_arguments() + lib_paths = get_library_paths_to_arguments() + + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + remove_partials_source_objects( testable.objects, testable.partials.configs ) + + arg_hash = { + context: state.context, + build_path: testable.paths[:build], + executable: testable.executable, + objects: testable.objects, + flags: testable.link_flags, + lib_args: lib_args, + lib_paths: lib_paths, + options: state.options + } + + generate_executable_now( **arg_hash ) + end + end + + # Stage 17: Execute test fixtures and collect results. + def stage_execute(state) + @batchinator.exec(workload: :test, things: state.testables) do |_, testable| + begin + arg_hash = { + context: state.context, + test_name: testable.name, + test_filepath: testable.filepath, + executable: testable.executable, + result: testable.results_pass, + options: state.options + } + + run_fixture_now( **arg_hash ) + + ensure + @plugin_manager.post_test( testable.filepath ) + end + end + end + + # ----------------------------------------------------------------------- + # Helper methods + # ----------------------------------------------------------------------- + + def generate_executable_now(context:, build_path:, executable:, objects:, flags:, lib_args:, lib_paths:, options:) + begin + @generator.generate_executable_file( + options[:test_linker], + context, + objects.map { |v| "\"#{v}\"" }, + flags, + executable, + @file_path_utils.form_test_build_map_filepath( build_path, executable ), + lib_args, + lib_paths + ) + rescue ShellException => ex + if ex.shell_result[:output] =~ /symbol/i + notice = "If the linker reports missing symbols, the following may be to blame:\n" + + " 1. This test lacks #include statements corresponding to needed source files (see note below).\n" + + " 2. Project file paths omit source files corresponding to #include statements in this test.\n" + + " 3. Complex macros, #ifdefs, etc. have obscured correct #include statements in this test.\n" + + " 4. Your project is attempting to mix C++ and C file extensions (not supported).\n" + if @configurator.project_use_mocks + notice += " 5. This test does not #include needed mocks (that triggers their generation).\n" + end + + notice += "\n" + notice += "NOTE: A test file directs the build of a test executable with #include statemetns:\n" + + " * By convention, Ceedling assumes header filenames correspond to source filenames.\n" + + " * Which code files to compile and link are determined by #include statements.\n" + if @configurator.project_use_mocks + notice += " * An #include statement convention directs the generation of mocks from header files.\n" + end + + notice += "\n" + notice += "OPTIONS:\n" + + " 1. Doublecheck this test's #include statements.\n" + + " 2. Simplify complex macros or fully specify symbols for this test in :project ↳ :defines.\n" + + " 3. If no header file corresponds to the needed source file, use the TEST_SOURCE_FILE()\n" + + " build diective macro in this test to inject a source file into the build.\n\n" + + "See the docs on conventions, paths, preprocessing, compilation symbols, and build directive macros.\n\n" + + @loginator.log( notice, Verbosity::COMPLAIN, LogLabels::NOTICE ) + end + + raise ex + end + end + + def run_fixture_now(context:, test_name:, test_filepath:, executable:, result:, options:) + @generator.generate_test_results( + tool: options[:test_fixture], + context: context, + test_name: test_name, + test_filepath: test_filepath, + executable: executable, + result: result + ) + end + + def convert_libraries_to_arguments() + args = ((@configurator.project_config_hash[:libraries_test] || []) + ((defined? LIBRARIES_SYSTEM) ? LIBRARIES_SYSTEM : [])).flatten + if (defined? LIBRARIES_FLAG) + args.map! { |v| LIBRARIES_FLAG.gsub( /\$\{1\}/, v ) } + end + return args + end + + def get_library_paths_to_arguments() + paths = (defined? PATHS_LIBRARIES) ? (PATHS_LIBRARIES || []).clone : [] + if (defined? LIBRARIES_PATH_FLAG) + paths.map! { |v| LIBRARIES_PATH_FLAG.gsub( /\$\{1\}/, v ) } + end + return paths + end + + private + + # Compile a single C or assembly source file into an object file. + def compile_test_component(tool:, context:, test:, source:, object:, state:) + testable = state.testables[test.to_sym] + defines = testable.compile_defines + search_paths = tailor_search_paths( search_paths: testable.search_paths, filepath: source ) + + if @file_wrapper.extname( source ) != @configurator.extension_assembly + flags = testable.compile_flags + + arg_hash = { + tool: tool, + module_name: test, + context: context, + source: source, + object: object, + search_paths: search_paths, + flags: flags, + defines: defines, + list: @file_path_utils.form_test_build_list_filepath( object ), + dependencies: @file_path_utils.form_test_dependencies_filepath( object ) + } + + @generator.generate_object_file_c( **arg_hash ) + + elsif @configurator.test_build_use_assembly + flags = testable.assembler_flags + + arg_hash = { + tool: tool, + module_name: test, + context: context, + source: source, + object: object, + search_paths: search_paths, + flags: flags, + defines: defines, + list: @file_path_utils.form_test_build_list_filepath( object ), + dependencies: @file_path_utils.form_test_dependencies_filepath( object ) + } + + @generator.generate_object_file_asm( **arg_hash ) + end + end + + def tailor_search_paths(filepath:, search_paths:) + _search_paths = [] + + if filepath == File.join( PROJECT_BUILD_VENDOR_UNITY_PATH, UNITY_C_FILE ) + _search_paths += @configurator.collection_paths_support + _search_paths << PROJECT_BUILD_VENDOR_UNITY_PATH + + elsif @configurator.project_use_mocks and + (filepath == File.join( PROJECT_BUILD_VENDOR_CMOCK_PATH, CMOCK_C_FILE )) + _search_paths += @configurator.collection_paths_support + _search_paths << PROJECT_BUILD_VENDOR_UNITY_PATH + _search_paths << PROJECT_BUILD_VENDOR_CMOCK_PATH + _search_paths << PROJECT_BUILD_VENDOR_CEXCEPTION_PATH if @configurator.project_use_exceptions + + elsif @configurator.project_use_exceptions and + (filepath == File.join( PROJECT_BUILD_VENDOR_CEXCEPTION_PATH, CEXCEPTION_C_FILE )) + _search_paths += @configurator.collection_paths_support + _search_paths << PROJECT_BUILD_VENDOR_CEXCEPTION_PATH + + elsif @configurator.collection_all_support.include?( filepath ) + _search_paths = search_paths + _search_paths += @configurator.collection_paths_support + _search_paths << PROJECT_BUILD_VENDOR_UNITY_PATH + _search_paths << PROJECT_BUILD_VENDOR_CMOCK_PATH if @configurator.project_use_mocks + _search_paths << PROJECT_BUILD_VENDOR_CEXCEPTION_PATH if @configurator.project_use_exceptions + end + + return search_paths if _search_paths.empty? + + return _search_paths.uniq + end + + def validate_build_directive_source_files(test:, filepath:) + sources = @test_context_extractor.lookup_build_directive_sources_list( filepath ) + + ext_message = @configurator.extension_source + if @configurator.test_build_use_assembly + ext_message += " or #{@configurator.extension_assembly}" + end + + sources.each do |source| + valid_extension = true + + if not @configurator.test_build_use_assembly + valid_extension = false if @file_wrapper.extname( source ) != @configurator.extension_source + else + ext = @file_wrapper.extname( source ) + valid_extension = false if (ext != @configurator.extension_assembly) and (ext != @configurator.extension_source) + end + + if not valid_extension + error = "File '#{source}' specified with TEST_SOURCE_FILE() in #{test} is not a #{ext_message} source file" + raise CeedlingException.new( error ) + end + + if @file_finder.find_build_input_file( filepath: source, complain: :ignore, context: TEST_SYM ).nil? + error = "File '#{source}' specified with TEST_SOURCE_FILE() in #{test} cannot be found in the source file collection" + raise CeedlingException.new( error ) + end + end + end + + def remove_partials_source_objects(objects, configs) + modules = configs.keys + objects.delete_if do |filepath| + modules.include?( File.basename( filepath ).ext() ) + end + end + +end diff --git a/lib/ceedling/test_invoker/test_build_planner.rb b/lib/ceedling/test_invoker/test_build_planner.rb new file mode 100644 index 000000000..0693afa31 --- /dev/null +++ b/lib/ceedling/test_invoker/test_build_planner.rb @@ -0,0 +1,277 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/constants' +require 'ceedling/test_invoker/test_invoker_types' + +class TestBuildPlanner + + include TestInvokerTypes + + constructor( + :configurator, + :loginator, + :reportinator, + :batchinator, + :test_context_extractor, + :partializer, + :file_finder, + :file_path_utils, + :file_wrapper, + :plugin_manager + ) + + def setup() + @context_extractor = @test_context_extractor + end + + # Stage 5: Determine runners, mocks, and partials for all tests. + def stage_determine_files(state) + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + test = testable.name + filepath = testable.filepath + + runner_filepath = @file_path_utils.form_runner_filepath_from_test( filepath ) + + mocks = {} + _mocks = @context_extractor.lookup_mock_header_includes_list( filepath ) + + _mocks.each do |include| + name = File.basename( include.filename ).ext() + source = nil + input = nil + + if is_mock_partial?( include ) + source = gnerate_header_input_for_mock_partial( include, test ) + input = source + else + source = find_header_input_for_mock( include ) + preprocessed_input = @file_path_utils.form_preprocessed_file_filepath( source, test ) + input = (@configurator.project_use_test_preprocessor_mocks ? preprocessed_input : source) + end + + mocks[name.to_sym] = { + name: name, + filepath: include.filepath, + path: include.path, + source: source, + input: input + } + end + + partials_configs = {} + if @configurator.project_use_partials + partials_configs = assemble_partials_config( filepath: filepath ) + end + + state.lock.synchronize do + testable.runner = { + output_filepath: runner_filepath, + input_filepath: filepath + } + testable.mocks = mocks + testable.partials.configs = partials_configs + + @plugin_manager.pre_test( filepath ) + end + end + end + + # Transform T1: Flatten partials into parallel-processing-friendly lists. + def stage_flatten_partials_lists(state) + state.testables.each do |_, testable| + testable.partials.configs.each do |_, config| + state.partials_headers << { + config: config.header, + testable: testable, + directives_only_filepath: nil + } if config.header.filepath + + state.partials_sources << { + config: config.source, + testable: testable, + directives_only_filepath: nil + } if config.source.filepath + end + end + end + + # Transform T2: Flatten mocks into a parallel-processing-friendly list. + def stage_flatten_mocks_list(state) + state.testables.each do |_, testable| + testable.mocks.each do |name, elems| + state.mocks_list << { + name: name, + details: elems, + testable: testable, + directives_only_filepath: nil + } + end + end + end + + # Stage 14: Determine the full set of objects to compile and link for each test. + def stage_determine_artifacts(state) + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + filepath = testable.filepath + mock_list = @context_extractor.lookup_mock_header_includes_list( filepath ) + + test_sources = extract_sources( state.context, filepath, testable.partials ) + test_core = test_sources + + mock_list.map { |mock| mock.filename.ext( EXTENSION_CORE_SOURCE ) } + + remove_mock_original_headers( + test_core, + mock_list.map { |mock| mock.filename } + ) + + test_frameworks = collect_test_framework_sources( !testable.mocks.empty? ) + test_support = @configurator.collection_all_support + + compilations = [] + compilations << filepath + compilations += test_core + compilations << testable.runner[:output_filepath] + compilations += test_frameworks + compilations += test_support + compilations.uniq! + + test_objects = @file_path_utils.form_test_build_objects_filelist( testable.paths[:build], compilations ) + test_executable = @file_path_utils.form_test_executable_filepath( testable.paths[:build], filepath ) + test_pass = @file_path_utils.form_pass_results_filepath( testable.paths[:results], filepath ) + test_fail = @file_path_utils.form_fail_results_filepath( testable.paths[:results], filepath ) + + test_no_link_objects = + @file_path_utils.form_test_build_objects_filelist( + testable.paths[:build], + fetch_shallow_source_includes( filepath ) + ) + + test_objects = (test_objects.uniq - test_no_link_objects) + + state.lock.synchronize do + testable.sources = test_sources + testable.frameworks = test_frameworks + testable.core = test_core + testable.objects = test_objects + testable.executable = test_executable + testable.no_link_objects = test_no_link_objects + testable.results_pass = test_pass + testable.results_fail = test_fail + testable.tool = TOOLS_TEST_COMPILER + end + end + end + + # Transform T3: Flatten testable objects into a parallel-processing-friendly list. + def stage_flatten_objects_list(state) + state.objects_list = state.testables.map do |_, testable| + testable.objects.map do |obj| + { + tool: testable.tool, + test: testable.name, + obj: obj + } + end + end.flatten + end + + # ----------------------------------------------------------------------- + # Helper methods + # ----------------------------------------------------------------------- + + def assemble_partials_config(filepath:) + configs = @test_context_extractor.lookup_partials_config( filepath ) + return @partializer.populate_filepaths( configs ) + end + + def collect_test_framework_sources(mocks) + sources = [] + sources << File.join( PROJECT_BUILD_VENDOR_UNITY_PATH, UNITY_C_FILE ) + sources << File.join( PROJECT_BUILD_VENDOR_CMOCK_PATH, CMOCK_C_FILE ) if @configurator.project_use_mocks and mocks + sources << File.join( PROJECT_BUILD_VENDOR_CEXCEPTION_PATH, CEXCEPTION_C_FILE ) if @configurator.project_use_exceptions + + if @configurator.project_use_mocks + @configurator.cmock_unity_helper_path.each do |helper| + if @file_wrapper.exist?( helper.ext( EXTENSION_SOURCE ) ) + sources << helper + end + end + end + + return sources + end + + def extract_sources(context, test_filepath, partials) + sources = [] + + _sources = @test_context_extractor.lookup_build_directive_sources_list( test_filepath ) + _sources.each do |source| + sources << @file_finder.find_build_input_file( filepath: source, complain: :ignore, context: context ) + end + + _support_headers = COLLECTION_ALL_SUPPORT.map { |filepath| File.basename( filepath ).ext( EXTENSION_HEADER ) } + + includes = @test_context_extractor.lookup_all_header_includes_list( test_filepath ) + includes.each do |include| + _basename = include.filename + next if _basename == UNITY_H_FILE + next if _basename.start_with?( CMOCK_MOCK_PREFIX ) + next if _support_headers.include?( _basename ) + + sources << @file_finder.find_build_input_file( filepath: include.filename, complain: :ignore, context: context ) + end + + # Add to the source list any testable Partials (no mock Partials) + partials.tests.each do |_module| + sources << @file_finder.find_build_input_file( filepath: _module, complain: :ignore, context: context ) + end + + return sources.compact.uniq + end + + def fetch_shallow_source_includes(test_filepath) + return @test_context_extractor.lookup_source_includes_list( test_filepath ) + end + + def fetch_include_search_paths_for_test_file(test_filepath) + return @test_context_extractor.lookup_include_paths_list( test_filepath ) + end + + def find_header_input_for_mock(mock) + return @file_finder.find_header_input_for_mock( mock.filename ) + end + + def is_mock_partial?(mock) + return mock.filename.start_with?( @configurator.cmock_mock_prefix + PARTIAL_FILENAME_PREFIX ) + end + + def gnerate_header_input_for_mock_partial(mock, test) + return @file_path_utils.form_partial_header_filepath( + test, + mock.filename.delete_prefix( @configurator.cmock_mock_prefix ) + ) + end + + def form_partials_filenames(partials) + return partials.map { |partial| @file_path_utils.form_partial_implementation_source_filename( partial ) } + end + + def remove_mock_original_headers(filelist, mocklist) + filelist.delete_if do |filepath| + mocklist.include?( @configurator.cmock_mock_prefix + File.basename( filepath ).ext( EXTENSION_CORE_HEADER ) ) + end + end + + def remove_partials_source_objects(objects, configs) + modules = configs.keys + objects.delete_if do |filepath| + modules.include?( File.basename( filepath ).ext() ) + end + end + +end diff --git a/lib/ceedling/test_invoker/test_build_setup.rb b/lib/ceedling/test_invoker/test_build_setup.rb new file mode 100644 index 000000000..0315fe860 --- /dev/null +++ b/lib/ceedling/test_invoker/test_build_setup.rb @@ -0,0 +1,454 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/constants' +require 'ceedling/exceptions' +require 'ceedling/test_context_extractor' +require 'ceedling/includes/includes' +require 'ceedling/partials/partials' +require 'ceedling/test_invoker/test_invoker_types' + +class TestBuildSetup + + include TestInvokerTypes + + constructor( + :configurator, + :loginator, + :reportinator, + :batchinator, + :test_context_extractor, + :include_pathinator, + :preprocessinator, + :defineinator, + :flaginator, + :file_wrapper, + :file_path_utils, + :test_runner_manager + ) + + def setup() + @context_extractor = @test_context_extractor + end + + # Stage 1: Create per-test build/results/mock/partial directory structure + # and populate the testables hash with initial entries. + def stage_prepare_build_paths(state) + results_path = File.join( @configurator.project_build_root, state.context.to_s, 'results' ) + + @batchinator.exec(workload: :compile, things: state.tests) do |filepath| + filepath = filepath.to_s + key = testable_symbolize( filepath ) + name = key.to_s + build_path = File.join( @configurator.project_build_root, state.context.to_s, 'out', name ) + mocks_path = File.join( @configurator.cmock_mock_path, name ) + partials_path = File.join( @configurator.project_test_partials_path, name ) + + preprocess_includes_path = File.join( @configurator.project_test_preprocess_includes_path, name ) + preprocess_files_path = File.join( @configurator.project_test_preprocess_files_path, name ) + + state.lock.synchronize do + state.testables[key] = Testable.new( + filepath: filepath, + name: name, + preprocess: {}, + paths: {} + ) + end + + testable = state.testables[key] + paths = testable.paths + paths[:build] = build_path + paths[:results] = results_path + paths[:mocks] = mocks_path if @configurator.project_use_mocks + paths[:partials] = partials_path if @configurator.project_use_partials + + if @configurator.project_use_test_preprocessor != :none + testable.preprocess[:includes] = [] + testable.preprocess[:directives_only] = { filepath: nil } + + paths[:preprocess_incudes] = preprocess_includes_path + paths[:preprocess_files] = preprocess_files_path + paths[:preprocess_files_full_expansion] = File.join( preprocess_files_path, PREPROCESS_FULL_EXPANSION_DIR ) + paths[:preprocess_files_directives_only] = File.join( preprocess_files_path, PREPROCESS_DIRECTIVES_ONLY_DIR ) + paths[:preprocess_files_raw_directives_only] = File.join( preprocess_files_path, PREPROCESS_RAW_DIRECTIVES_ONLY_DIR ) + end + + testable.paths.each { |_, path| @file_wrapper.mkdir( path ) } + end + + clean_test_results( results_path, state.testables.map { |_, t| t.name } ) + end + + # Stage 2: Collect includes, build directives, and test case context from each test file. + def stage_collect_test_context(state) + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + filepath = testable.filepath + filename = File.basename( filepath ) + + contexts = [TestContextExtractor::Context::INCLUDES] + + if @configurator.project_use_test_preprocessor_tests + msg = @reportinator.generate_progress( "Parsing #{filename} for user & system #includes (fallback for preprocessing failures)" ) + @loginator.log( msg ) + + contexts << TestContextExtractor::Context::BUILD_DIRECTIVE_INCLUDE_PATHS + + msg = @reportinator.generate_progress( "Parsing #{filename} for include path build directive macros" ) + @loginator.log( msg ) + + msg = @reportinator.generate_progress( "Parsing #{filename} for Partials directive macros" ) + @loginator.log( msg ) + contexts << TestContextExtractor::Context::PARTIALS_CONFIGURATION + else + msg = @reportinator.generate_progress( "Parsing #{filename} for user & system #includes" ) + @loginator.log( msg ) + + contexts << TestContextExtractor::Context::BUILD_DIRECTIVE_INCLUDE_PATHS + contexts << TestContextExtractor::Context::BUILD_DIRECTIVE_SOURCE_FILES + contexts << TestContextExtractor::Context::TEST_RUNNER_DETAILS + + msg = @reportinator.generate_progress( "Parsing #{filename} for build directive macros and test case names" ) + @loginator.log( msg ) + end + + @context_extractor.collect_simple_context_from_file( filepath, nil, *contexts ) + + validate_mocks_in_use( + filename: filename, + mocks: @context_extractor.lookup_mock_header_includes_list( filepath ) + ) + + validate_partials_in_use( + filename: filename, + partials_in_use: !(@context_extractor.lookup_partials_config( filepath )).empty?, + includes: @context_extractor.lookup_all_header_includes_list( filepath ) + ) + end + + process_project_include_paths() + end + + # Stage 3: Collect flags, defines, and search paths for each test. + def stage_ingest_configurations(state) + fw_defines = framework_defines() + run_defines = runner_defines() + + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + filepath = testable.filepath + + srch_paths = search_paths( filepath, testable.paths ) + cmp_flags = flags( context: state.context, operation: OPERATION_COMPILE_SYM, filepath: filepath ) + pre_flags = preprocess_flags( context: state.context, compile_flags: cmp_flags, filepath: filepath ) + asm_flags = flags( context: state.context, operation: OPERATION_ASSEMBLE_SYM, filepath: filepath ) + lnk_flags = flags( context: state.context, operation: OPERATION_LINK_SYM, filepath: filepath ) + cmp_defines = compile_defines( context: state.context, filepath: filepath ) + pre_defines = preprocess_defines( test_defines: cmp_defines, filepath: filepath ) + + msg = @reportinator.generate_module_progress( + operation: 'Collecting search paths, flags, and defines for', + module_name: testable.name, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + state.lock.synchronize do + testable.search_paths = srch_paths + testable.preprocess_flags = pre_flags + testable.compile_flags = cmp_flags + testable.assembler_flags = asm_flags + testable.link_flags = lnk_flags + testable.compile_defines = cmp_defines + fw_defines + run_defines + testable.preprocess_defines = pre_defines + fw_defines + end + end + end + + # Stage 4 (conditional on preprocessing): Extract includes using the preprocessor. + def stage_collect_preprocessor_context(state) + # First pass: extract bare includes; create stand-in files for mocks and partials + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + name = testable.name + filepath = testable.filepath + + if @preprocessinator.cached_includes_list?( test: name, filepath: filepath ) + msg = @reportinator.generate_module_progress( + operation: 'Skipping preprocessing for #includes in favor of cached #includes for', + module_name: name, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + next + end + + arg_hash = { + test: name, + filepath: filepath, + search_paths: [@configurator.project_build_vendor_ceedling_path], + flags: testable.preprocess_flags, + defines: testable.preprocess_defines + } + + msg = @reportinator.generate_module_progress( + operation: 'Extracting #includes from', + module_name: name, + filename: File.basename( filepath ) + ) + @loginator.log( msg ) + + includes = @preprocessinator.preprocess_bare_includes( **arg_hash ) + + testable.preprocess[:includes] = includes + + generate_test_includes_standins( name, includes ) + end + + # Second pass: generate directives-only preprocessor output after stand-ins exist + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + next unless @preprocessinator.directives_only_available? + + name = testable.name + filepath = testable.filepath + + unless @preprocessinator.directives_only_available? + msg = @reportinator.generate_module_progress( + operation: 'Will use fallback methods to extract #includes and other directives for', + module_name: name, + filename: File.basename( filepath ) + ) + @loginator.log( msg, Verbosity::COMPLAIN ) + next + end + + arg_hash = { + filepath: filepath, + test: name, + flags: testable.preprocess_flags, + include_paths: testable.search_paths, + vendor_paths: [@configurator.project_build_vendor_ceedling_path], + defines: testable.preprocess_defines + } + + msg = @reportinator.generate_module_progress( + operation: 'Preprocessing test files for follow-on details extraction steps', + module_name: name, + filename: File.basename( filepath ) + ) + @loginator.log( msg, Verbosity::OBNOXIOUS ) + + _filepath = nil + + begin + _filepath = @preprocessinator.generate_directives_only_output( **arg_hash ) + rescue => ex + msg = "Using fallback methods to extract #includes and other directives: #{ex.message}" + @loginator.log( msg, Verbosity::COMPLAIN ) + next + end + + testable.preprocess[:directives_only][:filepath] = _filepath + end + + # Third pass: reconcile includes from all extraction sources and ingest + @batchinator.exec(workload: :compile, things: state.testables) do |_, testable| + filepath = testable.filepath + filename = File.basename( filepath ) + name = testable.name + + cached, includes = @preprocessinator.load_includes_list( test: name, filepath: filepath ) + if cached + @context_extractor.ingest_includes( filepath, includes ) + next + end + + unless @preprocessinator.directives_only_available? + msg = @reportinator.generate_module_progress( + operation: 'Using fallback text-only includes extracted for', + module_name: name, + filename: filename + ) + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + next + end + + directive_only_filepath = testable.preprocess[:directives_only][:filepath] + system_includes = [] + user_includes = [] + + unless directive_only_filepath.nil? + arg_hash = { + name: name, + filepath: filepath, + directives_only_filepath: directive_only_filepath + } + + user_includes = @preprocessinator.preprocess_user_includes( **arg_hash ) + system_includes = @preprocessinator.preprocess_system_includes( **arg_hash ) + else + msg = @reportinator.generate_module_progress( + operation: 'Using fallback text-only includes extracted for', + module_name: name, + filename: filename + ) + @loginator.log( msg, Verbosity::OBNOXIOUS, LogLabels::WARNING ) + + all_includes = @context_extractor.lookup_all_header_includes_list( filepath ) + user_includes = Includes.user( all_includes ) + system_includes = Includes.system( all_includes ) + end + + bare_includes = testable.preprocess[:includes] + + all_includes = Includes.reconcile( + bare: bare_includes, + user: user_includes, + system: system_includes + ) + + header = "Extracted reconciled #include list from #{filepath}" + @loginator.log_list( all_includes, header, Verbosity::OBNOXIOUS ) + + @context_extractor.ingest_includes( filepath, all_includes ) + + @preprocessinator.store_includes_list( + test: name, + filepath: filepath, + includes: all_includes + ) + end + end + + # ----------------------------------------------------------------------- + # Helper methods + # ----------------------------------------------------------------------- + + def process_project_include_paths() + @include_pathinator.validate_test_build_directive_paths() + headers = @include_pathinator.validate_header_files_collection() + @include_pathinator.augment_environment_header_files( headers ) + end + + def generate_test_includes_standins(test, includes) + mocks = Includes.filter( includes, /^#{@configurator.cmock_mock_prefix}/ ) + partials = Includes.filter( includes, /^#{PARTIAL_FILENAME_PREFIX}/ ) + + mocks.each do |include| + filepath = @file_path_utils.form_mock_header_filepath( test, include.filepath ) + msg = @reportinator.generate_module_progress( + operation: 'Generating stand-in header for', + module_name: test, + filename: include.filename + ) + @loginator.log( msg, Verbosity::DEBUG ) + @file_wrapper.mkdir( File.dirname( filepath ) ) + @file_wrapper.write_blank_file( filepath ) + end + + partials.each do |include| + filepath = @file_path_utils.form_partial_header_filepath( test, include.filename ) + msg = @reportinator.generate_module_progress( + operation: 'Generating stand-in header for', + module_name: test, + filename: include.filename + ) + @loginator.log( msg, Verbosity::DEBUG ) + @file_wrapper.write_blank_file( filepath ) + end + end + + def validate_mocks_in_use(filename:, mocks:) + if !@configurator.project_use_mocks and !mocks.empty? + _mocks = mocks.map { |include| include.filename } + + if _mocks.length > 1 + _mocks = "[#{_mocks.join(', ')}]" + else + _mocks = _mocks[0] + end + + msg = "Your project is not configured for mocking, but #{filename} #includes #{_mocks}" + raise CeedlingException.new( msg ) + end + end + + def validate_partials_in_use(filename:, partials_in_use:, includes:) + partials_header_in_use = Includes.contains?( includes, CEEDLING_HEADER_FILENAME ) + + if partials_in_use && !@configurator.project_use_partials + msg = "Your project is not configured for Partials, but #{filename} is attempting to use Partial features" + raise CeedlingException.new( msg ) + end + + if partials_in_use && !partials_header_in_use + msg = "Your test file #{filename} is attempting to use Partial features without #including #{CEEDLING_HEADER_FILENAME}" + raise CeedlingException.new( msg ) + end + end + + def search_paths(filepath, paths) + _paths = [] + _paths << paths[:mocks] if paths[:mocks] + _paths << paths[:partials] if paths[:partials] + _paths += @include_pathinator.lookup_test_directive_include_paths( filepath ) + _paths += @include_pathinator.collect_test_include_paths() + _paths += @configurator.collection_paths_support + _paths += @configurator.collection_paths_include + _paths += @configurator.collection_paths_libraries + _paths += @configurator.collection_paths_vendor + _paths += @configurator.collection_paths_test_toolchain_include + return _paths.uniq + end + + def framework_defines() + defines = [] + defines += @defineinator.defines( topkey: UNITY_SYM, subkey: :defines ) + defines += @defineinator.defines( topkey: CMOCK_SYM, subkey: :defines ) + defines += @defineinator.defines( topkey: CEXCEPTION_SYM, subkey: :defines ) + return defines.uniq + end + + def runner_defines() + return @test_runner_manager.collect_defines() + end + + def compile_defines(context:, filepath:) + context = TEST_SYM unless @defineinator.defines_defined?( context: context ) + defines = @defineinator.generate_test_definition( filepath: filepath ) + defines += @defineinator.defines( subkey: context, filepath: filepath ) + return defines.uniq + end + + def preprocess_defines(test_defines:, filepath:) + preprocessing_defines = @defineinator.defines( subkey: PREPROCESS_SYM, filepath: filepath, default: nil ) + return test_defines if preprocessing_defines.nil? + return preprocessing_defines + end + + def flags(context:, operation:, filepath:, default:[]) + context = TEST_SYM unless @flaginator.flags_defined?( context: context, operation: operation ) + return @flaginator.flag_down( context: context, operation: operation, filepath: filepath, default: default ) + end + + def preprocess_flags(context:, compile_flags:, filepath:) + preprocessing_flags = flags( context: context, operation: OPERATION_PREPROCESS_SYM, filepath: filepath, default: nil ) + return compile_flags if preprocessing_flags.nil? + return preprocessing_flags + end + + def clean_test_results(path, tests) + tests.each do |test| + @file_wrapper.rm_f( Dir.glob( File.join( path, test + '.*' ) ) ) + end + end + + private + + def testable_symbolize(filepath) + return (File.basename( filepath ).ext( '' )).to_sym + end + +end diff --git a/lib/ceedling/test_invoker/test_invoker.rb b/lib/ceedling/test_invoker/test_invoker.rb new file mode 100644 index 000000000..43bd6e24e --- /dev/null +++ b/lib/ceedling/test_invoker/test_invoker.rb @@ -0,0 +1,225 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/constants' +require 'ceedling/test_invoker/test_invoker_types' + +class TestInvoker + + include TestInvokerTypes + + # ------------------------------------------------------------------------- + # Dependency injection + # ------------------------------------------------------------------------- + + constructor( + :application, + :configurator, + :test_build_setup, + :test_build_planner, + :test_build_executor, + :plugin_manager, + :batchinator, + :loginator, + :verbosinator + ) + + def setup + @state = nil + end + + # ------------------------------------------------------------------------- + # Public API + # ------------------------------------------------------------------------- + + def setup_and_invoke(tests:, context: TEST_SYM, options: {}) + @state = PipelineState.new( + tests: tests, + testables: {}, + context: context, + options: options, + partials_headers: [], + partials_sources: [], + mocks_list: [], + objects_list: [], + lock: Mutex.new + ) + + begin + run_pipeline( build_stage_sequence(), @state ) + rescue StandardError => ex + @application.register_build_failure + @loginator.log( ex.message, Verbosity::ERRORS, LogLabels::EXCEPTION ) + @loginator.log_debug_backtrace( ex ) + end + end + + def each_test_with_sources + @state.testables.each do |test, _| + yield( test.to_s, lookup_sources( test: test ) ) + end + end + + def lookup_sources(test:) + return @state.testables[test.to_sym].sources + end + + # ------------------------------------------------------------------------- + # Pipeline infrastructure + # ------------------------------------------------------------------------- + + private + + def run_pipeline(stages, state) + stages.each do |stage| + next unless stage.run?( state ) + + if stage.transform + stage.body.call( state ) + else + @batchinator.build_step( stage.name, heading: stage.heading ) do + stage.body.call( state ) + end + end + end + end + + def build_stage_sequence + use_preprocessing = -> (s) { @configurator.project_use_test_preprocessor_tests } + use_partials = -> (s) { @configurator.project_use_partials } + use_mocks = -> (s) { @configurator.project_use_mocks } + use_mocks_preproc = -> (s) { @configurator.project_use_mocks && @configurator.project_use_test_preprocessor_mocks } + not_build_only = -> (s) { !s.options[:build_only] } + + [ + # Stage 1 + stage("Preparing Build Paths", + heading: false, + body: ->(s) { @test_build_setup.stage_prepare_build_paths(s) } + ), + + # Stage 2 + stage("Collecting Essential Test Context", + body: ->(s) { @test_build_setup.stage_collect_test_context(s) } + ), + + # Stage 3 + stage("Ingesting Test Configurations", + body: ->(s) { @test_build_setup.stage_ingest_configurations(s) } + ), + + # Stage 4 + stage("Collecting More Test Context", + condition: use_preprocessing, + body: ->(s) { @test_build_setup.stage_collect_preprocessor_context(s) } + ), + + # Stage 5 + stage("Determining Files to Be Generated", + heading: false, + body: ->(s) { @test_build_planner.stage_determine_files(s) } + ), + + # Transform 1: Prepare partials parallel processing + stage(transform: true, + condition: use_partials, + body: ->(s) { @test_build_planner.stage_flatten_partials_lists(s) } + ), + + # Stage 6 + stage("Preprocessing for Testing & Mocking Partials", + condition: use_partials, + body: ->(s) { @test_build_executor.stage_preprocess_partial_headers(s) } + ), + + # Stage 7 + stage("Preprocessing for Testing Partials", + condition: use_partials, + body: ->(s) { @test_build_executor.stage_preprocess_partial_sources(s) } + ), + + # Stage 8 + stage("Partials", + condition: use_partials, + body: ->(s) { @test_build_executor.stage_generate_partials(s) } + ), + + # Transform 2: Prepare mocks for parallel processing + stage(transform: true, + condition: use_mocks, + body: ->(s) { @test_build_planner.stage_flatten_mocks_list(s) } + ), + + # Stage 9 + stage("Preprocessing for Mocks", + condition: use_mocks_preproc, + body: ->(s) { @test_build_executor.stage_preprocess_mocks(s) } + ), + + # Stage 10 + stage("Mocking", + condition: use_mocks, + body: ->(s) { @test_build_executor.stage_generate_mocks(s) } + ), + + # Stage 11 + stage("Preprocessing Test Files", + condition: use_preprocessing, + body: ->(s) { @test_build_executor.stage_preprocess_test_files(s) } + ), + + # Stage 12 + stage("Collecting More Test Context", + condition: use_preprocessing, + body: ->(s) { @test_build_executor.stage_collect_runner_details(s) } + ), + + # Stage 13 + stage("Test Runners", + body: ->(s) { @test_build_executor.stage_generate_runners(s) } + ), + + # Stage 14 + stage("Determining Artifacts to Be Built", + heading: false, + body: ->(s) { @test_build_planner.stage_determine_artifacts(s) } + ), + + # Transform 3: Prepare objects for parallel processing + stage(transform: true, + body: ->(s) { @test_build_planner.stage_flatten_objects_list(s) } + ), + + # Stage 15 + stage("Building Objects", + body: ->(s) { @test_build_executor.stage_build_objects(s) } + ), + + # Stage 16 + stage("Building Test Executables", + body: ->(s) { @test_build_executor.stage_build_executables(s) } + ), + + # Stage 17 + stage("Executing", + condition: not_build_only, + body: ->(s) { @test_build_executor.stage_execute(s) } + ), + ] + end + + def stage(name = nil, heading: true, condition: nil, transform: false, body:) + Stage.new( + name: name, + heading: heading, + condition: condition, + transform: transform, + body: body + ) + end + +end diff --git a/lib/ceedling/test_invoker/test_invoker_types.rb b/lib/ceedling/test_invoker/test_invoker_types.rb new file mode 100644 index 000000000..91e72abbf --- /dev/null +++ b/lib/ceedling/test_invoker/test_invoker_types.rb @@ -0,0 +1,56 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +module TestInvokerTypes + + # Partial build metadata for one test: config map plus accumulated output module names. + TestablePartials = Struct.new(:configs, :tests, :mocks, keyword_init: true) + + # Carries all mutable state across the pipeline stages. + PipelineState = Struct.new( + :tests, # Array of test filepaths (input to stage 1) + :testables, # Hash<Symbol, Testable> — accumulated across all stages + :context, + :options, + :partials_headers, # Produced by T1; consumed by stages 6 & 7 + :partials_sources, # Produced by T1; consumed by stages 6 & 7 + :mocks_list, # Produced by T2; consumed by stages 9 & 10 + :objects_list, # Produced by T3; consumed by stage 15 + :lock, # Mutex for thread-safe testable writes + keyword_init: true + ) + + # Named record replacing the raw hash per test file. Fields are populated + # across multiple stages; nil fields are valid until their stage sets them. + Testable = Struct.new( + :filepath, :name, + :paths, # Hash — build/results/mocks/partials/preprocess paths + :preprocess, # Hash — preprocessing scratch state + :search_paths, + :compile_flags, :preprocess_flags, :assembler_flags, :link_flags, + :compile_defines, :preprocess_defines, + :runner, # Hash — {output_filepath:, input_filepath:} + :mocks, # Hash — mock name → mock info + :partials, # TestablePartials — configs map + tests/mocks module name lists + :sources, :frameworks, :core, :objects, :executable, + :no_link_objects, :results_pass, :results_fail, :tool, + keyword_init: true + ) do + def initialize(**kwargs) + kwargs[:partials] ||= TestablePartials.new(configs: {}, tests: [], mocks: []) + super(**kwargs) + end + end + + # Describes one pipeline step — either a named build_step or a silent transform. + Stage = Struct.new(:name, :heading, :condition, :transform, :body, keyword_init: true) do + def run?(state) + condition.nil? || condition.call(state) + end + end + +end diff --git a/lib/ceedling/test_invoker_helper.rb b/lib/ceedling/test_invoker_helper.rb deleted file mode 100644 index 3748727e9..000000000 --- a/lib/ceedling/test_invoker_helper.rb +++ /dev/null @@ -1,345 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -require 'ceedling/exceptions' - -class TestInvokerHelper - - constructor :configurator, - :loginator, - :batchinator, - :task_invoker, - :test_context_extractor, - :include_pathinator, - :preprocessinator, - :defineinator, - :flaginator, - :file_finder, - :file_path_utils, - :file_wrapper, - :generator, - :test_runner_manager - - def setup() - end - - def process_project_include_paths() - @include_pathinator.validate_test_build_directive_paths() - headers = @include_pathinator.validate_header_files_collection() - @include_pathinator.augment_environment_header_files( headers ) - end - - def extract_include_directives(arg_hash) - # Run test file through preprocessor to parse out include statements and then collect header files, mocks, etc. - includes = @preprocessinator.preprocess_includes( **arg_hash ) - - # Store the include statements we found - @test_context_extractor.ingest_includes( arg_hash[:filepath], includes ) - end - - def validate_build_directive_source_files(test:, filepath:) - sources = @test_context_extractor.lookup_build_directive_sources_list(filepath) - - ext_message = @configurator.extension_source - if @configurator.test_build_use_assembly - ext_message += " or #{@configurator.extension_assembly}" - end - - sources.each do |source| - valid_extension = true - - # Only C files in test build - if not @configurator.test_build_use_assembly - valid_extension = false if @file_wrapper.extname(source) != @configurator.extension_source - # C and assembly files in test build - else - ext = @file_wrapper.extname(source) - valid_extension = false if (ext != @configurator.extension_assembly) and (ext != @configurator.extension_source) - end - - if not valid_extension - error = "File '#{source}' specified with TEST_SOURCE_FILE() in #{test} is not a #{ext_message} source file" - raise CeedlingException.new(error) - end - - if @file_finder.find_build_input_file(filepath: source, complain: :ignore, context: TEST_SYM).nil? - error = "File '#{source}' specified with TEST_SOURCE_FILE() in #{test} cannot be found in the source file collection" - raise CeedlingException.new(error) - end - end - end - - def search_paths(filepath, subdir) - paths = [] - - # Start with mock path to ensure any CMock-reworked header files are encountered first - paths << File.join( @configurator.cmock_mock_path, subdir ) if @configurator.project_use_mocks - paths += @include_pathinator.lookup_test_directive_include_paths( filepath ) - paths += @include_pathinator.collect_test_include_paths() - paths += @configurator.collection_paths_support - paths += @configurator.collection_paths_include - paths += @configurator.collection_paths_libraries - paths += @configurator.collection_paths_vendor - paths += @configurator.collection_paths_test_toolchain_include - - return paths.uniq - end - - def framework_defines() - defines = [] - - # Unity defines - defines += @defineinator.defines( topkey:UNITY_SYM, subkey: :defines ) - - # CMock defines - defines += @defineinator.defines( topkey:CMOCK_SYM, subkey: :defines ) - - # CException defines - defines += @defineinator.defines( topkey:CEXCEPTION_SYM, subkey: :defines ) - - return defines.uniq - end - - def tailor_search_paths(filepath:, search_paths:) - _search_paths = [] - - # Unity search paths - if filepath == File.join(PROJECT_BUILD_VENDOR_UNITY_PATH, UNITY_C_FILE) - _search_paths += @configurator.collection_paths_support - _search_paths << PROJECT_BUILD_VENDOR_UNITY_PATH - - # CMock search paths - elsif @configurator.project_use_mocks and - (filepath == File.join(PROJECT_BUILD_VENDOR_CMOCK_PATH, CMOCK_C_FILE)) - _search_paths += @configurator.collection_paths_support - _search_paths << PROJECT_BUILD_VENDOR_UNITY_PATH - _search_paths << PROJECT_BUILD_VENDOR_CMOCK_PATH - _search_paths << PROJECT_BUILD_VENDOR_CEXCEPTION_PATH if @configurator.project_use_exceptions - - # CException search paths - elsif @configurator.project_use_exceptions and - (filepath == File.join(PROJECT_BUILD_VENDOR_CEXCEPTION_PATH, CEXCEPTION_C_FILE)) - _search_paths += @configurator.collection_paths_support - _search_paths << PROJECT_BUILD_VENDOR_CEXCEPTION_PATH - - # Support files search paths - elsif (@configurator.collection_all_support.include?(filepath)) - _search_paths = search_paths - _search_paths += @configurator.collection_paths_support - _search_paths << PROJECT_BUILD_VENDOR_UNITY_PATH - _search_paths << PROJECT_BUILD_VENDOR_CMOCK_PATH if @configurator.project_use_mocks - _search_paths << PROJECT_BUILD_VENDOR_CEXCEPTION_PATH if @configurator.project_use_exceptions - end - - # Not a vendor file, return original search paths - if _search_paths.length == 0 - return search_paths - end - - return _search_paths.uniq - end - - def runner_defines() - return @test_runner_manager.collect_defines() - end - - def compile_defines(context:, filepath:) - # If this context exists ([:defines][context]), use it. Otherwise, default to test context. - context = TEST_SYM unless @defineinator.defines_defined?( context:context ) - - defines = @defineinator.generate_test_definition( filepath:filepath ) - defines += @defineinator.defines( subkey:context, filepath:filepath ) - - return defines.uniq - end - - def preprocess_defines(test_defines:, filepath:) - # Preprocessing defines for the test file - preprocessing_defines = @defineinator.defines( subkey:PREPROCESS_SYM, filepath:filepath, default:nil ) - - # If no defines were set, default to using test_defines - return test_defines if preprocessing_defines.nil? - - # Otherwise, return the defines we looked up - # This includes an explicitly set empty list to override / clear test_defines - return preprocessing_defines - end - - def flags(context:, operation:, filepath:, default:[]) - # If this context + operation exists ([:flags][context][operation]), use it. Otherwise, default to test context. - context = TEST_SYM unless @flaginator.flags_defined?( context:context, operation:operation ) - - return @flaginator.flag_down( context:context, operation:operation, filepath:filepath, default:default ) - end - - def preprocess_flags(context:, compile_flags:, filepath:) - preprocessing_flags = flags( context:context, operation:OPERATION_PREPROCESS_SYM, filepath:filepath, default:nil ) - - # If no flags were set, default to using compile_flags - return compile_flags if preprocessing_flags.nil? - - # Otherwise, return the flags we looked up - # This includes an explicitly set empty list to override / clear compile_flags - return preprocessing_flags - end - - def collect_test_framework_sources(mocks) - sources = [] - - sources << File.join(PROJECT_BUILD_VENDOR_UNITY_PATH, UNITY_C_FILE) - sources << File.join(PROJECT_BUILD_VENDOR_CMOCK_PATH, CMOCK_C_FILE) if @configurator.project_use_mocks and mocks - sources << File.join(PROJECT_BUILD_VENDOR_CEXCEPTION_PATH, CEXCEPTION_C_FILE) if @configurator.project_use_exceptions - - # If we're (a) using mocks (b) a Unity helper is defined and (c) that unity helper includes a source file component, - # then link in the unity_helper object file too. - if @configurator.project_use_mocks - @configurator.cmock_unity_helper_path.each do |helper| - if @file_wrapper.exist?( helper.ext( EXTENSION_SOURCE ) ) - sources << helper - end - end - end - - return sources - end - - def extract_sources(test_filepath) - sources = [] - - # Get any additional source files specified by TEST_SOURCE_FILE() in test file - _sources = @test_context_extractor.lookup_build_directive_sources_list(test_filepath) - _sources.each do |source| - sources << @file_finder.find_build_input_file(filepath: source, complain: :ignore, context: TEST_SYM) - end - - _support_headers = COLLECTION_ALL_SUPPORT.map { |filepath| File.basename(filepath).ext(EXTENSION_HEADER) } - - # Get all #include .h files from test file so we can find any source files by convention - includes = @test_context_extractor.lookup_full_header_includes_list(test_filepath) - includes.each do |include| - _basename = File.basename(include) - next if _basename == UNITY_H_FILE # Ignore Unity in this list - next if _basename.start_with?(CMOCK_MOCK_PREFIX) # Ignore mocks in this list - next if _support_headers.include?(_basename) # Ignore any sources in our support files list - - sources << @file_finder.find_build_input_file(filepath: include, complain: :ignore, context: TEST_SYM) - end - - # Remove any nil or duplicate entries in list - return sources.compact.uniq - end - - def fetch_shallow_source_includes(test_filepath) - return @test_context_extractor.lookup_source_includes_list(test_filepath) - end - - def fetch_include_search_paths_for_test_file(test_filepath) - return @test_context_extractor.lookup_include_paths_list(test_filepath) - end - - # TODO: Use search_paths to find/match header file from which to generate mock - # Today, this is just a pass-through wrapper - def find_header_input_for_mock(mock, search_paths) - return @file_finder.find_header_input_for_mock( mock ) - end - - # Transform list of mock names into filenames with source extension - def form_mock_filenames(mocklist) - return mocklist.map {|mock| mock + @configurator.extension_source} - end - - def remove_mock_original_headers( filelist, mocklist ) - filelist.delete_if do |filepath| - # Create a simple mock name from the filepath => mock prefix + filepath base name with no extension - mock_name = @configurator.cmock_mock_prefix + File.basename( filepath, '.*' ) - # Tell `delete_if()` logic to remove inspected filepath if simple mocklist includes the name we just generated - mocklist.include?( mock_name ) - end - end - - def clean_test_results(path, tests) - tests.each do |test| - @file_wrapper.rm_f( Dir.glob( File.join( path, test + '.*' ) ) ) - end - end - - # Convert libraries configuration form YAML configuration - # into a string that can be given to the compiler. - def convert_libraries_to_arguments() - args = ((@configurator.project_config_hash[:libraries_test] || []) + ((defined? LIBRARIES_SYSTEM) ? LIBRARIES_SYSTEM : [])).flatten - if (defined? LIBRARIES_FLAG) - args.map! {|v| LIBRARIES_FLAG.gsub(/\$\{1\}/, v) } - end - return args - end - - def get_library_paths_to_arguments() - paths = (defined? PATHS_LIBRARIES) ? (PATHS_LIBRARIES || []).clone : [] - if (defined? LIBRARIES_PATH_FLAG) - paths.map! {|v| LIBRARIES_PATH_FLAG.gsub(/\$\{1\}/, v) } - end - return paths - end - - def generate_executable_now(context:, build_path:, executable:, objects:, flags:, lib_args:, lib_paths:, options:) - begin - @generator.generate_executable_file( - options[:test_linker], - context, - objects.map{|v| "\"#{v}\""}, - flags, - executable, - @file_path_utils.form_test_build_map_filepath( build_path, executable ), - lib_args, - lib_paths ) - rescue ShellException => ex - if ex.shell_result[:output] =~ /symbol/i - notice = "If the linker reports missing symbols, the following may be to blame:\n" + - " 1. This test lacks #include statements corresponding to needed source files (see note below).\n" + - " 2. Project file paths omit source files corresponding to #include statements in this test.\n" + - " 3. Complex macros, #ifdefs, etc. have obscured correct #include statements in this test.\n" + - " 4. Your project is attempting to mix C++ and C file extensions (not supported).\n" - if (@configurator.project_use_mocks) - notice += " 5. This test does not #include needed mocks (that triggers their generation).\n" - end - - notice += "\n" - notice += "NOTE: A test file directs the build of a test executable with #include statemetns:\n" + - " * By convention, Ceedling assumes header filenames correspond to source filenames.\n" + - " * Which code files to compile and link are determined by #include statements.\n" - if (@configurator.project_use_mocks) - notice += " * An #include statement convention directs the generation of mocks from header files.\n" - end - - notice += "\n" - notice += "OPTIONS:\n" + - " 1. Doublecheck this test's #include statements.\n" + - " 2. Simplify complex macros or fully specify symbols for this test in :project ↳ :defines.\n" + - " 3. If no header file corresponds to the needed source file, use the TEST_SOURCE_FILE()\n" + - " build diective macro in this test to inject a source file into the build.\n\n" + - "See the docs on conventions, paths, preprocessing, compilation symbols, and build directive macros.\n\n" - - # Print helpful notice - @loginator.log( notice, Verbosity::COMPLAIN, LogLabels::NOTICE ) - end - - # Re-raise the exception - raise ex - end - end - - def run_fixture_now(context:, test_name:, test_filepath:, executable:, result:, options:) - @generator.generate_test_results( - tool: options[:test_fixture], - context: context, - test_name: test_name, - test_filepath: test_filepath, - executable: executable, - result: result) - end - -end diff --git a/lib/ceedling/tool_executor.rb b/lib/ceedling/tool_executor.rb index 42e226a42..778550b63 100644 --- a/lib/ceedling/tool_executor.rb +++ b/lib/ceedling/tool_executor.rb @@ -38,7 +38,7 @@ def build_command_line(tool_config, extra_params, *args) ].reject{|s| s.nil? || s.empty?}.join(' ').strip # Log command as is - @loginator.lazy( Verbosity::DEBUG ) { "Command: #{command}" } + @loginator.lazy( Verbosity::DEBUG ) { "> Command: #{command}\n\n" } # Update executable after any expansion command[:executable] = executable @@ -131,18 +131,26 @@ def build_arguments(tool_name, config, *args) end - # handle simple text string argument & argument array string replacement operators + # Handle simple text string argument & argument array string replacement operators def expandify_element(tool_name, element, *args) match = // to_process = nil args_index = 0 - # handle ${#} input replacement + # Handle ${#} input replacement if (element =~ PATTERNS::TOOL_EXECUTOR_ARGUMENT_REPLACEMENT) + # Convert argument numbering from configuration 1-indexed to array 0-indexed args_index = ($2.to_i - 1) - if (args.nil? or args[args_index].nil?) - error = "Tool '#{tool_name}' expected valid argument data to accompany replacement operator #{$1}." + args_size = args.nil? ? 0 : args.size() + + if (args_size == 0) + error = "Command building for tool '#{tool_name}' expects argument data but was provided none." + raise CeedlingException.new( error ) + end + + if (args_index >= args_size) + error = "Command building for tool '#{tool_name}' was provided only #{args_size} arguments but references a replacement operator #{$1}." raise CeedlingException.new( error ) end @@ -150,19 +158,19 @@ def expandify_element(tool_name, element, *args) to_process = args[args_index] end - # simple string argument: replace escaped '\$' and strip + # Simple string argument: replace escaped '\$' and strip element.sub!(/\\\$/, '$') element.strip! build_string = '' - # handle array or anything else passed into method to be expanded in place of replacement operators + # Handle array or anything else passed into method to be expanded in place of replacement operators case (to_process) when Array then to_process.each {|value| build_string.concat( "#{element.sub(match, value.to_s)} " ) } if (to_process.size > 0) else build_string.concat( element.sub(match, to_process.to_s) ) end - # handle inline ruby string substitution + # Handle inline ruby string substitution if (build_string =~ PATTERNS::RUBY_STRING_REPLACEMENT) build_string.replace(@system_wrapper.module_eval(build_string)) end diff --git a/lib/ceedling/tool_executor_helper.rb b/lib/ceedling/tool_executor_helper.rb index 1b10b8cc4..fe195a325 100644 --- a/lib/ceedling/tool_executor_helper.rb +++ b/lib/ceedling/tool_executor_helper.rb @@ -66,30 +66,26 @@ def log_results(command_str, shell_result) # No logging unless we're at least at Obnoxious return if !@verbosinator.should_output?( Verbosity::OBNOXIOUS ) - output = "> Shell executed command:\n" + output = "> Shell executed::\n" output += "`#{command_str}`\n" if !shell_result.empty? # Detailed debug logging if @verbosinator.should_output?( Verbosity::DEBUG ) - output += "> With $stdout: " + output += "> With $stdout:: " output += shell_result[:stdout].empty? ? "<empty>\n" : "\n#{shell_result[:stdout].strip()}\n" - output += "> With $stderr: " + output += "> With $stderr:: " output += shell_result[:stderr].empty? ? "<empty>\n" : "\n#{shell_result[:stderr].strip()}\n" output += "> And terminated with status: #{shell_result[:status]}\n" - @loginator.log( "\n#{output}\n\n", Verbosity::DEBUG ) + @loginator.log( "#{output}\n\n", Verbosity::DEBUG ) return # Bail out end - # Slightly less verbose obnoxious logging - if !shell_result[:output].empty? - output += "> Produced output: " - output += shell_result[:output].strip().empty? ? "<empty>\n" : "\n#{shell_result[:output].strip()}\n" - end + # Slightly less verbose obnoxious logging omits tool execution output if !shell_result[:exit_code].nil? output += "> And terminated with exit code: [#{shell_result[:exit_code]}]\n" @@ -98,6 +94,6 @@ def log_results(command_str, shell_result) end end - @loginator.log( "\n#{output}\n\n", Verbosity::OBNOXIOUS ) + @loginator.log( "#{output}\n\n", Verbosity::OBNOXIOUS ) end end diff --git a/lib/ceedling/tool_validator.rb b/lib/ceedling/tool_validator.rb index df608f7c4..50bbf5765 100644 --- a/lib/ceedling/tool_validator.rb +++ b/lib/ceedling/tool_validator.rb @@ -103,7 +103,7 @@ def validate_executable(tool:, name:, extension:, respect_optional:, boom:) end if !exists - error = "#{name} ↳ :executable => `#{executable}` " + error + error = "#{name} ↳ :executable ➡️ `#{executable}` " + error end # Raise exception if executable can't be found and boom is set @@ -130,7 +130,7 @@ def validate_stderr_redirect(tool:, name:, boom:) if redirect.class == Symbol if not StdErrRedirect.constants.map{|constant| constant.to_s}.include?( redirect.to_s.upcase ) options = StdErrRedirect.constants.map{|constant| ':' + constant.to_s.downcase}.join(', ') - error = "#{name} ↳ :stderr_redirect => :#{redirect} is not a recognized option {#{options}}" + error = "#{name} ↳ :stderr_redirect ➡️ :#{redirect} is not a recognized option {#{options}}" # Raise exception if requested raise CeedlingException.new( error ) if boom diff --git a/lib/snapshot.rb b/lib/snapshot.rb new file mode 100644 index 000000000..f328a9c2f --- /dev/null +++ b/lib/snapshot.rb @@ -0,0 +1,43 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# snapshot.rb <config-file> <snapshot-dir> +# +# Copies versioned project files into <snapshot-dir> so that the mkdocs documentation +# build can reference them with relative paths that are correct for each deployed version. +# +# <config-file> Path to the YAML snapshot configuration file. Lists source files to copy +# as paths relative to the project root. +# <snapshot-dir> Destination directory. Each file is written to <snapshot-dir>/<relative-path>, +# preserving directory structure. +# +# Invoked via `rake docs:snapshot` (or automatically by `rake docs:build`). +# +# This script assumes the destination directory does not exist. + +require 'fileutils' +require 'yaml' + +PROJECT_ROOT = File.expand_path('..', __dir__) +SNAPSHOT_CONFIG = ARGV[0] or abort("Usage: snapshot.rb <config-file> <snapshot-dir>") +SNAPSHOT_DIR = ARGV[1] or abort("Usage: snapshot.rb <config-file> <snapshot-dir>") + +config = YAML.load_file(SNAPSHOT_CONFIG) +files = config.fetch('files') + +files.each do |relative_path| + src = File.join(PROJECT_ROOT, relative_path) + dest = File.join(SNAPSHOT_DIR, relative_path) + + # Create the destnation directory + FileUtils.mkdir_p(File.dirname(dest)) + # Copy the path, including recursive copying for directories + FileUtils.cp_r(src, dest) + puts " snapshot: #{relative_path}" +end + +puts "Snapshot complete — #{files.length} file(s) written to #{SNAPSHOT_DIR}" diff --git a/mkdocs.local.yml b/mkdocs.local.yml new file mode 100644 index 000000000..ef02d4a9f --- /dev/null +++ b/mkdocs.local.yml @@ -0,0 +1,39 @@ +# mkdocs uses a simple overwrite merge -- must rewrite entire sections to make small changes. +INHERIT: mkdocs.yml + +# Produce index.html files with internal links for local HTML bundle. +# The default is to produce directory URLs where a web server silently serves a directory index.html file +use_directory_urls: false +# Blank — prevents absolute URLs being baked in that point back to the live site. +site_url: '' + +site_dir: site-local + +# Material theme configuration +theme: + name: material + favicon: assets/images/favicon.png + logo: assets/images/ceedling.svg + palette: + - scheme: default + primary: green + accent: deep_purple + features: + # No navigation.instant for local site + - navigation.tabs # Top-level nav rendered as tabs + - navigation.sections # Sections rendered as groups in left sidebar + - navigation.top # Back-to-top button + - navigation.indexes # Section index pages (first item in section = section landing page) + - navigation.path # Breadcrumb trail below page title + - search.suggest # Autocomplete suggestions in search + - search.highlight # Highlight search terms on result pages + - content.code.copy # Copy button on code blocks + - content.code.annotate # Inline annotation support in code blocks + +extra_css: + - assets/stylesheets/styling.css # Custom style tweaks + - assets/stylesheets/admonitions.css # Custom admonition types + - assets/stylesheets/fixes-local.css # Local site styling overrides and layout fixes + +# Remove all the optional plugins that we run on the server, including "search" and "mike" +plugins: [] diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..57d8c1597 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,185 @@ +# Site metadata +site_name: Ceedling +site_description: Test-Centered Build System for C +site_url: https://throwtheswitch.github.io/Ceedling/ +repo_url: https://github.com/ThrowTheSwitch/Ceedling +repo_name: ThrowTheSwitch/Ceedling + +docs_dir: docs/mkdocs/ + +site_dir: site-web + +# Footer copyright notice +copyright: >- + © 2010-2026 M. Karlesky, M. VanderVoord, and G. Williams — + Documentation license: + <a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en">CC BY-SA 4.0</a> + +# Material theme configuration +theme: + name: material + favicon: assets/images/favicon.png + logo: assets/images/ceedling.svg + palette: + - scheme: default + primary: green + accent: deep_purple + features: + - navigation.tabs # Top-level nav rendered as tabs + - navigation.sections # Sections rendered as groups in left sidebar + - navigation.top # Back-to-top button + - navigation.indexes # Section index pages (first item in section = section landing page) + - navigation.path # Breadcrumb trail below page title + - navigation.instant # Instant navigation with page content swaps (without page reloads) + - search.suggest # Autocomplete suggestions in search + - search.highlight # Highlight search terms on result pages + - content.code.copy # Copy button on code blocks + - content.code.annotate # Inline annotation support in code blocks + +# Extra site configuration +extra: + generator: false # Suppress "Made with Material for MkDocs" footer text + version: + provider: mike # Versioned documentation deployment via mike + default: latest + social: + - icon: fontawesome/brands/github + link: https://github.com/sponsors/ThrowTheSwitch + name: Sponsor ThrowTheSwitch on GitHub + +extra_css: + - assets/stylesheets/styling.css # Custom style tweaks + - assets/stylesheets/admonitions.css # Custom admonition types + +# Plugins +plugins: + - search # Full-text search index + - mike: # Versioned documentation deployment to gh-pages + alias_type: symlink + canonical_version: latest + +# Markdown extensions +markdown_extensions: + - toc: + toc_depth: 3 # Limits TOC to show headings H1 through H3 + # (H2 and H3 in reality since H1 is reserved for page titles) + - admonition # !!! note / tip / warning / etc. callout blocks + - md_in_html # Markdown inside raw HTML elements (required for card grids) + - footnotes + - pymdownx.emoji: # Emoji shortcodes (:material-icon:, :fontawesome-icon:, etc.) + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: # Syntax highlighting for fenced code blocks via Pygments + use_pygments: true + anchor_linenums: true + - pymdownx.superfences # Fenced code blocks inside admonitions and other nested elements + - pymdownx.inlinehilite # Inline code syntax highlighting with `#!lang code` syntax + +# Files in docs/ excluded from the generated site. +# These are standalone reference documents, developer files, PDFs, or historical records +# maintained separately from the versioned documentation bundle. +exclude_docs: | + snapshot.yml + +# Site navigation tree +nav: + - Home: index.md + - Overview: + - overview/index.md + - Build System: overview/build-system.md + - Tools & Frameworks: overview/tools-and-frameworks.md + - Test Environments: overview/test-environments.md + - Getting Started: + - getting-started/index.md + - Quick Start: getting-started/quick-start.md + - Installation: getting-started/installation.md + - Command Line: getting-started/command-line.md + - Testing Guide: + - testing-guide/index.md + - How Does a Test Case Work?: testing-guide/test-cases.md + - Commented Sample Test File: testing-guide/test-sample.md + - Anatomy of a Test Suite: testing-guide/test-suite-anatomy.md + - Conventions & Behaviors: testing-guide/conventions.md + - Unity, CMock & CException: testing-guide/frameworks.md + - Partials: + - testing-guide/partials/index.md + - What Is a Partial?: testing-guide/partials/overview.md + - Configuration: testing-guide/partials/configuration.md + - Walk-Through Example: testing-guide/partials/example.md + - Conventions & Terminology: testing-guide/partials/conventions.md + - Partial Directive Macros: testing-guide/partials/directives.md + - Accessing Static Variables: testing-guide/partials/variables.md + - Build Directive Macros: testing-guide/build-directives.md + - Configuration: + - configuration/index.md + - Loading Configuration: configuration/loading.md + - Project File Basics: configuration/project-file.md + - Parallel Builds: configuration/parallel-builds.md + - Environment Variables: configuration/environment-vars.md + - Which Ceedling: configuration/which-ceedling.md + - Global Collections: configuration/global-collections.md + - Configuration Reference: + - configuration/reference/index.md + - Global Project Settings: configuration/reference/project.md + - Mixins: configuration/reference/mixins.md + - Test Build Settings: configuration/reference/test-build.md + - Release Build Settings: configuration/reference/release-build.md + - Search Paths: configuration/reference/paths.md + - File Collections: configuration/reference/files.md + - Environment Variables: configuration/reference/environment.md + - File Extensions: configuration/reference/extension.md + - Defines: configuration/reference/defines.md + - Compilation & Link Flags: configuration/reference/flags.md + - Libraries: configuration/reference/libraries.md + - Unity: configuration/reference/unity.md + - CMock: configuration/reference/cmock.md + - Test Runner: configuration/reference/test-runner.md + - CException: configuration/reference/cexception.md + - Plugins: configuration/reference/plugins.md + - Tools: configuration/reference/tools.md + - Plugins: + - Overview: plugins/index.md + - Test Report — Pretty: plugins/report-tests-pretty-stdout.md + - Test Report — IDE: plugins/report-tests-ide-stdout.md + - Test Report — GTest-like: plugins/report-tests-gtestlike-stdout.md + - Test Report — TeamCity: plugins/report-tests-teamcity-stdout.md + - Test Report Log Factory: plugins/report-tests-log-factory.md + - Test Report — Raw Output: plugins/report-tests-raw-output-log.md + - Build Warnings Log: plugins/report-build-warnings-log.md + - GCov (Coverage): + - plugins/gcov/index.md + - Overview: plugins/gcov/overview.md + - Tool Versions: plugins/gcov/tool-versions.md + - Set Up & Configuration: plugins/gcov/setup.md + - Example Usage: plugins/gcov/examples.md + - Reporting Configuration: plugins/gcov/reporting.md + - GCovr Configuration: plugins/gcov/gcovr.md + - ReportGenerator Configuration: plugins/gcov/reportgenerator.md + - Advanced & Troubleshooting: plugins/gcov/troubleshooting.md + - Bullseye (Coverage): plugins/bullseye.md + - FFF (Fake Functions): plugins/fff.md + - Command Hooks: plugins/command-hooks.md + - Compile Commands JSON DB: plugins/compile-commands-json-db.md + - Beep: plugins/beep.md + - Module Generator: plugins/module-generator.md + - Dependencies: plugins/dependencies.md + - Reference: + - reference/index.md + - Command Line: reference/command-line.md + - Project Configuration: reference/project-configuration.md + - Test Build Directives: reference/build-directives.md + - Partials Macros: reference/partials-macros.md + - Environment Variables: reference/environment-vars.md + - Coverage Reporting: reference/gcov-plugin.md + - Global Collections: reference/global-collections.md + - Releases: + - History Documents: project/release-history.md + - Upgrading: project/upgrade.md + - Help: help.md + - Development: + - development/index.md + - Plugin Development: + - development/plugins/index.md + - Configuration Plugin: development/plugins/configuration.md + - Plugin Subclass: development/plugins/plugin-subclass.md + - Rake Task Plugin: development/plugins/rake-tasks.md diff --git a/plugins/beep/lib/beep.rb b/plugins/beep/lib/beep.rb index 617fd6f9e..60b55b385 100755 --- a/plugins/beep/lib/beep.rb +++ b/plugins/beep/lib/beep.rb @@ -59,6 +59,7 @@ def setup def post_build command = @ceedling[:tool_executor].build_command_line( @tools[:beep_on_done], + # No additional arguments [], # Only used by tools with `${1}` replacement argument 'ceedling build done' @@ -73,6 +74,7 @@ def post_build def post_error command = @ceedling[:tool_executor].build_command_line( @tools[:beep_on_error], + # No additional arguments [], # Only used by tools with `${1}` replacement argument 'ceedling build error' diff --git a/plugins/dependencies/example/boss/project.yml b/plugins/dependencies/example/boss/project.yml index 38a5bd49e..2826e73e0 100644 --- a/plugins/dependencies/example/boss/project.yml +++ b/plugins/dependencies/example/boss/project.yml @@ -111,7 +111,6 @@ #- command_hooks # write custom actions to be called at different points during the build process #- compile_commands_json # generate a compile_commands.json file - dependencies # automatically fetch 3rd party libraries, etc. - #- subprojects # managing builds and test for static libraries #- fake_function_framework # use FFF instead of CMock # Report options (You'll want to choose one stdout option, but may choose multiple stored options if desired) @@ -127,7 +126,6 @@ :executable: .out #:testpass: .pass #:testfail: .fail - :subprojects: .a # This is where Ceedling should look for your source and test files. # see documentation for the many options for specifying this. diff --git a/plugins/dependencies/example/supervisor/project.yml b/plugins/dependencies/example/supervisor/project.yml index f87b79496..28eb17517 100644 --- a/plugins/dependencies/example/supervisor/project.yml +++ b/plugins/dependencies/example/supervisor/project.yml @@ -54,7 +54,6 @@ #- command_hooks # write custom actions to be called at different points during the build process #- compile_commands_json # generate a compile_commands.json file #- dependencies # automatically fetch 3rd party libraries, etc. - #- subprojects # managing builds and test for static libraries #- fake_function_framework # use FFF instead of CMock # Report options (You'll want to choose one stdout option, but may choose multiple stored options if desired) @@ -70,7 +69,6 @@ :executable: .a #:testpass: .pass #:testfail: .fail - #:subprojects: .a # This is where Ceedling should look for your source and test files. # see documentation for the many options for specifying this. diff --git a/plugins/dependencies/lib/dependencies.rb b/plugins/dependencies/lib/dependencies.rb index 9aa7f1672..372241757 100644 --- a/plugins/dependencies/lib/dependencies.rb +++ b/plugins/dependencies/lib/dependencies.rb @@ -211,7 +211,9 @@ def fetch_if_required(lib_path) steps << @ceedling[:tool_executor].build_command_line( TOOLS_DEPS_ZIP, + # No additional arguments [], + # Argument replacement blob[:fetch][:source] ) @@ -219,7 +221,9 @@ def fetch_if_required(lib_path) steps << @ceedling[:tool_executor].build_command_line( TOOLS_DEPS_TARGZIP, + # No additional arguments [], + # Argument replacement blob[:fetch][:source] ) @@ -232,7 +236,9 @@ def fetch_if_required(lib_path) steps << @ceedling[:tool_executor].build_command_line( TOOLS_DEPS_GIT_CLONE, + # No additional arguments [], + # Argument replacement branch, '', # No depth blob[:fetch][:source] @@ -242,7 +248,9 @@ def fetch_if_required(lib_path) steps << @ceedling[:tool_executor].build_command_line( TOOLS_DEPS_GIT_CHECKOUT, + # No additional arguments [], + # Argument replacement blob[:fetch][:hash] ) else @@ -250,7 +258,9 @@ def fetch_if_required(lib_path) steps << @ceedling[:tool_executor].build_command_line( TOOLS_DEPS_GIT_CLONE, + # No additional arguments [], + # Argument replacement branch, '--depth 1', blob[:fetch][:source] @@ -264,7 +274,9 @@ def fetch_if_required(lib_path) steps << @ceedling[:tool_executor].build_command_line( TOOLS_DEPS_SUBVERSION, + # No additional arguments [], + # Argument replacement revision, blob[:fetch][:source] ) diff --git a/plugins/fff/examples/fff_example/project.yml b/plugins/fff/examples/fff_example/project.yml index f8749d9f3..0b2a6b9d4 100644 --- a/plugins/fff/examples/fff_example/project.yml +++ b/plugins/fff/examples/fff_example/project.yml @@ -7,6 +7,7 @@ --- :project: + :name: "FFF Example Project" # how to use ceedling. If you're not sure, leave this as `gem` and `?` :which_ceedling: ../../../.. :ceedling_version: '?' @@ -61,7 +62,6 @@ :executable: .out #:testpass: .pass #:testfail: .fail - #:subprojects: .a # This is where Ceedling should look for your source and test files. # see documentation for the many options for specifying this. diff --git a/plugins/gcov/README.md b/plugins/gcov/README.md deleted file mode 100644 index 654b7b1cf..000000000 --- a/plugins/gcov/README.md +++ /dev/null @@ -1,893 +0,0 @@ -# Ceedling Plugin: Gcov - -This plugin integrates the code coverage abilities of the GNU compiler -collection with test builds. It provides simple coverage metrics by default and -can optionally produce sophisticated coverage reports. - -# Plugin Overview - -When enabled, this plugin creates a new set of `gcov:` tasks that mirror -Ceedling's existing `test:` tasks. A `gcov:` task executes one or more tests -with coverage enabled for the source files exercised by those tests. - -This plugin also provides an extensive set of options for generating various -coverage reports for your project. The simplest is text-based coverage -summaries printed to the console after a `gcov:` test task is executed. - -This document details configuration, reporting options, and provides basic -[troubleshooting help][troubleshooting]. - -[troubleshooting]: #advanced-configuration--troubleshooting - -# Simple Coverage Summaries - -In its simplest usage, this plugin outputs coverage statistics to the console -for each source file exercised by a test. These console-based coverage -summaries are provided after the standard Ceedling test results summary. Other -than enabling the plugin and ensuring `gcov` is installed, no further set up -is necessary to produce these summaries. - -_Note_: Automatic summaries may be disabled (see configuration options below). - -When the Gcov plugin is active it enables Ceedling tasks like this: - -```shell - > ceedling gcov:Model -``` - -… that then generate output like this: - -``` --------------------------- -GCOV: OVERALL TEST SUMMARY --------------------------- -TESTED: 1 -PASSED: 1 -FAILED: 0 -IGNORED: 0 - ---------------------------- -GCOV: CODE COVERAGE SUMMARY ---------------------------- - -TestModel ---------- -Model.c | Lines executed:100.00% of 4 -Model.c | No branches -Model.c | No calls -TimerModel.c | Lines executed:0.00% of 3 -TimerModel.c | No branches -TimerModel.c | No calls -``` - -# Advanced Coverage Reports - -For more advanced visualizations and reporting, this plugin also supports a -variety of report generation options. - -Advanced report generation uses [gcovr] and / or [ReportGenerator] to generate -HTML, XML, JSON, or text-based reports from coverage-instrumented test runs. -See the tools' respective sites for examples of the reports they can generate. - -In the default configuration, if reports are enabled, this plugin automatically -generates reports in the build's `artifacts/` directory after each execution of -a `gcov:` task. - -An optional setting documented below disables automatic report generation, -providing a separate Ceedling task instead. Reports can then be generated -on demand after test suite runs. - -[gcovr]: https://www.gcovr.com/ -[ReportGenerator]: https://reportgenerator.io - -# Important Notes on Coverage Summaries vs. Coverage Reports - -Coverage summaries and coverage reports provide different levels of fidelity -and usability. Summaries are relatively unsophisticated while reports are -sophisticated. As such, both provide different capabilities and levels of -usability. - -## Coverage summaries - -Optional coverage summaries are intentionally simple. They require no -configuration and, to oversimplify, are largely filtered output from the `gcov` -tool. - -Coverage summaries are reported to the console for each source file exercised by -the tests executed by `gcov:` tasks. That is, coverage summaries correspond to -the tests executed, and in turn, the source code that your tests call. This -could be all tests (and thus all source code) or a subset of tests (and some -subset of source code). The `gcov` tool is run multiple times after test suite -execution in direct relation to the set of tests you ran with `gcov:` testing -tasks. In short, the scope of coverage summaries is guaranteed to match the -test suite you run. - -Coverage summaries do not include any sort of grand total, final tallies. This -is the domain of full coverage reports. - -Note that Ceedling can exercise the same source code under multiple scenarios -using multiple test files. Practically, this means that the same source file -may be listed in the coverage summaries more than once. That said, its coverage -statistics will be the same each time — the aggregate result of all tests that -exercised it. - -## Coverage reports - -Coverage reports provide both much more detail and better overviews of coverage -than the console-based coverage summaries. However, with this comes the need -for more sophisticated configuration and certain caveats on what is reported. - -Later sections detail how to configure the reports this plugin can generate. - -Of note is a consequence of how reports are generated and the limits of the -tools that do so. Reports are generated using coverage results on disk. The -report generation tools slurp up the coverage results they find in the `gcov/` -build output directory. This means that previous test suite runs can “pollute” -coverage reports. The solution is simple if blunt — run the `clobber` task -before running a coverage-instrumented test suite. This will yield a coverage -report with scope that matches that of the test suite you run. - -Both the `gcovr` and `reportgeneator` reporting utilities include powerful -filters that can limit the scope of reports. Hypothetically, it's possible for -coverage reports to have the same clear scope as coverage summaries. However, -in large projects, these filters would cause impractically long command lines. -Both tools provide configuration file options that would solve the command line -problem. However, this feature is “experimental” for `gcovr` and considerable -work to implement for both reporting utilities. At present, running -`ceedling clobber` before generating reports is the best option to ensure -accurate reports. - -# Plugin Set Up & Configuration - -## Supported tool versions [May 10, 2024] - -At the time of the last major updates to the Gcov plugin, the following notes -on version compatibility were known to be accurate. - -Keep in mind that for proper functioning, you do not necessarily need to -install all the tooks the Gcov plugin works with. Depending on configuration -options documented in later sections, any of the following tool combinations -may be sufficient for your needs: - -1. `gcov` -1. `gcov` + `gcovr` -1. `gcov` + `reportgenerator` -1. `gcov` + `gcovr` + `reportgenerator` - -### `gcov` - -The Gcov plugin is known to work with `gcov` packaged with GNU Compiler -Collection 12.2 and should work with versions through at least 14. - -The maintainers of `gcov` introduced significant behavioral changes for version -12. Previous versions of `gcov` had a simple exit code scheme with only a -single non-zero exit code upon fatal errors. Since version 12 `gcov` emits a -variety of exit codes even if the noted issue is a non-fatal error. The Gcov -plugin’s logic assumes version 12 behavior and processes failure messages and -exit codes appropriately, taking into account plugin configuration options. - -The Gcov plugin should be compatible with versions of `gcov` before version 12. -That is, its improved `gcov` exit handling should not be broken by the prior -simpler behavior. The Gcov plugin dependes on the `gcov` command line and has -been compatible with it as far back as `gcov` version 7. - -Because long file paths are quite common in software development scenarios, by -default, the Gcov plugin depends on the `gcov` `-x` flag. This flag hashes long -file paths to ensure they are not a problem for certain platforms' file -systems. This flag became available with `gcov` version 7. At the time of this -README section’s last update, the GNU Compiler Collection was at version 14. We -do not recommend using `gcov` version 6 and earlier. And, in fact, because of -the Gcov plugin’s dependence on the `gcov` `-x` flag, attempting to use it will -fail. - -### `gcovr` - -The Gcov plugin is known to work with `gcovr` 5.2 through `gcovr` 6.x. The -Gcov plugin supports `gcovr` command line conventions since version 4.2 and -attempts to support `gcovr` command lines before version 4.2. We recommend -using `gcovr` 5 and later. - -### `reportgenerator` - -The Gcov plugin is known to work with `reportgenerator` 5.2.4. The command line -for executing `reportgenerator` that the Gcov plugin relies on has largely been -stable since version 4. We recommend using `reportgenerator` 5.0 and later. - -## Toolchain dependencies - -### GNU Compiler Collection - -This plugin relies on the GNU compiler collection. Coverage instrumentation -is enabled through `gcc` compiler flags. Coverage-insrumented executables -(i.e. test suites) output coverage result files to disk when run. `gcov`, -`gcovr`, and `reportgenerator` (the tools managed by this plugin) all produce -their coverage tallies from these files. `gcov` is part of the GNU compiler -collection. The other tools — detailed below — require separate installation. - -Ceedling's default toolchain is the same as needed by this plugin. If you -are already running Ceedling test suites with the GNU compiler toolchain, -you are good to go. If you are using another toolchain for test suite and/or -release builds you will need to install the GNU compiler collection to use -this plugin. Depending on your needs you may also need to install the reporting -utilities, `gcovr` and/or `reportgenerator`. - -### `gcovr` and `reportgenerator`’s dependence on `gcov` - -Both the `gcovr` and `reportgenerator` tools depend on the `gcov` tool. This -dependency plays out in two different ways. In both cases, the report -generation utilities ingest `gcov`'s output to produce their artifacts. As -such, `gcov` must be available in your environment if using report generation. - -1. `gcovr` calls `gcov` directly. - - Because it calls `gcov` directly, you are limited as to the - advanced Ceedling features you can employ to modify `gcov`'s execution. - However, with a configuration option (see below) you can instruct `gcovr` - to call something other than `gcov` (e.g. a script that intercepts and - modifies how `gcovr` calls out to `gcov`). - - `gcovr` instructs `gcov` to generate `.gcov` files that it processes and - discards. A `gcovr` option documented below will retain the `.gcov` files. - -2. `reportgenerator` expects the existence of `.gcov` files to do its work. - This Ceedling plugin calls `gcov` appropriately to generate the `.gcov` - files `reportgenerator` needs before then calling the report utility. - - You can use Ceedling's features to modify how `gcov` is run before - `reportgenerator`. - -## Enable this plugin - -To use this plugin it must be enabled in your Ceedling project file: - -```yaml -:plugins: - :enabled: - - gcov -``` - -This simple configuration will create new `gcov:` tasks to run tests with -source coverage and output simple coverage summaries to the console as above. - -## Disabling automatic coverage summaries - -To disable the coverage summaries generated immediately following `gcov:` tasks, -simply add the following to a top-level `:gcov:` section in your project -configuration file. - -```yaml -:plugins: - :enabled: - - gcov - -:gcov: - :summaries: FALSE -``` - -## Report generation - -To generate reports: - -1. GCovr and / or ReportGenerator must installed or otherwise ready to run in - Ceedling's environment. -1. Reporting options must be configured in your project file beneath a `:gcov:` - entry. - -The next sections explain each of these steps. - -### Installation of report generation utilities - -[gcovr] is available on any platform supported by Python. - -`gcovr` can be installed via pip like this: - -```shell - > pip install gcovr -``` - -[ReportGenerator] is available on any platform supported by .Net. - -`ReportGenerator` can be installed via .NET Core like so: - -```shell - > dotnet tool install -g dotnet-reportgenerator-globaltool -``` - -Either or both of `gcovr` or `ReportGenerator` may be used. Only one must -be installed for advanced report generation. - -## Enabling report generation utilities - -If reports are configured (see next sections) but no `:utilities:` subsection -exists, this plugin defaults to using `gcovr` for report generation. - -Otherwise, enable Gcovr and / or ReportGenerator to create coverage reports. - -```yaml -:gcov: - :utilities: - - gcovr # Use `gcovr` to create reports (default if no :utilities set). - - ReportGenerator # Use `ReportGenerator` to create reports. -``` - -## Automatic and manual report generation - -By default, if reports are specified, this plugin automatically generates -reports after any `gcov:` task is executed. To disable this behavior, add -`:report_task: TRUE` to your project file's `:gcov:` configuration. - -With this setting enabled, an additional Ceedling task `report:gcov` is enabled. -It may be executed after `gcov:` tasks to generate the configured reports. - -For small projects, the default behavior is likely preferred. This alernative -setting allows large or complex projects to execute potentially time intensive -report generation only when desired. - -Enabling the manual report generation task looks like this: - -```yaml -:gcov: - :report_task: TRUE -``` - -# Example Usage - -_Note_: Unless disabled, basic coverage summaries are always printed to the -console regardless of report generation options. - -## Automatic report generation (default) - -If coverage report generation is configured, the plugin defaults to running -reports after any `gcov:` task. - -```yaml -:plugins: - :enabled: - - gcov - -:gcov: - :utilities: - - gcovr # Enabled by default -- shown for completeness - :report_task: FALSE # Disabled by default -- shown for completeness - :reports: # See later section for report configuration - - HtmlBasic - - ... # Further configuration for reporting (not shown) - -``` - -```shell - > ceedling gcov:all -``` - -## Report generation configured as manual task - -If the `:report_task:` configuration option is enabled, reports are not -automatically generaed after test suite coverage builds. Instead, report -generation is triggered by the `report:gcov` task. - -```yaml -:plugins: - :enabled: - - gcov - -:gcov: - :utilities: - - gcovr # Enabled by default -- shown for completeness - :report_task: TRUE - :reports: # See later section for report configuration - - HtmlBasic # Enabled by default -- shown for completeness - - ... # Further configuration for reporting (not shown) - -``` - -With the separate reporting task enabled, it can be used like any other Ceedling task. - -```shell - > ceedling gcov:all report:gcov -``` - -or - -```shell - > ceedling gcov:all - - > ceedling report:gcov -``` - -### Full report generation configuration example - -```yaml -:plugins: - :enabled: - - gcov - -:gcov: - :summaries: FALSE # Simple coverage summaries to console disabled - :reports: # `gcovr` tool enabled by default - - HtmlDetailed - - Text - - Cobertura - :gcovr: # `gcovr` common and report-specific options - :report_root: "../../" # Atypical layout -- project.yml is inside a subdirectoy below <build root> - :sort_percentage: TRUE - :sort_uncovered: FALSE - :html_medium_threshold: 60 - :html_high_threshold: 85 - :print_summary: TRUE - :threads: 4 - :keep: FALSE -``` - -# Report Generation Configuration - -Various reports are available. Each must be enabled in `:gcov` ↳ `:reports`. - -If no report types are specified, report generation (but not coverage summaries) -is disabled regardless of any other setting. - -Most report types can only be generated by `gcovr` or `ReportGenerator`. Some -can be generated by both. This means that your selection of report is impacted by -which generation utility is enabled. In fact, in some cases, the same report type -could be generated by each utility (to different artifact build output folders). - -Reports are configured with: - -1. General or common options for each report generation utility -1. Specific options for types of report per each report generation utility - -These are detailed in the sections that follow. See the -[GCovr User Guide][gcovr-user-guide] and the -[ReportGenerator Wiki][report-generator-wiki] for full details. - -[gcovr-user-guide]: https://www.gcovr.com/en/stable/guide.html -[report-generator-wiki]: https://github.com/danielpalme/ReportGenerator/wiki - -```yaml -:gcov: - # Specify one or more reports to generate. - # Defaults to HtmlBasic. - :reports: - # Generate an HTML summary report. - # Supported utilities: gcovr, ReportGenerator - - HtmlBasic - - # Generate an HTML report with line by line coverage of each source file. - # Supported utilities: gcovr, ReportGenerator - - HtmlDetailed - - # Generate a Text report, which may be output to the console with gcovr or a file in both gcovr and ReportGenerator. - # Supported utilities: gcovr, ReportGenerator - - Text - - # Generate a Cobertura XML report. - # Supported utilities: gcovr, ReportGenerator - - Cobertura - - # Generate a SonarQube XML report. - # Supported utilities: gcovr, ReportGenerator - - SonarQube - - # Generate a JSON report. - # Supported utilities: gcovr - - JSON - - # Generate a detailed HTML report with CSS and JavaScript included in every HTML page. Useful for build servers. - # Supported utilities: ReportGenerator - - HtmlInline - - # Generate a detailed HTML report with a light theme and CSS and JavaScript included in every HTML page for Azure DevOps. - # Supported utilities: ReportGenerator - - HtmlInlineAzure - - # Generate a detailed HTML report with a dark theme and CSS and JavaScript included in every HTML page for Azure DevOps. - # Supported utilities: ReportGenerator - - HtmlInlineAzureDark - - # Generate a single HTML file containing a chart with historic coverage information. - # Supported utilities: ReportGenerator - - HtmlChart - - # Generate a detailed HTML report in a single file. - # Supported utilities: ReportGenerator - - MHtml - - # Generate SVG and PNG files that show line and / or branch coverage information. - # Supported utilities: ReportGenerator - - Badges - - # Generate a single CSV file containing coverage information per file. - # Supported utilities: ReportGenerator - - CsvSummary - - # Generate a single TEX file containing a summary for all files and detailed reports for each files. - # Supported utilities: ReportGenerator - - Latex - - # Generate a single TEX file containing a summary for all files. - # Supported utilities: ReportGenerator - - LatexSummary - - # Generate a single PNG file containing a chart with historic coverage information. - # Supported utilities: ReportGenerator - - PngChart - - # Command line output interpreted by TeamCity. - # Supported utilities: ReportGenerator - - TeamCitySummary - - # Generate a text file in lcov format. - # Supported utilities: ReportGenerator - - lcov - - # Generate a XML file containing a summary for all classes and detailed reports for each class. - # Supported utilities: ReportGenerator - - Xml - - # Generate a single XML file containing a summary for all files. - # Supported utilities: ReportGenerator - - XmlSummary -``` - -## Gcovr report output - -All reports generated by `gcovr` are found in `<build root>/artifacts/gcov/gcovr/`. - -## Gcovr HTML reports - -Generation of HTML reports may be modified with the following configuration items. - -```yaml -:gcov: - :gcovr: - # HTML report filename. - :html_artifact_filename: <filename> - - # Use 'title' as title for the HTML report. - # Default is 'Head'. (gcovr --html-title) - :html_title: <title> - - # If the coverage is below MEDIUM, the value is marked as low coverage in the HTML report. - # MEDIUM has to be lower than or equal to value of html_high_threshold. - # If MEDIUM is equal to value of html_high_threshold the report has only high and low coverage. - # Default is 75.0. (gcovr --html-medium-threshold) - :html_medium_threshold: 75 - - # If the coverage is below HIGH, the value is marked as medium coverage in the HTML report. - # HIGH has to be greater than or equal to value of html_medium_threshold. - # If HIGH is equal to value of html_medium_threshold the report has only high and low coverage. - # Default is 90.0. (gcovr -html-high-threshold) - :html_high_threshold: 90 - - # Set to 'true' to use absolute paths to link the 'detailed' reports. - # Defaults to relative links. (gcovr --html-absolute-paths) - :html_absolute_paths: <true|false> - - # Override the declared HTML report encoding. Defaults to UTF-8. (gcovr --html-encoding) - :html_encoding: <html_encoding> -``` - -## Gcovr Cobertura XML reports - -Generation of Cobertura XML reports may be modified with the following configuration items. - -```yaml -:gcov: - :gcovr: - # Set to 'true' to pretty-print the Cobertura XML report, otherwise set to 'false'. - # Defaults to disabled. (gcovr --xml-pretty) - :cobertura_pretty: <true|false> - - # Override default Cobertura XML report filename. - :cobertura_artifact_filename: <filename> -``` - -## Gcovr SonarQube XML reports - -Generation of SonarQube XML reports may be modified with the following configuration items. - -```yaml -:gcov: - :gcovr: - # Override default SonarQube XML report filename. - :sonarqube_artifact_filename: <filename> -``` - -## Gcovr JSON reports - -Generation of JSON reports may be modified with the following configuration items. - -```yaml -:gcov: - :gcovr: - # Set to 'true' to pretty-print the JSON report, otherwise set 'false'. - # Defaults to disabled. (gcovr --json-pretty) - :json_pretty: <true|false> - - # Override default JSON report filename. - :json_artifact_filename: <filename> -``` - -## Gcovr text reports - -Generation of text reports may be modified with the following configuration items. -Text reports may be printed to the console or output to a file. - -```yaml -:gcov: - :gcovr: - # Override default text report filename. - :text_artifact_filename: <filename> -``` - -## Common gcovr options - -A number of options exist to control which files are considered part of a -coverage report. This Ceedling gcov plugin itself handles the most important -aspect — only source files under test are compiled with coverage. Tests, mocks, -and test runners, are not compiled with coverage. - -**Note:** `gcovr` will only accept a single path for `:report_root`. In typical -usage, this is of no concern as it is handled automatically. In unusual project -layouts, you may need to specify a folder that encompasses _all_ build folders -containing coverage result files and optionally, selectively exclude patterns -of paths or files. For instance, if your Ceedling project file is not at the -root of your project, you may need set `:report_root` as well as -`:report_exclude` and `:exclude_directories`. - -```yaml -:gcov: - :gcovr: - # The root directory of your source files. Defaults to ".", the current directory. - # File names are reported relative to this root. The report_root is the default report_include. - # Default if unspecified: "." - :report_root: <path> - - # Load the specified configuration file. - # Defaults to gcovr.cfg in the report_root directory. (gcovr --config) - :config_file: <config_file> - - # Exit with a status of 2 if the total line coverage is less than MIN percentage. - # Can be ORed with exit status of other fail options. (gcovr --fail-under-line) - :fail_under_line: <1-100> - - # Exit with a status of 4 if the total branch coverage is less than MIN percentage. - # Can be ORed with exit status of other fail options. (gcovr --fail-under-branch) - :fail_under_branch: <1-100> - - # Exit with a status of 8 if the total decision coverage is less than MIN percentage. - # Can be ORed with exit status of other fail options. (gcovr --fail-under-decision) - :fail_under_decision: <1-100> - - # Exit with a status of 16 if the total function coverage is less than MIN percentage. - # Can be ORed with exit status of other fail options. (gcovr --fail-under-function) - :fail_under_function: <1-100> - - # If the fail options above are set, specify whether those conditions should break a build. - # The default option is false and simply logs a warning without breaking the build. - :exception_on_fail: <true|false> - - # Select the source file encoding. - # Defaults to the system default encoding (UTF-8). (gcovr --source-encoding) - :source_encoding: <encoding> - - # Report the branch coverage instead of the line coverage. For text report only. (gcovr --branches). - :branches: <true|false> - - # Sort entries by increasing number of uncovered lines. - # For text and HTML report. (gcovr --sort-uncovered) - :sort_uncovered: <true|false> - - # Sort entries by increasing percentage of uncovered lines. - # For text and HTML report. (gcovr --sort-percentage) - :sort_percentage: <true|false> - - # Print a small report to stdout with line & branch percentage coverage. - # This is in addition to other reports. (gcovr --print-summary). - :print_summary: <true|false> - - # Keep only source files that match this filter. (gcovr --filter). - # Filters are regular expressions (ex: "^src") - :report_include: <filter> - - # Exclude source files that match this filter. (gcovr --exclude). - # Filters are regular expressions (ex: "^vendor.*|^build.*|^test.*|^lib.*") - :report_exclude: <filter> - - # Keep only gcov data files that match this filter. (gcovr --gcov-filter). - # Filters are regular expressions - :gcov_filter: <filter> - - # Exclude gcov data files that match this filter. (gcovr --gcov-exclude). - # Filters are regular expressions - :gcov_exclude: <filter> - - # Exclude directories that match this filter while searching - # raw coverage files. (gcovr --exclude-directories). - # Filters are regular expressions - :exclude_directories: <filters> - - # Use a particular gcov executable. (gcovr --gcov-executable). - # (This may be appropriate and necessary in special circumstances. - # Please review Ceedling's options for modifying tools first.) - :gcov_executable: <cmd> - - # Exclude branch coverage from lines without useful - # source code. (gcovr --exclude-unreachable-branches). - :exclude_unreachable_branches: <true|false> - - # For branch coverage, exclude branches that the compiler - # generates for exception handling. (gcovr --exclude-throw-branches). - :exclude_throw_branches: <true|false> - - # For Gcovr 6.0+, multiple instances of the same function in coverage results can - # cause a fatal error. Since Ceedling can test multiple build variations of the - # same source function, this is bad. - # Default value for Gcov plugin is 'merge-use-line-max'. See Gcovr docs for more. - # https://gcovr.com/en/stable/guide/merging.html - :merge_mode_function: <...> - - # Use existing gcov files for analysis. Default: False. (gcovr --use-gcov-files) - :use_gcov_files: <true|false> - - # Skip lines with parse errors in GCOV files instead of - # exiting with an error. (gcovr --gcov-ignore-parse-errors). - :gcov_ignore_parse_errors: <true|false> - - # Override normal working directory detection. (gcovr --object-directory) - :object_directory: <path> - - # Keep gcov files after processing. (gcovr --keep). - :keep: <true|false> - - # Delete gcda files after processing. (gcovr --delete). - :delete: <true|false> - - # Set the number of threads to use in parallel. (gcovr -j). - :threads: <count> -``` - -## ReportGenerator configuration - -The `ReportGenerator` utility may be configured with the following configuration items. - -All generated reports are found in `<build root>/artifacts/gcov/ReportGenerator/`. - -```yaml -:gcov: - :report_generator: - # Optional directory for storing persistent coverage information. - # Can be used in future reports to show coverage evolution. - :history_directory: <path> - - # Optional plugin files for custom reports or custom history storage (separated by semicolon). - :plugins: <plugin.dll>;<*.dll> - - # Optional list of assemblies that should be included or excluded in the report (separated by semicolon). - # Exclusion filters take precedence over inclusion filters. - # Wildcards are allowed, but not regular expressions. - :assembly_filters: +<included>;-<excluded> - - # Optional list of classes that should be included or excluded in the report (separated by semicolon). - # Exclusion filters take precedence over inclusion filters. - # Wildcards are allowed, but not regular expressions. - :class_filters: +<included>;-<excluded> - - # Optional list of files that should be included or excluded in the report (separated by semicolon). - # Exclusion filters take precedence over inclusion filters. - # Wildcards are allowed, but not regular expressions. - # Example: "-./vendor/*;-./build/*;-./test/*;-./lib/*;+./src/*" - :file_filters: +<included>;-<excluded> - - # The verbosity level of the log messages. - # Values: Verbose, Info, Warning, Error, Off (defaults to Warning) - :verbosity: <level> - - # Optional tag or build version. - :tag: <tag> - - # Optional list of one or more regular expressions to exclude gcov notes files that match these filters. - :gcov_exclude: - - <regex> - - ... - - # Optionally set the number of threads to use in parallel. Defaults to 1. - :threads: <count> - - # Optional list of one or more command line arguments to pass to Report Generator. - # Useful for configuring Risk Hotspots and Other Settings. - # https://github.com/danielpalme/ReportGenerator/wiki/Settings - # Note: This can be accomplished with Ceedling's tool configuration options outside of plugin - # configuration but is supported here to collect configuration options in one place. - :custom_args: - - <argument> - - ... -``` - -# Advanced Configuration & Troubleshooting - -See the _Ceedling Cookbook_ for options on how to use Ceedling's advanced -features to modify how this plugin is configured, especially tool -configurations. - -Details of interest for this plugin to be modified or made use of using -Ceedling's advanced features are primarily contained in -[defaults_gcov.rb](conig/defaults_gcov.rb) and [defaults.yml](config/defaults.yml). - -## “gcovr not found” - -`gcovr` is a Python-based application. Depending on the particulars of its -installation and your platform, you may encounter a “gcovr not found” error. -This is usually related to complications of running a Python script as an -executable. - -### Check your `PATH` - -The problem may be as simple to solve as ensuring your user or system path -include the path to `python` and/or the `gcovr` script. `gcovr` may be -successfully installed and findable by Python; this does not necessarily -mean that shell commands Ceedling spawns can find these tools. - -Options: - -1. Modify your user or system path to include your Python installation, `gcovr` - location, or both. -1. Use Ceedling's `:environment` project configuration with its special - handling of `PATH` to modify the search path Ceedling accesses when it - executes shell commands. xample below. - -```yaml -:environment: - - :path: # Concatenates the following with OS-specific path separator - - <path to add> # Add Python and/or `gcovr` path - - "#{ENV['PATH']}" # Fetch existing path entries -``` - -### Redefine `gcovr` to call Python directly - -Another solution is simple in concept. Instead of calling `gcovr` directly, call -`python` with the `gcovr` script as a command line argument (followed by all of -the configured `gcovr` arguments). - -To implement the solution, we make use of two features: - -* `gcovr`'s tool `:executable` definition that looks up an environment variable. -* Ceedling's `:environment` settings to redefine `gcovr`. - -Gcovr's tool defintion, like many of Ceedling's tool defintions, defaults to an -environment variable (`GCOVR`) if it is defined. If we set that environment -variable to call Python with the path to the `gcovr` script, Ceedling will call -that instead of only `gcovr`. Ceedling enables you to set environment variables -that only exist while it runs. - -In your project file: - -```yaml -:environment: - # Fill in / omit paths on your system as appropritate to your circumstances - - :gcovr: <path>/python <path>/gcovr -``` - -Alternatively, a slightly more elegant approach may work in some cases: - -```yaml -:environment: - - ":gcovr: python #{`which gcovr`}" # Shell out to look up the path to gcovr -``` - -A variation of this concept relies on Python's knowledge of its runtime -environment and packages: - -```yaml -:environment: - - :gcovr: python -m gcovr # Call the gcovr module -``` - -# References - -Much of the text describing report generations options in this document was -taken from the [Gcovr User Guide][gcovr-user-guide] and the -[ReportGenerator Wiki][report-generator-wiki]. - -The text is repeated here to provide as useful documenation as possible. diff --git a/plugins/gcov/config/defaults.yml b/plugins/gcov/config/defaults.yml index b70184e01..bd5a3966a 100644 --- a/plugins/gcov/config/defaults.yml +++ b/plugins/gcov/config/defaults.yml @@ -9,6 +9,7 @@ :gcov: :summaries: TRUE # Enable simple coverage summaries to console after tests :report_task: FALSE # Disabled dedicated report generation task (this enables automatic report generation) + :mcdc: FALSE # MC/DC (modified condition/decision) coverage — requires GCC 14+ and gcovr 8+ :utilities: - gcovr # Defaults to `gcovr` as report generation utility diff --git a/plugins/gcov/config/defaults_gcov.rb b/plugins/gcov/config/defaults_gcov.rb index ba21549ab..3f0d499a6 100644 --- a/plugins/gcov/config/defaults_gcov.rb +++ b/plugins/gcov/config/defaults_gcov.rb @@ -53,10 +53,10 @@ :name => 'default_gcov_summary'.freeze, :optional => true.freeze, :arguments => [ - "-n".freeze, - "-p".freeze, - "-b".freeze, - "-o \"${2}\"".freeze, + "-n".freeze, # --no-output + "-p".freeze, # --preserve-paths + "-b".freeze, # --branch-probabilities + "-o \"${2}\"".freeze, # --object-directory "\"${1}\"".freeze ].freeze } @@ -67,10 +67,10 @@ :name => 'default_gcov_report'.freeze, :optional => true.freeze, :arguments => [ - "-b".freeze, - "-c".freeze, - "-r".freeze, - "-x".freeze, + "-b".freeze, # --branch-probabilities + "-c".freeze, # --branch-counts + "-r".freeze, # --relative-only + "-x".freeze, # --hash-filenames "${1}".freeze ].freeze } @@ -96,14 +96,33 @@ ].freeze } +# Used internally to query GCC version at startup +DEFAULT_GCOV_GCC_VERSION_TOOL = { + :executable => FilePathUtils.os_executable_ext('gcc').freeze, + :name => 'default_gcov_gcc_version'.freeze, + :optional => false.freeze, + :arguments => ["--version"].freeze + } + +# Used internally to query gcovr version at startup +DEFAULT_GCOV_GCOVR_VERSION_TOOL = { + # No extension handling -- `gcovr` is generally an extensionless Python script + :executable => 'gcovr'.freeze, + :name => 'default_gcov_gcovr_version'.freeze, + :optional => true.freeze, + :arguments => ["--version"].freeze + } + def get_default_config return :tools => { :gcov_compiler => DEFAULT_GCOV_COMPILER_TOOL, :gcov_linker => DEFAULT_GCOV_LINKER_TOOL, :gcov_fixture => DEFAULT_GCOV_FIXTURE_TOOL, :gcov_summary => DEFAULT_GCOV_SUMMARY_TOOL, + :gcov_gcc_version => DEFAULT_GCOV_GCC_VERSION_TOOL, + :gcov_gcovr_version => DEFAULT_GCOV_GCOVR_VERSION_TOOL, :gcov_report => DEFAULT_GCOV_REPORT_TOOL, :gcov_gcovr_report => DEFAULT_GCOV_GCOVR_REPORT_TOOL, - :gcov_reportgenerator_report => DEFAULT_GCOV_REPORTGENERATOR_REPORT_TOOL + :gcov_reportgenerator_report => DEFAULT_GCOV_REPORTGENERATOR_REPORT_TOOL, } end diff --git a/plugins/gcov/lib/console_reportinator.rb b/plugins/gcov/lib/console_reportinator.rb new file mode 100644 index 000000000..497567e1b --- /dev/null +++ b/plugins/gcov/lib/console_reportinator.rb @@ -0,0 +1,155 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'ceedling/constants' + +class ConsoleReportinator + + attr_reader :artifacts_path # nil — console output only, no filesystem artifacts + + def initialize(system_objects) + @loginator = system_objects[:loginator] + @plugin_reportinator = system_objects[:plugin_reportinator] + @test_invoker = system_objects[:test_invoker] + @tool_executor = system_objects[:tool_executor] + end + + def generate_reports(opts) + banner = @plugin_reportinator.generate_banner( "#{GCOV_ROOT_NAME.upcase}: CODE COVERAGE SUMMARY" ) + @loginator.log( "\n" + banner ) + + # Iterate over each test run and its list of source files + @test_invoker.each_test_with_sources do |test, sources| + @loginator.log( @plugin_reportinator.generate_heading( test ) ) + + _sources = remap_partial_sources( sources ) + _sources.each do |source| + results = run_gcov_summary( test, source, opts ) + next if results.nil? + gcov_source = extract_gcov_source_path( results, test, source ) + log_coverage_report( test, source, results, gcov_source ) + end + end + end + + ### Private ### + + private + + def remap_partial_sources(sources) + # Remap sources: if Partial files are present, remove the original source file they replace. + # Coverage is then reported against the Partial implementation rather than the original module. + partials = sources.select { |s| File.basename(s).match?(PATTERNS::PARTIAL_IMPL_FILENAME) } + return sources if partials.empty? + + # Extract module names covered by Partials (strip prefix and _impl suffix) + partialized = partials.map { |p| + File.basename(p, '.*').delete_prefix(PARTIAL_FILENAME_PREFIX).delete_suffix('_impl') + } + # Drop any original source file whose module is now covered by a Partial + sources.reject { |s| partialized.include?( File.basename(s, '.*') ) } + end + + def run_gcov_summary(test, source, opts) + filename = File.basename(source) + + # Run gcov to extract the coverage summary + command = @tool_executor.build_command_line( + TOOLS_GCOV_SUMMARY, + # Conditionally include -g flag for MC/DC coverage + (opts[:gcov_mcdc] ? ['-g'] : []), + # Argument replacement + filename, # .c source file compiled with coverage + File.join(GCOV_BUILD_OUTPUT_PATH, test) # <build>/gcov/out/<test name> for coverage data files + ) + + # Do not raise an exception if `gcov` terminates with a non-zero exit code, just note it and move on. + # Recent releases of `gcov` have become more strict and vocal about errors and exit codes. + command[:options][:boom] = false + + # Run the gcov tool and collect the raw coverage report + shell_results = @tool_executor.exec( command ) + results = shell_results[:output].strip + + # Handle errors instead of raising a shell exception + if shell_results[:exit_code] != 0 + @loginator.lazy( Verbosity::DEBUG, LogLabels::ERROR ) do + "gcov error (#{shell_results[:exit_code]}) while processing #{filename}... #{results}" + end + @loginator.lazy( Verbosity::COMPLAIN ) do + "gcov was unable to process coverage for #{filename}" + end + return nil + end + + # A source component may have been compiled with coverage but none of its code actually called in a test. + # In this case, versions of gcov may not produce an error, only blank results. + if results.empty? + @loginator.lazy( Verbosity::COMPLAIN, LogLabels::NOTICE ) do + "No functions called or code paths exercised by test for #{filename}" + end + return nil + end + + results + end + + def extract_gcov_source_path(results, test, source) + # Source filepath to be extracted from gcov coverage results via regex + # Extract (relative) filepath from results and expand to absolute path + matches = results.match(/File\s+'(.+)'/) + if matches.nil? || matches.length != 2 + @loginator.lazy( Verbosity::DEBUG, LogLabels::ERROR ) do + "Could not extract filepath via regex from gcov results for #{test}::#{File.basename(source)}" + end + return '' + end + # Expand to full path from likely partial path to ensure correct matches on source component within gcov results + File.expand_path( matches[1] ) + end + + # test — test name (string); used in log messages to identify which test produced the results + # source — filepath of the source file as known to Ceedling (may be a Partial implementation file) + # results — raw stdout from `gcov`; may contain coverage data for multiple files + # gcov_source — absolute path extracted from the `File '...'` line in gcov output; for Partial files + # this is the original module source (due to #line remapping), not the Partial filepath; + # empty string ('') when the gcov File header could not be parsed + def log_coverage_report(test, source, results, gcov_source) + filename = File.basename(source) + + # If gcov results include intended source (comparing absolute paths), report coverage details summaries. + # For Partial files, #line directives remap to the original source so path comparison never matches; + # produce the report for any Partial that returned non-empty gcov output. + if gcov_source == File.expand_path(source) || File.basename(source).match?(PATTERNS::PARTIAL_IMPL_FILENAME) + # For Partials, use the original source name from gcov output (gcov_source) rather than the Partial filename. + report_name = gcov_source.empty? ? filename : File.basename(gcov_source) + + lines = results.lines + # Find the File header line matching the queried source filename + start_idx = lines.index { |l| l.start_with?('File') && l.include?(report_name) } + + if start_idx + # Extract statistics lines between this File header and the next File header (or end of results). + # Reformat each line labeled with the source filename. + remaining = lines[(start_idx + 1)..] || [] + next_file_idx = remaining.index { |l| l.start_with?('File') } + section = next_file_idx ? remaining[0...next_file_idx] : remaining + # Filter out gcov informational messages emitted while inspecting coverage binary files + section = section.reject { |line| line.include?( File.basename( source,'.*') ) } + report = section.map { |line| report_name + ' | ' + line }.join('') + @loginator.log( report ) + end + + # Otherwise, found no coverage results + else + @loginator.lazy( Verbosity::COMPLAIN ) do + "Found no coverage results for #{test}::#{File.basename(source)}" + end + end + end + +end diff --git a/plugins/gcov/lib/gcov.rb b/plugins/gcov/lib/gcov.rb index 933e7b46c..fe98f7813 100755 --- a/plugins/gcov/lib/gcov.rb +++ b/plugins/gcov/lib/gcov.rb @@ -9,6 +9,8 @@ require 'ceedling/constants' require 'ceedling/exceptions' require 'gcov_constants' +require 'gcov_types' +require 'console_reportinator' require 'gcovr_reportinator' require 'reportgenerator_reportinator' @@ -42,6 +44,7 @@ def setup # Convenient instance variable references @configurator = @ceedling[:configurator] @loginator = @ceedling[:loginator] + @reportinator = @ceedling[:reportinator] @test_invoker = @ceedling[:test_invoker] @plugin_reportinator = @ceedling[:plugin_reportinator] @file_path_utils = @ceedling[:file_path_utils] @@ -49,6 +52,17 @@ def setup @tool_executor = @ceedling[:tool_executor] @mutex = Mutex.new() + + # Validate MC/DC configuration against GCC version (only incurs gcc --version when :mcdc: TRUE) + if @project_config[:gcov_mcdc] + gcc_version = get_gcc_version() + if gcc_version.major < 14 + raise CeedlingException.new( + ":gcov ↳ :mcdc ➡️ Modified condition/decision coverage requires GCC 14 or higher " \ + "(found #{gcc_version.major}.#{gcc_version.minor})" + ) + end + end end # Called within class and also externally by plugin Rakefile @@ -61,10 +75,15 @@ def pre_compile_execute(arg_hash) if arg_hash[:context] == GCOV_SYM source = arg_hash[:source] - # If a source file (not unity, mocks, etc.) is to be compiled use code coverage compiler - if (File.extname(source) != EXTENSION_ASSEMBLY) && @configurator.collection_all_source.include?(source) + # Compile all non-assembly files with coverage; gcovr --exclude filters non-production files from reports + if File.extname(source) != EXTENSION_ASSEMBLY arg_hash[:tool] = TOOLS_GCOV_COMPILER - arg_hash[:msg] = "Compiling #{File.basename(source)} with coverage..." + arg_hash[:msg] = @reportinator.generate_module_progress( + operation: "Compiling with coverage", + module_name: arg_hash[:module_name], + filename: File.basename(source) + ) + arg_hash[:flags] += ['-fcondition-coverage'] if @project_config[:gcov_mcdc] end end end @@ -73,6 +92,7 @@ def pre_link_execute(arg_hash) if arg_hash[:context] == GCOV_SYM @cli_gcov_task = true arg_hash[:tool] = TOOLS_GCOV_LINKER + arg_hash[:flags] += ['-fcondition-coverage'] if @project_config[:gcov_mcdc] end end @@ -115,7 +135,7 @@ def post_build end # Print summary of coverage to console for each source file exercised by a test - console_coverage_summaries() if summaries_enabled?( @project_config ) + @console_reportinator&.generate_reports( @project_config ) # Run full coverage report generation generate_coverage_reports() if automatic_reporting_enabled? @@ -152,7 +172,7 @@ def generate_coverage_reports() @reportinators.each do |reportinator| # Create the artifacts output directory. - @file_wrapper.mkdir( reportinator.artifacts_path ) + @file_wrapper.mkdir( reportinator.artifacts_path ) if reportinator.artifacts_path # Generate reports reportinator.generate_reports( @configurator.project_config_hash ) @@ -175,92 +195,20 @@ def utility_enabled?(opts, utility_name) return opts.map(&:upcase).include?( utility_name.upcase ) end - def console_coverage_summaries() - banner = @plugin_reportinator.generate_banner( "#{GCOV_ROOT_NAME.upcase}: CODE COVERAGE SUMMARY" ) - @loginator.log "\n" + banner - - # Iterate over each test run and its list of source files - @test_invoker.each_test_with_sources do |test, sources| - heading = @plugin_reportinator.generate_heading( test ) - @loginator.log(heading) - - sources.each do |source| - filename = File.basename(source) - name = filename.ext('') - command = @tool_executor.build_command_line( - TOOLS_GCOV_SUMMARY, - [], # No additional arguments - filename, # .c source file that should have been compiled with coverage - File.join(GCOV_BUILD_OUTPUT_PATH, test) # <build>/gcov/out/<test name> for coverage data files - ) - - # Do not raise an exception if `gcov` terminates with a non-zero exit code, just note it and move on. - # Recent releases of `gcov` have become more strict and vocal about errors and exit codes. - command[:options][:boom] = false - - # Run the gcov tool and collect the raw coverage report - shell_results = @tool_executor.exec( command ) - results = shell_results[:output].strip - - # Handle errors instead of raising a shell exception - if shell_results[:exit_code] != 0 - @loginator.lazy( Verbosity::DEBUG, LogLabels::ERROR ) do - "gcov error (#{shell_results[:exit_code]}) while processing #{filename}... #{results}" - end - @loginator.lazy( Verbosity::COMPLAIN ) do - "gcov was unable to process coverage for #{filename}" - end - next # Skip to next loop iteration - end - - # A source component may have been compiled with coverage but none of its code actually called in a test. - # In this case, versions of gcov may not produce an error, only blank results. - if results.empty? - @loginator.lazy( msg, Verbosity::COMPLAIN, LogLabels::NOTICE ) do - "No functions called or code paths exercised by test for #{filename}" - end - next # Skip to next loop iteration - end - - # Source filepath to be extracted from gcov coverage results via regex - _source = '' - - # Extract (relative) filepath from results and expand to absolute path - matches = results.match(/File\s+'(.+)'/) - if matches.nil? or matches.length() != 2 - @loginator.lazy( Verbosity::DEBUG, LogLabels::ERROR ) do - "Could not extract filepath via regex from gcov results for #{test}::#{File.basename(source)}" - end - else - # Expand to full path from likely partial path to ensure correct matches on source component within gcov results - _source = File.expand_path(matches[1]) - end - - # If gcov results include intended source (comparing absolute paths), report coverage details summaries - if _source == File.expand_path(source) - # Reformat from first line as filename banner to each line of statistics labeled with the filename - # Only extract the first four lines of the console report (to avoid spidering coverage reports through libs, etc.) - report = results.lines.to_a[1..4].map { |line| filename + ' | ' + line }.join('') - @loginator.log(report + "\n") - - # Otherwise, found no coverage results - else - @loginator.lazy( Verbosity::COMPLAIN ) { "Found no coverage results for #{test}::#{File.basename(source)}" } - end - end - end - end - def build_reportinators(config, enabled) reportinators = [] - # Do not instantiate reportinators (and tool validation) unless reports enabled + # Instantiate console summary reportinator if summaries enabled + @console_reportinator = summaries_enabled?(@project_config) ? + ConsoleReportinator.new(@ceedling) : nil + + # Do not instantiate file reportinators (and tool validation) unless reports enabled return reportinators if (!enabled) config.each do |reportinator| if not GCOV_UTILITY_NAMES.map(&:upcase).include?( reportinator.upcase ) options = GCOV_UTILITY_NAMES.map{ |utility| "'#{utility}'" }.join(', ') - msg = "Plugin configuration :gcov ↳ :utilities => `#{reportinator}` is not a recognized option {#{options}}." + msg = "Plugin configuration :gcov ↳ :utilities ➡️ `#{reportinator}` is not a recognized option {#{options}}." raise CeedlingException.new(msg) end end @@ -280,5 +228,24 @@ def build_reportinators(config, enabled) return reportinators end + def get_gcc_version() + command = @tool_executor.build_command_line( TOOLS_GCOV_GCC_VERSION, []) + + @loginator.lazy( Verbosity::OBNOXIOUS ) do + @reportinator.generate_progress("Collecting GCC version for conditional feature handling") + end + + shell_result = @tool_executor.exec( command ) + + # First line of gcc --version: "gcc (...platform info...) major.minor.patch" + version_match = shell_result[:output].match(/^gcc\s+.*\s+(\d+)\.(\d+)\.\d+/) + + if version_match.nil? || version_match[1].nil? || version_match[2].nil? + raise CeedlingException.new("Could not collect `gcc` version from its command line") + end + + return GcovToolVersion.new( version_match[1].to_i, version_match[2].to_i ) + end + end diff --git a/plugins/gcov/lib/gcov_types.rb b/plugins/gcov/lib/gcov_types.rb new file mode 100644 index 000000000..846d352e8 --- /dev/null +++ b/plugins/gcov/lib/gcov_types.rb @@ -0,0 +1,8 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +GcovToolVersion = Struct.new(:major, :minor) diff --git a/plugins/gcov/lib/gcovr_reportinator.rb b/plugins/gcov/lib/gcovr_reportinator.rb index 6b5828fe3..abaab6c46 100644 --- a/plugins/gcov/lib/gcovr_reportinator.rb +++ b/plugins/gcov/lib/gcovr_reportinator.rb @@ -8,11 +8,14 @@ require 'reportinator_helper' require 'ceedling/exceptions' require 'ceedling/constants' +require 'gcov_types' class GcovrReportinator attr_reader :artifacts_path + GCOVR_SETTING_PREFIX = "gcov_gcovr" + def initialize(system_objects) @artifacts_path = GCOV_GCOVR_ARTIFACTS_PATH @ceedling = system_objects @@ -28,27 +31,35 @@ def initialize(system_objects) @loginator = @ceedling[:loginator] @reportinator = @ceedling[:reportinator] @tool_executor = @ceedling[:tool_executor] + @configurator = @ceedling[:configurator] + + @gcovr_version = get_gcovr_version() + + # MC/DC reporting requires gcovr 8+ + if @configurator.gcov_mcdc && !min_version?( @gcovr_version, 8, 0 ) + raise CeedlingException.new( + ":gcov ↳ :mcdc ➡️ Modified condition/decision coverage reporting requires gcovr 8 or higher " \ + "(found #{@gcovr_version.major}.#{@gcovr_version.minor})" + ) + end end # Generate the gcovr report(s) specified in the options. def generate_reports(opts) - # Get the gcovr version number. - gcovr_version = get_gcovr_version() - # Get gcovr options from project configuration options - gcovr_opts = get_gcovr_opts(opts) + gcovr_opts = collect_gcovr_opts(opts) # Extract exception_on_fail setting exception_on_fail = !!gcovr_opts[:exception_on_fail] # Build the common gcovr arguments. - args_common = args_builder_common( gcovr_opts, gcovr_version ) + args_common = args_builder_common( gcovr_opts, @gcovr_version ) msg = @reportinator.generate_heading( "Running Gcovr Coverage Reports" ) @loginator.log( msg ) # gcovr version 4.2 and later supports generating multiple reports with a single call. - if min_version?( gcovr_version, 4, 2 ) + if min_version?( @gcovr_version, 4, 2 ) reports = [] args = args_common @@ -67,7 +78,7 @@ def generate_reports(opts) reports << "HTML" if not _args.empty? reports.each do |report| - msg = @reportinator.generate_progress("Generating #{report} coverage report in '#{GCOV_GCOVR_ARTIFACTS_PATH}'") + msg = @reportinator.generate_progress("Generating #{report} coverage report in '#{GCOV_GCOVR_ARTIFACTS_PATH}/'") @loginator.log( msg ) end @@ -121,15 +132,13 @@ def generate_reports(opts) private - GCOVR_SETTING_PREFIX = "gcov_gcovr" - # Build the gcovr report generation common arguments. def args_builder_common(gcovr_opts, gcovr_version) args = "" args += "--root \"#{gcovr_opts[:report_root]}\" " unless gcovr_opts[:report_root].nil? args += "--config \"#{gcovr_opts[:config_file]}\" " unless gcovr_opts[:config_file].nil? args += "--filter \"#{gcovr_opts[:report_include]}\" " unless gcovr_opts[:report_include].nil? - args += "--exclude \"#{gcovr_opts[:report_exclude]}\" " unless gcovr_opts[:report_exclude].nil? + Array(gcovr_opts[:report_exclude]).each { |pat| args += "--exclude \"#{pat}\" " } args += "--gcov-filter \"#{gcovr_opts[:gcov_filter]}\" " unless gcovr_opts[:gcov_filter].nil? args += "--gcov-exclude \"#{gcovr_opts[:gcov_exclude]}\" " unless gcovr_opts[:gcov_exclude].nil? args += "--exclude-directories \"#{gcovr_opts[:exclude_directories]}\" " unless gcovr_opts[:exclude_directories].nil? @@ -165,9 +174,9 @@ def args_builder_common(gcovr_opts, gcovr_version) # Value sanity checks for :fail_under_* settings if opt.to_s =~ /fail_/ if not value.is_a? Integer - raise CeedlingException.new(":gcov ↳ :gcovr ↳ :#{opt} => '#{value}' must be an integer") + raise CeedlingException.new(":gcov ↳ :gcovr ↳ :#{opt} ➡️ '#{value}' must be an integer") elsif (value < 0) || (value > 100) - raise CeedlingException.new(":gcov ↳ :gcovr ↳ :#{opt} => '#{value}' must be an integer percentage 0 – 100") + raise CeedlingException.new(":gcov ↳ :gcovr ↳ :#{opt} ➡️ '#{value}' must be an integer percentage 0 – 100") end end @@ -181,7 +190,7 @@ def args_builder_common(gcovr_opts, gcovr_version) # Build the gcovr Cobertura XML report generation arguments. def args_builder_cobertura(opts, use_output_option=false) - gcovr_opts = get_gcovr_opts(opts) + gcovr_opts = collect_gcovr_opts(opts) args = "" # Determine if the Cobertura XML report is enabled. Defaults to disabled. @@ -202,7 +211,7 @@ def args_builder_cobertura(opts, use_output_option=false) # Build the gcovr SonarQube report generation arguments. def args_builder_sonarqube(opts, use_output_option=false) - gcovr_opts = get_gcovr_opts(opts) + gcovr_opts = collect_gcovr_opts(opts) args = "" # Determine if the gcovr SonarQube XML report is enabled. Defaults to disabled. @@ -222,7 +231,7 @@ def args_builder_sonarqube(opts, use_output_option=false) # Build the gcovr JSON report generation arguments. def args_builder_json(opts, use_output_option=false) - gcovr_opts = get_gcovr_opts( opts ) + gcovr_opts = collect_gcovr_opts( opts ) args = "" # Determine if the gcovr JSON report is enabled. Defaults to disabled. @@ -245,7 +254,7 @@ def args_builder_json(opts, use_output_option=false) # Build the gcovr HTML report generation arguments. def args_builder_html(opts, use_output_option=false) - gcovr_opts = get_gcovr_opts(opts) + gcovr_opts = collect_gcovr_opts(opts) args = "" # Determine if the gcovr HTML report is enabled. @@ -280,7 +289,7 @@ def args_builder_html(opts, use_output_option=false) # Generate a gcovr text report def generate_text_report(opts, args_common, boom) - gcovr_opts = get_gcovr_opts(opts) + gcovr_opts = collect_gcovr_opts(opts) args_text = "" message_text = "Generating a text coverage report" @@ -299,8 +308,36 @@ def generate_text_report(opts, args_common, boom) # Get the gcovr options from the project options. - def get_gcovr_opts(opts) - return opts[GCOVR_SETTING_PREFIX.to_sym] + def collect_gcovr_opts(opts) + _opts = opts[GCOVR_SETTING_PREFIX.to_sym] + + # Build array of --exclude patterns: user-provided string (if any) + auto-generated per-file patterns + excludes = build_report_exclusions() + excludes.unshift( _opts[:report_exclude] ) if _opts[:report_exclude] + _opts[:report_exclude] = excludes unless excludes.empty? + + _opts[:mcdc] = true if opts[:gcov_mcdc] + + return _opts + end + + + # Build a combined Python regex for gcovr's `--exclude`` flag covering all + # non-production file categories: test files, mocks, partials, and framework. + def build_report_exclusions() + patterns = [] + + test_prefix = @configurator.project_test_file_prefix + @configurator.collection_paths_test.each do |path| + # Test files (e.g. test_foo.c) + patterns << ".*#{path}.*/#{test_prefix}.+\\#{@configurator.extension_source}$" + end + + # Any generated files for tests or vendored framework C source files below the root of the build directory + build_root = @configurator.project_build_root + patterns << ".*#{build_root}/.+\\#{EXTENSION_CORE_SOURCE}$" + + return patterns end @@ -322,41 +359,29 @@ def run(opts, args, boom) end - # Get the gcovr version number as components - # Return {:major, :minor} + # Get the gcovr version number as GcovToolVersion struct def get_gcovr_version() - major = 0 - minor = 0 - - command = @tool_executor.build_command_line(TOOLS_GCOV_GCOVR_REPORT, [], "--version") + command = @tool_executor.build_command_line( TOOLS_GCOV_GCOVR_VERSION, [] ) - @loginator.lazy( Verbosity::OBNOXIOUS ) do + @loginator.lazy( Verbosity::OBNOXIOUS ) do @reportinator.generate_progress("Collecting gcovr version for conditional feature handling") end shell_result = @tool_executor.exec( command ) - version_number_match_data = shell_result[:output].match(/gcovr ([0-9]+)\.([0-9]+)/) + version_match = shell_result[:output].match(/gcovr (\d+)\.(\d+)/) - if !(version_number_match_data.nil?) && !(version_number_match_data[1].nil?) && !(version_number_match_data[2].nil?) - major = version_number_match_data[1].to_i - minor = version_number_match_data[2].to_i - else + if version_match.nil? || version_match[1].nil? || version_match[2].nil? raise CeedlingException.new( "Could not collect `gcovr` version from its command line" ) end - return {:major => major, :minor => minor} + return GcovToolVersion.new( version_match[1].to_i, version_match[2].to_i ) end - # Process version hash from `get_gcovr_version()` + # Process GcovToolVersion struct from `get_gcovr_version()` def min_version?(version, major, minor) - # Meet minimum requirement if major version is greater than minimum major threshold - return true if version[:major] > major - - # Meet minimum requirement only if greater than or equal to minor version for the same major version - return true if version[:major] == major and version[:minor] >= minor - - # Version is less than major.minor + return true if version.major > major + return true if version.major == major && version.minor >= minor return false end diff --git a/plugins/gcov/lib/reportgenerator_reportinator.rb b/plugins/gcov/lib/reportgenerator_reportinator.rb index 383a9fe89..a7973bbbb 100644 --- a/plugins/gcov/lib/reportgenerator_reportinator.rb +++ b/plugins/gcov/lib/reportgenerator_reportinator.rb @@ -43,13 +43,13 @@ def initialize(system_objects) def generate_reports(opts) shell_result = nil total_time = Benchmark.realtime do - rg_opts = get_opts(opts) + rg_opts = collect_reportgenerator_opts(opts) msg = @reportinator.generate_heading( "Running ReportGenerator Coverage Reports" ) @loginator.log( msg ) opts[:gcov_reports].each do |report| - msg = @reportinator.generate_progress("Generating #{report} coverage report in '#{GCOV_REPORT_GENERATOR_ARTIFACTS_PATH}'") + msg = @reportinator.generate_progress("Generating #{report} coverage report in '#{GCOV_REPORT_GENERATOR_ARTIFACTS_PATH}/'") @loginator.log( msg ) end @@ -149,7 +149,7 @@ def generate_reports(opts) # Build the ReportGenerator arguments. def args_builder(opts) - rg_opts = get_opts(opts) + rg_opts = collect_reportgenerator_opts(opts) report_type_count = 0 args = "" @@ -197,8 +197,16 @@ def args_builder(opts) # Get the ReportGenerator options from the project options. - def get_opts(opts) - return opts[REPORT_GENERATOR_SETTING_PREFIX.to_sym] + def collect_reportgenerator_opts(opts) + _opts = opts[REPORT_GENERATOR_SETTING_PREFIX.to_sym] + + # Insert an exclusion for Ceedling Partials that will merge with any other exclusions + if @configurator.project_use_partials + partials_exclude = '-' + PARTIAL_FILENAME_PREFIX + '*' + _opts[:file_filters] = [_opts[:file_filters], partials_exclude].compact.join(';').then { |s| s.empty? ? nil : s } + end + + return _opts end diff --git a/plugins/module_generator/example/project.yml b/plugins/module_generator/example/project.yml index 595bc5b4f..9ee22f6b8 100644 --- a/plugins/module_generator/example/project.yml +++ b/plugins/module_generator/example/project.yml @@ -54,7 +54,6 @@ #- command_hooks # write custom actions to be called at different points during the build process #- compile_commands_json_db # generate a compile_commands.json file #- dependencies # automatically fetch 3rd party libraries, etc. - #- subprojects # managing builds and test for static libraries #- fake_function_framework # use FFF instead of CMock # Report options (You'll want to choose one stdout option, but may choose multiple stored options if desired) @@ -76,7 +75,6 @@ :executable: .out #:testpass: .pass #:testfail: .fail - #:subprojects: .a # This is where Ceedling should look for your source and test files. # see documentation for the many options for specifying this. diff --git a/spec/preprocessinator_includes_handler_spec.rb b/spec/preprocessinator_includes_handler_spec.rb deleted file mode 100644 index fc2e745ed..000000000 --- a/spec/preprocessinator_includes_handler_spec.rb +++ /dev/null @@ -1,289 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -require 'spec_helper' -require 'ceedling/preprocessinator_includes_handler' - -describe PreprocessinatorIncludesHandler do - before :each do - @configurator = double('configurator') - @tool_executor = double('tool_executor') - @test_context_extractor = double('test_context_extractor') - @yaml_wrapper = double('yaml_wrapper') - @loginator = double('loginator') - @reportinator = double('reportinator') - end - - subject do - PreprocessinatorIncludesHandler.new( - :configurator => @configurator, - :tool_executor => @tool_executor, - :test_context_extractor => @test_context_extractor, - :yaml_wrapper => @yaml_wrapper, - :loginator => @loginator, - :reportinator => @reportinator - ) - end - - #TODO REWRITE TESTS FOR THIS MODULE: - # context 'invoke_shallow_includes_list' do - # it 'should invoke the rake task which will build included files' do - # # create test state/variables - # # mocks/stubs/expected calls - # inc_list_double = double('inc-list-double') - # expect(@file_path_utils).to receive(:form_preprocessed_includes_list_filepath).with('some_source_file.c').and_return(inc_list_double) - # expect(@task_invoker).to receive(:invoke_test_shallow_include_lists).with( [inc_list_double] ) - # # execute method - # subject.invoke_shallow_includes_list('some_source_file.c') - # # validate results - # end - # end - - # context 'form_shallow_dependencies_rule' do - # it 'should return an annotated dependency rule generated by the preprocessor' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@file_path_utils).to receive(:form_temp_path).with('some_source_file.c','_').and_return('_some_source_file.c') - # contents_double = double('contents-double') - # expect(@file_wrapper).to receive(:read).with('some_source_file.c').and_return(contents_double) - # expect(contents_double).to receive(:valid_encoding?).and_return(true) - # expect(contents_double).to receive(:gsub!).with(/^\s*#include\s+[\"<]\s*(\S+)\s*[\">]/, "#include \"\\1\"\n#include \"@@@@\\1\"") - # expect(contents_double).to receive(:gsub!).with(/^\s*TEST_FILE\(\s*\"\s*(\S+)\s*\"\s*\)/, "#include \"\\1\"\n#include \"@@@@\\1\"") - # expect(@file_wrapper).to receive(:write).with('_some_source_file.c', contents_double) - # expect(@configurator).to receive(:tools_test_includes_preprocessor).and_return('cpp') - # command_double = double('command-double') - # expect(@tool_executor).to receive(:build_command_line).with('cpp', [], '_some_source_file.c').and_return(command_double) - # expect(command_double).to receive(:[]).with(:line).and_return('cpp') - # expect(command_double).to receive(:[]).with(:options).and_return(['arg1','arg2']) - # output_double = double('output-double') - # expect(@tool_executor).to receive(:exec).with('cpp',['arg1','arg2']).and_return(output_double) - # expect(output_double).to receive(:[]).with(:output).and_return('make-rule').twice() - # # execute method - # results = subject.form_shallow_dependencies_rule('some_source_file.c') - # # validate results - # expect(results).to eq 'make-rule' - # end - # end - - # context 'extract_includes_helper' do - # it 'should return the list of direct dependencies for the given test file' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@configurator).to receive(:extension_header).and_return('.h') - # expect(@configurator).to receive(:extension_source).and_return('.c') - # expect(@configurator).to receive(:project_config_hash).and_return( {:cmock_mock_prefix => 'mock_'}) - # expect(@configurator).to receive(:tools_test_includes_preprocessor) - # expect(@configurator).to receive(:project_config_hash).and_return({ }) - # expect(@file_path_utils).to receive(:form_temp_path).and_return("/_dummy_file.c") - # expect(@file_wrapper).to receive(:read).and_return("") - # expect(@file_wrapper).to receive(:write) - # expect(@tool_executor).to receive(:build_command_line).and_return({:line => "", :options => ""}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # _test_DUMMY.o: Build/temp/_test_DUMMY.c \ - # source/new_some_header1.h \ - # source/some_header1.h \ - # source/some_lib/some_header2.h \ - # source/some_other_lib/some_header2.h \ - # source/DUMMY.c \ - # @@@@new_some_header1.h \ - # @@@@some_header1.h \ - # @@@@some_lib/some_header2.h \ - # @@@@some_other_lib/some_header2.h \ - # @@@@source/DUMMY.c - # }}) - # # execute method - # results = subject.extract_includes_helper("/dummy_file_1.c", [], [], []) - # # validate results - # expect(results).to eq [ - # [ 'source/new_some_header1.h', - # 'source/some_header1.h', - # 'source/some_lib/some_header2.h', - # 'source/some_other_lib/some_header2.h', - # 'source/DUMMY.c'], - # [], [] - # ] - # end - - # it 'should correctly handle path separators' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@configurator).to receive(:extension_header).and_return('.h') - # expect(@configurator).to receive(:extension_source).and_return('.c') - # expect(@configurator).to receive(:project_config_hash).and_return( {:cmock_mock_prefix => 'mock_'}) - # expect(@configurator).to receive(:tools_test_includes_preprocessor) - # expect(@configurator).to receive(:project_config_hash).and_return({ }) - # expect(@file_path_utils).to receive(:form_temp_path).and_return("/_dummy_file.c") - # expect(@file_wrapper).to receive(:read).and_return("") - # expect(@file_wrapper).to receive(:write) - # expect(@tool_executor).to receive(:build_command_line).and_return({:line => "", :options => ""}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # _test_DUMMY.o: Build/temp/_test_DUMMY.c \ - # source\some_header1.h \ - # source\some_lib\some_header2.h \ - # source\some_lib1\some_lib\some_header2.h \ - # source\some_other_lib\some_header2.h \ - # @@@@some_header1.h \ - # @@@@some_lib/some_header2.h \ - # @@@@some_lib1/some_lib/some_header2.h \ - # @@@@some_other_lib/some_header2.h - # }}) - # # execute method - # results = subject.extract_includes_helper("/dummy_file_2.c", [], [], []) - # # validate results - # expect(results).to eq [ - # ['source/some_header1.h', - # 'source/some_lib/some_header2.h', - # 'source/some_lib1/some_lib/some_header2.h', - # 'source/some_other_lib/some_header2.h'], - # [], [] - # ] - # end - - # it 'exclude annotated headers with no matching "real" header' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@configurator).to receive(:extension_header).and_return('.h') - # expect(@configurator).to receive(:extension_source).and_return('.c') - # expect(@configurator).to receive(:project_config_hash).and_return( {:cmock_mock_prefix => 'mock_'}) - # expect(@configurator).to receive(:tools_test_includes_preprocessor) - # expect(@configurator).to receive(:project_config_hash).and_return({ }) - # expect(@file_path_utils).to receive(:form_temp_path).and_return("/_dummy_file.c") - # expect(@file_wrapper).to receive(:read).and_return("") - # expect(@file_wrapper).to receive(:write) - # expect(@tool_executor).to receive(:build_command_line).and_return({:line => "", :options => ""}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # _test_DUMMY.o: Build/temp/_test_DUMMY.c \ - # source/some_header1.h \ - # @@@@some_header1.h \ - # @@@@some_lib/some_header2.h - # }}) - # # execute method - # results = subject.extract_includes_helper("/dummy_file_3.c", [], [], []) - # # validate results - # expect(results).to eq [ - # ['source/some_header1.h'], - # [], [] - # ] - # end - - # it 'should correctly filter secondary dependencies' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@configurator).to receive(:extension_header).and_return('.h') - # expect(@configurator).to receive(:extension_source).and_return('.c') - # expect(@configurator).to receive(:project_config_hash).and_return( {:cmock_mock_prefix => 'mock_'}) - # expect(@configurator).to receive(:tools_test_includes_preprocessor) - # expect(@configurator).to receive(:project_config_hash).and_return({ }) - # expect(@file_path_utils).to receive(:form_temp_path).and_return("/_dummy_file.c") - # expect(@file_wrapper).to receive(:read).and_return("") - # expect(@file_wrapper).to receive(:write) - # expect(@tool_executor).to receive(:build_command_line).and_return({:line => "", :options => ""}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # _test_DUMMY.o: Build/temp/_test_DUMMY.c \ - # source\some_header1.h \ - # source\some_lib\some_header2.h \ - # source\some_lib1\some_lib\some_header2.h \ - # source\some_other_lib\some_header2.h \ - # source\some_other_lib\another.h \ - # @@@@some_header1.h \ - # @@@@some_lib/some_header2.h \ - # @@@@lib/some_header2.h \ - # @@@@some_other_lib/some_header2.h - # }}) - # # execute method - # results = subject.extract_includes_helper("/dummy_file_4.c", [], [], []) - # # validate results - # expect(results).to eq [ - # ['source/some_header1.h', - # 'source/some_lib/some_header2.h', - # 'source/some_other_lib/some_header2.h'], - # [], [] - # ] - # end - - # it 'should return the list of direct dependencies for the given source file' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@configurator).to receive(:extension_header).and_return('.h') - # expect(@configurator).to receive(:extension_source).and_return('.c') - # expect(@configurator).to receive(:project_config_hash).and_return( {:cmock_mock_prefix => 'mock_'}) - # expect(@configurator).to receive(:tools_test_includes_preprocessor) - # expect(@configurator).to receive(:project_config_hash).and_return({ }) - # expect(@file_path_utils).to receive(:form_temp_path).and_return("/_dummy_file.c") - # expect(@file_wrapper).to receive(:read).and_return("") - # expect(@file_wrapper).to receive(:write) - # expect(@tool_executor).to receive(:build_command_line).and_return({:line => "", :options => ""}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # _DUMMY.o: Build/temp/_DUMMY.c \ - # source/new_some_header1_DUMMY.h \ - # source/some_header1__DUMMY.h \ - # @@@@new_some_header1_DUMMY.h \ - # @@@@some_header1__DUMMY.h \ - # }}) - # # execute method - # results = subject.extract_includes_helper("/dummy_file_5.c", [], [], []) - # # validate results - # expect(results).to eq [ - # [ 'source/new_some_header1_DUMMY.h', - # 'source/some_header1__DUMMY.h'], - # [], [] - # ] - # end - # end - - # context 'extract_includes' do - # it 'should correctly filter auto link deep dependencies with mocks' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@configurator).to receive(:project_config_hash).and_return({:cmock_mock_prefix => 'mock_', - # :project_auto_link_deep_dependencies => true, - # :collection_paths_include => []}).at_least(:once) - # expect(@configurator).to receive(:extension_header).and_return('.h').exactly(3).times - # expect(@configurator).to receive(:extension_source).and_return('.c').exactly(3).times - # expect(@configurator).to receive(:tools_test_includes_preprocessor).exactly(3).times - # expect(@file_wrapper).to receive(:read).and_return("").exactly(3).times - # expect(@file_wrapper).to receive(:write).exactly(3).times - # expect(@file_finder).to receive(:find_build_input_file).and_return("assets\example_file.c") - # expect(@tool_executor).to receive(:build_command_line).and_return({:line => "", :options => ""}).exactly(3).times - # expect(@file_path_utils).to receive(:form_temp_path).and_return("_test_DUMMY.c") - # expect(@file_path_utils).to receive(:form_temp_path).and_return("assets\_example_file.h") - # expect(@file_path_utils).to receive(:form_temp_path).and_return("assets\_example_file.c") - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # _test_DUMMY.o: build/temp/_test_DUMMY.c \ - # assets\example_file.h \ - # build\mocks\mock_dependency.h \ - # @@@@assets/example_file.h \ - # @@@@build/mocks/mock_dependency.h - # }}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # assets\_example_file.o: assets\_example_file.h - # }}) - # expect(@tool_executor).to receive(:exec).and_return({ :output => %q{ - # assets\_example_file.o: assets\_example_file.c \ - # source\dependency.h \ - # @@@@source/dependency.h - # }}) - # # execute method - # results = subject.extract_includes("test_dummy.c") - # # validate results - # expect(results).to eq [ - # 'assets/example_file.h', - # 'build/mocks/mock_dependency.h'] - # end - # end - - # context 'invoke_shallow_includes_list' do - # it 'should invoke the rake task which will build included files' do - # # create test state/variables - # # mocks/stubs/expected calls - # expect(@yaml_wrapper).to receive(:dump).with('some_source_file.c', []) - # # execute method - # subject.write_shallow_includes_list('some_source_file.c', []) - # # validate results - # end - # end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 9a338bc82..000000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,32 +0,0 @@ -# ========================================================================= -# Ceedling - Test-Centered Build System for C -# ThrowTheSwitch.org -# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams -# SPDX-License-Identifier: MIT -# ========================================================================= - -require 'require_all' -require 'constructor' - -RSpec.configure do |config| - config.raise_errors_for_deprecations! -end - -here = File.dirname(__FILE__) - -$: << File.join(here, '../bin') -$: << File.join(here, '../lib') -$: << File.join(here, '../vendor/cmock/lib') -$: << File.join(here, '../vendor/unity/auto') - -support_files = File.join(File.dirname(__FILE__), "support/**/*.rb") -require_all Dir.glob(support_files, File::FNM_PATHNAME) -support_dir = File.join(File.dirname(__FILE__), 'support') - -# Eventually, we should use this. -# -# # ceedling_files = File.join(File.dirname(__FILE__), '../lib/**/*.rb') -# # require_all Dir.glob(ceedling_files, File::FNM_PATHNAME) - - - diff --git a/spec/support/spec_helper.rb b/spec/support/spec_helper.rb new file mode 100644 index 000000000..527c566dc --- /dev/null +++ b/spec/support/spec_helper.rb @@ -0,0 +1,20 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'require_all' +require 'constructor' + +RSpec.configure do |config| + config.raise_errors_for_deprecations! +end + +here = File.dirname(__FILE__) + +$: << File.join(here, '../../bin') +$: << File.join(here, '../../lib') +$: << File.join(here, '../../vendor/cmock/lib') +$: << File.join(here, '../../vendor/unity/auto') diff --git a/spec/support/system/gem_dir_layout.rb b/spec/support/system/gem_dir_layout.rb new file mode 100644 index 000000000..2eb2e9ac2 --- /dev/null +++ b/spec/support/system/gem_dir_layout.rb @@ -0,0 +1,20 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +class GemDirLayout + attr_reader :gem_dir_base_name + + def initialize(install_dir) + @gem_dir_base_name = "gems" + @d = File.join install_dir, @gem_dir_base_name + FileUtils.mkdir_p @d + end + + def install_dir; convert_slashes(@d) end + def bin; File.join(@d, 'bin') end + def lib; File.join(@d, 'lib') end +end diff --git a/spec/support/system/spec_system_helper.rb b/spec/support/system/spec_system_helper.rb new file mode 100644 index 000000000..3e9b3fdfa --- /dev/null +++ b/spec/support/system/spec_system_helper.rb @@ -0,0 +1,109 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'fileutils' +require 'tmpdir' +require 'open3' +require 'ceedling/yaml_wrapper' +require 'spec_helper' +require 'deep_merge' + +require_relative 'system_test_output' +require_relative 'gem_dir_layout' +require_relative 'system_context' +require_relative '../../system/support/common_test_cases' + +module CeedlingSystemSpecHelpers + SYSTEM_TESTS_LABEL = "Ceedling System Tests" + + # Helper method to convert method name to readable description + def test_case(method_name) + description = method_name.to_s.gsub('_', ' ').capitalize + it(description) { send(method_name) } + end +end + +# Top-level DSL wrapper — replaces `describe "Ceedling System Tests" do` in each spec file. +# Must be a top-level def (not inside a module) because config.extend only injects methods +# into the RSpec example group DSL (inside describe blocks), not into main:Object where +# the outermost describe call in each spec file is made. +def ceedling_system_tests(&block) + describe(CeedlingSystemSpecHelpers::SYSTEM_TESTS_LABEL, &block) +end + +# Extend RSpec's DSL to include our helper above, and add system-test failure diagnostics +RSpec.configure do |config| + config.extend CeedlingSystemSpecHelpers + + # Exclude any line that does NOT contain "system" and ends with .rb from backtraces + # This helps reduce RSpec backtrace noise that is irrelevant to system test failures + config.backtrace_formatter.exclusion_patterns = [ + /\A(?!.*system.*\.rb)/ + ] + + # Rebuild the full description from the group hierarchy using " :: " as separator. + # example.full_description concatenates with spaces, which is unreadable at 3-4 levels deep. + format_description = lambda do |example| + groups = example.example_group.parent_groups.reverse.drop(1) + parts = groups.map(&:description).reject(&:empty?) + parts << example.description unless example.description.empty? + parts.join(' :: ') + end + + config.after(:each) do |example| + next unless example.exception + next unless defined?(@c) && @c.respond_to?(:raw_output) && !@c.raw_output.nil? + + test_name = + example.full_description + # Remove "ceedling" and "system test(s)" from the test name as redundant in the log filename + .gsub(/^ceedling/i, '') + .gsub(/system tests?/i, '') + .gsub(/systests?/i, '') + # Replace non-filesystem-safe chars with underscores + .gsub(/[^a-zA-Z0-9_-]/, '_') + # Collapse runs of underscores into a single underscore + .squeeze('_') + # Strip leading/trailing underscores + .gsub(/\A_+|_+\z/, '') + # Truncate long names by keeping the last 120 chars (preserves the specific end of the name). + # String#slice(negative, length) returns nil when the string is shorter than the offset; + # use a conditional instead. + # After slicing, strip any partial leading word + # (e.g. "s_" from "Project's" -> "Project_s_" when the cut lands mid-segment). + .then { |s| s.length > 120 ? s[-120..].sub(/\A[^_]*_+/, '') : s } + timestamp = Time.now.utc.strftime('%Y%m%dT%H%M%SZ') + log_path = File.join(Dir.pwd, "systest.#{test_name}.#{timestamp}.fail.log") + + log_content = "" + log_content << "Command: `#{@c.last_cmd}`\n\n" if @c.respond_to?(:last_cmd) && !@c.last_cmd.nil? + log_content << @c.raw_output.to_s + File.write(log_path, log_content) + + $stderr.puts "\n" + ("=" * 72) + $stderr.puts "FAILED: #{format_description.call(example)}" + $stderr.puts "Temp dir: #{@c.dir}" + $stderr.puts "Log file: #{log_path}" + if @c.respond_to?(:console_summary) && !@c.console_summary.nil? + $stderr.puts "-" * 72 + $stderr.puts @c.console_summary + end + $stderr.puts "=" * 72 + "\n" + end +end + +def test_asset_path(asset_file_name) + File.join(File.dirname(__FILE__), '..', '..', '..', 'assets', asset_file_name) +end + +def convert_slashes(path) + if RUBY_PLATFORM.downcase.match(/mingw|win32/) + path.gsub("/","\\") + else + path + end +end diff --git a/spec/support/system/system_context.rb b/spec/support/system/system_context.rb new file mode 100644 index 000000000..9ecaae2b4 --- /dev/null +++ b/spec/support/system/system_context.rb @@ -0,0 +1,236 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require_relative 'gem_dir_layout' + +class SystemContext + class VerificationFailed < RuntimeError; end + class InvalidBackupEnv < RuntimeError; end + + attr_reader :dir, :gem, :console_summary, :raw_output, :last_exit_status, :last_cmd + + def initialize + @dir = Dir.mktmpdir + @gem = GemDirLayout.new(@dir) + end + + SYSTEM_TEST_KEEP_ENV = 'CEEDLING_SYSTEM_TEST_KEEP' + + def done! + if ENV[SYSTEM_TEST_KEEP_ENV] + $stderr.puts "Keeping test artifacts at: #{@dir} (#{SYSTEM_TEST_KEEP_ENV} is set)" + else + FileUtils.rm_rf(@dir) + end + end + + def deploy_gem + git_repo = File.expand_path( File.join( File.dirname( __FILE__ ), '..', '..', '..') ) + bundler_gem_file_data = [ + %Q{source "http://rubygems.org/"}, + %Q{gem "rake"}, + %Q{gem "constructor"}, + %Q{gem "diy"}, + %Q{gem "thor"}, + %Q{gem "deep_merge"}, + %Q{gem "unicode-display_width"}, + %Q{gem "ceedling", :path => '#{git_repo}'} + ].join("\n") + + File.open(File.join(@dir, "Gemfile"), "w+") do |f| + f.write(bundler_gem_file_data) + end + + Dir.chdir @dir do + with_constrained_env do + deploy_output = `bundle config set --local path '#{@gem.install_dir}' 2>&1` + deploy_output += `bundle install 2>&1` + raise VerificationFailed, "bundle install failed:\n#{deploy_output}" unless $?.success? + + checks = ["bundle exec ruby -S ceedling version 2>&1"] + checks.each do |c| + result = `#{c}` + unless $?.success? + raise VerificationFailed, + "Ceedling does not appear to be installed or ready for use.\n" \ + "Command: `#{c}`\n" \ + "Output:\n#{result}" + end + end + end + end + + end + + # Does a few things: + # - Configures the environment. + # - Runs the command from the temporary context directory. + # - Restores everything to where it was when finished. + def context_exec(cmd, *args) + with_context do + `#{args.unshift(cmd).join(" ")}` + end + end + + def with_context + Dir.chdir @dir do |current_dir| + with_constrained_env do + ENV['RUBYLIB'] = @gem.lib + ENV['RUBYPATH'] = @gem.bin + + ENV['LANG'] = 'en_US.UTF-8' + ENV['LANGUAGE'] = 'en_US.UTF-8' + ENV['LC_ALL'] = 'en_US.UTF-8' + + yield + end + end + end + + ############################################################ + # Functions for manipulating environment settings during tests: + def backup_env + @_env = ENV.to_hash + end + + def reduce_env(destroy_keys=[]) + ENV.keys.each {|k| ENV.delete(k) if destroy_keys.include?(k) } + end + + def constrain_env + destroy_keys = %w{BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT} + reduce_env(destroy_keys) + end + + def restore_env + if @_env + # delete environment variables we've added since we started + ENV.to_hash.each_pair {|k,v| ENV.delete(k) unless @_env.include?(k) } + + # restore environment variables we've modified since we started + @_env.each_pair {|k,v| ENV[k] = v} + else + raise InvalidBackupEnv.new + end + end + + def with_constrained_env + begin + backup_env + constrain_env + yield + ensure + restore_env + end + end + + ############################################################ + # Functions for manipulating project.yml files during tests: + def merge_project_yml_for_test(settings, show_final=false) + yaml_wrapper = YamlWrapper.new + project_hash = yaml_wrapper.load('project.yml') + project_hash.deep_merge!(settings) + puts "\n\n#{project_hash.to_yaml}\n\n" if show_final + yaml_wrapper.dump('project.yml', project_hash) + end + + def append_project_yml_for_test(new_args) + fake_prj_yml= "#{File.read('project.yml')}\n#{new_args}" + File.write('project.yml', fake_prj_yml, mode: 'w') + end + + def uncomment_project_yml_option_for_test(option) + fake_prj_yml= File.read('project.yml').gsub(/\##{option}/,option) + File.write('project.yml', fake_prj_yml, mode: 'w') + end + + def comment_project_yml_option_for_test(option) + fake_prj_yml= File.read('project.yml').gsub(/#{option}/,"##{option}") + File.write('project.yml', fake_prj_yml, mode: 'w') + end + + ############################################################ + # Ceedling command execution with structured failure reporting: + + # For build/test (Rake tasks): Routes through the Ceedling CLI application command `build` + # This is the only command that accepts --verbosity. + def ceedling_build_exec(*args) + cmd = "bundle exec ruby -S ceedling build --verbosity=debug #{args.join(' ')}".strip + stdout, stderr, status = Open3.capture3(cmd) + + @last_cmd = cmd + @last_exit_status = status.exitstatus + @raw_output = stdout + stderr + @console_summary = compose_failure_report(stdout, stderr) + + SystemTestOutput.new(@raw_output) + end + + # All other Ceedling CLI application commands other than `build` + # (new, upgrade, version, help, examples, example, etc.) + def ceedling_appcmd_exec(*args) + cmd = "bundle exec ruby -S ceedling #{args.join(' ')}".strip + stdout, stderr, status = Open3.capture3(cmd) + + @last_cmd = cmd + @last_exit_status = status.exitstatus + @raw_output = stdout + stderr + @console_summary = compose_failure_report(stdout, stderr) + + SystemTestOutput.new(@raw_output) + end + + private + + def compose_failure_report(stdout, stderr) + sections = [] + + error_lines = stdout.lines.select { |l| l.include?('ERROR') } + exception_lines = stdout.lines.select { |l| l.include?('EXCEPTION') } + + unless error_lines.empty? && exception_lines.empty? + sections << ">> ERRORS & EXCEPTIONS" + sections.concat(error_lines) + sections.concat(exception_lines) + end + + unless stderr.strip.empty? + sections << ">> STDERR" + sections << stderr.strip + end + + label = 'FAILED TEST SUMMARY' + failed_block = extract_section(stdout, label) + unless failed_block.empty? + sections << ">> #{label}" + sections.concat(failed_block) + end + + label = 'OVERALL TEST SUMMARY' + overall_block = extract_section(stdout, label) + unless overall_block.empty? + sections << ">> #{label}" + sections.concat(overall_block) + end + + sections.join("\n") + end + + def extract_section(output, banner) + lines = output.lines + idx = lines.index { |l| l.include?(banner) } + return [] unless idx + + # Skip the banner line and any following pure-separator lines + start = idx + 1 + start += 1 while start < lines.length && lines[start].strip.match?(/\A[-=]+\z/) + + # Collect contiguous non-blank lines + lines[start..].take_while { |l| !l.strip.empty? } + end + ############################################################ +end diff --git a/spec/support/system/system_test_output.rb b/spec/support/system/system_test_output.rb new file mode 100644 index 000000000..0a1b9d5c8 --- /dev/null +++ b/spec/support/system/system_test_output.rb @@ -0,0 +1,28 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +# Wraps Ceedling command output to control how it appears in RSpec assertion failure messages. +# RSpec formats the "actual" value via #inspect. For large outputs this class returns a +# sentinel string so failures show only the expected pattern/substring, not thousands of lines. +# For small outputs (≤1000 characters or ≤12 lines) the real content is shown inline so +# short failures are immediately readable without consulting a log file. +# All RSpec string matchers (match, include) continue to work unchanged at every call site. +class SystemTestOutput + def initialize(output) + @output = output + end + + def match(pattern) = @output.match(pattern) + def include?(str) = @output.include?(str) + def to_s = @output + def to_str = @output + def inspect + # Show actual output when it is small enough to read inline; suppress it otherwise. + return @output.inspect if @output.length <= 1000 || @output.count("\n") <= 12 + '(<Ceedling build output> -- See log file)' + end +end diff --git a/spec/system/deployment_as_gem_spec.rb b/spec/system/deployment_as_gem_spec.rb index 808e55484..6e51dc9b0 100644 --- a/spec/system/deployment_as_gem_spec.rb +++ b/spec/system/deployment_as_gem_spec.rb @@ -7,8 +7,8 @@ require 'spec_system_helper' -describe "Ceedling" do - include CeedlingTestCases +ceedling_system_tests do + include CommonSystemTestCases before :all do @c = SystemContext.new @@ -23,54 +23,85 @@ before { @proj_name = "fake_project" } after { @c.with_context { FileUtils.rm_rf @proj_name } } - describe "deployed as a gem" do + describe "Deployed as a gem" do before do FileUtils.rm_rf( 'GIT_COMMIT_SHA' ) @c.with_context do - `bundle exec ruby -S ceedling new #{@proj_name} 2>&1` + @c.ceedling_appcmd_exec("new #{@proj_name}") end end - it { can_report_version_no_git_commit_sha } - it { can_report_version_with_git_commit_sha } - it { can_create_projects } - it { does_not_contain_a_vendor_directory } - it { can_fetch_non_project_help } - it { can_fetch_project_help } - it { can_test_projects_with_success } - it { can_test_projects_with_success_test_alias } - it { can_test_projects_with_test_name_replaced_defines_with_success } - it { can_test_projects_unity_parameterized_test_cases_with_success } - #it { can_test_projects_unity_parameterized_test_cases_with_preprocessor_with_success } - it { can_test_projects_with_preprocessing_for_test_files_symbols_undefined } - it { can_test_projects_with_preprocessing_for_test_files_symbols_defined } - it { can_test_projects_with_preprocessing_for_mocks_success } - it { can_test_projects_with_preprocessing_for_mocks_intentional_build_failure } - it { can_test_projects_with_preprocessing_all } - it { can_test_projects_with_success_default } - it { can_test_projects_with_unity_exec_time } - it { can_test_projects_with_test_and_vendor_defines_with_success } - it { can_test_projects_with_fail } - it { can_test_projects_with_fail_alias } - it { can_test_projects_with_fail_default } - it { can_test_projects_with_compile_error } - it { can_test_projects_with_both_mock_and_real_header } - it { can_test_projects_with_success_when_space_appears_between_hash_and_include } - it { can_test_projects_with_named_verbosity } - it { can_test_projects_with_numerical_verbosity } - it { uses_report_tests_raw_output_log_plugin } - it { test_run_of_projects_fail_because_of_crash_without_report } - it { test_run_of_projects_fail_because_of_crash_with_report } - it { execute_all_test_cases_from_crashing_test_runner_and_return_test_report_with_failue } - it { execute_and_collect_debug_logs_from_crashing_test_case_defined_by_test_case_argument_with_enabled_debug } - it { execute_and_collect_debug_logs_from_crashing_test_case_defined_by_exclude_test_case_argument_with_enabled_debug } - it { confirm_if_notification_for_cmdline_args_not_enabled_is_disabled } - it { can_run_single_test_with_full_test_case_name_from_test_file_with_success } - it { can_run_single_test_with_partial_test_case_name_from_test_file_with_success } - it { exclude_test_case_name_filter_works_and_only_one_test_case_is_executed } - it { none_of_test_is_executed_if_test_case_name_passed_does_not_fit_defined_in_test_file } - it { none_of_test_is_executed_if_test_case_name_and_exclude_test_case_name_is_the_same } - it { run_one_testcase_from_one_test_file_when_test_case_name_is_passed } - end + describe "Version reporting" do + test_case :can_report_version_no_git_commit_sha + test_case :can_report_version_with_git_commit_sha + end + + describe "Project creation" do + test_case :can_create_projects + test_case :does_not_contain_a_vendor_directory + end + + describe "Help system" do + test_case :can_fetch_non_project_help + test_case :can_fetch_project_help + end + + describe "Basic test project execution" do + test_case :can_test_projects_with_success + test_case :can_test_projects_with_success_test_alias + test_case :can_test_projects_with_success_default + test_case :can_test_projects_with_fail + test_case :can_test_projects_with_fail_alias + test_case :can_test_projects_with_fail_default + test_case :can_test_projects_with_compile_error + test_case :can_test_projects_with_test_file_directly_including_source_file + end + + describe "Test builds with preprocessing" do + test_case :can_test_projects_with_preprocessing_for_test_files_symbols_undefined + test_case :can_test_projects_with_preprocessing_for_test_files_symbols_defined + test_case :can_test_projects_with_preprocessing_for_mocks_success + test_case :can_test_projects_with_preprocessing_for_mocks_intentional_build_failure + test_case :can_test_projects_with_preprocessing_all + end + + describe "Defines and configuration" do + test_case :can_test_projects_with_test_name_replaced_defines_with_success + test_case :can_test_projects_with_test_and_vendor_defines_with_success + test_case :can_test_projects_with_both_mock_and_real_header + end + + describe "Unity features" do + test_case :can_test_projects_unity_parameterized_test_cases_with_success + #test_case :can_test_projects_unity_parameterized_test_cases_with_preprocessor_with_success + test_case :can_test_projects_with_unity_exec_time + end + describe "Edge case parsing" do + test_case :can_test_projects_with_success_when_space_appears_between_hash_and_include + end + + describe "Verbosity and output" do + test_case :can_test_projects_with_named_verbosity + test_case :can_test_projects_with_numerical_verbosity + test_case :uses_report_tests_raw_output_log_plugin + end + + describe "Crash handling" do + test_case :test_run_of_projects_fail_because_of_crash_without_report + test_case :test_run_of_projects_fail_because_of_crash_with_report + test_case :execute_all_test_cases_from_crashing_test_runner_and_return_test_report_with_failue + test_case :execute_and_collect_debug_logs_from_crashing_test_case_defined_by_test_case_argument_with_enabled_debug + test_case :execute_and_collect_debug_logs_from_crashing_test_case_defined_by_exclude_test_case_argument_with_enabled_debug + end + + describe "Test filtering" do + test_case :can_run_single_test_with_full_test_case_name_from_test_file_with_success + test_case :can_run_single_test_with_partial_test_case_name_from_test_file_with_success + test_case :exclude_test_case_name_filter_works_and_only_one_test_case_is_executed + test_case :none_of_test_is_executed_if_test_case_name_passed_does_not_fit_defined_in_test_file + test_case :none_of_test_is_executed_if_test_case_name_and_exclude_test_case_name_is_the_same + test_case :run_one_testcase_from_one_test_file_when_test_case_name_is_passed + end + end end diff --git a/spec/system/deployment_as_vendor_spec.rb b/spec/system/deployment_as_vendor_spec.rb index 3e7c96605..84bc89fa3 100644 --- a/spec/system/deployment_as_vendor_spec.rb +++ b/spec/system/deployment_as_vendor_spec.rb @@ -7,8 +7,8 @@ require 'spec_system_helper' -describe "Ceedling" do - include CeedlingTestCases +ceedling_system_tests do + include CommonSystemTestCases before :all do @c = SystemContext.new @@ -24,100 +24,155 @@ before { @proj_name = "fake_project" } after { @c.with_context { FileUtils.rm_rf @proj_name } } - describe "deployed in a project's `vendor` directory." do + describe "Deployed in a project's `vendor` directory" do before do # Ensure version commit file is cleaned up FileUtils.rm_rf( 'GIT_COMMIT_SHA' ) @c.with_context do - `bundle exec ruby -S ceedling new --local --docs #{@proj_name} 2>&1` + @c.ceedling_appcmd_exec("new --local --docs #{@proj_name}") end end - it { can_report_version_no_git_commit_sha } - it { can_report_version_with_git_commit_sha } - it { can_create_projects } - it { contains_a_vendor_directory } - it { contains_documentation } - it { can_fetch_non_project_help } - it { can_fetch_project_help } - it { can_test_projects_with_success } - it { can_test_projects_with_success_test_alias } - it { can_test_projects_with_test_name_replaced_defines_with_success } - it { can_test_projects_unity_parameterized_test_cases_with_success } - #it { can_test_projects_unity_parameterized_test_cases_with_preprocessor_with_success } - it { can_test_projects_with_preprocessing_for_test_files_symbols_undefined } - it { can_test_projects_with_preprocessing_for_test_files_symbols_defined } - it { can_test_projects_with_preprocessing_for_mocks_success } - it { can_test_projects_with_preprocessing_for_mocks_intentional_build_failure } - it { can_test_projects_with_preprocessing_all } - it { can_test_projects_with_success_default } - it { can_test_projects_with_unity_exec_time } - it { can_test_projects_with_test_and_vendor_defines_with_success } - it { can_test_projects_with_fail } - it { can_test_projects_with_fail_alias } - it { can_test_projects_with_fail_default } - it { can_test_projects_with_compile_error } - it { can_test_projects_with_both_mock_and_real_header } - it { can_test_projects_with_success_when_space_appears_between_hash_and_include } - it { can_test_projects_with_named_verbosity } - it { can_test_projects_with_numerical_verbosity } - it { uses_report_tests_raw_output_log_plugin } - it { test_run_of_projects_fail_because_of_crash_without_report } - it { test_run_of_projects_fail_because_of_crash_with_report } - it { execute_all_test_cases_from_crashing_test_runner_and_return_test_report_with_failue } - it { execute_and_collect_debug_logs_from_crashing_test_case_defined_by_test_case_argument_with_enabled_debug } - it { execute_and_collect_debug_logs_from_crashing_test_case_defined_by_exclude_test_case_argument_with_enabled_debug } - it { confirm_if_notification_for_cmdline_args_not_enabled_is_disabled } - it { can_run_single_test_with_full_test_case_name_from_test_file_with_success } - it { can_run_single_test_with_partial_test_case_name_from_test_file_with_success } - it { exclude_test_case_name_filter_works_and_only_one_test_case_is_executed } - it { none_of_test_is_executed_if_test_case_name_passed_does_not_fit_defined_in_test_file } - it { none_of_test_is_executed_if_test_case_name_and_exclude_test_case_name_is_the_same } - it { run_one_testcase_from_one_test_file_when_test_case_name_is_passed } + describe "Version reporting" do + test_case :can_report_version_no_git_commit_sha + test_case :can_report_version_with_git_commit_sha + end + + describe "Project creation" do + test_case :can_create_projects + test_case :contains_a_vendor_directory + test_case :contains_documentation + end + + describe "Help system" do + test_case :can_fetch_non_project_help + test_case :can_fetch_project_help + end + + describe "Basic test execution" do + test_case :can_test_projects_with_success + test_case :can_test_projects_with_success_test_alias + test_case :can_test_projects_with_success_default + test_case :can_test_projects_with_fail + test_case :can_test_projects_with_fail_alias + test_case :can_test_projects_with_fail_default + test_case :can_test_projects_with_compile_error + test_case :can_test_projects_with_test_file_directly_including_source_file + end + + describe "Test builds with preprocessing" do + test_case :can_test_projects_with_preprocessing_for_test_files_symbols_undefined + test_case :can_test_projects_with_preprocessing_for_test_files_symbols_defined + test_case :can_test_projects_with_preprocessing_for_mocks_success + test_case :can_test_projects_with_preprocessing_for_mocks_intentional_build_failure + test_case :can_test_projects_with_preprocessing_all + end + + describe "Defines and configuration" do + test_case :can_test_projects_with_test_name_replaced_defines_with_success + test_case :can_test_projects_with_test_and_vendor_defines_with_success + test_case :can_test_projects_with_both_mock_and_real_header + end + + describe "Unity features" do + test_case :can_test_projects_unity_parameterized_test_cases_with_success + #test_case :can_test_projects_unity_parameterized_test_cases_with_preprocessor_with_success + test_case :can_test_projects_with_unity_exec_time + end + + describe "Edge cases parsing" do + test_case :can_test_projects_with_success_when_space_appears_between_hash_and_include + end + + describe "Verbosity and output" do + test_case :can_test_projects_with_named_verbosity + test_case :can_test_projects_with_numerical_verbosity + test_case :uses_report_tests_raw_output_log_plugin + end + + describe "Crash handling" do + test_case :test_run_of_projects_fail_because_of_crash_without_report + test_case :test_run_of_projects_fail_because_of_crash_with_report + test_case :execute_all_test_cases_from_crashing_test_runner_and_return_test_report_with_failue + test_case :execute_and_collect_debug_logs_from_crashing_test_case_defined_by_test_case_argument_with_enabled_debug + test_case :execute_and_collect_debug_logs_from_crashing_test_case_defined_by_exclude_test_case_argument_with_enabled_debug + end + + describe "Test filtering" do + test_case :can_run_single_test_with_full_test_case_name_from_test_file_with_success + test_case :can_run_single_test_with_partial_test_case_name_from_test_file_with_success + test_case :exclude_test_case_name_filter_works_and_only_one_test_case_is_executed + test_case :none_of_test_is_executed_if_test_case_name_passed_does_not_fit_defined_in_test_file + test_case :none_of_test_is_executed_if_test_case_name_and_exclude_test_case_name_is_the_same + test_case :run_one_testcase_from_one_test_file_when_test_case_name_is_passed + end end - describe "deployed in a project's `vendor` directory with git support." do + describe "Deployed in a project's `vendor` directory with Git support" do before do @c.with_context do - `bundle exec ruby -S ceedling new --local --docs --gitsupport #{@proj_name} 2>&1` + @c.ceedling_appcmd_exec("new --local --docs --gitsupport #{@proj_name}") end end - it { can_create_projects } - it { has_git_support } - it { contains_a_vendor_directory } - it { contains_documentation } - it { can_test_projects_with_success } + describe "Project creation" do + test_case :can_create_projects + test_case :has_git_support + test_case :contains_a_vendor_directory + test_case :contains_documentation + end + + describe "Basic test execution" do + test_case :can_test_projects_with_success + test_case :can_test_projects_with_test_file_directly_including_source_file + end end - describe "deployed in a project's `vendor` directory without docs." do + describe "Deployed in a project's `vendor` directory without docs" do before do @c.with_context do - `bundle exec ruby -S ceedling new --local #{@proj_name} 2>&1` + @c.ceedling_appcmd_exec("new --local #{@proj_name}") end end - it { can_create_projects } - it { contains_a_vendor_directory } - it { does_not_contain_documentation } - it { can_fetch_non_project_help } - it { can_fetch_project_help } - it { can_test_projects_with_success } - it { can_test_projects_with_success_test_alias } - it { can_test_projects_with_test_name_replaced_defines_with_success } - it { can_test_projects_unity_parameterized_test_cases_with_success } - it { can_test_projects_with_preprocessing_for_test_files_symbols_undefined } - it { can_test_projects_with_preprocessing_for_test_files_symbols_defined } - it { can_test_projects_with_preprocessing_for_mocks_success } - it { can_test_projects_with_preprocessing_for_mocks_intentional_build_failure } - it { can_test_projects_with_preprocessing_all } - it { can_test_projects_with_success_default } - it { can_test_projects_with_unity_exec_time } - it { can_test_projects_with_test_and_vendor_defines_with_success } - it { can_test_projects_with_fail } - it { can_test_projects_with_fail_alias } - it { can_test_projects_with_fail_default } - it { can_test_projects_with_compile_error } - end + describe "Project creation" do + test_case :can_create_projects + test_case :contains_a_vendor_directory + test_case :does_not_contain_documentation + end + + describe "Help system" do + test_case :can_fetch_non_project_help + test_case :can_fetch_project_help + end + + describe "Basic test execution" do + test_case :can_test_projects_with_success + test_case :can_test_projects_with_success_test_alias + test_case :can_test_projects_with_success_default + test_case :can_test_projects_with_fail + test_case :can_test_projects_with_fail_alias + test_case :can_test_projects_with_fail_default + test_case :can_test_projects_with_compile_error + test_case :can_test_projects_with_test_file_directly_including_source_file + end + + describe "Test builds with preprocessing" do + test_case :can_test_projects_with_preprocessing_for_test_files_symbols_undefined + test_case :can_test_projects_with_preprocessing_for_test_files_symbols_defined + test_case :can_test_projects_with_preprocessing_for_mocks_success + test_case :can_test_projects_with_preprocessing_for_mocks_intentional_build_failure + test_case :can_test_projects_with_preprocessing_all + end -end + describe "Defines and configuration" do + test_case :can_test_projects_with_test_name_replaced_defines_with_success + test_case :can_test_projects_with_test_and_vendor_defines_with_success + end + + describe "Unity features" do + test_case :can_test_projects_unity_parameterized_test_cases_with_success + test_case :can_test_projects_with_unity_exec_time + end + end +end \ No newline at end of file diff --git a/spec/system/example_temp_sensor_spec.rb b/spec/system/example_temp_sensor_spec.rb index f655ff9b6..3dc998320 100644 --- a/spec/system/example_temp_sensor_spec.rb +++ b/spec/system/example_temp_sensor_spec.rb @@ -7,8 +7,8 @@ require 'spec_system_helper' -describe "Ceedling" do - include CeedlingTestCases +ceedling_system_tests do + include CommonSystemTestCases before :all do @c = SystemContext.new @@ -22,10 +22,10 @@ before { @proj_name = "temp_sensor" } after { @c.with_context { FileUtils.rm_rf @proj_name } } - describe "command: `ceedling examples`" do + describe "Command: `ceedling examples`" do before do @c.with_context do - @output = `bundle exec ruby -S ceedling examples 2>&1` + @output = @c.ceedling_appcmd_exec("examples") end end @@ -34,11 +34,11 @@ end end - describe "command: `ceedling example temp_sensor`" do + describe "Command: `ceedling example temp_sensor`" do describe "temp_sensor" do before do @c.with_context do - output = `bundle exec ruby -S ceedling example temp_sensor 2>&1` + output = @c.ceedling_appcmd_exec("example temp_sensor") expect(output).to match(/created/) end end @@ -46,7 +46,7 @@ it "should be testable" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:all 2>&1` + @output = @c.ceedling_build_exec("test:all") expect(@output).to match(/TESTED:\s+51/) expect(@output).to match(/PASSED:\s+51/) end @@ -56,7 +56,7 @@ it "should be able to test a single module (it includes file-specific flags)" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:TemperatureCalculator 2>&1` + @output = @c.ceedling_build_exec("test:TemperatureCalculator") expect(@output).to match(/TESTED:\s+2/) expect(@output).to match(/PASSED:\s+2/) @@ -68,7 +68,7 @@ it "should be able to test multiple files matching a pattern" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:pattern[Temp] 2>&1` + @output = @c.ceedling_build_exec("test:pattern[Temp]") expect(@output).to match(/TESTED:\s+6/) expect(@output).to match(/PASSED:\s+6/) @@ -81,7 +81,7 @@ it "should be able to test all files matching in a path" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:path[adc] 2>&1` + @output = @c.ceedling_build_exec("test:path[adc]") expect(@output).to match(/TESTED:\s+15/) expect(@output).to match(/PASSED:\s+15/) @@ -95,7 +95,7 @@ it "should be able to test specific test cases in a file" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:path[adc] --test-case="RunShouldNot" 2>&1` + @output = @c.ceedling_build_exec('test:path[adc] --test-case="RunShouldNot"') expect(@output).to match(/TESTED:\s+2/) expect(@output).to match(/PASSED:\s+2/) @@ -109,7 +109,7 @@ it "should be able to test when using a custom Unity Helper file added by relative-path mixin" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:all --verbosity=obnoxious --mixin=mixin/add_unity_helper.yml 2>&1` + @output = @c.ceedling_build_exec("test:all --verbosity=obnoxious --mixin=mixin/add_unity_helper.yml") expect(@output).to match(/Merging command line mixin using mixin\/add_unity_helper\.yml/) expect(@output).to match(/TESTED:\s+51/) expect(@output).to match(/PASSED:\s+51/) @@ -120,7 +120,7 @@ it "should be able to test when using a custom Unity Helper file added by simple-named mixin" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:all --verbosity=obnoxious --mixin=add_unity_helper 2>&1` + @output = @c.ceedling_build_exec("test:all --verbosity=obnoxious --mixin=add_unity_helper") expect(@output).to match(/Merging command line mixin using mixin\/add_unity_helper\.yml/) expect(@output).to match(/TESTED:\s+51/) expect(@output).to match(/PASSED:\s+51/) @@ -132,7 +132,7 @@ @c.with_context do ENV['CEEDLING_MIXIN_1'] = 'mixin/add_unity_helper.yml' Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling test:all --verbosity=obnoxious 2>&1` + @output = @c.ceedling_build_exec("test:all --verbosity=obnoxious") expect(@output).to match(/Merging CEEDLING_MIXIN_1 mixin using mixin\/add_unity_helper\.yml/) expect(@output).to match(/TESTED:\s+51/) expect(@output).to match(/PASSED:\s+51/) @@ -143,7 +143,7 @@ it "should be able to report the assembly files found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling files:assembly 2>&1` + @output = @c.ceedling_build_exec("files:assembly") expect(@output).to match(/Assembly files: None/i) end @@ -153,7 +153,7 @@ it "should be able to report the header files found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling files:header 2>&1` + @output = @c.ceedling_build_exec("files:header") expect(@output).to match(/Header files:/i) expect(@output).to match(/src\/AdcModel\.h/i) @@ -171,7 +171,7 @@ it "should be able to report the source files found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling files:source 2>&1` + @output = @c.ceedling_build_exec("files:source") expect(@output).to match(/Source files:/i) expect(@output).to match(/src\/AdcModel\.c/i) @@ -189,7 +189,7 @@ it "should be able to report the support files found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling files:support 2>&1` + @output = @c.ceedling_build_exec("files:support") expect(@output).to match(/Support files:/i) expect(@output).to match(/test\/support\/UnityHelper\.c/i) @@ -200,7 +200,7 @@ it "should be able to report the test files found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling files:test 2>&1` + @output = @c.ceedling_build_exec("files:test") expect(@output).to match(/Test files:/i) expect(@output).to match(/test\/adc\/TestAdcModel\.c/i) @@ -215,7 +215,7 @@ it "should be able to report the header paths found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling paths:include 2>&1` + @output = @c.ceedling_build_exec("paths:include") expect(@output).to match(/Include paths:/i) expect(@output).to match(/src/i) @@ -226,7 +226,7 @@ it "should be able to report the source paths found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling paths:source 2>&1` + @output = @c.ceedling_build_exec("paths:source") expect(@output).to match(/Source paths:/i) expect(@output).to match(/src/i) @@ -237,7 +237,7 @@ it "should be able to report the support paths found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling paths:support 2>&1` + @output = @c.ceedling_build_exec("paths:support") expect(@output).to match(/Support paths:/i) expect(@output).to match(/test\/support/i) @@ -248,7 +248,7 @@ it "should be able to report the test paths found in paths" do @c.with_context do Dir.chdir "temp_sensor" do - @output = `bundle exec ruby -S ceedling paths:test 2>&1` + @output = @c.ceedling_build_exec("paths:test") expect(@output).to match(/Test paths:/i) expect(@output).to match(/test/i) diff --git a/spec/system/example_wondrous_forest_spec.rb b/spec/system/example_wondrous_forest_spec.rb new file mode 100644 index 000000000..da52fac01 --- /dev/null +++ b/spec/system/example_wondrous_forest_spec.rb @@ -0,0 +1,118 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_system_helper' + +ceedling_system_tests do + include CommonSystemTestCases + + before :all do + @c = SystemContext.new + @c.deploy_gem + end + + after :all do + @c.done! + end + + before { @proj_name = "wondrous_forest" } + after { @c.with_context { FileUtils.rm_rf @proj_name } } + + describe "Command: `ceedling examples`" do + before do + @c.with_context do + @output = @c.ceedling_appcmd_exec("examples") + end + end + + it "should list wondrous_forest as an available example" do + expect(@output).to match(/wondrous_forest/) + end + end + + describe "Command: `ceedling example wondrous_forest`" do + describe "wondrous_forest" do + before do + @c.with_context do + output = @c.ceedling_appcmd_exec("example wondrous_forest") + expect(output).to match(/created/) + end + end + + it "should be testable with all tests passing" do + @c.with_context do + Dir.chdir "wondrous_forest" do + @output = @c.ceedling_build_exec("test:all") + expect(@output).to match(/TESTED:\s+65/) + expect(@output).to match(/PASSED:\s+65/) + expect(@output).to match(/FAILED:\s+0/) + end + end + end + + it "should be able to test a single module" do + @c.with_context do + Dir.chdir "wondrous_forest" do + @output = @c.ceedling_build_exec("test:SoilMoisture") + expect(@output).to match(/TESTED:\s+7/) + expect(@output).to match(/PASSED:\s+7/) + expect(@output).to match(/SoilMoisture\.out/i) + end + end + end + + it "should be able to test multiple modules matching a pattern" do + @c.with_context do + Dir.chdir "wondrous_forest" do + @output = @c.ceedling_build_exec("test:pattern[Sensor]") + expect(@output).to match(/PASSED/) + expect(@output).to match(/TemperatureSensor\.out/i) + expect(@output).to match(/HumiditySensor\.out/i) + expect(@output).to match(/LightSensor\.out/i) + end + end + end + + it "should be able to report header files" do + @c.with_context do + Dir.chdir "wondrous_forest" do + @output = @c.ceedling_build_exec("files:header") + expect(@output).to match(/Header files:/i) + expect(@output).to match(/TemperatureSensor\.h/i) + expect(@output).to match(/AlertManager\.h/i) + expect(@output).to match(/EventQueue\.h/i) + expect(@output).to match(/ForestMonitor\.h/i) + end + end + end + + it "should be able to report source files" do + @c.with_context do + Dir.chdir "wondrous_forest" do + @output = @c.ceedling_build_exec("files:source") + expect(@output).to match(/Source files:/i) + expect(@output).to match(/TemperatureSensor\.c/i) + expect(@output).to match(/SensorHal\.c/i) + expect(@output).to match(/ForestMonitor\.c/i) + end + end + end + + it "should be able to report test files" do + @c.with_context do + Dir.chdir "wondrous_forest" do + @output = @c.ceedling_build_exec("files:test") + expect(@output).to match(/Test files:/i) + expect(@output).to match(/TestTemperatureSensor\.c/i) + expect(@output).to match(/TestAlertManager\.c/i) + expect(@output).to match(/TestForestMonitor\.c/i) + end + end + end + end + end +end diff --git a/spec/gcov/gcov_deployment_spec.rb b/spec/system/gcov_deployment_spec.rb similarity index 78% rename from spec/gcov/gcov_deployment_spec.rb rename to spec/system/gcov_deployment_spec.rb index 9f390c823..584c94545 100644 --- a/spec/gcov/gcov_deployment_spec.rb +++ b/spec/system/gcov_deployment_spec.rb @@ -6,12 +6,12 @@ # ========================================================================= require 'spec_system_helper' -require 'gcov/gcov_test_cases_spec' +require_relative 'support/gcov_common_test_cases' -describe "Ceedling" do +ceedling_system_tests do describe "Gcov" do - include CeedlingTestCases - include GcovTestCases + include CommonSystemTestCases + include GcovCommonTestCases before :all do determine_reports_to_test @c = SystemContext.new @@ -25,29 +25,29 @@ before { @proj_name = "fake_project" } after { @c.with_context { FileUtils.rm_rf @proj_name } } - describe "basic operations" do + describe "Basic operations" do before do @c.with_context do - `bundle exec ruby -S ceedling new --local #{@proj_name} 2>&1` + @c.ceedling_appcmd_exec("new --local #{@proj_name}") end end - it { can_test_projects_with_gcov_with_success } - it { can_test_projects_with_gcov_with_fail } + test_case :can_test_projects_with_gcov_with_success + test_case :can_test_projects_with_gcov_with_fail # TODO: Restore these tests when the :abort_on_uncovered option is restored in the Gcov plugin - # it { can_test_projects_with_gcov_with_fail_because_of_uncovered_files } - # it { can_test_projects_with_gcov_with_success_because_of_ignore_uncovered_list } - # it { can_test_projects_with_gcov_with_success_because_of_ignore_uncovered_list_with_globs } - it { can_test_projects_with_gcov_with_compile_error } - it { can_fetch_project_help_for_gcov } - it { can_create_html_reports } - it { can_create_html_reports_from_crashing_test_runner_with_enabled_debug_for_test_cases_not_causing_crash } - it { can_create_html_reports_from_crashing_test_runner_with_enabled_debug_with_zero_coverage } - it { can_create_html_reports_from_test_runner_with_enabled_debug_with_100_coverage_when_excluding_crashing_test_case } + # test_case :can_test_projects_with_gcov_with_fail_because_of_uncovered_files + # test_case :can_test_projects_with_gcov_with_success_because_of_ignore_uncovered_list + # test_case :can_test_projects_with_gcov_with_success_because_of_ignore_uncovered_list_with_globs + test_case :can_test_projects_with_gcov_with_compile_error + test_case :can_fetch_project_help_for_gcov + test_case :can_create_html_reports + test_case :can_create_html_reports_from_crashing_test_runner_with_enabled_debug_for_test_cases_not_causing_crash + test_case :can_create_html_reports_from_crashing_test_runner_with_enabled_debug_with_zero_coverage + test_case :can_create_html_reports_from_test_runner_with_enabled_debug_with_100_coverage_when_excluding_crashing_test_case end - describe "command: `ceedling example temp_sensor`" do + describe "Command: `ceedling example temp_sensor`" do describe "temp_sensor" do before do @c.with_context do diff --git a/spec/spec_system_helper.rb b/spec/system/support/common_test_cases.rb similarity index 68% rename from spec/spec_system_helper.rb rename to spec/system/support/common_test_cases.rb index d23c439fa..f3cf30b23 100644 --- a/spec/spec_system_helper.rb +++ b/spec/system/support/common_test_cases.rb @@ -5,179 +5,12 @@ # SPDX-License-Identifier: MIT # ========================================================================= -require 'fileutils' -require 'tmpdir' -require 'ceedling/yaml_wrapper' -require 'spec_helper' -require 'deep_merge' - -def test_asset_path(asset_file_name) - File.join(File.dirname(__FILE__), '..', 'assets', asset_file_name) -end - -def convert_slashes(path) - if RUBY_PLATFORM.downcase.match(/mingw|win32/) - path.gsub("/","\\") - else - path - end -end - -class GemDirLayout - attr_reader :gem_dir_base_name - - def initialize(install_dir) - @gem_dir_base_name = "gems" - @d = File.join install_dir, @gem_dir_base_name - FileUtils.mkdir_p @d - end - - def install_dir; convert_slashes(@d) end - def bin; File.join(@d, 'bin') end - def lib; File.join(@d, 'lib') end -end - -class SystemContext - class VerificationFailed < RuntimeError; end - class InvalidBackupEnv < RuntimeError; end - - attr_reader :dir, :gem - - def initialize - @dir = Dir.mktmpdir - @gem = GemDirLayout.new(@dir) - end - - def done! - FileUtils.rm_rf(@dir) - end - - def deploy_gem - git_repo = File.expand_path( File.join( File.dirname( __FILE__ ), '..') ) - bundler_gem_file_data = [ - %Q{source "http://rubygems.org/"}, - %Q{gem "rake"}, - %Q{gem "constructor"}, - %Q{gem "diy"}, - %Q{gem "thor"}, - %Q{gem "deep_merge"}, - %Q{gem "unicode-display_width"}, - %Q{gem "ceedling", :path => '#{git_repo}'} - ].join("\n") - - File.open(File.join(@dir, "Gemfile"), "w+") do |f| - f.write(bundler_gem_file_data) - end - - Dir.chdir @dir do - with_constrained_env do - `bundle config set --local path '#{@gem.install_dir}'` - `bundle install` - checks = ["bundle exec ruby -S ceedling 2>&1"] - checks.each do |c| - `#{c}` - #raise VerificationFailed.new(c) unless $?.success? - end - end - end - - end - - # Does a few things: - # - Configures the environment. - # - Runs the command from the temporary context directory. - # - Restores everything to where it was when finished. - def context_exec(cmd, *args) - with_context do - `#{args.unshift(cmd).join(" ")}` - end - end - - def with_context - Dir.chdir @dir do |current_dir| - with_constrained_env do - ENV['RUBYLIB'] = @gem.lib - ENV['RUBYPATH'] = @gem.bin - - ENV['LANG'] = 'en_US.UTF-8' - ENV['LANGUAGE'] = 'en_US.UTF-8' - ENV['LC_ALL'] = 'en_US.UTF-8' - - yield - end - end - end - - ############################################################ - # Functions for manipulating environment settings during tests: - def backup_env - @_env = ENV.to_hash - end - - def reduce_env(destroy_keys=[]) - ENV.keys.each {|k| ENV.delete(k) if destroy_keys.include?(k) } - end - - def constrain_env - destroy_keys = %w{BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT} - reduce_env(destroy_keys) - end - - def restore_env - if @_env - # delete environment variables we've added since we started - ENV.to_hash.each_pair {|k,v| ENV.delete(k) unless @_env.include?(k) } - - # restore environment variables we've modified since we started - @_env.each_pair {|k,v| ENV[k] = v} - else - raise InvalidBackupEnv.new - end - end - - def with_constrained_env - begin - backup_env - constrain_env - yield - ensure - restore_env - end - end - - ############################################################ - # Functions for manipulating project.yml files during tests: - def merge_project_yml_for_test(settings, show_final=false) - yaml_wrapper = YamlWrapper.new - project_hash = yaml_wrapper.load('project.yml') - project_hash.deep_merge!(settings) - puts "\n\n#{project_hash.to_yaml}\n\n" if show_final - yaml_wrapper.dump('project.yml', project_hash) - end - - def append_project_yml_for_test(new_args) - fake_prj_yml= "#{File.read('project.yml')}\n#{new_args}" - File.write('project.yml', fake_prj_yml, mode: 'w') - end - - def uncomment_project_yml_option_for_test(option) - fake_prj_yml= File.read('project.yml').gsub(/\##{option}/,option) - File.write('project.yml', fake_prj_yml, mode: 'w') - end - - def comment_project_yml_option_for_test(option) - fake_prj_yml= File.read('project.yml').gsub(/#{option}/,"##{option}") - File.write('project.yml', fake_prj_yml, mode: 'w') - end - ############################################################ -end - -module CeedlingTestCases +module CommonSystemTestCases def can_report_version_no_git_commit_sha @c.with_context do # Version without Git commit short SHA file in project - output = `bundle exec ruby -S ceedling version 2>&1` - expect($?.exitstatus).to match(0) + output = @c.ceedling_appcmd_exec("version") + expect(@c.last_exit_status).to eq(0) expect(output).to match(/Ceedling => \d\.\d\.\d\n/) end end @@ -190,8 +23,8 @@ def can_report_version_with_git_commit_sha end @c.with_context do - output = `bundle exec ruby -S ceedling version 2>&1` - expect($?.exitstatus).to match(0) + output = @c.ceedling_appcmd_exec("version") + expect(@c.last_exit_status).to eq(0) expect(output).to match(/Ceedling => \d\.\d\.\d----{-@\n/) end end @@ -218,8 +51,8 @@ def has_git_support def can_upgrade_projects @c.with_context do - output = `bundle exec ruby -S ceedling upgrade #{@proj_name} 2>&1` - expect($?.exitstatus).to match(0) + output = @c.ceedling_appcmd_exec("upgrade #{@proj_name}") + expect(@c.last_exit_status).to eq(0) expect(output).to match(/Upgraded/i) Dir.chdir @proj_name do expect(File.exist?("project.yml")).to eq true @@ -232,7 +65,7 @@ def can_upgrade_projects def can_upgrade_projects_even_if_test_support_folder_does_not_exist @c.with_context do - output = `bundle exec ruby -S ceedling upgrade #{@proj_name} 2>&1` + output = @c.ceedling_appcmd_exec("upgrade #{@proj_name}") FileUtils.rm_rf("#{@proj_name}/test/support") updated_prj_yml = [] @@ -241,21 +74,20 @@ def can_upgrade_projects_even_if_test_support_folder_does_not_exist end File.write("#{@proj_name}/project.yml", updated_prj_yml.join("\n"), mode: 'w') - expect($?.exitstatus).to match(0) + expect(@c.last_exit_status).to eq(0) expect(output).to match(/Upgraded/i) Dir.chdir @proj_name do expect(File.exist?("project.yml")).to eq true expect(File.exist?("src")).to eq true expect(File.exist?("test")).to eq true - all_docs = Dir["vendor/ceedling/docs/*.pdf"].length + Dir["vendor/ceedling/docs/*.md"].length end end end def cannot_upgrade_non_existing_project @c.with_context do - output = `bundle exec ruby -S ceedling upgrade #{@proj_name} 2>&1` - expect($?.exitstatus).to match(1) + output = @c.ceedling_appcmd_exec("upgrade #{@proj_name}") + expect(@c.last_exit_status).to eq(1) expect(output).to match(/Could not find an existing project/i) end end @@ -279,8 +111,8 @@ def does_not_contain_a_vendor_directory def contains_documentation @c.with_context do Dir.chdir @proj_name do - all_docs = Dir["docs/*.md"].length + Dir["vendor/ceedling/docs/*.md"].length - expect(all_docs).to be >= 4 + all_docs = Dir["docs/*"] + expect(all_docs).to contain_exactly('docs/ceedling', 'docs/unity', 'docs/cmock', 'docs/c_exception', 'docs/license.txt') end end end @@ -288,8 +120,7 @@ def contains_documentation def does_not_contain_documentation @c.with_context do Dir.chdir @proj_name do - expect(File.exist?("vendor/ceedling/docs")).to eq false - expect(Dir["vendor/ceedling/**/*.pdf"].length).to eq 0 + expect(Dir.exist?("docs/")).to eq false end end end @@ -301,8 +132,8 @@ def can_test_projects_with_success FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -318,8 +149,8 @@ def can_test_projects_with_success_test_alias FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec("test") + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -335,8 +166,8 @@ def can_test_projects_with_success_default FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -352,14 +183,14 @@ def can_test_projects_with_named_verbosity FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling --verbosity=obnoxious 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec("--verbosity=obnoxious") + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) expect(output).to match(/IGNORED:\s+\d/) - expect(output).to match (/:post_test_fixture_execute/) + expect(output).to match(/:post_test_fixture_execute/) end end end @@ -371,14 +202,14 @@ def can_test_projects_with_numerical_verbosity FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling -v=4 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec("-v=4") + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) expect(output).to match(/IGNORED:\s+\d/) - expect(output).to match (/:post_test_fixture_execute/) + expect(output).to match(/:post_test_fixture_execute/) end end end @@ -392,8 +223,8 @@ def can_test_projects_with_unity_exec_time settings = { :unity => { :defines => [ "UNITY_INCLUDE_EXEC_TIME" ] } } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -413,8 +244,8 @@ def can_test_projects_with_test_and_vendor_defines_with_success } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -434,8 +265,8 @@ def can_test_projects_with_test_name_replaced_defines_with_success } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either passes or is ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either passes or is ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -454,8 +285,8 @@ def can_test_projects_unity_parameterized_test_cases_with_success } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -469,13 +300,13 @@ def can_test_projects_unity_parameterized_test_cases_with_preprocessor_with_succ @c.with_context do Dir.chdir @proj_name do FileUtils.cp test_asset_path("test_example_with_parameterized_tests.c"), 'test/' - settings = { :project => { :use_test_preprocessor => :all, :use_deep_preprocessor => :mocks }, + settings = { :project => { :use_test_preprocessor => :all }, :unity => { :use_param_tests => true } } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -497,8 +328,8 @@ def can_test_projects_with_preprocessing_for_test_files_symbols_undefined } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling test:adc_hardwareA 2>&1` - expect($?.exitstatus).to match(1) # Intentional test failure in successful build + output = @c.ceedling_build_exec("test:adc_hardwareA") + expect(@c.last_exit_status).to eq(1) # Intentional test failure in successful build expect(output).to match(/TESTED:\s+2/) expect(output).to match(/PASSED:\s+0/) expect(output).to match(/FAILED:\s+2/) @@ -519,8 +350,8 @@ def can_test_projects_with_preprocessing_for_test_files_symbols_defined } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling test:adc_hardwareA 2>&1` - expect($?.exitstatus).to match(0) # Successful build and tests + output = @c.ceedling_build_exec("test:adc_hardwareA") + expect(@c.last_exit_status).to eq(0) # Successful build and tests expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+1/) expect(output).to match(/FAILED:\s+0/) @@ -541,8 +372,8 @@ def can_test_projects_with_preprocessing_for_mocks_success } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling test:adc_hardwareB 2>&1` - expect($?.exitstatus).to match(0) # Successful build and tests + output = @c.ceedling_build_exec("test:adc_hardwareB") + expect(@c.last_exit_status).to eq(0) # Successful build and tests expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+1/) expect(output).to match(/FAILED:\s+0/) @@ -562,8 +393,8 @@ def can_test_projects_with_preprocessing_for_mocks_intentional_build_failure } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling test:adc_hardwareB 2>&1` - expect($?.exitstatus).to match(1) # Failing build because of missing mock + output = @c.ceedling_build_exec("test:adc_hardwareB") + expect(@c.last_exit_status).to eq(1) # Failing build because of missing mock expect(output).to match(/(undeclared|undefined|implicit).+Adc_Reset/) end end @@ -582,8 +413,8 @@ def can_test_projects_with_preprocessing_all } @c.merge_project_yml_for_test(settings) - output = `bundle exec ruby -S ceedling test:adc_hardwareC 2>&1` - expect($?.exitstatus).to match(0) # Successful build and tests + output = @c.ceedling_build_exec("test:adc_hardwareC") + expect(@c.last_exit_status).to eq(0) # Successful build and tests expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+1/) expect(output).to match(/FAILED:\s+0/) @@ -598,8 +429,8 @@ def can_test_projects_with_fail FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file.c"), 'test/' - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(1) # Since a test fails, we return error here + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(1) # Since a test fails, we return error here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -615,8 +446,8 @@ def can_test_projects_with_fail_alias FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file.c"), 'test/' - output = `bundle exec ruby -S ceedling test 2>&1` - expect($?.exitstatus).to match(1) # Since a test fails, we return error here + output = @c.ceedling_build_exec("test") + expect(@c.last_exit_status).to eq(1) # Since a test fails, we return error here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -632,8 +463,8 @@ def can_test_projects_with_fail_default FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file.c"), 'test/' - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(1) # Since a test fails, we return error here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(1) # Since a test fails, we return error here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -649,8 +480,8 @@ def can_test_projects_with_compile_error FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_boom.c"), 'test/' - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(1) # Since a test explodes, we return error here + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(1) # Since a test explodes, we return error here expect(output).to match(/(?:ERROR: Ceedling Failed)|(?:Ceedling could not complete operations because of errors)/) end end @@ -665,8 +496,8 @@ def can_test_projects_with_both_mock_and_real_header FileUtils.cp test_asset_path("example_file_call.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_with_mock.c"), 'test/' - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either passed or was ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either passed or was ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -684,8 +515,8 @@ def uses_report_tests_raw_output_log_plugin @c.uncomment_project_yml_option_for_test('- report_tests_raw_output_log') - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) @@ -698,21 +529,21 @@ def uses_report_tests_raw_output_log_plugin def can_fetch_non_project_help @c.with_context do - #notice we don't change directory into the project - output = `bundle exec ruby -S ceedling help` - expect($?.exitstatus).to match(0) - expect(output).to match(/ceedling example/i) - expect(output).to match(/ceedling new/i) - expect(output).to match(/ceedling upgrade/i) - expect(output).to match(/ceedling version/i) + # notice we don't change directory into the project + output = @c.ceedling_appcmd_exec("help") + expect(@c.last_exit_status).to eq(0) + expect(output).to match(/ceedling example/i) + expect(output).to match(/ceedling new/i) + expect(output).to match(/ceedling upgrade/i) + expect(output).to match(/ceedling version/i) end end def can_fetch_project_help @c.with_context do Dir.chdir @proj_name do - output = `bundle exec ruby -S ceedling help` - expect($?.exitstatus).to match(0) + output = @c.ceedling_appcmd_exec("help") + expect(@c.last_exit_status).to eq(0) expect(output).to match(/ceedling clean/i) expect(output).to match(/ceedling clobber/i) expect(output).to match(/ceedling module:create/i) @@ -732,9 +563,9 @@ def can_run_single_test_with_full_test_case_name_from_test_file_with_success FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:test_example_file_success --test_case=test_add_numbers_adds_numbers 2>&1` + output = @c.ceedling_build_exec("test:test_example_file_success --test_case=test_add_numbers_adds_numbers") - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+1/) expect(output).to match(/FAILED:\s+0/) @@ -750,9 +581,9 @@ def can_run_single_test_with_partial_test_case_name_from_test_file_with_success FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:test_example_file_success --test_case=_adds_numbers 2>&1` + output = @c.ceedling_build_exec("test:test_example_file_success --test_case=_adds_numbers") - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+1/) expect(output).to match(/FAILED:\s+0/) @@ -768,9 +599,9 @@ def none_of_test_is_executed_if_test_case_name_passed_does_not_fit_defined_in_te FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:test_example_file_success --test_case=zumzum 2>&1` + output = @c.ceedling_build_exec("test:test_example_file_success --test_case=zumzum") - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/No tests executed./) end end @@ -783,33 +614,14 @@ def none_of_test_is_executed_if_test_case_name_and_exclude_test_case_name_is_the FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:test_example_file_success --test_case=_adds_numbers --exclude_test_case=_adds_numbers 2>&1` + output = @c.ceedling_build_exec("test:test_example_file_success --test_case=_adds_numbers --exclude_test_case=_adds_numbers") - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/No tests executed./) end end end - def confirm_if_notification_for_cmdline_args_not_enabled_is_disabled - @c.with_context do - Dir.chdir @proj_name do - FileUtils.cp test_asset_path("example_file.h"), 'src/' - FileUtils.cp test_asset_path("example_file.c"), 'src/' - FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - - output = `bundle exec ruby -S ceedling test:test_example_file_success 2>&1` - - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here - expect(output).to match(/TESTED:\s+2/) - expect(output).to match(/PASSED:\s+1/) - expect(output).to match(/FAILED:\s+0/) - expect(output).to match(/IGNORED:\s+1/) - expect(output).not_to match(/:cmdline_args/) - end - end - end - def exclude_test_case_name_filter_works_and_only_one_test_case_is_executed @c.with_context do Dir.chdir @proj_name do @@ -817,9 +629,9 @@ def exclude_test_case_name_filter_works_and_only_one_test_case_is_executed FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:all --exclude_test_case=test_add_numbers_adds_numbers 2>&1` + output = @c.ceedling_build_exec("test:all --exclude_test_case=test_add_numbers_adds_numbers") - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+0/) expect(output).to match(/FAILED:\s+0/) @@ -835,9 +647,9 @@ def run_one_testcase_from_one_test_file_when_test_case_name_is_passed FileUtils.cp test_asset_path("example_file.c"), 'src/' FileUtils.cp test_asset_path("test_example_file_success.c"), 'test/' - output = `bundle exec ruby -S ceedling test:test_example_file_success --test_case=_adds_numbers 2>&1` + output = @c.ceedling_build_exec("test:test_example_file_success --test_case=_adds_numbers") - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+1/) expect(output).to match(/PASSED:\s+1/) expect(output).to match(/FAILED:\s+0/) @@ -856,8 +668,8 @@ def test_run_of_projects_fail_because_of_crash_without_report @c.merge_project_yml_for_test({:project => { :use_backtrace => :none }}) - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(1) # Test should fail because of crash + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(1) # Test should fail because of crash expect(output).to match(/Test Executable Crashed/i) expect(output).to match(/Unit test failures./) expect(!File.exist?('./build/test/results/test_add.fail')) @@ -874,8 +686,8 @@ def test_run_of_projects_fail_because_of_crash_with_report @c.merge_project_yml_for_test({:project => { :use_backtrace => :none }}) - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(1) # Test should fail because of crash + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(1) # Test should fail because of crash expect(output).to match(/Test Executable Crashed/i) expect(output).to match(/Unit test failures./) expect(File.exist?('./build/test/results/test_example_file_crash.fail')) @@ -894,8 +706,8 @@ def execute_all_test_cases_from_crashing_test_runner_and_return_test_report_with @c.merge_project_yml_for_test({:project => { :use_backtrace => :gdb }}) - output = `bundle exec ruby -S ceedling test:all 2>&1` - expect($?.exitstatus).to match(1) # Test should fail because of crash + output = @c.ceedling_build_exec("test:all") + expect(@c.last_exit_status).to eq(1) # Test should fail because of crash expect(output).to match(/Test Case Crashed/i) expect(output).to match(/Unit test failures./) expect(File.exist?('./build/test/results/test_example_file_crash.fail')) @@ -918,8 +730,8 @@ def execute_and_collect_debug_logs_from_crashing_test_case_defined_by_test_case_ @c.merge_project_yml_for_test({:project => { :use_backtrace => :gdb }}) - output = `bundle exec ruby -S ceedling test:all --test_case=test_add_numbers_will_fail 2>&1` - expect($?.exitstatus).to match(1) # Test should fail because of crash + output = @c.ceedling_build_exec("test:all --test_case=test_add_numbers_will_fail") + expect(@c.last_exit_status).to eq(1) # Test should fail because of crash expect(output).to match(/Test Case Crashed/i) expect(output).to match(/Unit test failures./) expect(File.exist?('./build/test/results/test_example_file_crash.fail')) @@ -942,8 +754,8 @@ def execute_and_collect_debug_logs_from_crashing_test_case_defined_by_exclude_te @c.merge_project_yml_for_test({:project => { :use_backtrace => :gdb }}) - output = `bundle exec ruby -S ceedling test:all --exclude_test_case=add_numbers_adds_numbers 2>&1` - expect($?.exitstatus).to match(1) # Test should fail because of crash + output = @c.ceedling_build_exec("test:all --exclude_test_case=add_numbers_adds_numbers") + expect(@c.last_exit_status).to eq(1) # Test should fail because of crash expect(output).to match(/Test Case Crashed/i) expect(output).to match(/Unit test failures./) expect(File.exist?('./build/test/results/test_example_file_crash.fail')) @@ -957,6 +769,22 @@ def execute_and_collect_debug_logs_from_crashing_test_case_defined_by_exclude_te end end + def can_test_projects_with_test_file_directly_including_source_file + @c.with_context do + Dir.chdir @proj_name do + FileUtils.cp test_asset_path("example_file_with_statics.c"), 'src/' + FileUtils.cp test_asset_path("test_example_file_source_include.c"), 'test/' + + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) + expect(output).to match(/TESTED:\s+\d/) + expect(output).to match(/PASSED:\s+\d/) + expect(output).to match(/FAILED:\s+\d/) + expect(output).to match(/IGNORED:\s+\d/) + end + end + end + def can_test_projects_with_success_when_space_appears_between_hash_and_include # test case cover issue described in https://github.com/ThrowTheSwitch/Ceedling/issues/588 @c.with_context do @@ -982,8 +810,8 @@ def can_test_projects_with_success_when_space_appears_between_hash_and_include File.write(File.join('test','test_example_file_success.c'), updated_test_file.join("\n"), mode: 'w') - output = `bundle exec ruby -S ceedling 2>&1` - expect($?.exitstatus).to match(0) # Since a test either pass or are ignored, we return success here + output = @c.ceedling_build_exec + expect(@c.last_exit_status).to eq(0) # Since a test either pass or are ignored, we return success here expect(output).to match(/TESTED:\s+\d/) expect(output).to match(/PASSED:\s+\d/) expect(output).to match(/FAILED:\s+\d/) diff --git a/spec/gcov/gcov_test_cases_spec.rb b/spec/system/support/gcov_common_test_cases.rb similarity index 82% rename from spec/gcov/gcov_test_cases_spec.rb rename to spec/system/support/gcov_common_test_cases.rb index 36ee8c6c7..5993e3ca4 100644 --- a/spec/gcov/gcov_test_cases_spec.rb +++ b/spec/system/support/gcov_common_test_cases.rb @@ -5,82 +5,10 @@ # SPDX-License-Identifier: MIT # ========================================================================= -require 'fileutils' -require 'tmpdir' -require 'yaml' -require 'spec_system_helper' -require 'pp' +require_relative 'gcov_helpers' - -module GcovTestCases - - def determine_reports_to_test - @gcov_reports = [] - - begin - `gcovr --version 2>&1` - @gcov_reports << :gcovr if $?.exitstatus == 0 - rescue - puts "No GCOVR exec to test against" - end - - begin - `reportgenerator --version 2>&1` - @gcov_reports << :reportgenerator if $?.exitstatus == 0 - rescue - puts "No ReportGenerator exec to test against" - end - end - - def prep_project_yml_for_coverage - FileUtils.cp test_asset_path("project.yml"), "project.yml" - @c.uncomment_project_yml_option_for_test("- gcov") - @c.comment_project_yml_option_for_test("- gcovr") unless @gcov_reports.include? :gcovr - @c.uncomment_project_yml_option_for_test("- ReportGenerator") if @gcov_reports.include? :reportgenerator - end - - def _add_gcov_section_in_project(project_file_path, name, values) - project_file_contents = File.readlines(project_file_path) - name_index = project_file_contents.index(":gcov:\n") - - if name_index.nil? - # Something wrong with project.yml file, no project section? - return - end - - project_file_contents.insert(name_index + 1, " :#{name}:\n") - values.each.with_index(2) do |value, index| - project_file_contents.insert(name_index + index, " - #{value}\n") - end - - File.open(project_file_path, "w+") do |f| - f.puts(project_file_contents) - end - end - - def _add_gcov_option_in_project(project_file_path, option, value) - project_file_contents = File.readlines(project_file_path) - option_index = project_file_contents.index(":gcov:\n") - - if option_index.nil? - # Something wrong with project.yml file, no project section? - return - end - - project_file_contents.insert(option_index + 1, " :#{option}: #{value}\n") - - File.open(project_file_path, "w+") do |f| - f.puts(project_file_contents) - end - end - - def add_gcov_section(name, values) - _add_gcov_section_in_project("project.yml", name, values) - end - - def add_gcov_option(option, value) - _add_gcov_option_in_project("project.yml", option, value) - end +module GcovCommonTestCases + include GcovHelpers def can_test_projects_with_gcov_with_success @c.with_context do @@ -224,11 +152,11 @@ def can_create_html_reports output = `bundle exec ruby -S ceedling gcov:all 2>&1` if @gcov_reports.include? :gcovr - expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr'\.\.\./) + expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/gcovr/GcovCoverageResults.html')).to eq true end if @gcov_reports.include? :modulegenerator - expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator'\.\.\./) + expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/ReportGenerator/summary.htm')).to eq true end end @@ -258,11 +186,11 @@ def can_create_html_reports_from_crashing_test_runner_with_enabled_debug_for_tes expect(output).to match(/IGNORED:\s+0/) expect(output).to match(/example_file.c \| Lines executed:5?0.00% of 4/) if @gcov_reports.include? :gcovr - expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr'\.\.\./) + expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/gcovr/GcovCoverageResults.html')).to eq true end if @gcov_reports.include? :modulegenerator - expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator'\.\.\./) + expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/ReportGenerator/summary.htm')).to eq true end end @@ -293,11 +221,11 @@ def can_create_html_reports_from_crashing_test_runner_with_enabled_debug_with_ze expect(output).to match(/example_file.c \| Lines executed:0.00% of 4/) if @gcov_reports.include? :gcovr - expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr'\.\.\./) + expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/gcovr/GcovCoverageResults.html')).to eq true end if @gcov_reports.include? :modulegenerator - expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator'\.\.\./) + expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/ReportGenerator/summary.htm')).to eq true end end @@ -316,7 +244,7 @@ def can_create_html_reports_from_test_runner_with_enabled_debug_with_100_coverag "{\n" \ " TEST_ASSERT_EQUAL_INT(0, difference_between_numbers(1,1));\n" \ "}\n" - + updated_test_file = File.read('test/test_example_file_crash.c').split("\n") updated_test_file.insert(updated_test_file.length(), add_test_case) File.write('test/test_example_file_crash.c', updated_test_file.join("\n"), mode: 'w') @@ -331,11 +259,11 @@ def can_create_html_reports_from_test_runner_with_enabled_debug_with_100_coverag expect(output).to match(/example_file.c \| Lines executed:100.00% of 4/) if @gcov_reports.include? :gcovr - expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr'\.\.\./) + expect(output).to match(/Generating HTML coverage report in 'build\/artifacts\/gcov\/gcovr\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/gcovr/GcovCoverageResults.html')).to eq true end if @gcov_reports.include? :modulegenerator - expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator'\.\.\./) + expect(output).to match(/Generating HtmlBasic coverage report in 'build\/artifacts\/gcov\/ReportGenerator\/'\.\.\./) expect(File.exist?('build/artifacts/gcov/ReportGenerator/summary.htm')).to eq true end end diff --git a/spec/system/support/gcov_helpers.rb b/spec/system/support/gcov_helpers.rb new file mode 100644 index 000000000..980cb0ff1 --- /dev/null +++ b/spec/system/support/gcov_helpers.rb @@ -0,0 +1,81 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'fileutils' +require 'yaml' + +module GcovHelpers + + def determine_reports_to_test + @gcov_reports = [] + + begin + `gcovr --version 2>&1` + @gcov_reports << :gcovr if $?.exitstatus == 0 + rescue + puts "No GCOVR exec to test against" + end + + begin + `reportgenerator --version 2>&1` + @gcov_reports << :reportgenerator if $?.exitstatus == 0 + rescue + puts "No ReportGenerator exec to test against" + end + end + + def prep_project_yml_for_coverage + FileUtils.cp test_asset_path("project.yml"), "project.yml" + @c.uncomment_project_yml_option_for_test("- gcov") + @c.comment_project_yml_option_for_test("- gcovr") unless @gcov_reports.include? :gcovr + @c.uncomment_project_yml_option_for_test("- ReportGenerator") if @gcov_reports.include? :reportgenerator + end + + def _add_gcov_section_in_project(project_file_path, name, values) + project_file_contents = File.readlines(project_file_path) + name_index = project_file_contents.index(":gcov:\n") + + if name_index.nil? + # Something wrong with project.yml file, no project section? + return + end + + project_file_contents.insert(name_index + 1, " :#{name}:\n") + values.each.with_index(2) do |value, index| + project_file_contents.insert(name_index + index, " - #{value}\n") + end + + File.open(project_file_path, "w+") do |f| + f.puts(project_file_contents) + end + end + + def _add_gcov_option_in_project(project_file_path, option, value) + project_file_contents = File.readlines(project_file_path) + option_index = project_file_contents.index(":gcov:\n") + + if option_index.nil? + # Something wrong with project.yml file, no project section? + return + end + + project_file_contents.insert(option_index + 1, " :#{option}: #{value}\n") + + File.open(project_file_path, "w+") do |f| + f.puts(project_file_contents) + end + end + + def add_gcov_section(name, values) + _add_gcov_section_in_project("project.yml", name, values) + end + + def add_gcov_option(option, value) + _add_gcov_option_in_project("project.yml", option, value) + end + +end diff --git a/spec/system/upgrade_as_vendor_spec.rb b/spec/system/upgrade_as_vendor_spec.rb index 4ea5473e5..ac3ca9b97 100644 --- a/spec/system/upgrade_as_vendor_spec.rb +++ b/spec/system/upgrade_as_vendor_spec.rb @@ -7,8 +7,8 @@ require 'spec_system_helper' -describe "Ceedling" do - include CeedlingTestCases +ceedling_system_tests do + include CommonSystemTestCases before :all do @c = SystemContext.new @@ -22,47 +22,83 @@ before { @proj_name = "fake_project" } after { @c.with_context { FileUtils.rm_rf @proj_name } } - describe "upgrade a project's `vendor` directory" do + describe "Upgrade a project's `vendor` directory" do before do @c.with_context do - `bundle exec ruby -S ceedling new --local #{@proj_name} 2>&1` + @c.ceedling_appcmd_exec("new --local #{@proj_name}") end end - it { can_create_projects } - it { contains_a_vendor_directory } - it { does_not_contain_documentation } - it { can_fetch_non_project_help } - it { can_fetch_project_help } - it { can_test_projects_with_success } - it { can_test_projects_with_success_test_alias } - it { can_test_projects_with_success_default } - it { can_test_projects_with_unity_exec_time } - it { can_test_projects_with_test_and_vendor_defines_with_success } - it { can_test_projects_with_fail } - it { can_test_projects_with_fail_alias } - it { can_test_projects_with_fail_default } - it { can_test_projects_with_compile_error } - - it { can_upgrade_projects } - it { can_upgrade_projects_even_if_test_support_folder_does_not_exist } - it { contains_a_vendor_directory } - it { does_not_contain_documentation } - it { can_fetch_non_project_help } - it { can_fetch_project_help } - it { can_test_projects_with_success } - it { can_test_projects_with_success_test_alias } - it { can_test_projects_with_success_default } - it { can_test_projects_with_unity_exec_time } - it { can_test_projects_with_test_and_vendor_defines_with_success } - it { can_test_projects_with_fail } - it { can_test_projects_with_fail_alias } - it { can_test_projects_with_fail_default } - it { can_test_projects_with_compile_error } - end + describe "Initial project state" do + describe "Project creation" do + test_case :can_create_projects + test_case :contains_a_vendor_directory + test_case :does_not_contain_documentation + end + + describe "Help system" do + test_case :can_fetch_non_project_help + test_case :can_fetch_project_help + end + + describe "Basic test execution" do + test_case :can_test_projects_with_success + test_case :can_test_projects_with_success_test_alias + test_case :can_test_projects_with_success_default + test_case :can_test_projects_with_fail + test_case :can_test_projects_with_fail_alias + test_case :can_test_projects_with_fail_default + test_case :can_test_projects_with_compile_error + test_case :can_test_projects_with_test_file_directly_including_source_file + end + + describe "Unity features" do + test_case :can_test_projects_with_unity_exec_time + end + + describe "Defines and configuration" do + test_case :can_test_projects_with_test_and_vendor_defines_with_success + end + end + + describe "After upgrade" do + describe "Upgrade operations" do + test_case :can_upgrade_projects + test_case :can_upgrade_projects_even_if_test_support_folder_does_not_exist + end + + describe "Project structure" do + test_case :contains_a_vendor_directory + test_case :does_not_contain_documentation + end + + describe "Help system" do + test_case :can_fetch_non_project_help + test_case :can_fetch_project_help + end + + describe "Basic test execution" do + test_case :can_test_projects_with_success + test_case :can_test_projects_with_success_test_alias + test_case :can_test_projects_with_success_default + test_case :can_test_projects_with_fail + test_case :can_test_projects_with_fail_alias + test_case :can_test_projects_with_fail_default + test_case :can_test_projects_with_compile_error + test_case :can_test_projects_with_test_file_directly_including_source_file + end + + describe "Unity features" do + test_case :can_test_projects_with_unity_exec_time + end - describe "Cannot upgrade a non existing project" do - it { cannot_upgrade_non_existing_project } + describe "Defines and configuration" do + test_case :can_test_projects_with_test_and_vendor_defines_with_success + end + end end + describe "Upgrade error handling" do + test_case :cannot_upgrade_non_existing_project + end end diff --git a/spec/bin/merginator_spec.rb b/spec/units/bin/merginator_spec.rb similarity index 100% rename from spec/bin/merginator_spec.rb rename to spec/units/bin/merginator_spec.rb diff --git a/spec/bin/mixin_standardizer_spec.rb b/spec/units/bin/mixin_standardizer_spec.rb similarity index 100% rename from spec/bin/mixin_standardizer_spec.rb rename to spec/units/bin/mixin_standardizer_spec.rb diff --git a/spec/units/c_extractor/c_extractor_code_text_spec.rb b/spec/units/c_extractor/c_extractor_code_text_spec.rb new file mode 100644 index 000000000..01e378a0b --- /dev/null +++ b/spec/units/c_extractor/c_extractor_code_text_spec.rb @@ -0,0 +1,2091 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'stringio' + +describe CExtractorCodeText do + + ### + ### skip_c_string() + ### + describe "#skip_c_string" do + # Helper to access private method + let(:skip_c_string) do + ->(content, quote) do + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new + bytes_skipped = code_text.skip_c_string( scanner, quote ) + return [bytes_skipped, scanner.pos, scanner.rest] + end + end + + context "double-quoted string handling" do + it "skips simple double-quoted string" do + content = '"hello"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(7) + expect(pos).to eq(7) + expect(rest).to eq("code") + end + + it "skips empty double-quoted string" do + content = '""code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(2) + expect(pos).to eq(2) + expect(rest).to eq("code") + end + + it "skips string with spaces" do + content = '"hello world"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(15) + expect(pos).to eq(15) + expect(rest).to eq("code") + end + + it "skips string with special characters" do + content = '"hello!@#$%^&*()"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(17) + expect(pos).to eq(17) + expect(rest).to eq("code") + end + + it "skips string with newlines" do + content = "\"hello\nworld\"code" + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(13) + expect(pos).to eq(13) + expect(rest).to eq("code") + end + + it "skips string with tabs" do + content = "\"hello\tworld\"code" + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(13) + expect(pos).to eq(13) + expect(rest).to eq("code") + end + end + + context "single-quoted character handling" do + it "skips simple single-quoted character" do + content = "'a'code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "skips single-quoted digit" do + content = "'5'code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "skips single-quoted special character" do + content = "'@'code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "skips single-quoted space" do + content = "' 'code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + end + + context "escape sequence handling" do + it "skips string with escaped double quote" do + content = '"hello \\"world\\""code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(17) + expect(pos).to eq(17) + expect(rest).to eq("code") + end + + it "skips string with escaped backslash" do + content = '"path\\\\to\\\\file"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(16) + expect(pos).to eq(16) + expect(rest).to eq("code") + end + + it "skips string with escaped newline" do + content = '"hello\\nworld"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips string with escaped tab" do + content = '"hello\\tworld"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips string with escaped carriage return" do + content = '"hello\\rworld"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips string with multiple escape sequences" do + content = '"\\n\\t\\r\\\\\\"\\\'"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips character with escaped single quote" do + content = "'\\\''code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "skips character with escaped backslash" do + content = "'\\\\'code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "skips character with escaped newline" do + content = "'\\n'code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "skips string with octal escape sequence" do + content = '"\\101\\102\\103"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips string with hexadecimal escape sequence" do + content = '"\\x41\\x42\\x43"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips string with unicode escape sequence" do + content = '"\\u0041\\u0042"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + end + + context "unterminated string handling" do + it "handles unterminated double-quoted string" do + content = '"hello world' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(12) + expect(pos).to eq(12) + expect(rest).to eq("") + end + + it "handles unterminated single-quoted character" do + content = "'a" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(2) + expect(pos).to eq(2) + expect(rest).to eq("") + end + + it "handles unterminated string with escape at end" do + content = '"hello\\' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(7) + expect(pos).to eq(7) + expect(rest).to eq("") + end + + it "handles unterminated string with newline" do + content = "\"hello\nworld" + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(12) + expect(pos).to eq(12) + expect(rest).to eq("") + end + end + + context "edge cases" do + it "handles string with only opening quote" do + content = '"' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(1) + expect(pos).to eq(1) + expect(rest).to eq("") + end + + it "handles character with only opening quote" do + content = "'" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(1) + expect(pos).to eq(1) + expect(rest).to eq("") + end + + it "handles string with consecutive escaped backslashes" do + content = '"\\\\\\\\"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(6) + expect(pos).to eq(6) + expect(rest).to eq("code") + end + + it "handles string ending with backslash before quote" do + content = '"test\\\\"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(8) + expect(pos).to eq(8) + expect(rest).to eq("code") + end + + it "does not confuse single quote inside double-quoted string" do + content = '"don\'t"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(7) + expect(pos).to eq(7) + expect(rest).to eq("code") + end + + it "does not confuse double quote inside single-quoted character" do + content = '\'"\'code' + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "handles very long string" do + long_string = '"' + ('x' * 1000) + '"code' + bytes_skipped, pos, rest = skip_c_string.call(long_string, '"') + + expect(bytes_skipped).to eq(1002) + expect(pos).to eq(1002) + expect(rest).to eq("code") + end + + it "handles string with null character" do + content = "\"hello\\0world\"code" + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + end + + context "real-world C code patterns" do + it "skips string literal in printf statement" do + content = '"Hello, World!")' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(15) + expect(pos).to eq(15) + expect(rest).to eq(")") + end + + it "skips format string with specifiers" do + content = '"%d %s %f", x, y, z)' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(10) + expect(pos).to eq(10) + expect(rest).to eq(', x, y, z)') + end + + it "skips file path string" do + content = '"C:\\\\Users\\\\file.txt"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(21) + expect(pos).to eq(21) + expect(rest).to eq("code") + end + + it "skips character constant in switch case" do + content = "'a': return 1;" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq(": return 1;") + end + + it "skips string with JSON-like content" do + content = '"{\"key\": \"value\"}"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(22) + expect(pos).to eq(22) + expect(rest).to eq("code") + end + + it "skips multi-line string literal (C11 style)" do + content = "\"line1\\\nline2\\\nline3\"code" + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(21) + expect(pos).to eq(21) + expect(rest).to eq("code") + end + + it "skips string with SQL query" do + content = '"SELECT * FROM users WHERE id = 1"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(34) + expect(pos).to eq(34) + expect(rest).to eq("code") + end + end + + context "consecutive strings" do + it "skips first string when followed by another string" do + content = '"first" "second"' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(7) + expect(pos).to eq(7) + expect(rest).to eq(' "second"') + end + + it "skips string followed by character literal" do + content = '"string" \'c\'' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(8) + expect(pos).to eq(8) + expect(rest).to eq(" 'c'") + end + + it "skips character literal followed by string" do + content = "'c' \"string\"" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq(' "string"') + end + end + + context "strings in complex expressions" do + it "skips string in array initialization" do + content = '"element1", "element2"' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(10) + expect(pos).to eq(10) + expect(rest).to eq(', "element2"') + end + + it "skips string in function call" do + content = '"argument", 42)' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(10) + expect(pos).to eq(10) + expect(rest).to eq(", 42)") + end + + it "skips string in ternary operator" do + content = '"true" : "false"' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(6) + expect(pos).to eq(6) + expect(rest).to eq(' : "false"') + end + end + + context "performance and boundary conditions" do + it "handles string at end of input" do + content = '"last"' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(6) + expect(pos).to eq(6) + expect(rest).to eq("") + end + + it "handles character at end of input" do + content = "'x'" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("") + end + + it "handles alternating escaped and regular characters" do + content = '"a\\nb\\tc\\rd\\\\e"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(15) + expect(pos).to eq(15) + expect(rest).to eq("code") + end + end + + context "invalid but handled gracefully" do + it "handles empty character literal" do + content = "''code" + bytes_skipped, pos, rest = skip_c_string.call(content, "'") + + expect(bytes_skipped).to eq(2) + expect(pos).to eq(2) + expect(rest).to eq("code") + end + + it "handles string with unrecognized escape sequence" do + # \q is not a valid escape, but should still consume it + content = '"\\q"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "handles incomplete octal escape at end of string" do + content = '"\\1"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "handles incomplete hex escape at end of string" do + content = '"\\x"code' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "handles backslash at very end of unterminated string" do + content = '"test\\' + bytes_skipped, pos, rest = skip_c_string.call(content, '"') + + expect(bytes_skipped).to eq(6) + expect(pos).to eq(6) + expect(rest).to eq("") + end + end + end + + ### + ### skip_semicolons() + ### + describe "#skip_semicolons" do + let(:skip_semicolons) do + ->(content) do + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + code_text.skip_semicolons( scanner ) + return [scanner.pos, scanner.rest] + end + end + + context "single semicolon handling" do + it "skips single semicolon" do + content = ";code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(1) + expect(rest).to eq("code") + end + + it "skips semicolon with trailing space" do + content = "; code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(1) + expect(rest).to eq(" code") + end + + it "skips semicolon at end of input" do + content = ";" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(1) + expect(rest).to eq("") + end + end + + context "multiple consecutive semicolons" do + it "skips two consecutive semicolons" do + content = ";;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(2) + expect(rest).to eq("code") + end + + it "skips three consecutive semicolons" do + content = ";;;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "skips many consecutive semicolons" do + content = ";;;;;;;;;;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(10) + expect(rest).to eq("code") + end + end + + context "semicolons with whitespace" do + it "skips semicolons separated by spaces" do + content = "; ; ;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(5) + expect(rest).to eq("code") + end + + it "skips semicolons separated by tabs" do + content = ";\t;\t;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(5) + expect(rest).to eq("code") + end + + it "skips semicolons separated by newlines" do + content = ";\n;\n;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(5) + expect(rest).to eq("code") + end + + it "skips semicolons with mixed whitespace" do + content = "; \t\n ; \r\n ;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(11) + expect(rest).to eq("code") + end + + it "skips leading whitespace before first semicolon" do + content = " \t;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(4) + expect(rest).to eq("code") + end + end + + context "semicolons with comments" do + it "skips semicolons separated by line comments" do + content = "; // comment\n;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "skips semicolons separated by block comments" do + content = "; /* comment */ ;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(17) + expect(rest).to eq("code") + end + + it "skips semicolons with multi-line block comments" do + content = ";\n/* comment\n spanning\n lines */\n;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(38) + expect(rest).to eq("code") + end + + it "skips semicolons with multiple comments" do + content = "; // first\n; /* second */ ;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(27) + expect(rest).to eq("code") + end + end + + context "stopping at non-semicolon content" do + it "stops at identifier" do + content = ";code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(1) + expect(rest).to eq("code") + end + + it "stops at number" do + content = ";;123" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(2) + expect(rest).to eq("123") + end + + it "stops at opening brace" do + content = "; ; {" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(3) + expect(rest).to eq(" {") + end + + it "stops at closing brace" do + content = ";;}" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(2) + expect(rest).to eq("}") + end + + it "stops at comma" do + content = "; , next" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(1) + expect(rest).to eq(" , next") + end + + it "stops at operator" do + content = ";;++" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(2) + expect(rest).to eq("++") + end + end + + context "no semicolons present" do + it "does not advance when no semicolons" do + content = "code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(0) + expect(rest).to eq("code") + end + + it "does not advance with only whitespace" do + content = " \t\ncode" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(0) + expect(rest).to eq(" \t\ncode") + end + + it "does not advance with only comments" do + content = "// comment\ncode" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(0) + expect(rest).to eq("// comment\ncode") + end + + it "does not advance with only preprocessor" do + content = "#define FOO\ncode" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(0) + expect(rest).to eq("#define FOO\ncode") + end + end + + context "edge cases" do + it "handles empty string" do + content = "" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(0) + expect(rest).to eq("") + end + + it "handles only semicolons" do + content = ";;;;;" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(5) + expect(rest).to eq("") + end + + it "handles only semicolons and whitespace" do + content = "; ; ; " + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(6) + expect(rest).to eq("") + end + + it "handles semicolons with unterminated comment" do + content = "; /* unterminated" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(17) + expect(rest).to eq("") + end + + end + + context "real-world C code patterns" do + it "skips null statements in for loop" do + content = ";;) {" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(2) + expect(rest).to eq(") {") + end + + it "skips multiple null statements" do + content = ";;;\nreturn 0;" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(3) + expect(rest).to eq("\nreturn 0;") + end + + it "handles semicolons from macro expansion pattern" do + content = "; // from MACRO_CALL()\n;code" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(24) + expect(rest).to eq("code") + end + + it "skips null statements in switch case" do + content = ";case 2:" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(1) + expect(rest).to eq("case 2:") + end + + it "skips multiple null statements between switch cases" do + content = ";;;\ncase 3:" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(3) + expect(rest).to eq("\ncase 3:") + end + + it "skips null statements after break in switch" do + content = ";;default:" + pos, rest = skip_semicolons.call(content) + + expect(pos).to eq(2) + expect(rest).to eq("default:") + end + end + end + + ### + ### skip_deadspace() + ### + describe "#skip_deadspace" do + # Helper to access private method + let(:skip_deadspace) do + ->(content) do + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + bytes_skipped = code_text.skip_deadspace( scanner ) + return [bytes_skipped, scanner.pos, scanner.rest] + end + end + + context "whitespace handling" do + it "skips spaces" do + content = " code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(5) + expect(pos).to eq(5) + expect(rest).to eq("code") + end + + it "skips tabs" do + content = "\t\t\tcode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "skips newlines" do + content = "\n\n\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(3) + expect(pos).to eq(3) + expect(rest).to eq("code") + end + + it "skips carriage returns" do + content = "\r\n\r\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(4) + expect(pos).to eq(4) + expect(rest).to eq("code") + end + + it "skips mixed whitespace" do + content = " \t\n \r\n\t code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(8) + expect(pos).to eq(8) + expect(rest).to eq("code") + end + + it "returns 0 when no whitespace present" do + content = "code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(0) + expect(pos).to eq(0) + expect(rest).to eq("code") + end + end + + context "line comment handling" do + it "skips single line comment" do + content = "// comment\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(11) + expect(pos).to eq(11) + expect(rest).to eq("code") + end + + it "skips line comment without trailing newline" do + content = "// comment at end" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(17) + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "skips multiple consecutive line comments" do + content = "// comment 1\n// comment 2\n// comment 3\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(39) + expect(pos).to eq(39) + expect(rest).to eq("code") + end + + it "skips line comment with whitespace before it" do + content = " // comment\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(14) + expect(pos).to eq(14) + expect(rest).to eq("code") + end + + it "handles line comment with special characters" do + content = "// TODO: fix this!!!\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(21) + expect(pos).to eq(21) + expect(rest).to eq("code") + end + end + + context "block comment handling" do + it "skips single-line block comment" do + content = "/* comment */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(13) + expect(pos).to eq(13) + expect(rest).to eq("code") + end + + it "skips multi-line block comment" do + content = "/* comment\nline 2\nline 3 */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(27) + expect(pos).to eq(27) + expect(rest).to eq("code") + end + + it "skips multiple consecutive block comments" do + content = "/* first *//* second *//* third */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(34) + expect(pos).to eq(34) + expect(rest).to eq("code") + end + + it "skips block comment with whitespace before it" do + content = " /* comment */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(16) + expect(pos).to eq(16) + expect(rest).to eq("code") + end + + it "handles block comment with asterisks inside" do + content = "/* ** comment ** */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(19) + expect(pos).to eq(19) + expect(rest).to eq("code") + end + + it "handles block comment with forward slashes inside" do + content = "/* comment // not a line comment */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(35) + expect(pos).to eq(35) + expect(rest).to eq("code") + end + + it "handles unterminated block comment" do + content = "/* unterminated comment" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(23) + expect(pos).to eq(23) + expect(rest).to eq("") + end + end + + context "mixed deadspace handling" do + it "skips whitespace followed by comment" do + content = " \t/* comment */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(16) + expect(pos).to eq(16) + expect(rest).to eq("code") + end + + it "skips comment followed by whitespace" do + content = "/* comment */ \ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(16) + expect(pos).to eq(16) + expect(rest).to eq("code") + end + + it "skips line comment followed by block comment" do + content = "// line\n/* block */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(19) + expect(pos).to eq(19) + expect(rest).to eq("code") + end + + end + + context "edge cases" do + it "handles empty string" do + content = "" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(0) + expect(pos).to eq(0) + expect(rest).to eq("") + end + + it "handles string with only whitespace" do + content = " \t\n " + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(7) + expect(pos).to eq(7) + expect(rest).to eq("") + end + + it "handles string with only comments" do + content = "// comment\n/* block */" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(22) + expect(pos).to eq(22) + expect(rest).to eq("") + end + + it "does not skip code that looks like comment but isn't" do + content = "int a = 5 / 2; // actual comment\ncode" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + expect(bytes_skipped).to eq(0) + expect(pos).to eq(0) + expect(rest).to start_with("int a") + end + + it "handles nested block comments (not standard C)" do + # NOTE: C extraction is not implemented as a full C parser and/or preprocessor + # We assume that the file to be processed is either relatively simple or has already been preprocessed + # to remove complex preprocessor directives, etc. + + content = "/* outer /* inner */ still outer */code" + bytes_skipped, pos, rest = skip_deadspace.call(content) + + # We expect only partial comment block handling for nested blocks + expect(bytes_skipped).to eq(21) + expect(pos).to eq(21) + expect(rest).to eq("still outer */code") + end + end + + context "real-world C code patterns" do + it "handles Doxygen-style comments" do + content = <<~CODE + /** + * @brief Function description + * @param x The parameter + * @return The result + */ + int func(int x) { + CODE + + _, _, rest = skip_deadspace.call(content) + + expect(rest).to start_with("int func(int x)") + end + + end + end + + ### + ### collect_balanced() + ### + + describe "#collect_balanced" do + let(:collect_balanced) do + ->(content, open_char, close_char) do + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new + success, text = code_text.collect_balanced(scanner, open_char, close_char) + [success, text, scanner.pos, scanner.rest] + end + end + + context "failure cases" do + it "returns [false, nil] when not positioned at open_char" do + success, text, pos, _ = collect_balanced.call("x{content}", '{', '}') + expect(success).to be false + expect(text).to be_nil + expect(pos).to eq(0) # scanner not advanced + end + + it "returns [false, nil] for empty input" do + success, text, _, _ = collect_balanced.call("", '{', '}') + expect(success).to be false + expect(text).to be_nil + end + + it "returns [false, nil] for unbalanced input (no matching close)" do + success, text, _, _ = collect_balanced.call("{content without close", '{', '}') + expect(success).to be false + expect(text).to be_nil + end + end + + context "successful extraction" do + it "extracts a simple brace pair and returns text including delimiters" do + success, text, pos, rest = collect_balanced.call("{content}", '{', '}') + expect(success).to be true + expect(text).to eq("{content}") + expect(pos).to eq(9) + expect(rest).to eq("") + end + + it "extracts a simple paren pair" do + success, text, pos, rest = collect_balanced.call("(args)", '(', ')') + expect(success).to be true + expect(text).to eq("(args)") + expect(pos).to eq(6) + end + + it "extracts a simple bracket pair" do + success, text, pos, rest = collect_balanced.call("[items]", '[', ']') + expect(success).to be true + expect(text).to eq("[items]") + expect(pos).to eq(7) + end + + it "handles nested same-pair delimiters" do + success, text, pos, rest = collect_balanced.call("{a {b} c}", '{', '}') + expect(success).to be true + expect(text).to eq("{a {b} c}") + expect(pos).to eq(9) + expect(rest).to eq("") + end + + it "stops at matching close and does not consume following code" do + success, text, pos, rest = collect_balanced.call("{block} remaining", '{', '}') + expect(success).to be true + expect(text).to eq("{block}") + expect(pos).to eq(7) + expect(rest).to eq(" remaining") + end + end + + context "string literals" do + it "preserves string literals verbatim and does not treat close_char inside them as closer" do + success, text, _, _ = collect_balanced.call('{"string with }"}', '{', '}') + expect(success).to be true + expect(text).to eq('{"string with }"}') + end + + it "preserves char literals verbatim" do + success, text, _, _ = collect_balanced.call("{ char c = '}'; }", '{', '}') + expect(success).to be true + expect(text).to eq("{ char c = '}'; }") + end + end + + context "comments replaced with a single space" do + it "replaces a block comment containing close_char with a space — does not terminate early" do + success, text, pos, _ = collect_balanced.call("{ /* } */ }", '{', '}') + expect(success).to be true + expect(text).to eq("{ }") # space + space (before/after comment) + space for comment + expect(pos).to eq(11) # scanner advanced through full original content + end + + it "replaces a line comment containing close_char with a space — does not terminate early" do + success, text, pos, _ = collect_balanced.call("{ // }\n}", '{', '}') + expect(success).to be true + expect(text).to eq("{ }") # space before comment + space for comment+newline + expect(pos).to eq(8) + end + + it "replaces a block comment with exactly one space regardless of comment length" do + success, text, _, _ = collect_balanced.call("{ /* long comment text */ code }", '{', '}') + expect(success).to be true + expect(text).to eq("{ code }") # space + comment→space + space before code + end + + it "replaces a line comment with exactly one space (consuming the newline too)" do + success, text, _, _ = collect_balanced.call("{ code // comment\n more }", '{', '}') + expect(success).to be true + expect(text).to eq("{ code more }") # space before comment + comment→space + space before more + end + end + end + + ### + ### extract_balanced_braces() + ### + + describe "#extract_balanced_braces" do + # Helper to access private method + let(:extract_braces) do + ->(content) do + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + success, block = code_text.extract_balanced_braces( scanner ) + return [success, block, scanner.pos, scanner.rest] + end + end + + context "simple balanced braces" do + it "extracts single-level braces" do + content = "{ code }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code }") + expect(pos).to eq(8) + expect(rest).to eq("") + end + + it "extracts empty braces" do + content = "{}" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{}") + expect(pos).to eq(2) + expect(rest).to eq("") + end + + it "extracts braces with content after" do + content = "{ code } more" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code }") + expect(pos).to eq(8) + expect(rest).to eq(" more") + end + + it "extracts braces with whitespace" do + content = "{ \n\t }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ \n\t }") + expect(pos).to eq(9) + expect(rest).to eq("") + end + end + + context "nested braces" do + it "extracts one level of nesting" do + content = "{ outer { inner } }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ outer { inner } }") + expect(pos).to eq(19) + expect(rest).to eq("") + end + + it "extracts two levels of nesting" do + content = "{ a { b { c } } }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ a { b { c } } }") + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "extracts multiple nested blocks at same level" do + content = "{ { a } { b } { c } }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ { a } { b } { c } }") + expect(pos).to eq(21) + expect(rest).to eq("") + end + + it "extracts deeply nested braces" do + content = "{ { { { { deep } } } } }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ { { { { deep } } } } }") + expect(pos).to eq(24) + expect(rest).to eq("") + end + end + + context "braces in strings" do + it "ignores braces in double-quoted strings" do + content = '{ "string with brace }" }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ "string with brace }" }') + expect(pos).to eq(25) + expect(rest).to eq("") + end + + it "ignores braces in single-quoted strings" do + content = "{ 'char {' }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ 'char {' }") + expect(pos).to eq(12) + expect(rest).to eq("") + end + + it "handles escaped quotes in strings with braces" do + content = '{ "string with \\" and { brace" }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ "string with \\" and { brace" }') + expect(pos).to eq(32) + expect(rest).to eq("") + end + + it "handles multiple strings with braces" do + content = '{ "first { }" "second } " }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ "first { }" "second } " }') + expect(pos).to eq(27) + expect(rest).to eq("") + end + + it "handles empty strings" do + content = '{ "" }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ "" }') + expect(pos).to eq(6) + expect(rest).to eq("") + end + end + + context "braces in comments" do + it "ignores braces in line comments (comment replaced with space)" do + content = "{ code // comment with { brace\n}" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code }") # comment+newline → single space + expect(pos).to eq(32) # scanner.pos spans full original content + expect(rest).to eq("") + end + + it "ignores braces in block comments (comment replaced with space)" do + content = "{ code /* comment with { brace */ }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code }") # comment → single space; space before } preserved + expect(pos).to eq(35) + expect(rest).to eq("") + end + + it "handles multiple comments with braces (each comment replaced with space)" do + content = "{ /* { */ code // }\n}" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code }") # block comment → space; line comment+newline → space + expect(pos).to eq(21) + expect(rest).to eq("") + end + + it "handles nested block comments with braces (only innermost comment consumed)" do + content = "{ /* outer { /* inner } */ */ }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + # First */ closes the comment; remaining */ */ is literal text + expect(block).to eq("{ */ }") + expect(pos).to eq(31) + expect(rest).to eq("") + end + end + + context "real C code patterns" do + it "extracts simple function body" do + content = "{ return 0; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ return 0; }") + expect(pos).to eq(13) + expect(rest).to eq("") + end + + it "extracts function body with nested blocks" do + content = "{ if (x) { do_something(); } }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ if (x) { do_something(); } }") + expect(pos).to eq(30) + expect(rest).to eq("") + end + + it "extracts function body with multiple statements" do + content = <<~CODE.chomp + { + int x = 5; + printf("value: %d", x); + return x; + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "extracts struct initialization" do + content = '{ .field1 = 10, .field2 = "test" }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ .field1 = 10, .field2 = "test" }') + expect(pos).to eq(34) + expect(rest).to eq("") + end + + it "extracts array initialization" do + content = "{ 1, 2, 3, 4, 5 }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ 1, 2, 3, 4, 5 }") + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "extracts switch statement" do + content = <<~CODE.chomp + { + switch (x) { + case 1: { action1(); break; } + case 2: { action2(); break; } + default: { action_default(); } + } + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "extracts do-while loop" do + content = "{ do { process(); } while (condition); }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ do { process(); } while (condition); }") + expect(pos).to eq(40) + expect(rest).to eq("") + end + + it "extracts nested if-else blocks" do + content = <<~CODE.chomp + { + if (a) { + if (b) { x(); } + else { y(); } + } else { + z(); + } + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + end + + context "failure cases" do + it "fails when not starting at opening brace" do + content = "not a brace" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be false + expect(block).to be_nil + expect(pos).to eq(0) # Scanner not advanced on failure (peek, not getch) + expect(rest).to eq("not a brace") + end + + it "fails on unbalanced braces (missing closing)" do + content = "{ incomplete" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be false + expect(block).to be_nil + expect(pos).to eq(12) # At end of string + expect(rest).to eq("") + end + + it "fails on unbalanced braces (extra closing)" do + content = "{ code } }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code }") + expect(pos).to eq(8) + expect(rest).to eq(" }") + end + + it "fails on unbalanced nested braces" do + content = "{ outer { inner }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be false + expect(block).to be_nil + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "fails on empty content" do + content = "" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be false + expect(block).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("") + end + + it "fails when starting with closing brace" do + content = "} wrong" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be false + expect(block).to be_nil + expect(pos).to eq(0) # Scanner not advanced on failure (peek, not getch) + expect(rest).to eq("} wrong") + end + end + + context "edge cases with strings and comments" do + it "handles string with escaped backslash before quote" do + content = '{ "path\\\\file" }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ "path\\\\file" }') + expect(pos).to eq(16) + expect(rest).to eq("") + end + + it "handles string with escaped newline" do + content = '{ "line1\\nline2" }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ "line1\\nline2" }') + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "handles character literal with closing brace" do + content = "{ char c = '}'; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ char c = '}'; }") + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "handles character literal with opening brace" do + content = "{ char c = '{'; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ char c = '{'; }") + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "handles escaped single quote in character literal" do + content = "{ char c = '\\''; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ char c = '\\''; }") + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "handles comment at end of line with brace (comment replaced with space)" do + content = "{ code; // comment }\n}" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code; }") # comment+newline → single space; } is the real closer + expect(pos).to eq(22) + expect(rest).to eq("") + end + + it "handles block comment spanning multiple lines with braces (comment replaced with space)" do + content = <<~CODE.chomp + { + /* This is a comment + with { braces } in it + spanning lines */ + code; + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{\n \n code;\n}") # comment block → single space; newline+indent after preserved + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "handles unterminated string (malformed C)" do + # NOTE: This tests behavior with malformed C code + content = '{ "unterminated }' + success, block, _, _ = extract_braces.call(content) + + # The extractor should fail because the closing brace is inside an unterminated string + expect(success).to be false + expect(block).to be_nil + end + + it "handles unterminated comment (malformed C)" do + # NOTE: This tests behavior with malformed C code + content = "{ /* unterminated }" + success, block, _, _ = extract_braces.call(content) + + # The extractor should fail because the closing brace is inside an unterminated comment + expect(success).to be false + expect(block).to be_nil + end + end + + context "complex real-world patterns" do + it "extracts function with macro usage" do + content = <<~CODE.chomp + { + MACRO_CALL(arg1, arg2); + if (CHECK_FLAG(x)) { + DO_SOMETHING(); + } + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "extracts function with string containing comment-like text" do + content = '{ printf("/* not a comment */"); }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq('{ printf("/* not a comment */"); }') + expect(pos).to eq(34) + expect(rest).to eq("") + end + + it "extracts function with comment containing string-like text (comment replaced with space)" do + content = '{ /* "not a string" */ code; }' + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code; }") # comment → space; surrounding spaces preserved + expect(pos).to eq(30) + expect(rest).to eq("") + end + + it "extracts nested struct and array initializers" do + content = <<~CODE.chomp + { + struct data d = { + .array = { 1, 2, 3 }, + .nested = { { 4, 5 }, { 6, 7 } } + }; + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "extracts function with ternary operator and braces" do + content = "{ result = condition ? { .a = 1 } : { .b = 2 }; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ result = condition ? { .a = 1 } : { .b = 2 }; }") + expect(pos).to eq(49) + expect(rest).to eq("") + end + + it "extracts function with compound literal" do + content = "{ func((struct point){ .x = 10, .y = 20 }); }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ func((struct point){ .x = 10, .y = 20 }); }") + expect(pos).to eq(45) + expect(rest).to eq("") + end + + it "extracts function with designated initializers and nested braces" do + content = <<~CODE.chomp + { + struct config cfg = { + [0] = { .name = "first", .value = { 1, 2 } }, + [1] = { .name = "second", .value = { 3, 4 } } + }; + } + CODE + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + end + + context "scanner position management" do + it "leaves scanner at correct position after successful extraction" do + content = "{ first }{ second }{third}" + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + + success1, block1 = code_text.extract_balanced_braces( scanner ) + + expect(success1).to be true + expect(block1).to eq("{ first }") + + success2, block2 = code_text.extract_balanced_braces( scanner ) + + expect(success2).to be true + expect(block2).to eq("{ second }") + + success3, block3 = code_text.extract_balanced_braces( scanner ) + + expect(success3).to be true + expect(block3).to eq("{third}") + + expect(scanner.eos?).to be true + end + + it "leaves scanner at correct position after failed extraction" do + content = "not_brace { valid }" + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + + success1, block1 = code_text.extract_balanced_braces( scanner ) + + expect(success1).to be false + expect(block1).to be_nil + expect(scanner.pos).to eq(0) # Scanner not advanced on failure (peek, not getch) + + # Skip to the valid brace + scanner.scan(/[^{]*/) + success2, block2 = code_text.extract_balanced_braces( scanner ) + + expect(success2).to be true + expect(block2).to eq("{ valid }") + end + + it "handles scanner at end of string" do + content = "{ code }" + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + + # Extract the only block + code_text.extract_balanced_braces( scanner ) + + # Try to extract again at end of string + success, block = code_text.extract_balanced_braces( scanner ) + + expect(success).to be false + expect(block).to be_nil + expect(scanner.eos?).to be true + end + end + + context "performance considerations" do + it "handles very long brace blocks" do + # Create a large but balanced brace block + inner_content = "int x = 0;\n" * 100 + content = "{\n#{inner_content}}" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "handles deeply nested braces" do + # Create 50 levels of nesting + opening = "{ " * 50 + closing = " }" * 50 + content = opening + "core" + closing + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "handles many sequential brace blocks" do + # Create 100 sequential blocks + blocks = (1..100).map { |i| "{ block#{i} }" }.join(" ") + scanner = StringScanner.new(blocks) + code_text = CExtractorCodeText.new() + + count = 0 + while !scanner.eos? + scanner.scan(/\s*/) + break if scanner.eos? + success, _ = code_text.extract_balanced_braces( scanner ) + break unless success + count += 1 + end + + expect(count).to eq(100) + end + + it "handles large strings within braces" do + large_string = "x" * 1000 + content = "{ char* str = \"#{large_string}\"; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "handles large comments within braces (comment replaced with space)" do + large_comment = "comment text " * 100 + content = "{ /* #{large_comment} */ code; }" + success, block, pos, rest = extract_braces.call(content) + + expect(success).to be true + expect(block).to eq("{ code; }") # entire comment → single space; spaces around preserved + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + end + end + + ### + ### skip_compiler_extension() + ### + describe "#skip_compiler_extension" do + let(:skip_ext) do + ->(content) do + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new + result = code_text.skip_compiler_extension(scanner) + [result, scanner.pos, scanner.rest] + end + end + + it "returns false and does not advance on plain identifier" do + result, pos, rest = skip_ext.call("int foo") + expect(result).to be false + expect(rest).to eq("int foo") + end + + it "returns false and does not advance on empty input" do + result, pos, rest = skip_ext.call("") + expect(result).to be false + expect(pos).to eq(0) + end + + it "skips __cdecl calling convention" do + result, _pos, rest = skip_ext.call("__cdecl foo") + expect(result).to be true + expect(rest).to eq(" foo") + end + + it "skips __stdcall calling convention" do + result, _pos, rest = skip_ext.call("__stdcall foo") + expect(result).to be true + expect(rest).to eq(" foo") + end + + it "skips __fastcall calling convention" do + result, _pos, rest = skip_ext.call("__fastcall foo") + expect(result).to be true + expect(rest).to eq(" foo") + end + + it "skips __declspec(dllexport)" do + result, _pos, rest = skip_ext.call("__declspec(dllexport) void") + expect(result).to be true + expect(rest).to eq(" void") + end + + it "skips __declspec with nested parens" do + result, _pos, rest = skip_ext.call("__declspec(align(8)) void") + expect(result).to be true + expect(rest).to eq(" void") + end + + it 'skips __declspec with nested parens and string argument' do + result, _pos, rest = skip_ext.call('__declspec(deprecated("msg")) void') + expect(result).to be true + expect(rest).to eq(" void") + end + + it "skips __attribute__((interrupt))" do + result, _pos, rest = skip_ext.call("__attribute__((interrupt)) void") + expect(result).to be true + expect(rest).to eq(" void") + end + + it "skips __attribute__ with deeply nested arguments" do + result, _pos, rest = skip_ext.call("__attribute__((format(printf,1,2))) void") + expect(result).to be true + expect(rest).to eq(" void") + end + + it "returns false for __int64 (MSVC type — must not be skipped)" do + result, _pos, rest = skip_ext.call("__int64 x") + expect(result).to be false + expect(rest).to eq("__int64 x") + end + end + + ### + ### strip_compiler_extensions() + ### + describe "#strip_compiler_extensions" do + let(:strip_ext) do + ->(text) { CExtractorCodeText.new.strip_compiler_extensions(text) } + end + + it "returns input unchanged when there are no extensions" do + expect(strip_ext.call("int foo(void)")).to eq("int foo(void)") + end + + it "returns empty string for empty input" do + expect(strip_ext.call("")).to eq("") + end + + it "strips __declspec(dllexport)" do + expect(strip_ext.call("__declspec(dllexport) void foo(void)")).to eq("void foo(void)") + end + + it "strips __declspec with nested parens" do + expect(strip_ext.call("__declspec(align(8)) int foo(void)")).to eq("int foo(void)") + end + + it "strips __attribute__((interrupt)) in mid-signature position" do + expect(strip_ext.call("void __attribute__((interrupt)) ISR(void)")).to eq("void ISR(void)") + end + + it "strips __attribute__ with deeply nested arguments" do + expect(strip_ext.call("__attribute__((format(printf,1,2))) int foo(char *fmt, ...)")).to eq("int foo(char *fmt, ...)") + end + + it "strips __cdecl calling convention" do + expect(strip_ext.call("int __cdecl foo(void)")).to eq("int foo(void)") + end + + it "strips __stdcall calling convention" do + expect(strip_ext.call("int __stdcall foo(void)")).to eq("int foo(void)") + end + + it "strips __forceinline" do + expect(strip_ext.call("__forceinline void foo(void)")).to eq("void foo(void)") + end + + it "strips __inline__" do + expect(strip_ext.call("__inline__ void foo(void)")).to eq("void foo(void)") + end + + it "strips _Noreturn C11 specifier" do + expect(strip_ext.call("_Noreturn void exit_prog(int code)")).to eq("void exit_prog(int code)") + end + + it "strips _Thread_local C11 specifier" do + expect(strip_ext.call("_Thread_local int counter")).to eq("int counter") + end + + it "strips multiple extensions in one signature" do + expect(strip_ext.call("__declspec(dllexport) __cdecl void foo(void)")).to eq("void foo(void)") + end + + it "preserves __int64 (MSVC type — must not be stripped)" do + expect(strip_ext.call("__int64 foo(void)")).to eq("__int64 foo(void)") + end + + it "preserves __int32 (MSVC type — must not be stripped)" do + expect(strip_ext.call("static __int32 foo(void)")).to eq("static __int32 foo(void)") + end + + it "normalizes whitespace left by stripping" do + expect(strip_ext.call("void __cdecl foo(void)")).to eq("void foo(void)") + end + end + +end \ No newline at end of file diff --git a/spec/units/c_extractor/c_extractor_declarations_spec.rb b/spec/units/c_extractor/c_extractor_declarations_spec.rb new file mode 100644 index 000000000..b62ac625a --- /dev/null +++ b/spec/units/c_extractor/c_extractor_declarations_spec.rb @@ -0,0 +1,746 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/c_extractor/c_extractor_declarations' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'stringio' + +describe CExtractorDeclarations do + + ### + ### try_extract_variable() + ### + + describe "#try_extract_variable" do + # Helper to create extractor and test variable extraction + let(:extract_variable) do + ->(content, max_line_length=1000) do + scanner = StringScanner.new(content) + declarations = CExtractorDeclarations.new({ c_extractor_code_text: CExtractorCodeText.new() }) + declarations.setup() + declarations.max_line_length = max_line_length + success, variable = declarations.try_extract_variable(scanner) + return [success, variable, scanner.pos, scanner.rest] + end + end + + # Shorthand to check a single-variable result + def check_single(variable, name:, type:, decorators: [], text:, array_suffix: '') + expect(variable).to be_an(Array) + expect(variable.length).to eq 1 + v = variable[0] + expect(v.name).to eq name + expect(v.type).to eq type + expect(v.decorators).to eq decorators + expect(v.text).to eq text + expect(v.array_suffix).to eq array_suffix + end + + context "simple variable declarations" do + it "extracts simple int variable" do + content = "int x;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'x', type: 'int', text: 'int x;') + expect(variable[0].original).to eq 'int x;' + expect(pos).to eq(6) + expect(rest).to eq("") + end + + it "extracts simple char variable" do + content = "char c;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'c', type: 'char', text: 'char c;') + expect(pos).to eq(7) + expect(rest).to eq("") + end + + it "extracts simple float variable" do + content = "float value;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'value', type: 'float', text: 'float value;') + expect(pos).to eq(12) + expect(rest).to eq("") + end + + it "extracts simple double variable" do + content = "double pi;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'pi', type: 'double', text: 'double pi;') + expect(pos).to eq(10) + expect(rest).to eq("") + end + + it "extracts variable with underscore in name" do + content = "int my_variable;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'my_variable', type: 'int', text: 'int my_variable;') + expect(pos).to eq(16) + expect(rest).to eq("") + end + + it "extracts variable with camelCase name" do + content = "int myVariable;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'myVariable', type: 'int', text: 'int myVariable;') + expect(pos).to eq(15) + expect(rest).to eq("") + end + + it "extracts variable with number in name" do + content = "int value123;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'value123', type: 'int', text: 'int value123;') + expect(pos).to eq(13) + expect(rest).to eq("") + end + end + + context "pointer variable declarations" do + it "extracts pointer variable with asterisk next to type" do + content = "int* ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'ptr', type: 'int*', text: 'int* ptr;') + expect(pos).to eq(9) + expect(rest).to eq("") + end + + it "extracts pointer variable with asterisk next to name" do + content = "int *ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'ptr', type: 'int *', text: 'int *ptr;') + expect(pos).to eq(9) + expect(rest).to eq("") + end + + it "extracts pointer variable with asterisk in middle" do + content = "int * ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'ptr', type: 'int *', text: 'int * ptr;') + expect(pos).to eq(10) + expect(rest).to eq("") + end + + it "extracts double pointer variable" do + content = "char** buffer;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'buffer', type: 'char**', text: 'char** buffer;') + expect(pos).to eq(14) + expect(rest).to eq("") + end + + it "extracts triple pointer variable" do + content = "void*** ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'ptr', type: 'void***', text: 'void*** ptr;') + expect(pos).to eq(12) + expect(rest).to eq("") + end + + it "extracts pointer to const" do + content = "const int* ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'ptr', type: 'int*', decorators: ['const'], text: 'int* ptr;') + expect(pos).to eq(15) + expect(rest).to eq("") + end + + it "extracts const pointer" do + content = "int* const ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + # `const` is not a leading decorator here (int* comes first), no stripping + expect(variable.length).to eq 1 + expect(variable[0].name).to eq 'ptr' + expect(variable[0].decorators).to eq [] + expect(variable[0].text).to eq 'int* const ptr;' + expect(pos).to eq(15) + expect(rest).to eq("") + end + + it "extracts const pointer to const" do + content = "const int* const ptr;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + # Leading `const` is a decorator; gsub removes all `const` occurrences + check_single(variable, name: 'ptr', type: 'int*', decorators: ['const'], text: 'int* ptr;') + expect(pos).to eq(21) + expect(rest).to eq("") + end + end + + context "array variable declarations" do + it "extracts simple array" do + content = "int arr[10];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[10];', array_suffix: '[10]') + expect(pos).to eq(12) + expect(rest).to eq("") + end + + it "extracts array without size" do + content = "int arr[];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[];', array_suffix: '[]') + expect(pos).to eq(10) + expect(rest).to eq("") + end + + it "extracts multi-dimensional array" do + content = "int matrix[3][4];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'matrix', type: 'int', text: 'int matrix[3][4];', array_suffix: '[3][4]') + expect(pos).to eq(17) + expect(rest).to eq("") + end + + it "extracts three-dimensional array" do + content = "int cube[2][3][4];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'cube', type: 'int', text: 'int cube[2][3][4];', array_suffix: '[2][3][4]') + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "extracts array of pointers" do + content = "char* strings[10];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'strings', type: 'char*', text: 'char* strings[10];', array_suffix: '[10]') + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "extracts pointer to array" do + content = "int (*ptr)[10];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'ptr', type: 'int', text: 'int (*ptr)[10];') + expect(pos).to eq(15) + expect(rest).to eq("") + end + + it "extracts array with expression size" do + content = "int arr[MAX_SIZE];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[MAX_SIZE];', array_suffix: '[MAX_SIZE]') + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "extracts array with arithmetic expression size" do + content = "int arr[10 + 5];" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[10 + 5];', array_suffix: '[10 + 5]') + expect(pos).to eq(16) + expect(rest).to eq("") + end + end + + context "array variable initialization" do + it "extracts array with simple initializer list" do + content = "int arr[] = {1, 2, 3};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[] = {1, 2, 3};', array_suffix: '[]') + expect(pos).to eq(22) + expect(rest).to eq("") + end + + it "extracts array with sized initializer list" do + content = "int arr[5] = {1, 2, 3, 4, 5};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[5] = {1, 2, 3, 4, 5};', array_suffix: '[5]') + expect(pos).to eq(29) + expect(rest).to eq("") + end + + it "extracts array with partial initializer list" do + content = "int arr[10] = {1, 2, 3};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[10] = {1, 2, 3};', array_suffix: '[10]') + expect(pos).to eq(24) + expect(rest).to eq("") + end + + it "extracts array with empty initializer list" do + content = "int arr[5] = {};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[5] = {};', array_suffix: '[5]') + expect(pos).to eq(16) + expect(rest).to eq("") + end + + it "extracts char array with string literal" do + content = 'char str[] = "hello";' + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'str', type: 'char', text: 'char str[] = "hello";', array_suffix: '[]') + expect(pos).to eq(21) + expect(rest).to eq("") + end + + it "extracts char array with sized string literal" do + content = 'char str[10] = "hello";' + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'str', type: 'char', text: 'char str[10] = "hello";', array_suffix: '[10]') + expect(pos).to eq(23) + expect(rest).to eq("") + end + + it "extracts multi-dimensional array with nested initializers" do + content = "int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'matrix', type: 'int', text: 'int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};', array_suffix: '[2][3]') + expect(pos).to eq(42) + expect(rest).to eq("") + end + + it "extracts array with designated initializers" do + content = "int arr[5] = {[0] = 1, [4] = 5};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[5] = {[0] = 1, [4] = 5};', array_suffix: '[5]') + expect(pos).to eq(32) + expect(rest).to eq("") + end + + it "extracts array with negative values" do + content = "int arr[] = {-1, -2, -3};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[] = {-1, -2, -3};', array_suffix: '[]') + expect(pos).to eq(25) + expect(rest).to eq("") + end + + it "extracts float array with decimal values" do + content = "float arr[] = {1.5, 2.7, 3.14};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'float', text: 'float arr[] = {1.5, 2.7, 3.14};', array_suffix: '[]') + expect(pos).to eq(31) + expect(rest).to eq("") + end + + it "extracts array with hex values" do + content = "int arr[] = {0x01, 0xFF, 0xAB};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[] = {0x01, 0xFF, 0xAB};', array_suffix: '[]') + expect(pos).to eq(31) + expect(rest).to eq("") + end + + it "extracts const array with initializer" do + content = "const int arr[] = {1, 2, 3};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', decorators: ['const'], text: 'int arr[] = {1, 2, 3};', array_suffix: '[]') + expect(pos).to eq(28) + expect(rest).to eq("") + end + + it "extracts static array with initializer" do + content = "static int arr[] = {10, 20, 30};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', decorators: ['static'], text: 'int arr[] = {10, 20, 30};', array_suffix: '[]') + expect(pos).to eq(32) + expect(rest).to eq("") + end + + it "extracts array of pointers with initializer" do + content = 'char* arr[] = {"hello", "world"};' + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'char*', text: 'char* arr[] = {"hello", "world"};', array_suffix: '[]') + expect(pos).to eq(33) + expect(rest).to eq("") + end + + it "extracts array with macro values" do + content = "int arr[] = {MAX_VALUE, MIN_VALUE};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[] = {MAX_VALUE, MIN_VALUE};', array_suffix: '[]') + expect(pos).to eq(35) + expect(rest).to eq("") + end + + it "extracts array with expression values" do + content = "int arr[] = {1 + 2, 3 * 4, 5 - 1};" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', text: 'int arr[] = {1 + 2, 3 * 4, 5 - 1};', array_suffix: '[]') + expect(pos).to eq(34) + expect(rest).to eq("") + end + end + + context "qualified type declarations" do + it "extracts const variable" do + content = "const int value;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'value', type: 'int', decorators: ['const'], text: 'int value;') + expect(pos).to eq(16) + expect(rest).to eq("") + end + + it "extracts volatile variable" do + content = "volatile int flag;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'flag', type: 'int', decorators: ['volatile'], text: 'int flag;') + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "extracts const volatile variable" do + content = "const volatile int reg;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'reg', type: 'int', decorators: ['const', 'volatile'], text: 'int reg;') + expect(pos).to eq(23) + expect(rest).to eq("") + end + + it "extracts static variable" do + content = "static int counter;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'counter', type: 'int', decorators: ['static'], text: 'int counter;') + expect(pos).to eq(19) + expect(rest).to eq("") + end + + it "extracts extern variable" do + content = "extern int global;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'global', type: 'int', decorators: ['extern'], text: 'int global;') + expect(pos).to eq(18) + expect(rest).to eq("") + end + + it "extracts static const variable" do + content = "static const int MAX;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + check_single(variable, name: 'MAX', type: 'int', decorators: ['static', 'const'], text: 'int MAX;') + expect(pos).to eq(21) + expect(rest).to eq("") + end + + it "extracts register variable" do + content = "register int fast;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + # `register` is not a decorator keyword -- no stripping + expect(variable.length).to eq 1 + expect(variable[0].name).to eq 'fast' + expect(variable[0].decorators).to eq [] + expect(variable[0].text).to eq 'register int fast;' + expect(pos).to eq(18) + expect(rest).to eq("") + end + end + + context "compound variable declarations" do + it "expands two-variable compound declaration" do + content = "int x, y;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + expect(variable.length).to eq 2 + + expect(variable[0].original).to eq 'int x, y;' + expect(variable[0].name).to eq 'x' + expect(variable[0].type).to eq 'int' + expect(variable[0].decorators).to eq [] + expect(variable[0].text).to eq 'int x;' + + expect(variable[1].original).to eq 'int x, y;' + expect(variable[1].name).to eq 'y' + expect(variable[1].type).to eq 'int' + expect(variable[1].decorators).to eq [] + expect(variable[1].text).to eq 'int y;' + + expect(pos).to eq(9) + expect(rest).to eq("") + end + + it "expands three-variable compound declaration with decorator" do + content = "static int a, b, c;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + expect(variable.length).to eq 3 + + variable.each do |v| + expect(v.original).to eq 'static int a, b, c;' + expect(v.decorators).to eq ['static'] + end + + expect(variable[0].name).to eq 'a' + expect(variable[0].text).to eq 'int a;' + + expect(variable[1].name).to eq 'b' + expect(variable[1].text).to eq 'int b;' + + expect(variable[2].name).to eq 'c' + expect(variable[2].text).to eq 'int c;' + + expect(pos).to eq(19) + expect(rest).to eq("") + end + + it "expands compound declaration with pointer first declarator" do + content = "int *p, q;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + expect(variable.length).to eq 2 + + expect(variable[0].name).to eq 'p' + expect(variable[0].type).to eq 'int *' + expect(variable[0].decorators).to eq [] + expect(variable[0].text).to eq 'int *p;' + + expect(variable[1].name).to eq 'q' + expect(variable[1].type).to eq 'int' + expect(variable[1].decorators).to eq [] + expect(variable[1].text).to eq 'int q;' + + expect(pos).to eq(10) + expect(rest).to eq("") + end + + it "expands compound declaration with pointer declarators and decorator" do + content = "const char *s1, *s2;" + success, variable, pos, rest = extract_variable.call(content) + + expect(success).to be true + expect(variable.length).to eq 2 + + expect(variable[0].name).to eq 's1' + expect(variable[0].decorators).to eq ['const'] + expect(variable[0].text).to eq 'char *s1;' + + expect(variable[1].name).to eq 's2' + expect(variable[1].decorators).to eq ['const'] + expect(variable[1].text).to eq 'char *s2;' + + expect(pos).to eq(20) + expect(rest).to eq("") + end + end + + context "compiler extensions on variable declarations" do + it "extracts name and type from variable with trailing __attribute__" do + content = "int x __attribute__((aligned(16)));" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'x', type: 'int', text: 'int x __attribute__((aligned(16)));') + end + + it "extracts name and type with __attribute__ having nested args" do + content = 'char* buf __attribute__((section(".data")));' + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'buf', type: 'char*', text: 'char* buf __attribute__((section(".data")));') + end + + it "extracts clean name and type from variable with __declspec prefix" do + content = "__declspec(dllexport) int counter;" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + expect(variable[0].name).to eq('counter') + expect(variable[0].type).to eq('int') + # __declspec is not a DECORATOR_KEYWORD so it remains in .text + expect(variable[0].text).to include('int counter;') + end + + it "preserves __attribute__ in .text field for correct compilation" do + content = "int x __attribute__((aligned(16)));" + _, variable, _, _ = extract_variable.call(content) + expect(variable[0].text).to eq('int x __attribute__((aligned(16)));') + end + + it "extracts name from variable with __attribute__ and initializer" do + content = "int x __attribute__((unused)) = 0;" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + expect(variable[0].name).to eq('x') + expect(variable[0].type).to eq('int') + end + + it "extracts name with static decorator and trailing __attribute__" do + content = 'static int counter __attribute__((section(".bss")));' + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'counter', type: 'int', decorators: ['static'], + text: 'int counter __attribute__((section(".bss")));') + end + + it "extracts name and type from struct-type variable with trailing __attribute__" do + content = "struct Point my_point __attribute__((aligned(4)));" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'my_point', type: 'struct Point', + text: 'struct Point my_point __attribute__((aligned(4)));') + end + + it "extracts name and type from anonymous-struct variable with trailing __attribute__" do + content = "struct { int x; int y; } coords __attribute__((aligned(8)));" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + expect(variable[0].name).to eq('coords') + expect(variable[0].type).to eq('struct { int x; int y; }') + expect(variable[0].text).to eq('struct { int x; int y; } coords __attribute__((aligned(8)));') + end + + it "extracts name and type when __attribute__ appears on a struct member inside the body" do + content = "struct Point { int x __attribute__((aligned(4))); int y; } my_point;" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + expect(variable[0].name).to eq('my_point') + expect(variable[0].type).to eq('struct Point { int x ; int y; }') + expect(variable[0].text).to eq('struct Point { int x __attribute__((aligned(4))); int y; } my_point;') + end + end + + context "array_suffix field" do + it "returns empty string for a scalar variable" do + content = "static uint8 s_count;" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 's_count', type: 'uint8', decorators: ['static'], + text: 'uint8 s_count;', array_suffix: '') + end + + it "returns subscript for a single-dimension array" do + content = "static int arr[10];" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', decorators: ['static'], + text: 'int arr[10];', array_suffix: '[10]') + end + + it "returns both subscripts for a two-dimensional array" do + content = "static char matrix[3][4];" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'matrix', type: 'char', decorators: ['static'], + text: 'char matrix[3][4];', array_suffix: '[3][4]') + end + + it "returns macro-sized subscript for a named-constant array" do + content = "static AlertEntry_t s_table[MAX_ALERTS];" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 's_table', type: 'AlertEntry_t', decorators: ['static'], + text: 'AlertEntry_t s_table[MAX_ALERTS];', array_suffix: '[MAX_ALERTS]') + end + + it "returns empty subscript for an unsized array with initializer" do + content = "static int arr[] = {1, 2, 3};" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', decorators: ['static'], + text: 'int arr[] = {1, 2, 3};', array_suffix: '[]') + end + + it "returns subscript for a sized array with initializer" do + content = "static int arr[5] = {0};" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'arr', type: 'int', decorators: ['static'], + text: 'int arr[5] = {0};', array_suffix: '[5]') + end + + it "returns empty string for a function pointer variable" do + content = "static void (*cb)(int);" + success, variable, _, _ = extract_variable.call(content) + expect(success).to be true + check_single(variable, name: 'cb', type: 'void', decorators: ['static'], + text: 'void (*cb)(int);', array_suffix: '') + end + end + end +end diff --git a/spec/units/c_extractor/c_extractor_definitions_spec.rb b/spec/units/c_extractor/c_extractor_definitions_spec.rb new file mode 100644 index 000000000..d8f7438bc --- /dev/null +++ b/spec/units/c_extractor/c_extractor_definitions_spec.rb @@ -0,0 +1,417 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'strscan' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_definitions' + +describe CExtractorDefinitions do + + before(:each) do + code_text = CExtractorCodeText.new + @definitions = described_class.new( { c_extractor_code_text: code_text } ) + end + + # --------------------------------------------------------------------------- + context "#try_extract_typedef" do + + def try_typedef(text) + scanner = StringScanner.new(text) + result = @definitions.try_extract_typedef(scanner) + [result, scanner.pos] + end + + # --- Failure cases --- + + it "returns [false, nil] when scanner is not at typedef" do + result, pos = try_typedef('int x;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] for empty input" do + result, pos = try_typedef('') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "does not advance scanner on failure" do + scanner = StringScanner.new('int x;') + @definitions.try_extract_typedef(scanner) + expect(scanner.pos).to eq 0 + end + + it "returns [false, nil] when typedef has no terminating semicolon (EOF)" do + result, _pos = try_typedef('typedef int MyInt') + expect(result).to eq [false, nil] + end + + # --- Simple scalar and pointer typedefs --- + + it "extracts a simple scalar typedef" do + input = "typedef int MyInt;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a pointer typedef" do + input = "typedef char* StringPtr;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a const pointer typedef" do + input = "typedef const char* CStringPtr;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a typedef without trailing newline (EOS)" do + input = "typedef int MyInt;" + result, pos = try_typedef(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + # --- Function-pointer typedef --- + + it "extracts a function pointer typedef" do + input = "typedef int (*Fn)(int, char*);\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a void function pointer typedef with no parameters" do + input = "typedef void (*Callback)(void);\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- Struct / union / enum with brace bodies --- + + it "extracts a single-line struct typedef" do + input = "typedef struct { int x; int y; } Point;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a multiline struct typedef" do + input = "typedef struct {\n int x;\n int y;\n} Point;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a struct typedef with a tag name" do + input = "typedef struct Foo { int x; } Foo;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts an enum typedef" do + input = "typedef enum { RED, GREEN, BLUE } Color;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a multiline enum typedef" do + input = "typedef enum {\n RED,\n GREEN,\n BLUE\n} Color;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a union typedef" do + input = "typedef union { int i; float f; } Number;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a forward-declaration struct typedef" do + input = "typedef struct Foo Foo;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a struct with nested brace initializer in a member" do + input = "typedef struct { int flags; struct { int a; int b; } nested; } Outer;\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- Comments replaced with a single space (not preserved verbatim) --- + + it "replaces a line comment inside the typedef body with a space" do + input = "typedef struct {\n int x; // x coordinate\n int y; // y coordinate\n} Point;\n" + # Line comment (including its trailing \n) is replaced by one space; + # following two-space indent is preserved, so 4 chars between x; and int y + expected = "typedef struct {\n int x; int y; } Point;" + result, pos = try_typedef(input) + expect(result).to eq [true, expected] + expect(pos).to eq input.length + end + + it "replaces a block comment inside the typedef body with a space" do + input = "typedef struct { int x; /* width */ int y; /* height */ } Rect;\n" + # Each block comment → one space; the surrounding spaces are preserved + # so 3 chars between x; and int y (original space + comment space + space-after-comment) + expected = "typedef struct { int x; int y; } Rect;" + result, pos = try_typedef(input) + expect(result).to eq [true, expected] + expect(pos).to eq input.length + end + + it "replaces a block comment containing a semicolon with a space — does not terminate early" do + input = "typedef int /* looks; like; semicolons */ MyInt;\n" + # original space before comment + comment space + space after comment = 3 spaces + expected = "typedef int MyInt;" + result, pos = try_typedef(input) + expect(result).to eq [true, expected] + expect(pos).to eq input.length + end + + # --- String literals: still preserved verbatim --- + + it "handles a string literal containing a semicolon" do + input = "typedef char Delim[sizeof(\";\")];\n" + result, pos = try_typedef(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- Scanner position / boundary behaviour --- + + it "stops at the semicolon and does not consume following code" do + input = "typedef int MyInt;\nint x = 0;" + result, pos = try_typedef(input) + expect(result).to eq [true, "typedef int MyInt;"] + expect(pos).to eq "typedef int MyInt;\n".length + end + + it "leaves scanner position unchanged on failure" do + scanner = StringScanner.new("int x;") + scanner.pos = 0 + @definitions.try_extract_typedef(scanner) + expect(scanner.pos).to eq 0 + end + + end # #try_extract_typedef + + # --------------------------------------------------------------------------- + context "#try_extract_aggregate_definition" do + + def try_aggregate(text) + scanner = StringScanner.new(text) + result = @definitions.try_extract_aggregate_definition(scanner) + [result, scanner.pos] + end + + # --- Failure cases --- + + it "returns [false, nil] when scanner is not at struct/enum/union" do + result, pos = try_aggregate('int x;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] for empty input" do + result, pos = try_aggregate('') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "does not advance scanner on failure" do + scanner = StringScanner.new('int x;') + @definitions.try_extract_aggregate_definition(scanner) + expect(scanner.pos).to eq 0 + end + + it "returns [false, nil] for a forward declaration without body (struct Foo;)" do + result, pos = try_aggregate('struct Foo;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] for a forward declaration without body (enum Color;)" do + result, pos = try_aggregate('enum Color;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] and rolls scanner back to start when declarator follows '}'" do + result, pos = try_aggregate('struct Foo { int x; } instance;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] and rolls back for a pointer declarator after '}'" do + result, pos = try_aggregate('struct Foo { int x; } *ptr;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] when the body is missing a closing '}' (EOF)" do + result, pos = try_aggregate('struct Foo { int x;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + # --- struct --- + + it "extracts a named struct with a single-line body" do + input = "struct Foo { int x; int y; };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a named struct without trailing newline (EOS)" do + input = "struct Foo { int x; };" + result, pos = try_aggregate(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "extracts a named struct with a multiline body" do + input = "struct Point {\n int x;\n int y;\n};\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts an anonymous struct (no tag name)" do + input = "struct { int x; int y; };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- enum --- + + it "extracts a named enum with a single-line body" do + input = "enum Color { RED, GREEN, BLUE };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts a named enum with a multiline body" do + input = "enum Direction {\n NORTH,\n SOUTH,\n EAST,\n WEST\n};\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts an anonymous enum" do + input = "enum { A, B, C };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- union --- + + it "extracts a named union with a single-line body" do + input = "union Data { int i; float f; };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- Nested bodies --- + + it "extracts a struct with a nested struct member body" do + input = "struct Outer { struct Inner { int n; } inner; };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + it "extracts deeply nested struct bodies" do + input = "struct A { struct B { struct C { int x; } c; } b; };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- Comments replaced with a single space (not preserved verbatim) --- + + it "replaces a line comment inside the body with a space" do + input = "struct Foo {\n int x; // x field\n int y; // y field\n};\n" + # Same space-count logic as typedef: orig space + comment space + 2-space indent = 4 before int y + expected = "struct Foo {\n int x; int y; };" + result, pos = try_aggregate(input) + expect(result).to eq [true, expected] + expect(pos).to eq input.length + end + + it "replaces a block comment inside the body with a space" do + input = "struct Foo { int x; /* width */ int y; /* height */ };\n" + # Same as typedef: 3 chars between x; and int y (orig + comment + space-after-comment) + expected = "struct Foo { int x; int y; };" + result, pos = try_aggregate(input) + expect(result).to eq [true, expected] + expect(pos).to eq input.length + end + + it "handles a block comment between '}' and ';' — still collects as standalone" do + # Whitespace/comment in the lookahead region is committed verbatim (sliced from original string) + input = "struct Foo { int x; } /* comment */;\n" + result, pos = try_aggregate(input) + expect(result[0]).to be true + expect(pos).to eq input.length + end + + # --- String literals: verbatim (';' inside does not terminate) --- + + it "handles a string literal containing ';' in a member" do + input = "struct Foo { char delim[sizeof(\";\")]; };\n" + result, pos = try_aggregate(input) + expect(result).to eq [true, input.chomp] + expect(pos).to eq input.length + end + + # --- Whitespace between '}' and ';' --- + + it "handles whitespace between '}' and ';'" do + input = "struct Foo { int x; } ;\n" + result, pos = try_aggregate(input) + expect(result[0]).to be true + expect(pos).to eq input.length + end + + # --- Boundary behaviour --- + + it "stops at ';' and does not consume following code" do + input = "struct Foo { int x; };\nint global = 0;" + result, pos = try_aggregate(input) + expect(result[0]).to be true + expect(pos).to eq "struct Foo { int x; };\n".length + end + + it "leaves scanner at start_pos after rollback so the variable extractor can retry" do + scanner = StringScanner.new("struct Foo { int x; } instance;\nstruct Bar { int y; };") + result = @definitions.try_extract_aggregate_definition(scanner) + expect(result).to eq [false, nil] + expect(scanner.pos).to eq 0 + end + + end # #try_extract_aggregate_definition + +end diff --git a/spec/units/c_extractor/c_extractor_functions_spec.rb b/spec/units/c_extractor/c_extractor_functions_spec.rb new file mode 100644 index 000000000..bf613a735 --- /dev/null +++ b/spec/units/c_extractor/c_extractor_functions_spec.rb @@ -0,0 +1,2742 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_functions' +require 'ceedling/c_extractor/c_extractor_types' +require 'stringio' + +describe CExtractorFunctions do + + ### + ### extract_function_signature() + ### + + describe "#extract_function_signature (private method testing)" do + # Helper to access private method + let(:extract_signature) do + ->(content, type, max_line_length=1000) do + scanner = StringScanner.new(content) + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = max_line_length + signature = functions.send( :extract_function_signature, scanner, type ) + return [signature, scanner.pos, scanner.rest] + end + end + + context "simple signatures from function definitions" do + it "extracts void function signature with void parameters" do + content = "void foo(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void foo(void)") + expect(pos).to eq(14) + expect(rest).to eq("{") + end + + it "extracts void function signature with no parameters" do + content = "void foo(){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void foo()") + expect(pos).to eq(10) + expect(rest).to eq("{") + end + + it "extracts void function signature with no parameters and brace after newline" do + content = "void foo()\n{" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void foo()") + expect(pos).to eq(11) + expect(rest).to eq("{") + end + + it "extracts int function signature with no parameters and whitespace between signature and function body brace" do + content = "int bar(void) {" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int bar(void)") + expect(pos).to eq(17) + expect(rest).to eq("{") + end + + it "extracts signature followed by line comment" do + content = "void foo(void) // comment\n{" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void foo(void)") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + + it "extracts function signature with single parameter and comment between signature and function body brace" do + content = "int add(int x)/* */{ int a;" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int add(int x)") + expect(pos).to eq(19) + expect(rest).to eq("{ int a;") + end + + it "extracts function signature with multiple parameters" do + content = "int multiply(int a, int b){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int multiply(int a, int b)") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + + it "extracts function signature returning pointer" do + content = "char* getString(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("char* getString(void)") + expect(pos).to eq(21) + expect(rest).to eq("{") + end + + it "extracts function signature with pointer parameter" do + content = "void process(int* ptr){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void process(int* ptr)") + expect(pos).to eq(22) + expect(rest).to eq("{") + end + end + + context "function declarations" do + it "does not extract signature from declaration" do + content = "void process(int* ptr);" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("void process(int* ptr);") + end + + it "does not extract signature from declaration with whitespace" do + content = "void process(int* ptr) ;" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("void process(int* ptr) ;") + end + + it "does not extract signature from declaration with comment" do + content = "void process(int* ptr)/***/;" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("void process(int* ptr)/***/;") + end + + it "does not extract signature from declaration with newline" do + content = "void process(int* ptr)\n;" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("void process(int* ptr)\n;") + end + + it "does extract declaration" do + content = "void process(int* ptr);" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to eq("void process(int* ptr);") + expect(pos).to eq(23) + expect(rest).to eq("") + end + + it "does extract declaration with whitespace" do + content = "void process(int* ptr) ;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to eq("void process(int* ptr);") + expect(pos).to eq(28) + expect(rest).to eq("") + end + + it "does extract declaration with comment" do + content = "void process(int* ptr)/***/;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to eq("void process(int* ptr);") + expect(pos).to eq(28) + expect(rest).to eq("") + end + + it "does extract declaration with newline" do + content = "void process(int* ptr)\n;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to eq("void process(int* ptr);") + expect(pos).to eq(24) + expect(rest).to eq("") + end + end + + context "rejecting variable declarations when extracting function declarations" do + it "rejects simple variable declaration" do + content = "int x;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int x;") + end + + it "rejects pointer variable declaration" do + content = "int* ptr;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int* ptr;") + end + + it "rejects array variable declaration" do + content = "int arr[10];" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int arr[10];") + end + + it "rejects function pointer variable declaration" do + content = "int (*func_ptr)(int, int);" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int (*func_ptr)(int, int);") + end + + it "rejects const variable declaration" do + content = "const int MAX_VALUE;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("const int MAX_VALUE;") + end + + it "rejects static variable declaration" do + content = "static int counter;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("static int counter;") + end + + it "rejects struct variable declaration" do + content = "struct point p;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("struct point p;") + end + + it "rejects variable with initializer" do + content = "int value = 42;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int value = 42;") + end + + it "rejects array with initializer" do + content = "int arr[] = {1, 2, 3};" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int arr[] = {1, 2, 3};") + end + + it "rejects string variable declaration" do + content = 'char str[] = "hello";' + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq('char str[] = "hello";') + end + + it "rejects extern variable declaration" do + content = "extern int global_var;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("extern int global_var;") + end + + it "rejects volatile variable declaration" do + content = "volatile int flag;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("volatile int flag;") + end + + it "rejects typedef'd type variable declaration" do + content = "size_t length;" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("size_t length;") + end + + it "rejects multi-dimensional array declaration" do + content = "int matrix[3][4];" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int matrix[3][4];") + end + + it "rejects pointer to array declaration" do + content = "int (*ptr)[10];" + signature, pos, rest = extract_signature.call(content, :declaration) + + expect(signature).to be_nil + expect(pos).to eq(0) + expect(rest).to eq("int (*ptr)[10];") + end + end + + context "function signatures with whitespace variations" do + it "extracts signature with extra spaces" do + content = "int foo ( int x ){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int foo ( int x )") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + + it "extracts clean signature from one with tabs" do + content = "int\tfoo\t(\tint\tx\t){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int foo ( int x )") + expect(pos).to eq(17) + expect(rest).to eq("{") + end + + it "extracts clean signature from one with newlines" do + content = "int\nfoo\n(\nint x\n){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int foo ( int x )") + expect(pos).to eq(17) + expect(rest).to eq("{") + end + + it "extracts clean signature from one with mixed whitespace" do + content = "int \t\n foo \t\n ( \t\n int x \t\n ){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int foo ( int x )") + expect(pos).to eq(29) + expect(rest).to eq("{") + end + end + + context "complex return types" do + it "extracts function returning struct" do + content = "struct point getPoint(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("struct point getPoint(void)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function returning pointer to struct" do + content = "struct node* getNode(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("struct node* getNode(void)") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + + it "extracts function returning const pointer" do + content = "const char* getMessage(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("const char* getMessage(void)") + expect(pos).to eq(28) + expect(rest).to eq("{") + end + + it "extracts function returning pointer to const" do + content = "char* const getBuffer(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("char* const getBuffer(void)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function returning unsigned type" do + content = "unsigned int getValue(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("unsigned int getValue(void)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function returning long long" do + content = "long long getBigValue(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("long long getBigValue(void)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function returning enum" do + content = "enum status getStatus(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("enum status getStatus(void)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function returning typedef'd type" do + content = "size_t getSize(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("size_t getSize(void)") + expect(pos).to eq(20) + expect(rest).to eq("{") + end + + it "extracts function returning double pointer" do + content = "char** getStringArray(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("char** getStringArray(void)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + end + + context "complex parameter types" do + it "extracts function with array parameter" do + content = "void process(int arr[]){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void process(int arr[])") + expect(pos).to eq(23) + expect(rest).to eq("{") + end + + it "extracts function with sized array parameter" do + content = "void process(int arr[10]){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void process(int arr[10])") + expect(pos).to eq(25) + expect(rest).to eq("{") + end + + it "extracts function with const parameter" do + content = "void print(const char* str){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void print(const char* str)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function with struct parameter" do + content = "void update(struct data d){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void update(struct data d)") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + + it "extracts function with pointer to struct parameter" do + content = "void modify(struct node* n){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void modify(struct node* n)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + + it "extracts function with multiple complex parameters" do + content = "int compare(const char* s1, const char* s2, size_t len){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int compare(const char* s1, const char* s2, size_t len)") + expect(pos).to eq(55) + expect(rest).to eq("{") + end + + it "extracts function with function pointer parameter" do + content = "void callback(void (*func)(int)){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void callback(void (*func)(int))") + expect(pos).to eq(32) + expect(rest).to eq("{") + end + + it "extracts function with complex function pointer parameter" do + content = "void register(int (*compare)(const void*, const void*)){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void register(int (*compare)(const void*, const void*))") + expect(pos).to eq(55) + expect(rest).to eq("{") + end + + it "extracts function with double pointer parameter" do + content = "void allocate(char** buffer){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void allocate(char** buffer)") + expect(pos).to eq(28) + expect(rest).to eq("{") + end + + it "extracts function with enum parameter" do + content = "void setState(enum state s){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void setState(enum state s)") + expect(pos).to eq(27) + expect(rest).to eq("{") + end + end + + context "function signatures with storage class specifiers" do + it "extracts static function" do + content = "static int helper(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("static int helper(void)") + expect(pos).to eq(23) + expect(rest).to eq("{") + end + + it "extracts inline function" do + content = "inline int fast(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("inline int fast(void)") + expect(pos).to eq(21) + expect(rest).to eq("{") + end + + it "extracts extern function" do + content = "extern void external(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("extern void external(void)") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + + it "extracts static inline function" do + content = "static inline int optimize(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("static inline int optimize(void)") + expect(pos).to eq(32) + expect(rest).to eq("{") + end + end + + context "function signatures with qualifiers" do + it "extracts function with const qualifier" do + content = "const int getValue(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("const int getValue(void)") + expect(pos).to eq(24) + expect(rest).to eq("{") + end + + it "extracts function with volatile qualifier" do + content = "volatile int getRegister(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("volatile int getRegister(void)") + expect(pos).to eq(30) + expect(rest).to eq("{") + end + + it "extracts function with restrict qualifier" do + content = "void copy(char* restrict dest, const char* restrict src){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void copy(char* restrict dest, const char* restrict src)") + expect(pos).to eq(56) + expect(rest).to eq("{") + end + + it "extracts function with multiple qualifiers" do + content = "static const volatile int getSpecial(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("static const volatile int getSpecial(void)") + expect(pos).to eq(42) + expect(rest).to eq("{") + end + end + + context "function signatures with variadic parameters" do + it "extracts function with variadic parameters" do + content = "int printf(const char* format, ...){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int printf(const char* format, ...)") + expect(pos).to eq(35) + expect(rest).to eq("{") + end + + it "extracts function with only variadic parameters" do + content = "void log(...){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void log(...)") + expect(pos).to eq(13) + expect(rest).to eq("{") + end + + it "extracts function with multiple parameters and variadic" do + content = "int sprintf(char* buffer, const char* format, ...){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int sprintf(char* buffer, const char* format, ...)") + expect(pos).to eq(50) + expect(rest).to eq("{") + end + end + + context "function signatures with nested parentheses" do + it "extracts signature with function pointer return type" do + content = "int (*getFunction(void))(int, int){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int (*getFunction(void))(int, int)") + expect(pos).to eq(34) + expect(rest).to eq("{") + end + + it "extracts signature with complex function pointer parameter" do + content = "void sort(int* array, int (*compare)(int, int)){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void sort(int* array, int (*compare)(int, int))") + expect(pos).to eq(47) + expect(rest).to eq("{") + end + + it "extracts signature with multiple function pointer parameters" do + content = "void process(void (*init)(void), void (*cleanup)(void)){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void process(void (*init)(void), void (*cleanup)(void))") + expect(pos).to eq(55) + expect(rest).to eq("{") + end + + it "extracts signature with nested function pointers" do + content = "void register(void (*callback)(int (*)(void))){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void register(void (*callback)(int (*)(void)))") + expect(pos).to eq(46) + expect(rest).to eq("{") + end + + it "extracts signature with array of function pointers" do + content = "void dispatch(void (*handlers[])(int)){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void dispatch(void (*handlers[])(int))") + expect(pos).to eq(38) + expect(rest).to eq("{") + end + end + + context "function signatures with strings and comments" do + it "extracts signature with string in default parameter (C++ style, but testing robustness)" do + content = 'void log(const char* msg = "default"){' + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq('void log(const char* msg = "default")') + expect(pos).to eq(37) + expect(rest).to eq("{") + end + + it "extracts signature with parentheses in string" do + content = 'void print(const char* format = "value: (%d)"){' + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq('void print(const char* format = "value: (%d)")') + expect(pos).to eq(46) + expect(rest).to eq("{") + end + + it "extracts signature with character literal containing parenthesis" do + content = "void process(char c = ')'){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void process(char c = ')')") + expect(pos).to eq(26) + expect(rest).to eq("{") + end + end + + context "edge cases and boundary conditions" do + it "extracts very long signature" do + params = (1..50).map { |i| "int param#{i}" }.join(", ") + content = "void longFunction(#{params})" + signature, pos, rest = extract_signature.call(content + '{}', :definition) + + expect(signature).to eq(content) + expect(pos).to eq(content.length) + expect(rest).to eq("{}") + end + + it "extracts signature with deeply nested parentheses" do + content = "void complex(int (*(*(*f)(int))(int))(int)){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void complex(int (*(*(*f)(int))(int))(int))") + expect(pos).to eq(43) + expect(rest).to eq("{") + end + end + + context "real-world C function patterns" do + it "extracts main function signature" do + content = "int main(int argc, char* argv[]){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int main(int argc, char* argv[])") + expect(pos).to eq(32) + expect(rest).to eq("{") + end + + it "extracts signal handler signature" do + content = "void signal_handler(int signum){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void signal_handler(int signum)") + expect(pos).to eq(31) + expect(rest).to eq("{") + end + + it "extracts qsort compare function signature" do + content = "int compare(const void* a, const void* b){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("int compare(const void* a, const void* b)") + expect(pos).to eq(41) + expect(rest).to eq("{") + end + + it "extracts pthread function signature" do + content = "void* thread_function(void* arg){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void* thread_function(void* arg)") + expect(pos).to eq(32) + expect(rest).to eq("{") + end + + it "extracts interrupt handler signature" do + content = "void __attribute__((interrupt)) ISR_Handler(void){" + signature, pos, rest = extract_signature.call(content, :definition) + + expect(signature).to eq("void __attribute__((interrupt)) ISR_Handler(void)") + expect(pos).to eq(49) + expect(rest).to eq("{") + end + end + end + + ### + ### extract_function_name() + ### + + describe "#extract_function_name (private method testing)" do + # Helper to access private method + let(:parse_name) do + ->(signature) do + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = 1000 + name = functions.send( :extract_function_name, signature ) + return name + end + end + + context "simple function names" do + it "extracts name from void function with void parameters" do + signature = "void foo(void)" + name = parse_name.call(signature) + + expect(name).to eq("foo") + end + + it "extracts name from int function with no parameters" do + signature = "int bar()" + name = parse_name.call(signature) + + expect(name).to eq("bar") + end + + it "extracts name from function with single parameter" do + signature = "int add(int x)" + name = parse_name.call(signature) + + expect(name).to eq("add") + end + + it "extracts name from function with multiple parameters" do + signature = "int multiply(int a, int b)" + name = parse_name.call(signature) + + expect(name).to eq("multiply") + end + + it "extracts name from function returning pointer" do + signature = "char* getString(void)" + name = parse_name.call(signature) + + expect(name).to eq("getString") + end + + it "extracts name from function with pointer parameter" do + signature = "void process(int* ptr)" + name = parse_name.call(signature) + + expect(name).to eq("process") + end + end + + context "function names with whitespace variations" do + it "extracts name with extra spaces before parenthesis" do + signature = "int foo (int x)" + name = parse_name.call(signature) + + expect(name).to eq("foo") + end + + it "extracts name with tabs before parenthesis" do + signature = "int\tfoo\t(int x)" + name = parse_name.call(signature) + + expect(name).to eq("foo") + end + + it "extracts name with newlines before parenthesis" do + signature = "int\nfoo\n(int x)" + name = parse_name.call(signature) + + expect(name).to eq("foo") + end + + it "extracts name with mixed whitespace" do + signature = "int \t\n foo \t\n (int x)" + name = parse_name.call(signature) + + expect(name).to eq("foo") + end + end + + context "complex return types" do + it "extracts name from function returning struct" do + signature = "struct point getPoint(void)" + name = parse_name.call(signature) + + expect(name).to eq("getPoint") + end + + it "extracts name from function returning pointer to struct" do + signature = "struct node* getNode(void)" + name = parse_name.call(signature) + + expect(name).to eq("getNode") + end + + it "extracts name from function returning const pointer" do + signature = "const char* getMessage(void)" + name = parse_name.call(signature) + + expect(name).to eq("getMessage") + end + + it "extracts name from function returning pointer to const" do + signature = "char* const getBuffer(void)" + name = parse_name.call(signature) + + expect(name).to eq("getBuffer") + end + + it "extracts name from function returning unsigned type" do + signature = "unsigned int getValue(void)" + name = parse_name.call(signature) + + expect(name).to eq("getValue") + end + + it "extracts name from function returning long long" do + signature = "long long getBigValue(void)" + name = parse_name.call(signature) + + expect(name).to eq("getBigValue") + end + + it "extracts name from function returning enum" do + signature = "enum status getStatus(void)" + name = parse_name.call(signature) + + expect(name).to eq("getStatus") + end + + it "extracts name from function returning typedef'd type" do + signature = "size_t getSize(void)" + name = parse_name.call(signature) + + expect(name).to eq("getSize") + end + + it "extracts name from function returning double pointer" do + signature = "char** getStringArray(void)" + name = parse_name.call(signature) + + expect(name).to eq("getStringArray") + end + end + + context "function names with storage class specifiers" do + it "extracts name from static function" do + signature = "static int helper(void)" + name = parse_name.call(signature) + + expect(name).to eq("helper") + end + + it "extracts name from inline function" do + signature = "inline int fast(void)" + name = parse_name.call(signature) + + expect(name).to eq("fast") + end + + it "extracts name from extern function" do + signature = "extern void external(void)" + name = parse_name.call(signature) + + expect(name).to eq("external") + end + + it "extracts name from static inline function" do + signature = "static inline int optimize(void)" + name = parse_name.call(signature) + + expect(name).to eq("optimize") + end + end + + context "function names with qualifiers" do + it "extracts name from function with const qualifier" do + signature = "const int getValue(void)" + name = parse_name.call(signature) + + expect(name).to eq("getValue") + end + + it "extracts name from function with volatile qualifier" do + signature = "volatile int getRegister(void)" + name = parse_name.call(signature) + + expect(name).to eq("getRegister") + end + + it "extracts name from function with multiple qualifiers" do + signature = "static const volatile int getSpecial(void)" + name = parse_name.call(signature) + + expect(name).to eq("getSpecial") + end + end + + context "function names with nested parentheses" do + it "extracts name from function pointer return type" do + signature = "int (*getFunction(void))(int, int)" + name = parse_name.call(signature) + + expect(name).to eq("getFunction") + end + + it "extracts name from function with function pointer parameter" do + signature = "void sort(int* array, int (*compare)(int, int))" + name = parse_name.call(signature) + + expect(name).to eq("sort") + end + + it "extracts name from function with multiple function pointer parameters" do + signature = "void process(void (*init)(void), void (*cleanup)(void))" + name = parse_name.call(signature) + + expect(name).to eq("process") + end + + it "extracts name from function with nested function pointers" do + signature = "void register(void (*callback)(int (*)(void)))" + name = parse_name.call(signature) + + expect(name).to eq("register") + end + + it "extracts name from function with array of function pointers" do + signature = "void dispatch(void (*handlers[])(int))" + name = parse_name.call(signature) + + expect(name).to eq("dispatch") + end + end + + context "function names with underscores and naming conventions" do + it "extracts name with leading underscore" do + signature = "void _internal(void)" + name = parse_name.call(signature) + + expect(name).to eq("_internal") + end + + it "extracts name with double leading underscore" do + signature = "void __private(void)" + name = parse_name.call(signature) + + expect(name).to eq("__private") + end + + it "extracts name with trailing underscore" do + signature = "void function_(void)" + name = parse_name.call(signature) + + expect(name).to eq("function_") + end + + it "extracts name with multiple underscores" do + signature = "void my_long_function_name(void)" + name = parse_name.call(signature) + + expect(name).to eq("my_long_function_name") + end + + it "extracts camelCase name" do + signature = "void myFunctionName(void)" + name = parse_name.call(signature) + + expect(name).to eq("myFunctionName") + end + + it "extracts PascalCase name" do + signature = "void MyFunctionName(void)" + name = parse_name.call(signature) + + expect(name).to eq("MyFunctionName") + end + + it "extracts UPPER_CASE name" do + signature = "void MY_FUNCTION(void)" + name = parse_name.call(signature) + + expect(name).to eq("MY_FUNCTION") + end + end + + context "function names with numbers" do + it "extracts name with trailing number" do + signature = "void function1(void)" + name = parse_name.call(signature) + + expect(name).to eq("function1") + end + + it "extracts name with embedded numbers" do + signature = "void func2tion3(void)" + name = parse_name.call(signature) + + expect(name).to eq("func2tion3") + end + + it "extracts name with multiple numbers" do + signature = "void test123(void)" + name = parse_name.call(signature) + + expect(name).to eq("test123") + end + end + + context "real-world C function patterns" do + it "extracts name from main function" do + signature = "int main(int argc, char* argv[])" + name = parse_name.call(signature) + + expect(name).to eq("main") + end + + it "extracts name from signal handler" do + signature = "void signal_handler(int signum)" + name = parse_name.call(signature) + + expect(name).to eq("signal_handler") + end + + it "extracts name from qsort compare function" do + signature = "int compare(const void* a, const void* b)" + name = parse_name.call(signature) + + expect(name).to eq("compare") + end + + it "extracts name from pthread function" do + signature = "void* thread_function(void* arg)" + name = parse_name.call(signature) + + expect(name).to eq("thread_function") + end + + it "extracts name from interrupt handler with attributes" do + signature = "void __attribute__((interrupt)) ISR_Handler(void)" + name = parse_name.call(signature) + + expect(name).to eq("ISR_Handler") + end + + it "extracts name from constructor function" do + signature = "void __attribute__((constructor)) init_module(void)" + name = parse_name.call(signature) + + expect(name).to eq("init_module") + end + + it "extracts name from destructor function" do + signature = "void __attribute__((destructor)) cleanup_module(void)" + name = parse_name.call(signature) + + expect(name).to eq("cleanup_module") + end + end + + context "edge cases" do + it "extracts name from very long signature" do + params = (1..50).map { |i| "int param#{i}" }.join(", ") + signature = "void longFunctionName(#{params})" + name = parse_name.call(signature) + + expect(name).to eq("longFunctionName") + end + + it "extracts name with deeply nested parentheses in parameters" do + signature = "void complex(int (*(*f)(int (*)(void)))(void))" + name = parse_name.call(signature) + + expect(name).to eq("complex") + end + + it "extracts name from function with array parameters with sizes" do + signature = "void matrix(int arr[10][20][30])" + name = parse_name.call(signature) + + expect(name).to eq("matrix") + end + + it "extracts name from function with variadic parameters" do + signature = "int printf(const char* format, ...)" + name = parse_name.call(signature) + + expect(name).to eq("printf") + end + + it "extracts name from function with restrict qualifier" do + signature = "void copy(char* restrict dest, const char* restrict src)" + name = parse_name.call(signature) + + expect(name).to eq("copy") + end + + it "extracts name from function with _Noreturn specifier" do + signature = "_Noreturn void exit_program(int code)" + name = parse_name.call(signature) + + expect(name).to eq("exit_program") + end + + it "extracts name from function with __declspec" do + signature = "__declspec(dllexport) void exported_function(void)" + name = parse_name.call(signature) + + expect(name).to eq("exported_function") + end + + it "extracts name from function with multiple pointer levels" do + signature = "void*** getTriplePointer(void)" + name = parse_name.call(signature) + + expect(name).to eq("getTriplePointer") + end + + it "extracts name from function with const pointer to const" do + signature = "const char* const getConstString(void)" + name = parse_name.call(signature) + + expect(name).to eq("getConstString") + end + + it "extracts name from function with volatile pointer" do + signature = "volatile int* getVolatilePtr(void)" + name = parse_name.call(signature) + + expect(name).to eq("getVolatilePtr") + end + + it "extracts name from function with struct tag and pointer" do + signature = "struct my_struct* create_struct(void)" + name = parse_name.call(signature) + + expect(name).to eq("create_struct") + end + + it "extracts name from function with union return type" do + signature = "union data getData(void)" + name = parse_name.call(signature) + + expect(name).to eq("getData") + end + + it "extracts name from function with typedef'd struct" do + signature = "MyStruct_t createMyStruct(void)" + name = parse_name.call(signature) + + expect(name).to eq("createMyStruct") + end + + it "extracts name from function with anonymous struct parameter" do + signature = "void process(struct { int x; int y; } point)" + name = parse_name.call(signature) + + expect(name).to eq("process") + end + + it "extracts name from function with bit field in struct parameter" do + signature = "void setBits(struct flags { unsigned int a:1; unsigned int b:1; } f)" + name = parse_name.call(signature) + + expect(name).to eq("setBits") + end + + it "extracts name from function with complex spacing around asterisks" do + signature = "char * * * getMultiPointer(void)" + name = parse_name.call(signature) + + expect(name).to eq("getMultiPointer") + end + + it "extracts name from function with register storage class" do + signature = "register int fastFunction(void)" + name = parse_name.call(signature) + + expect(name).to eq("fastFunction") + end + + it "extracts name from function with auto storage class" do + signature = "auto void localFunction(void)" + name = parse_name.call(signature) + + expect(name).to eq("localFunction") + end + + it "extracts name from function with _Thread_local specifier" do + signature = "_Thread_local int getThreadLocal(void)" + name = parse_name.call(signature) + + expect(name).to eq("getThreadLocal") + end + + it "extracts name from function with _Atomic qualifier" do + signature = "_Atomic int getAtomic(void)" + name = parse_name.call(signature) + + expect(name).to eq("getAtomic") + end + + it "extracts name from function with _Bool return type" do + signature = "_Bool isValid(void)" + name = parse_name.call(signature) + + expect(name).to eq("isValid") + end + + it "extracts name from function with _Complex type" do + signature = "double _Complex getComplex(void)" + name = parse_name.call(signature) + + expect(name).to eq("getComplex") + end + + it "extracts name from function with _Imaginary type" do + signature = "double _Imaginary getImaginary(void)" + name = parse_name.call(signature) + + expect(name).to eq("getImaginary") + end + + it "extracts name from function with GCC __attribute__ before name" do + signature = "void __attribute__((always_inline)) inlineFunc(void)" + name = parse_name.call(signature) + + expect(name).to eq("inlineFunc") + end + + it "extracts name from function with multiple __attribute__ specifiers" do + signature = "void __attribute__((noreturn)) __attribute__((cold)) exitFunc(void)" + name = parse_name.call(signature) + + expect(name).to eq("exitFunc") + end + + it "extracts name from function with __asm__ label" do + signature = "void myFunction(void) __asm__(\"_myFunction\")" + name = parse_name.call(signature) + + expect(name).to eq("myFunction") + end + + it "extracts name from function with Windows calling convention" do + signature = "void __stdcall WindowsFunc(void)" + name = parse_name.call(signature) + + expect(name).to eq("WindowsFunc") + end + + it "extracts name from function with __cdecl calling convention" do + signature = "void __cdecl CFunc(void)" + name = parse_name.call(signature) + + expect(name).to eq("CFunc") + end + + it "extracts name from function with __fastcall calling convention" do + signature = "void __fastcall FastFunc(void)" + name = parse_name.call(signature) + + expect(name).to eq("FastFunc") + end + + it "extracts name from function with mixed qualifiers and specifiers" do + signature = "static inline const volatile unsigned long long int complexFunc(void)" + name = parse_name.call(signature) + + expect(name).to eq("complexFunc") + end + + it "extracts name from function with array of pointers parameter" do + signature = "void processArray(int* arr[10])" + name = parse_name.call(signature) + + expect(name).to eq("processArray") + end + + it "extracts name from function with pointer to array parameter" do + signature = "void processPointerToArray(int (*arr)[10])" + name = parse_name.call(signature) + + expect(name).to eq("processPointerToArray") + end + + it "extracts name from function with array of function pointers" do + signature = "void dispatch(void (*handlers[10])(int))" + name = parse_name.call(signature) + + expect(name).to eq("dispatch") + end + + it "extracts name from function returning pointer to array" do + signature = "int (*getArray(void))[10]" + name = parse_name.call(signature) + + expect(name).to eq("getArray") + end + + it "extracts name from function with VLA parameter" do + signature = "void processVLA(int n, int arr[n])" + name = parse_name.call(signature) + + expect(name).to eq("processVLA") + end + + it "extracts name from function with static array parameter" do + signature = "void processStatic(int arr[static 10])" + name = parse_name.call(signature) + + expect(name).to eq("processStatic") + end + + it "extracts name from function with restrict and const" do + signature = "void copy(char* restrict const dest, const char* restrict src)" + name = parse_name.call(signature) + + expect(name).to eq("copy") + end + end + + context "malformed or unusual signatures" do + it "returns nil for empty signature" do + signature = "" + name = parse_name.call(signature) + + expect(name).to be_nil + end + + it "returns nil for signature with no parentheses" do + signature = "void foo" + name = parse_name.call(signature) + + expect(name).to be_nil + end + + it "returns nil for signature with only return type" do + signature = "int" + name = parse_name.call(signature) + + expect(name).to be_nil + end + + it "returns nil for signature with only opening parenthesis" do + signature = "void foo(" + name = parse_name.call(signature) + + expect(name).to be_nil + end + + it "return nil for signature with unbalanced parentheses" do + signature = "void foo(int x" + name = parse_name.call(signature) + + expect(name).to be_nil + end + end + + context "MSVC __forceinline specifier" do + it "extracts name from __forceinline prefixed function" do + name = parse_name.call("__forceinline void foo(void)") + expect(name).to eq("foo") + end + + it "extracts name from static __forceinline function" do + name = parse_name.call("static __forceinline int compute(int x)") + expect(name).to eq("compute") + end + + it "extracts name from __forceinline with pointer return type" do + name = parse_name.call("__forceinline char* getStr(void)") + expect(name).to eq("getStr") + end + end + end + + ### + ### try_extract_function_definition() + ### + + describe "#try_extract_function_definition" do + # Helper to access private method and extract function from content + let(:try_extract) do + ->(content, filepath='/path/to/source.c') do + scanner = StringScanner.new(content) + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = 1000 + success, func = functions.try_extract_function_definition( scanner, filepath ) + return [success, func, scanner.pos, scanner.rest] + end + end + + context "successful function extraction" do + it "extracts simple void function" do + content = "void foo(void) { int a = 1; }" + success, func, pos, rest = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("foo") + expect(func.signature).to eq("void foo(void)") + expect(func.body).to eq("{ int a = 1; }") + expect(func.code_block).to eq(content) + expect(func.line_count).to eq(1) + expect(func.source_filepath).to eq('/path/to/source.c') + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "assigns source_filepath from filepath argument" do + content = "void foo(void) { return; }" + success, func, _, _ = try_extract.call(content, '/custom/path/module.c') + + expect(success).to be true + expect(func.source_filepath).to eq('/custom/path/module.c') + end + + it "extracts function with return value" do + content = "int add(int a, int b) { return a + b; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("add") + expect(func.signature).to eq("int add(int a, int b)") + expect(func.body).to eq("{ return a + b; }") + expect(func.code_block).to eq(content) + expect(func.line_count).to eq(1) + end + + it "extracts multi-line function" do + content = <<~CONTENT + void process(void) { + int x = 1; + int y = 2; + return x + y; + } + CONTENT + + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("process") + expect(func.signature).to eq("void process(void)") + expect(func.body).to eq("{\n int x = 1;\n int y = 2;\n return x + y;\n}") + expect(func.code_block).to eq(content.strip()) + expect(func.line_count).to eq(5) + end + + it "extracts function with nested braces" do + content = "void nested(void) { if (x) { do_something(); } }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("nested") + expect(func.signature).to eq("void nested(void)") + expect(func.code_block).to eq(content) + expect(func.body).to eq("{ if (x) { do_something(); } }") + end + + it "extracts function with complex parameters" do + content = "void callback(void (*func)(int), int* data) { func(*data); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("callback") + expect(func.signature).to eq("void callback(void (*func)(int), int* data)") + expect(func.body).to eq("{ func(*data); }") + expect(func.code_block).to eq(content) + end + + it "extracts function with pointer return type" do + content = "char* getString(void) { return \"hello\"; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("getString") + expect(func.signature).to eq("char* getString(void)") + expect(func.body).to eq("{ return \"hello\"; }") + expect(func.code_block).to eq(content) + end + + it "extracts function with struct return type" do + content = "struct point getPoint(void) { struct point p = {0, 0}; return p; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("getPoint") + expect(func.signature).to eq("struct point getPoint(void)") + expect(func.body).to eq("{ struct point p = {0, 0}; return p; }") + expect(func.code_block).to eq(content) + end + + it "extracts static function" do + content = "static int helper(void) { return 42; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("helper") + expect(func.signature).to eq("static int helper(void)") + expect(func.body).to eq("{ return 42; }") + expect(func.code_block).to eq(content) + end + + it "extracts inline function" do + content = "inline int fast(void) { return 1; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("fast") + expect(func.signature).to eq("inline int fast(void)") + expect(func.body).to eq("{ return 1; }") + expect(func.code_block).to eq(content) + end + + it "extracts function with attributes" do + content = "void __attribute__((interrupt)) ISR(void) { handle_interrupt(); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("ISR") + expect(func.signature).to eq("void __attribute__((interrupt)) ISR(void)") + expect(func.body).to eq("{ handle_interrupt(); }") + expect(func.code_block).to eq(content) + end + + it "extracts function with whitespace before opening brace" do + content = "void foo(void) \n\t { return; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("foo") + expect(func.signature).to eq("void foo(void)") + expect(func.body).to eq("{ return; }") + expect(func.code_block).to eq(content) + end + + it "extracts function with array parameters" do + content = "void process(int arr[10]) { arr[0] = 1; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("process") + expect(func.signature).to eq("void process(int arr[10])") + expect(func.body).to eq("{ arr[0] = 1; }") + expect(func.code_block).to eq(content) + end + + it "extracts function with variadic parameters" do + content = "int printf(const char* fmt, ...) { return 0; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("printf") + expect(func.signature).to eq("int printf(const char* fmt, ...)") + expect(func.body).to eq("{ return 0; }") + expect(func.code_block).to eq(content) + end + + it "extracts function with const parameters" do + content = "void print(const char* str) { puts(str); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("print") + expect(func.signature).to eq("void print(const char* str)") + expect(func.body).to eq("{ puts(str); }") + expect(func.code_block).to eq(content) + end + + it "extracts function with volatile parameters" do + content = "int read(volatile int* reg) { return *reg; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("read") + expect(func.signature).to eq("int read(volatile int* reg)") + expect(func.body).to eq("{ return *reg; }") + expect(func.code_block).to eq(content) + end + + it "extracts function with restrict qualifier" do + content = "void copy(char* restrict dst, const char* restrict src) { strcpy(dst, src); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("copy") + expect(func.signature).to eq("void copy(char* restrict dst, const char* restrict src)") + expect(func.body).to eq("{ strcpy(dst, src); }") + expect(func.code_block).to eq(content) + end + + it "extracts function with deeply nested braces" do + content = "void deep(void) { if (a) { while (b) { for (;;) { break; } } } }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("deep") + expect(func.body).to eq("{ if (a) { while (b) { for (;;) { break; } } } }") + end + + it "extracts function with string literals containing braces" do + content = 'void print(void) { printf("{ } braces"); }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("print") + expect(func.body).to eq('{ printf("{ } braces"); }') + end + + it "extracts function with character literals containing braces" do + content = "void check(void) { char c = '{'; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("check") + expect(func.body).to eq("{ char c = '{'; }") + end + + it "extracts function with comments containing braces (block comment replaced with space)" do + content = "void func(void) { /* { comment } */ int x = 1; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("func") + expect(func.body).to eq("{ int x = 1; }") # space + comment→space + space after comment + end + + it "extracts function with line comments containing braces (line comment replaced with space)" do + content = "void func(void) { // { comment }\nint x = 1; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("func") + expect(func.body).to eq("{ int x = 1; }") # space + comment+newline→space; no leading space on next line + end + + it "extracts function with struct initialization" do + content = "void init(void) { struct point p = {1, 2}; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("init") + expect(func.body).to eq("{ struct point p = {1, 2}; }") + end + + it "extracts function with array initialization" do + content = "void setup(void) { int arr[] = {1, 2, 3}; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("setup") + expect(func.body).to eq("{ int arr[] = {1, 2, 3}; }") + end + + it "extracts function with compound literal" do + content = "void use(void) { process((struct point){1, 2}); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("use") + expect(func.body).to eq("{ process((struct point){1, 2}); }") + end + + it "extracts function with designated initializers" do + content = "void init(void) { struct point p = {.x = 1, .y = 2}; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("init") + expect(func.body).to eq("{ struct point p = {.x = 1, .y = 2}; }") + end + end + + context "function extraction with various body styles" do + it "extracts function with empty body" do + content = "void empty(void) {}" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("empty") + expect(func.body).to eq("{}") + expect(func.line_count).to eq(1) + end + + it "extracts function with only whitespace in body" do + content = "void whitespace(void) { \n\t }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("whitespace") + expect(func.body).to eq("{ \n\t }") + end + + it "extracts function with single statement" do + content = "void single(void) { return; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("single") + expect(func.body).to eq("{ return; }") + end + + it "extracts function with multiple statements" do + content = "void multi(void) { int a = 1; int b = 2; return; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("multi") + expect(func.body).to eq("{ int a = 1; int b = 2; return; }") + end + + it "extracts function with switch statement" do + content = "void switcher(int x) { switch(x) { case 1: break; default: break; } }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("switcher") + expect(func.body).to eq("{ switch(x) { case 1: break; default: break; } }") + end + + it "extracts function with do-while loop" do + content = "void loop(void) { do { work(); } while(condition); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("loop") + expect(func.body).to eq("{ do { work(); } while(condition); }") + end + + it "extracts function with for loop" do + content = "void iterate(void) { for(int i = 0; i < 10; i++) { process(i); } }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("iterate") + expect(func.body).to eq("{ for(int i = 0; i < 10; i++) { process(i); } }") + end + + it "extracts function with while loop" do + content = "void wait(void) { while(ready()) { check(); } }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("wait") + expect(func.body).to eq("{ while(ready()) { check(); } }") + end + + it "extracts function with if-else chain" do + content = "void decide(int x) { if(x > 0) { pos(); } else if(x < 0) { neg(); } else { zero(); } }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("decide") + expect(func.body).to eq("{ if(x > 0) { pos(); } else if(x < 0) { neg(); } else { zero(); } }") + end + + it "extracts function with ternary operator" do + content = "int max(int a, int b) { return a > b ? a : b; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("max") + expect(func.body).to eq("{ return a > b ? a : b; }") + end + + it "extracts function with goto statement" do + content = "void jump(void) { goto label; label: return; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("jump") + expect(func.body).to eq("{ goto label; label: return; }") + end + + it "extracts function with labeled statement" do + content = "void labeled(void) { start: process(); goto start; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("labeled") + expect(func.body).to eq("{ start: process(); goto start; }") + end + end + + context "function extraction with preprocessor directives in body" do + it "extracts function with #ifdef in body" do + content = "void conditional(void) { #ifdef DEBUG\nlog();\n#endif\n }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("conditional") + expect(func.body).to eq("{ #ifdef DEBUG\nlog();\n#endif\n }") + end + + it "extracts function with #define in body" do + content = "void macro(void) { #define LOCAL 1\nint x = LOCAL; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("macro") + expect(func.body).to eq("{ #define LOCAL 1\nint x = LOCAL; }") + end + + it "extracts function with #include in body" do + content = "void include(void) { #include \"local.h\"\nuse_local(); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("include") + expect(func.body).to eq("{ #include \"local.h\"\nuse_local(); }") + end + + it "extracts function with #pragma in body" do + content = "void pragma(void) { #pragma pack(1)\nstruct s { int x; }; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("pragma") + expect(func.body).to eq("{ #pragma pack(1)\nstruct s { int x; }; }") + end + + it "extracts function with #error in body" do + content = "void error(void) { #error \"Not implemented\"\n }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("error") + expect(func.body).to eq("{ #error \"Not implemented\"\n }") + end + + it "extracts function with #warning in body" do + content = "void warning(void) { #warning \"Deprecated\"\nold_api(); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("warning") + expect(func.body).to eq("{ #warning \"Deprecated\"\nold_api(); }") + end + + it "extracts function with multi-line macro in body" do + content = "void multiline(void) { #define MACRO(x) \\\n do { \\\n work(x); \\\n } while(0)\nMACRO(1); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("multiline") + end + end + + context "function extraction with special characters and literals" do + it "extracts function with escaped quotes in string" do + content = 'void quotes(void) { printf("He said \"hello\""); }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("quotes") + expect(func.body).to eq('{ printf("He said \"hello\""); }') + end + + it "extracts function with escaped backslash in string" do + content = 'void backslash(void) { printf("path\\\\file"); }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("backslash") + expect(func.body).to eq('{ printf("path\\\\file"); }') + end + + it "extracts function with newline in string" do + content = 'void newline(void) { printf("line1\\nline2"); }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("newline") + expect(func.body).to eq('{ printf("line1\\nline2"); }') + end + + it "extracts function with tab in string" do + content = 'void tab(void) { printf("col1\\tcol2"); }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("tab") + expect(func.body).to eq('{ printf("col1\\tcol2"); }') + end + + it "extracts function with hex escape in string" do + content = 'void hex(void) { char c = "\\x41"; }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("hex") + expect(func.body).to eq('{ char c = "\\x41"; }') + end + + it "extracts function with octal escape in string" do + content = 'void octal(void) { char c = "\\101"; }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("octal") + expect(func.body).to eq('{ char c = "\\101"; }') + end + + it "extracts function with raw string literal (C++11)" do + content = 'void raw(void) { const char* s = R"(raw { } string)"; }' + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("raw") + expect(func.body).to eq('{ const char* s = R"(raw { } string)"; }') + end + + it "extracts function with multi-line string literal" do + content = "void multiline(void) { printf(\"line1\"\n\"line2\"); }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("multiline") + expect(func.body).to eq("{ printf(\"line1\"\n\"line2\"); }") + end + + it "extracts function with character literal" do + content = "void character(void) { char c = 'A'; }" + success, func, _, _ = try_extract.call(content) + + expect(success).to be true + expect(func.name).to eq("character") + expect(func.body).to eq("{ char c = 'A'; }") + end + end + + context "extracting multiple functions from same content" do + it "extracts two simple functions in sequence" do + content = "void first(void) { int a = 1; }\nvoid second(void) { int b = 2; }" + filepath = '/path/to/source.c' + scanner = StringScanner.new(content) + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = 1000 + + success1, func1 = functions.try_extract_function_definition(scanner, filepath) + expect(success1).to be true + expect(func1.name).to eq("first") + expect(func1.body).to eq("{ int a = 1; }") + expect(func1.source_filepath).to eq(filepath) + + success2, func2 = functions.try_extract_function_definition(scanner, filepath) + expect(success2).to be true + expect(func2.name).to eq("second") + expect(func2.body).to eq("{ int b = 2; }") + expect(func2.source_filepath).to eq(filepath) + + expect(scanner.eos?).to be true + end + + it "extracts three functions with different signatures" do + content = <<~CONTENT + int add(int a, int b) { return a + b; } + + void print(const char* msg) { printf("%s", msg); } + + static inline bool check(void) { return true; } + + CONTENT + filepath = '/path/to/source.c' + scanner = StringScanner.new(content) + code_text = CExtractorCodeText.new() + functions = CExtractorFunctions.new({ c_extractor_code_text: code_text }) + functions.setup() + functions.max_line_length = 1000 + + success1, func1 = functions.try_extract_function_definition(scanner, filepath) + expect(success1).to be true + expect(func1.name).to eq("add") + expect(func1.signature).to eq("int add(int a, int b)") + expect(func1.source_filepath).to eq(filepath) + + success2, func2 = functions.try_extract_function_definition(scanner, filepath) + expect(success2).to be true + expect(func2.name).to eq("print") + expect(func2.signature).to eq("void print(const char* msg)") + expect(func2.source_filepath).to eq(filepath) + + success3, func3 = functions.try_extract_function_definition(scanner, filepath) + expect(success3).to be true + expect(func3.name).to eq("check") + expect(func3.signature).to eq("static inline bool check(void)") + expect(func3.source_filepath).to eq(filepath) + + code_text.skip_deadspace(scanner) + + expect(scanner.eos?).to be true + end + + it "extracts functions separated by comments" do + content = <<~CONTENT + void first(void) { work(); } + // Comment between functions + void second(void) { more_work(); } + /* Multi-line comment + * between functions + */ + void third(void) { final_work(); } + CONTENT + filepath = '/path/to/source.c' + scanner = StringScanner.new(content) + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = 1000 + + success1, func1 = functions.try_extract_function_definition(scanner, filepath) + expect(success1).to be true + expect(func1.name).to eq("first") + expect(func1.source_filepath).to eq(filepath) + + success2, func2 = functions.try_extract_function_definition(scanner, filepath) + expect(success2).to be true + expect(func2.name).to eq("second") + expect(func2.source_filepath).to eq(filepath) + + success3, func3 = functions.try_extract_function_definition(scanner, filepath) + expect(success3).to be true + expect(func3.name).to eq("third") + expect(func3.source_filepath).to eq(filepath) + end + end + end + + describe "#clean_signature" do + let(:functions) do + f = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + f.setup() + f.max_line_length = 1000 + f + end + + it "removes carriage return and newline" do + signature = "void foo(int a,\r\nint b)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a, int b)") + end + + it "removes standalone carriage return" do + signature = "void foo(int a,\rint b)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a, int b)") + end + + it "removes standalone newline" do + signature = "void foo(int a,\nint b)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a, int b)") + end + + it "removes tabs" do + signature = "void\tfoo(int\ta,\tint\tb)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a, int b)") + end + + it "collapses multiple spaces into single space" do + signature = "void foo(int a, int b)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a, int b)") + end + + it "removes C-style block comments" do + signature = "void /* comment */ foo(int a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a)") + end + + it "removes multi-line C-style block comments" do + signature = "void /* multi\nline\ncomment */ foo(int a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a)") + end + + it "removes multiple C-style block comments" do + signature = "void /* comment1 */ foo /* comment2 */ (int a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo (int a)") + end + + it "strips leading whitespace" do + signature = " void foo(int a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a)") + end + + it "strips trailing whitespace" do + signature = "void foo(int a) " + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a)") + end + + it "strips leading and trailing whitespace" do + signature = " void foo(int a) " + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a)") + end + + it "handles empty signature" do + signature = "" + result = functions.send(:clean_signature, signature) + expect(result).to eq("") + end + + it "handles signature with only whitespace" do + signature = " \t\n\r\n " + result = functions.send(:clean_signature, signature) + expect(result).to eq("") + end + + it "handles signature with only comments" do + signature = "/* comment */" + result = functions.send(:clean_signature, signature) + expect(result).to eq("") + end + + it "preserves single spaces between tokens" do + signature = "void foo(int a, int b)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a, int b)") + end + + it "handles complex signature with all whitespace types" do + signature = "void\t\r\nfoo(\nint\ta,\r\nint b\t)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo( int a, int b )") + end + + it "handles signature with nested comments" do + signature = "void /* outer /* inner */ outer */ foo(int a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void outer */ foo(int a)") + end + + it "handles signature with comment containing special characters" do + signature = "void /* comment with {braces} and (parens) */ foo(int a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int a)") + end + + it "handles signature with pointer types" do + signature = "void * foo(int * a, char ** b)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void * foo(int * a, char ** b)") + end + + it "handles signature with const qualifiers" do + signature = "const void * foo(const int * a)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("const void * foo(const int * a)") + end + + it "handles signature with function pointer parameters" do + signature = "void foo(void (*callback)(int), int * data)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(void (*callback)(int), int * data)") + end + + it "handles signature with array parameters" do + signature = "void foo(int arr[10], char matrix[5][5])" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(int arr[10], char matrix[5][5])") + end + + it "handles signature with variadic parameters" do + signature = "int printf(const char * fmt, ...)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("int printf(const char * fmt, ...)") + end + + it "handles signature with struct parameters" do + signature = "void foo(struct point p, struct rect * r)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(struct point p, struct rect * r)") + end + + it "handles signature with enum parameters" do + signature = "void foo(enum color c, enum size * s)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(enum color c, enum size * s)") + end + + it "handles signature with union parameters" do + signature = "void foo(union data d, union value * v)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(union data d, union value * v)") + end + + it "handles signature with typedef'd types" do + signature = "void foo(uint32_t value, size_t * size)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void foo(uint32_t value, size_t * size)") + end + + it "handles signature with storage class specifiers" do + signature = "static inline void foo(register int x)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("static inline void foo(register int x)") + end + + it "handles signature with attributes" do + signature = "void __attribute__((interrupt)) ISR(void)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void __attribute__((interrupt)) ISR(void)") + end + + it "handles signature with multiple attributes" do + signature = "__attribute__((noreturn)) void __attribute__((cold)) fatal(void)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("__attribute__((noreturn)) void __attribute__((cold)) fatal(void)") + end + + it "handles signature with restrict qualifier" do + signature = "void memcpy(void * restrict dst, const void * restrict src)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void memcpy(void * restrict dst, const void * restrict src)") + end + + it "handles signature with volatile qualifier" do + signature = "int read(volatile int * reg)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("int read(volatile int * reg)") + end + + it "handles signature with _Atomic qualifier" do + signature = "void atomic_store(_Atomic int * ptr, int value)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void atomic_store(_Atomic int * ptr, int value)") + end + + it "handles signature with complex return type" do + signature = "const struct point * get_point(void)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("const struct point * get_point(void)") + end + + it "handles signature with function pointer return type" do + signature = "void (*get_callback(void))(int)" + result = functions.send(:clean_signature, signature) + expect(result).to eq("void (*get_callback(void))(int)") + end + + it "handles real-world complex signature" do + signature = <<~SIG.chomp + static inline const struct device * + /* Get device pointer */ + get_device( + const char * name, // Device name + size_t len + ) + SIG + result = functions.send(:clean_signature, signature) + expect(result).to eq("static inline const struct device * get_device( const char * name, size_t len )") + end + end + + describe "#clean_declaration" do + # NOTE: These test cases do not duplicate `clean_signature()` test coverage. + # `clean_declaration()` calls `clean_signature()` internally. + let(:functions) do + f = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + f.setup() + f.max_line_length = 1000 + f + end + + context "whitespace handling before final semicolon" do + it "removes single space before semicolon" do + declaration = "void foo(void) ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "removes multiple spaces before semicolon" do + declaration = "void foo(void) ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "removes tab before semicolon" do + declaration = "void foo(void)\t;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "removes newline before semicolon" do + declaration = "void foo(void)\n;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "removes carriage return and newline before semicolon" do + declaration = "void foo(void)\r\n;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "removes mixed whitespace before semicolon" do + declaration = "void foo(void) \t\n\r\n ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "preserves declaration with no space before semicolon" do + declaration = "void foo(void);" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "handles declaration with parameters and whitespace before semicolon" do + declaration = "int add(int a, int b) ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("int add(int a, int b);") + end + end + + context "combined cleaning operations" do + it "cleans internal whitespace and removes space before semicolon" do + declaration = "void foo(int a, int b) ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(int a, int b);") + end + + it "removes comments and whitespace before semicolon" do + declaration = "void foo(void) /* comment */ ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo(void);") + end + + it "handles multiline declaration with whitespace before semicolon" do + declaration = "void foo(\n int a,\n int b\n) ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo( int a, int b );") + end + + it "handles declaration with tabs, newlines, and space before semicolon" do + declaration = "void\tfoo(\nint\ta\n)\t\n ;" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("void foo( int a );") + end + + it "handles complex real-world declaration" do + declaration = <<~DECL.chomp + extern const struct device * + /* Get device */ + get_device( + const char * name, + size_t len + ) ; + DECL + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("extern const struct device * get_device( const char * name, size_t len );") + end + end + + context "edge cases" do + it "handles empty declaration" do + declaration = "" + result = functions.send(:clean_declaration, declaration) + expect(result).to eq("") + end + + it "preserves semicolons within string literals in parameters" do + declaration = 'void log(const char* msg = "error ;") ;' + result = functions.send(:clean_declaration, declaration) + expect(result).to eq('void log(const char* msg = "error ;");') + end + end + end + + ### + ### try_extract_function_declaration() + ### + + describe "#try_extract_function_declaration" do + let(:try_extract_decl) do + ->(content) do + scanner = StringScanner.new(content) + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = 1000 + success, decl = functions.try_extract_function_declaration(scanner) + return [success, decl, scanner.pos, scanner.rest] + end + end + + context "successful extraction returns CFunctionDeclaration struct" do + it "extracts simple function declaration" do + content = "int foo(void);" + success, decl, pos, rest = try_extract_decl.call(content) + + expect(success).to be true + expect(decl).to be_a(CExtractorTypes::CFunctionDeclaration) + expect(decl.name).to eq("foo") + expect(decl.signature).to eq("int foo(void);") + expect(decl.decorators).to eq([]) + expect(decl.signature_stripped).to eq("int foo(void);") + expect(pos).to eq(content.length) + expect(rest).to eq("") + end + + it "extracts void function declaration" do + content = "void process(int x, int y);" + success, decl, _, _ = try_extract_decl.call(content) + + expect(success).to be true + expect(decl.name).to eq("process") + expect(decl.signature).to eq("void process(int x, int y);") + expect(decl.decorators).to eq([]) + expect(decl.signature_stripped).to eq("void process(int x, int y);") + end + + it "extracts static function declaration and populates decorators" do + content = "static int foo(void);" + success, decl, _, _ = try_extract_decl.call(content) + + expect(success).to be true + expect(decl.name).to eq("foo") + expect(decl.decorators).to eq(["static"]) + expect(decl.signature_stripped).to eq("int foo(void);") + end + + it "extracts static inline function declaration" do + content = "static inline int foo(void);" + success, decl, _, _ = try_extract_decl.call(content) + + expect(success).to be true + expect(decl.name).to eq("foo") + expect(decl.decorators).to eq(["static", "inline"]) + expect(decl.signature_stripped).to eq("int foo(void);") + end + + it "extracts extern function declaration" do + content = "extern void bar(int x);" + success, decl, _, _ = try_extract_decl.call(content) + + expect(success).to be true + expect(decl.name).to eq("bar") + expect(decl.decorators).to eq(["extern"]) + expect(decl.signature_stripped).to eq("void bar(int x);") + end + end + + context "failed extraction" do + it "returns false for variable declaration" do + content = "int x;" + success, decl, _, _ = try_extract_decl.call(content) + + expect(success).to be false + expect(decl).to be_nil + end + + it "returns false for function definition" do + content = "void foo(void) { return; }" + success, decl, _, _ = try_extract_decl.call(content) + + expect(success).to be false + expect(decl).to be_nil + end + end + end + + ### + ### parse_decorators_and_strip() (private) + ### + + describe "#parse_decorators_and_strip (private method)" do + let(:functions) do + f = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + f.setup() + f + end + + it "returns empty decorators and original for simple function" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "int foo(void)", "foo") + expect(decorators).to eq([]) + expect(stripped).to eq("int foo(void)") + end + + it "returns empty decorators and original for void function" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "void foo(void)", "foo") + expect(decorators).to eq([]) + expect(stripped).to eq("void foo(void)") + end + + it "extracts static decorator" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "static int foo(void)", "foo") + expect(decorators).to eq(["static"]) + expect(stripped).to eq("int foo(void)") + end + + it "extracts inline decorator" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "inline int foo(void)", "foo") + expect(decorators).to eq(["inline"]) + expect(stripped).to eq("int foo(void)") + end + + it "extracts static inline decorators" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "static inline int foo(void)", "foo") + expect(decorators).to eq(["static", "inline"]) + expect(stripped).to eq("int foo(void)") + end + + it "extracts extern decorator" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "extern int foo(void)", "foo") + expect(decorators).to eq(["extern"]) + expect(stripped).to eq("int foo(void)") + end + + it "extracts const decorator from return type" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "const int* foo(void)", "foo") + expect(decorators).to eq(["const"]) + expect(stripped).to eq("int* foo(void)") + end + + it "separates leading type keyword token and remainder for unsigned int return type" do + # The algorithm treats leading TYPE_KEYWORDS as the split point; "unsigned" becomes a decorator + # and "int" becomes the start of the return type + decorators, stripped = functions.send(:parse_decorators_and_strip, "unsigned int foo(void)", "foo") + expect(decorators).to eq(["unsigned"]) + expect(stripped).to eq("int foo(void)") + end + + it "separates leading struct keyword token and remainder for struct return type" do + # Similarly, "struct" is a TYPE_KEYWORD and becomes the split point + decorators, stripped = functions.send(:parse_decorators_and_strip, "struct Point* foo(void)", "foo") + expect(decorators).to eq(["struct"]) + expect(stripped).to eq("Point* foo(void)") + end + + it "separates leading long keyword token and remainder for long return type" do + # Similarly, "long" is a TYPE_KEYWORD and becomes the split point + decorators, stripped = functions.send(:parse_decorators_and_strip, "long int foo(void)", "foo") + expect(decorators).to eq(["long"]) + expect(stripped).to eq("int foo(void)") + end + + it "returns empty decorators and original signature when name is nil" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "int foo(void)", nil) + expect(decorators).to eq([]) + expect(stripped).to eq("int foo(void)") + end + + it "returns empty decorators and original signature when name not found" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "int foo(void)", "bar") + expect(decorators).to eq([]) + expect(stripped).to eq("int foo(void)") + end + + it "returns empty decorators and original signature for single-token prefix" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "int foo(void)", "foo") + expect(decorators).to eq([]) + expect(stripped).to eq("int foo(void)") + end + + it "handles __inline decorator" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "__inline int foo(void)", "foo") + expect(decorators).to eq(["__inline"]) + expect(stripped).to eq("int foo(void)") + end + + it "handles static with pointer return type" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "static char* foo(void)", "foo") + expect(decorators).to eq(["static"]) + expect(stripped).to eq("char* foo(void)") + end + + it "handles static const pointer return -- both static and const are decorators" do + # const is in MODIFIER_KEYWORDS, so algorithm treats it as decorator alongside static + decorators, stripped = functions.send(:parse_decorators_and_strip, "static const char* foo(void)", "foo") + expect(decorators).to eq(["static", "const"]) + expect(stripped).to eq("char* foo(void)") + end + + it "handles function with parameters that include type keywords" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "static int foo(unsigned int x)", "foo") + expect(decorators).to eq(["static"]) + expect(stripped).to eq("int foo(unsigned int x)") + end + + it "handles extern with struct return type" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "extern struct Node* foo(void)", "foo") + expect(decorators).to eq(["extern"]) + expect(stripped).to eq("struct Node* foo(void)") + end + + it "recognizes __forceinline as a private decorator" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "__forceinline void foo(void)", "foo") + expect(decorators).to eq(["__forceinline"]) + expect(stripped).to eq("void foo(void)") + end + + it "recognizes static __forceinline combination" do + decorators, stripped = functions.send(:parse_decorators_and_strip, "static __forceinline int foo(void)", "foo") + expect(decorators).to eq(["static", "__forceinline"]) + expect(stripped).to eq("int foo(void)") + end + end + + ### + ### try_extract_function_definition() -- decorator/signature_stripped fields + ### + + describe "#try_extract_function_definition decorator fields" do + let(:try_extract) do + ->(content, filepath='/path/to/source.c') do + scanner = StringScanner.new(content) + functions = CExtractorFunctions.new({ c_extractor_code_text: CExtractorCodeText.new() }) + functions.setup() + functions.max_line_length = 1000 + success, func = functions.try_extract_function_definition( scanner, filepath ) + return [success, func] + end + end + + it "sets empty decorators and signature_stripped matching signature for plain function" do + success, func = try_extract.call("void foo(void) { }") + expect(success).to be true + expect(func.decorators).to eq([]) + expect(func.signature_stripped).to eq("void foo(void)") + end + + it "populates decorators and signature_stripped for static function" do + success, func = try_extract.call("static void foo(void) { }") + expect(success).to be true + expect(func.decorators).to eq(["static"]) + expect(func.signature_stripped).to eq("void foo(void)") + end + + it "populates decorators and signature_stripped for static inline function" do + success, func = try_extract.call("static inline int bar(int x) { return x; }") + expect(success).to be true + expect(func.decorators).to eq(["static", "inline"]) + expect(func.signature_stripped).to eq("int bar(int x)") + end + end + +end \ No newline at end of file diff --git a/spec/units/c_extractor/c_extractor_integration_spec.rb b/spec/units/c_extractor/c_extractor_integration_spec.rb new file mode 100644 index 000000000..1d248b5a7 --- /dev/null +++ b/spec/units/c_extractor/c_extractor_integration_spec.rb @@ -0,0 +1,935 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/c_extractor/c_extractor' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_functions' +require 'ceedling/c_extractor/c_extractor_declarations' +require 'ceedling/c_extractor/c_extractor_preprocessing' +require 'ceedling/c_extractor/c_extractor_definitions' + +## +## These integration tests exercise the composition of all CExtractor* objects +## in extracting features from C source code. +## Other unit tests exhaustively exerise individual methods, including of CExtractor itself. +## +describe CExtractor do + + let(:extractor) do + code_text = CExtractorCodeText.new + declarations = CExtractorDeclarations.new({ c_extractor_code_text: code_text }) + functions = CExtractorFunctions.new({ c_extractor_code_text: code_text }) + preprocessing = CExtractorPreprocessing.new({ c_extractor_code_text: code_text }) + definitions = CExtractorDefinitions.new({ c_extractor_code_text: code_text }) + declarations.setup() + functions.setup() + extractor = CExtractor.new( + { + c_extractor_code_text: code_text, + c_extractor_functions: functions, + c_extractor_declarations: declarations, + c_extractor_preprocessing: preprocessing, + c_extractor_definitions: definitions + } + ) + extractor.setup() + extractor + end + + context "#from_string" do + # Helper method to extract contents from a string as CModule + let(:extract_from) do + ->(content) { extractor.from_string(content: content) } + end + + it "should extract nothing from blank input" do + contents = extract_from.call('') + expect( contents.function_definitions.length ).to eq 0 + expect( contents.variable_declarations.length ).to eq 0 + end + + it "should extract nothing from whitespace" do + contents = extract_from.call(" \n\n\r\n \t\n\n\n\n") + expect( contents.function_definitions.length ).to eq 0 + expect( contents.variable_declarations.length ).to eq 0 + end + + it "should extract a simple function" do + file_contents = <<~CONTENTS + void a_function(void) { + int a = 1 + 1; + } + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.variable_declarations.length ).to eq 0 + + expect( contents.function_definitions.length ).to eq 1 + expect( contents.function_definitions[0].name ).to eq 'a_function' + expect( contents.function_definitions[0].signature ).to eq 'void a_function(void)' + expect( contents.function_definitions[0].body ).to eq "{\n int a = 1 + 1;\n}" + expect( contents.function_definitions[0].code_block ).to eq file_contents.strip() + expect( contents.function_definitions[0].line_count ).to eq 3 + end + + it "should extract multiple simple functions" do + file_contents = <<~CONTENTS + int + a_function(int a, int b) { + int c = a + b; + c += 5; + return c; + } + + void BFUNCTION(void) { int a = 1 + 1; } + + uint16_t* C_Function ( void ) + { + return &global_var; + } + + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.variable_declarations.length ).to eq 0 + expect( contents.function_definitions.length ).to eq 3 + + expect( contents.function_definitions[0].name ).to eq 'a_function' + expect( contents.function_definitions[0].signature ).to eq "int a_function(int a, int b)" + expect( contents.function_definitions[0].body ).to eq "{\n int c = a + b;\n c += 5;\n return c;\n}" + expect( contents.function_definitions[0].code_block ).to eq "int\na_function(int a, int b) {\n int c = a + b;\n c += 5;\n return c;\n}" + expect( contents.function_definitions[0].line_count ).to eq 6 + + expect( contents.function_definitions[1].name ).to eq 'BFUNCTION' + expect( contents.function_definitions[1].signature ).to eq 'void BFUNCTION(void)' + expect( contents.function_definitions[1].body ).to eq "{ int a = 1 + 1; }" + expect( contents.function_definitions[1].code_block ).to eq "void BFUNCTION(void) { int a = 1 + 1; }" + expect( contents.function_definitions[1].line_count ).to eq 1 + + expect( contents.function_definitions[2].name ).to eq 'C_Function' + expect( contents.function_definitions[2].signature ).to eq 'uint16_t* C_Function ( void )' + expect( contents.function_definitions[2].body ).to eq "{\n return &global_var;\n}" + expect( contents.function_definitions[2].code_block ).to eq "uint16_t* C_Function ( void )\n{\n return &global_var;\n}" + expect( contents.function_definitions[2].line_count ).to eq 4 + end + + it "should extract functions and module variables while ignoring deadspace text and errant semicolons" do + file_contents = <<~'CONTENTS' + + #include <stdint.h> + #include "foo.h" + + int global_var; // Simple variable + static const char* ptr = "hello"; // Initialized variable + struct foo { int x; } instance;; // Struct with brackets and double semicolon + int array[] = {1, 2, 3}; // Array initialization with braces + + void a_function(void) { int a = 1 + 1; } + + #define FOO 123 + #define MACRO(x) \ + do { \ + // Triple semicolon after fuction call inside macro \ + something(x);;; \ + } while(0) + #pragma pack(1) + + void b_function(void) { int a = 1 + 1;; } // Function with extra semicolon + + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.variable_declarations.length ).to eq 4 + + expect( contents.variable_declarations[0].name ).to eq 'global_var' + expect( contents.variable_declarations[0].type ).to eq 'int' + expect( contents.variable_declarations[0].decorators ).to eq [] + expect( contents.variable_declarations[0].text ).to eq 'int global_var;' + + expect( contents.variable_declarations[1].original ).to eq 'static const char* ptr = "hello";' + expect( contents.variable_declarations[1].name ).to eq 'ptr' + expect( contents.variable_declarations[1].type ).to eq 'char*' + expect( contents.variable_declarations[1].decorators ).to eq ['static', 'const'] + expect( contents.variable_declarations[1].text ).to eq 'char* ptr = "hello";' + + expect( contents.variable_declarations[2].name ).to eq 'instance' + expect( contents.variable_declarations[2].decorators ).to eq [] + expect( contents.variable_declarations[2].text ).to eq 'struct foo { int x; } instance;' + + expect( contents.variable_declarations[3].name ).to eq 'array' + expect( contents.variable_declarations[3].type ).to eq 'int' + expect( contents.variable_declarations[3].decorators ).to eq [] + expect( contents.variable_declarations[3].text ).to eq 'int array[] = {1, 2, 3};' + + expect( contents.function_definitions.length ).to eq 2 + + expect( contents.function_definitions[0].name ).to eq 'a_function' + expect( contents.function_definitions[0].signature ).to eq 'void a_function(void)' + expect( contents.function_definitions[0].body ).to eq "{ int a = 1 + 1; }" + expect( contents.function_definitions[0].code_block ).to eq "void a_function(void) { int a = 1 + 1; }" + expect( contents.function_definitions[0].line_count ).to eq 1 + + expect( contents.function_definitions[1].name ).to eq 'b_function' + expect( contents.function_definitions[1].signature ).to eq 'void b_function(void)' + expect( contents.function_definitions[1].body ).to eq "{ int a = 1 + 1;; }" + expect( contents.function_definitions[1].code_block ).to eq "void b_function(void) { int a = 1 + 1;; }" + expect( contents.function_definitions[1].line_count ).to eq 1 + + expect( contents.macro_definitions.length ).to eq 2 + expect( contents.macro_definitions[0].text ).to eq "#define FOO 123" + expect( contents.macro_definitions[0].line_num ).to eq 12 + expect( contents.macro_definitions[1].text ).to start_with("#define MACRO(x) \\\n") + expect( contents.macro_definitions[1].line_num ).to eq 13 + expect( contents.variable_declarations[0].line_num ).to eq 5 + expect( contents.variable_declarations[1].line_num ).to eq 6 + expect( contents.variable_declarations[2].line_num ).to eq 7 + expect( contents.variable_declarations[3].line_num ).to eq 8 + end + + it "should ignore commented out functions and handle comments with braces" do + file_contents = <<~CONTENTS + + // This is a commented out function that should be ignored + // void commented_function(void) { + // int x = 1; + // } + + void real_function_a(void) { + int a = 1; + // Comment with braces: { } should not break extraction + int b = 2; + } + + /* + * Multi-line comment with function-like text + * void another_commented_function(void) { + * return 42; + * } + */ + + void real_function_b(void) { + /* Inline comment with braces { } */ + return; + } + + /* + void yet_another_commented_function(void) { + int z = 3; + } + */ + + void real_function_c(void) { + // Single line comment with opening brace { + int x = 1; + /* Multi-line comment with closing brace } */ + int y = 2; + } + + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.variable_declarations.length ).to eq 0 + expect( contents.function_definitions.length ).to eq 3 + + expect( contents.function_definitions[0].name ).to eq 'real_function_a' + expect( contents.function_definitions[0].signature ).to eq 'void real_function_a(void)' + # Line comment + its newline replaced by one space; 2-space indents on both sides preserved + expect( contents.function_definitions[0].body ).to eq "{\n int a = 1;\n int b = 2;\n}" + expect( contents.function_definitions[0].line_count ).to eq 5 # based on original code_block, not rebuilt body + + expect( contents.function_definitions[1].name ).to eq 'real_function_b' + expect( contents.function_definitions[1].signature ).to eq 'void real_function_b(void)' + # Block comment replaced by one space; newline + indent after comment preserved + expect( contents.function_definitions[1].body ).to eq "{\n \n return;\n}" + expect( contents.function_definitions[1].line_count ).to eq 4 # based on original code_block, not rebuilt body + + expect( contents.function_definitions[2].name ).to eq 'real_function_c' + expect( contents.function_definitions[2].signature ).to eq 'void real_function_c(void)' + # Line comment + its newline → space (5 chars before int x); block comment → space (3 chars on blank line) + expect( contents.function_definitions[2].body ).to eq "{ \n int x = 1;\n \n int y = 2;\n}" + expect( contents.function_definitions[2].line_count ).to eq 6 # based on original code_block, not rebuilt body + end + + it "should extract functions with nested braces from control flow and initializers" do + file_contents = <<~CONTENTS + + void function_with_if_else(int x) { + if (x > 0) { + do_something(); + } else { + do_something_else(); + } + } + + void function_with_loops(void) { + for (int i = 0; i < 10; i++) { + while (condition) { + do_work(); + } + } + } + + void function_with_switch(int value) { + switch (value) { + case 1: { + handle_case_1(); + break; + } + case 2: { + handle_case_2(); + break; + } + default: { + handle_default(); + } + } + } + + void function_with_struct_init(void) { + struct point { + int x; + int y; + } p = { + .x = 10, + .y = 20 + }; + + int array[] = {1, 2, 3, {4, 5, 6}}; + } + + void function_with_nested_blocks(void) { + { + int local_scope = 1; + { + int deeper_scope = 2; + if (condition) { + { + int deepest = 3; + } + } + } + } + } + + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.variable_declarations.length ).to eq 0 + expect( contents.function_definitions.length ).to eq 5 + + expect( contents.function_definitions[0].name ).to eq 'function_with_if_else' + expect( contents.function_definitions[0].signature ).to eq 'void function_with_if_else(int x)' + expect( contents.function_definitions[0].body ).to eq "{\n if (x > 0) {\n do_something();\n } else {\n do_something_else();\n }\n}" + expect( contents.function_definitions[0].line_count ).to eq 7 + + expect( contents.function_definitions[1].name ).to eq 'function_with_loops' + expect( contents.function_definitions[1].signature ).to eq 'void function_with_loops(void)' + expect( contents.function_definitions[1].body ).to eq "{\n for (int i = 0; i < 10; i++) {\n while (condition) {\n do_work();\n }\n }\n}" + expect( contents.function_definitions[1].line_count ).to eq 7 + + expect( contents.function_definitions[2].name ).to eq 'function_with_switch' + expect( contents.function_definitions[2].signature ).to eq 'void function_with_switch(int value)' + expect( contents.function_definitions[2].body ).to eq "{\n switch (value) {\n case 1: {\n handle_case_1();\n break;\n }\n case 2: {\n handle_case_2();\n break;\n }\n default: {\n handle_default();\n }\n }\n}" + expect( contents.function_definitions[2].line_count ).to eq 15 + + expect( contents.function_definitions[3].name ).to eq 'function_with_struct_init' + expect( contents.function_definitions[3].signature ).to eq 'void function_with_struct_init(void)' + expect( contents.function_definitions[3].body ).to eq "{\n struct point {\n int x;\n int y;\n } p = {\n .x = 10,\n .y = 20\n };\n \n int array[] = {1, 2, 3, {4, 5, 6}};\n}" + expect( contents.function_definitions[3].line_count ).to eq 11 + + expect( contents.function_definitions[4].name ).to eq 'function_with_nested_blocks' + expect( contents.function_definitions[4].signature ).to eq 'void function_with_nested_blocks(void)' + expect( contents.function_definitions[4].body ).to eq "{\n {\n int local_scope = 1;\n {\n int deeper_scope = 2;\n if (condition) {\n {\n int deepest = 3;\n }\n }\n }\n }\n}" + expect( contents.function_definitions[4].line_count ).to eq 13 + end + + it "should extract a lengthy function and variable declarations from complex code with various C constructs" do + file_contents = <<~CONTENTS + #include <stdio.h> + #include <stdlib.h> + + #define MAX_SIZE 100 + #define PROCESS(x) do { process_data(x); } while(0) + + // Global variables + static int global_counter = 0; + const char* global_message = "Hello, World!"; + + // Forward declarations + void helper_function(int value); + int calculate_result(int a, int b); + + /* + * This is a complex function that demonstrates + * various C language constructs + */ + int complex_function(int param1, const char* param2, void* param3) { + // Local variable declarations + int result = 0; + int array[MAX_SIZE] = {0}; + struct { + int x; + int y; + char name[50]; + } local_struct = { + .x = 10, + .y = 20, + .name = "test" + }; + + // String with special characters + const char* message = "This string has { braces } and (parens) and \"quotes\""; + + // Conditional logic + if (param1 > 0) { + for (int i = 0; i < param1; i++) { + array[i] = i * 2; + + // Nested conditionals + if (array[i] > 50) { + switch (array[i]) { + case 52: { + result += 1; + break; + } + case 54: { + result += 2; + break; + } + default: { + result += array[i]; + } + } + } else { + while (array[i] < 25) { + array[i]++; + result--; + } + } + } + } else { + // Negative parameter handling + result = -1; + } + + // Function calls with various argument types + helper_function(result); + int temp = calculate_result(param1, result); + + // Pointer operations + if (param3 != NULL) { + int* ptr = (int*)param3; + *ptr = temp; + } + + // Multi-line macro usage + PROCESS(result); + + // Comment with braces: { } should not break extraction + /* Another comment with braces { } */ + + // Final calculations + result = temp + local_struct.x + local_struct.y; + + // Return statement + return result; + } + + // Another function after the complex one + void simple_function(void) { + printf("Simple\\n"); + } + + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.variable_declarations.length ).to eq 2 + + expect( contents.variable_declarations[0].original ).to eq 'static int global_counter = 0;' + expect( contents.variable_declarations[0].name ).to eq 'global_counter' + expect( contents.variable_declarations[0].type ).to eq 'int' + expect( contents.variable_declarations[0].decorators ).to eq ['static'] + expect( contents.variable_declarations[0].text ).to eq 'int global_counter = 0;' + expect( contents.variable_declarations[0].line_num ).to eq 8 + + expect( contents.variable_declarations[1].original ).to eq 'const char* global_message = "Hello, World!";' + expect( contents.variable_declarations[1].name ).to eq 'global_message' + expect( contents.variable_declarations[1].type ).to eq 'char*' + expect( contents.variable_declarations[1].decorators ).to eq ['const'] + expect( contents.variable_declarations[1].text ).to eq 'char* global_message = "Hello, World!";' + expect( contents.variable_declarations[1].line_num ).to eq 9 + + expect( contents.function_declarations.length ).to eq 2 + expect( contents.function_declarations[0].line_num ).to eq 12 + expect( contents.function_declarations[1].line_num ).to eq 13 + + expect( contents.function_definitions.length ).to eq 2 + + # Note: For sake of space, `body` and `code_block` are not tested. + + expect( contents.function_definitions[0].name ).to eq 'complex_function' + expect( contents.function_definitions[0].signature ).to eq 'int complex_function(int param1, const char* param2, void* param3)' + expect( contents.function_definitions[0].line_count ).to eq 71 + expect( contents.function_definitions[0].line_num ).to eq 19 + + expect( contents.function_definitions[1].name ).to eq 'simple_function' + expect( contents.function_definitions[1].signature ).to eq 'void simple_function(void)' + expect( contents.function_definitions[1].line_count ).to eq 3 + expect( contents.function_definitions[1].line_num ).to eq 92 + + expect( contents.macro_definitions.length ).to eq 2 + expect( contents.macro_definitions[0].text ).to eq "#define MAX_SIZE 100" + expect( contents.macro_definitions[0].line_num ).to eq 4 + expect( contents.macro_definitions[1].text ).to eq "#define PROCESS(x) do { process_data(x); } while(0)" + expect( contents.macro_definitions[1].line_num ).to eq 5 + end + + it "should extract multiple simple functions longer than buffer chunk size" do + file_contents = <<~CONTENTS + int a_function(int a, int b) { + int c = a + b; + c += 5; + return c; + } + + void BFUNCTION(void) { int a = 1 + 1; } + + uint16_t* C_Function (void) + { + return &global_var; + } + + CONTENTS + + contents = extractor.from_string(content: file_contents, chunk_size: 10) + + expect( contents.variable_declarations.length ).to eq 0 + + expect( contents.function_definitions.length ).to eq 3 + + expect( contents.function_definitions[0].name ).to eq 'a_function' + expect( contents.function_definitions[0].signature ).to eq 'int a_function(int a, int b)' + expect( contents.function_definitions[0].body ).to eq "{\n int c = a + b;\n c += 5;\n return c;\n}" + expect( contents.function_definitions[0].code_block ).to eq "int a_function(int a, int b) {\n int c = a + b;\n c += 5;\n return c;\n}" + expect( contents.function_definitions[0].line_count ).to eq 5 + + expect( contents.function_definitions[1].name ).to eq 'BFUNCTION' + expect( contents.function_definitions[1].signature ).to eq 'void BFUNCTION(void)' + expect( contents.function_definitions[1].body ).to eq "{ int a = 1 + 1; }" + expect( contents.function_definitions[1].code_block ).to eq "void BFUNCTION(void) { int a = 1 + 1; }" + expect( contents.function_definitions[1].line_count ).to eq 1 + + expect( contents.function_definitions[2].name ).to eq 'C_Function' + expect( contents.function_definitions[2].signature ).to eq 'uint16_t* C_Function (void)' + expect( contents.function_definitions[2].body ).to eq "{\n return &global_var;\n}" + expect( contents.function_definitions[2].code_block ).to eq "uint16_t* C_Function (void)\n{\n return &global_var;\n}" + expect( contents.function_definitions[2].line_count ).to eq 4 + end + + it "should extract macro definitions and no other features from a macros-only input" do + file_contents = <<~'CONTENTS' + #define SIMPLE 1 + #define WITH_ARGS(x, y) ((x) + (y)) + #define MULTILINE(a) \ + do { \ + process(a); \ + } while(0) + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.macro_definitions.length ).to eq 3 + expect( contents.macro_definitions[0].text ).to eq "#define SIMPLE 1" + expect( contents.macro_definitions[0].line_num ).to eq 1 + expect( contents.macro_definitions[1].text ).to eq "#define WITH_ARGS(x, y) ((x) + (y))" + expect( contents.macro_definitions[1].line_num ).to eq 2 + expect( contents.macro_definitions[2].text ).to start_with("#define MULTILINE(a) \\\n") + expect( contents.macro_definitions[2].line_num ).to eq 3 + expect( contents.function_definitions.length ).to eq 0 + expect( contents.function_declarations.length ).to eq 0 + expect( contents.variable_declarations.length ).to eq 0 + end + + it "should consume #pragma and #include directives without storing them" do + file_contents = <<~CONTENTS + #pragma once + #include <stdio.h> + #include "myheader.h" + + void a_function(void) {} + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.macro_definitions.length ).to eq 0 + expect( contents.function_definitions.length ).to eq 1 + expect( contents.function_definitions[0].name ).to eq 'a_function' + end + + it "should fail to extract a function longer than max buffer length" do + file_contents = <<~CONTENTS + void a_function(void) { + int a = 1 + 1; + } + CONTENTS + + # TODO: Test for function name extraction after implementing generic handling of feature summaries + expect { extractor.from_string(content: file_contents, chunk_size: 10, max_buffer_length: 20) }.to raise_error(CeedlingException, /Feature extraction exceeded maximum length/) + end + + it "should fail to extract a signature longer than max length" do + file_contents = <<~CONTENTS + void a_function(void) { + int a = 1 + 1; + } + CONTENTS + + contents = extractor.from_string(content: file_contents, chunk_size: 10, max_line_length: 10) + expect(contents.function_definitions.length ).to eq 0 + end + + it "should extract typedef definitions and no other features from a typedefs-only input" do + file_contents = <<~CONTENTS + typedef int MyInt; + typedef const char* CStr; + typedef void (*Callback)(int, void*); + typedef enum { RED, GREEN, BLUE } Color; + typedef struct { + int x; + int y; + } Point; + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.type_definitions.length ).to eq 5 + + expect( contents.type_definitions[0].text ).to eq "typedef int MyInt;" + expect( contents.type_definitions[0].line_num ).to eq 1 + expect( contents.type_definitions[1].text ).to eq "typedef const char* CStr;" + expect( contents.type_definitions[1].line_num ).to eq 2 + expect( contents.type_definitions[2].text ).to eq "typedef void (*Callback)(int, void*);" + expect( contents.type_definitions[2].line_num ).to eq 3 + expect( contents.type_definitions[3].text ).to eq "typedef enum { RED, GREEN, BLUE } Color;" + expect( contents.type_definitions[3].line_num ).to eq 4 + expect( contents.type_definitions[4].text ).to start_with("typedef struct {\n") + expect( contents.type_definitions[4].text ).to end_with("} Point;") + expect( contents.type_definitions[4].line_num ).to eq 5 + + expect( contents.function_definitions.length ).to eq 0 + expect( contents.function_declarations.length ).to eq 0 + expect( contents.variable_declarations.length ).to eq 0 + expect( contents.macro_definitions.length ).to eq 0 + end + + it "should extract typedefs alongside functions, variables, and macros" do + file_contents = <<~CONTENTS + #include <stdint.h> + + #define MAX_VAL 255 + + typedef uint8_t Byte; + typedef struct { int x; int y; } Point; + + static int global_counter = 0; + + void helper(void); + + int compute(int a, int b) { + return a + b; + } + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.type_definitions.length ).to eq 2 + expect( contents.type_definitions[0].text ).to eq "typedef uint8_t Byte;" + expect( contents.type_definitions[0].line_num ).to eq 5 + expect( contents.type_definitions[1].text ).to eq "typedef struct { int x; int y; } Point;" + expect( contents.type_definitions[1].line_num ).to eq 6 + + expect( contents.macro_definitions.length ).to eq 1 + expect( contents.macro_definitions[0].text ).to eq "#define MAX_VAL 255" + expect( contents.macro_definitions[0].line_num ).to eq 3 + + expect( contents.variable_declarations.length ).to eq 1 + expect( contents.variable_declarations[0].name ).to eq 'global_counter' + expect( contents.variable_declarations[0].line_num ).to eq 8 + + expect( contents.function_declarations.length ).to eq 1 + expect( contents.function_declarations[0].name ).to eq 'helper' + + expect( contents.function_definitions.length ).to eq 1 + expect( contents.function_definitions[0].name ).to eq 'compute' + end + + it "should extract non-typedef struct, enum, and union definitions into aggregate_definitions" do + file_contents = <<~'CONTENTS' + #include <stdint.h> + + struct Point { + int x; + int y; + }; + + enum Color { RED, GREEN, BLUE }; + + union Number { + int i; + float f; + }; + + /* struct with declarator — stays in variable_declarations, not aggregate_definitions */ + struct Foo { int val; } foo_instance; + + typedef struct { int a; int b; } Pair; + + int some_global = 0; + + void a_function(void) { + int local = 1; + } + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.aggregate_definitions.length ).to eq 3 + expect( contents.aggregate_definitions[0].text ).to start_with('struct Point') + expect( contents.aggregate_definitions[0].text ).to include('int x;') + expect( contents.aggregate_definitions[0].line_num ).to eq 3 + expect( contents.aggregate_definitions[1].text ).to eq "enum Color { RED, GREEN, BLUE };" + expect( contents.aggregate_definitions[1].line_num ).to eq 8 + expect( contents.aggregate_definitions[2].text ).to start_with('union Number') + expect( contents.aggregate_definitions[2].line_num ).to eq 10 + + # struct Foo { int val; } foo_instance; stays in variable_declarations + expect( contents.variable_declarations.length ).to eq 2 + expect( contents.variable_declarations[0].name ).to eq 'foo_instance' + expect( contents.variable_declarations[1].name ).to eq 'some_global' + + expect( contents.type_definitions.length ).to eq 1 + expect( contents.type_definitions[0].text ).to include('typedef struct') + + expect( contents.function_definitions.length ).to eq 1 + expect( contents.function_definitions[0].name ).to eq 'a_function' + expect( contents.macro_definitions.length ).to eq 0 + expect( contents.function_declarations.length ).to eq 0 + end + + it "should consume static assert statements without collecting them" do + file_contents = <<~'CONTENTS' + #include <stdint.h> + + _Static_assert(sizeof(int) == 4, "int must be 32-bit"); + + typedef struct { + int x; + int y; + } Point; + + static_assert(sizeof(Point) == 8); + + static_assert( + offsetof(Point, y) == sizeof(int), + "y must follow x with no padding" + ); + + void some_function(void) { + int local = 0; + } + CONTENTS + + contents = extract_from.call(file_contents) + + # Static asserts are consumed — nothing lands in any CModule field + expect( contents.function_definitions.length ).to eq 1 + expect( contents.function_declarations.length ).to eq 0 + expect( contents.variable_declarations.length ).to eq 0 + expect( contents.type_definitions.length ).to eq 1 # the typedef struct + expect( contents.macro_definitions.length ).to eq 0 # #include is not #define + + expect( contents.function_definitions[0].name ).to eq 'some_function' + expect( contents.type_definitions[0].text ).to include('typedef struct') + end + + it "should populate element_sequence in cross-type line order from a single file" do + # One item of each extractable type, ordered by line number. + # Blank lines ensure each item lands on a predictable, distinct line. + file_contents = <<~CONTENTS + + typedef uint8_t Byte; + + #define MAX 100 + + int global_var; + + void helper(void); + + void compute(int x) { + return x; + } + CONTENTS + + contents = extract_from.call(file_contents) + + expect( contents.element_sequence.length ).to eq 5 + + expect( contents.element_sequence[0] ).to be_a( CExtractorTypes::CStatement ) + expect( contents.element_sequence[0].line_num ).to eq 2 # typedef + + expect( contents.element_sequence[1] ).to be_a( CExtractorTypes::CStatement ) + expect( contents.element_sequence[1].line_num ).to eq 4 # macro + + expect( contents.element_sequence[2] ).to be_a( CExtractorTypes::CVariableDeclaration ) + expect( contents.element_sequence[2].line_num ).to eq 6 + + expect( contents.element_sequence[3] ).to be_a( CExtractorTypes::CFunctionDeclaration ) + expect( contents.element_sequence[3].line_num ).to eq 8 + + expect( contents.element_sequence[4] ).to be_a( CExtractorTypes::CFunctionDefinition ) + expect( contents.element_sequence[4].line_num ).to eq 10 + + # Spot-check that element_sequence holds the same object references as the typed arrays — + # no duplication, just an ordering index into the same structs. + expect( contents.element_sequence[0] ).to equal( contents.type_definitions[0] ) + expect( contents.element_sequence[1] ).to equal( contents.macro_definitions[0] ) + expect( contents.element_sequence[2] ).to equal( contents.variable_declarations[0] ) + expect( contents.element_sequence[3] ).to equal( contents.function_declarations[0] ) + expect( contents.element_sequence[4] ).to equal( contents.function_definitions[0] ) + end + + it "should place all header items before all source items in element_sequence after CModule merge" do + # Header has a typedef at line 1 and a function declaration at line 3. + header_string = <<~CONTENTS + typedef uint8_t Byte; + + void helper(void); + CONTENTS + + # Source has a macro at line 1, a variable at line 3, and a function definition at line 5. + # Line numbers intentionally overlap with the header (both start at 1) to prove that + # element_sequence order is governed by the + operand order — not by line-number sorting. + source_string = <<~CONTENTS + #define FOO 1 + + int counter = 0; + + void helper(void) { + return; + } + CONTENTS + + header_module = extract_from.call(header_string) + source_module = extract_from.call(source_string) + + # Merge header-first (matching Partializer's extract_module_contents order) + merged = header_module + source_module + + expect( merged.element_sequence.length ).to eq 5 + + # Header items first, in their within-file order + expect( merged.element_sequence[0] ).to be_a( CExtractorTypes::CStatement ) + expect( merged.element_sequence[0].text ).to include( "typedef uint8_t Byte;" ) + expect( merged.element_sequence[0].line_num ).to eq 1 + + expect( merged.element_sequence[1] ).to be_a( CExtractorTypes::CFunctionDeclaration ) + expect( merged.element_sequence[1].name ).to eq 'helper' + expect( merged.element_sequence[1].line_num ).to eq 3 + + # Source items follow, also in their within-file order. + # element_sequence[2].line_num == 1 — same as element_sequence[0].line_num — but + # the source macro still appears after the header items, confirming that + ordering, + # not line-number sorting, determines the sequence. + expect( merged.element_sequence[2] ).to be_a( CExtractorTypes::CStatement ) + expect( merged.element_sequence[2].text ).to include( "#define FOO 1" ) + expect( merged.element_sequence[2].line_num ).to eq 1 + + expect( merged.element_sequence[3] ).to be_a( CExtractorTypes::CVariableDeclaration ) + expect( merged.element_sequence[3].name ).to eq 'counter' + expect( merged.element_sequence[3].line_num ).to eq 3 + + expect( merged.element_sequence[4] ).to be_a( CExtractorTypes::CFunctionDefinition ) + expect( merged.element_sequence[4].name ).to eq 'helper' + expect( merged.element_sequence[4].line_num ).to eq 5 + + # Confirm the typed arrays are unaffected by the merge + expect( merged.function_definitions.length ).to eq 1 + expect( merged.function_declarations.length ).to eq 1 + expect( merged.variable_declarations.length ).to eq 1 + expect( merged.type_definitions.length ).to eq 1 + expect( merged.macro_definitions.length ).to eq 1 + end + + it "extracts function definitions and declarations with MSVC/GCC compiler extensions" do + file_contents = <<~CONTENTS + __declspec(dllexport) void exported_func(int x) { return; } + int __cdecl cdecl_func(void) { return 0; } + void __attribute__((noreturn)) fatal_func(const char* msg) { while(1); } + static __forceinline int fast_add(int a, int b) { return a + b; } + void plain_func(void) { } + CONTENTS + + contents = extract_from.call(file_contents) + + expect(contents.function_definitions.length).to eq 5 + + names = contents.function_definitions.map(&:name) + expect(names).to include('exported_func', 'cdecl_func', 'fatal_func', 'fast_add', 'plain_func') + + # Signatures retain annotations verbatim + exported = contents.function_definitions.find { |f| f.name == 'exported_func' } + expect(exported.signature).to include('__declspec(dllexport)') + + fast = contents.function_definitions.find { |f| f.name == 'fast_add' } + expect(fast.decorators).to include('static', '__forceinline') + + plain = contents.function_definitions.find { |f| f.name == 'plain_func' } + expect(plain.decorators).to eq([]) + end + + it "extracts variable declarations with compiler extensions" do + file_contents = <<~CONTENTS + int counter __attribute__((aligned(16))); + char buf[] __attribute__((section(".data"))); + struct Point my_pt __attribute__((aligned(4))); + struct { int x; int y; } coords __attribute__((aligned(8))); + __declspec(dllexport) extern int exported_var; + int plain_var; + CONTENTS + + contents = extract_from.call(file_contents) + + expect(contents.variable_declarations.length).to eq 6 + + names = contents.variable_declarations.map(&:name) + expect(names).to include('counter', 'buf', 'my_pt', 'coords', 'exported_var', 'plain_var') + + counter = contents.variable_declarations.find { |v| v.name == 'counter' } + expect(counter.type).to eq('int') + expect(counter.text).to include('__attribute__((aligned(16)))') + + buf = contents.variable_declarations.find { |v| v.name == 'buf' } + expect(buf.type).to eq('char') + + exported = contents.variable_declarations.find { |v| v.name == 'exported_var' } + expect(exported.name).to eq('exported_var') + + plain = contents.variable_declarations.find { |v| v.name == 'plain_var' } + expect(plain.type).to eq('int') + expect(plain.decorators).to eq([]) + end + + end + +end diff --git a/spec/units/c_extractor/c_extractor_preprocessing_spec.rb b/spec/units/c_extractor/c_extractor_preprocessing_spec.rb new file mode 100644 index 000000000..3fa4ec260 --- /dev/null +++ b/spec/units/c_extractor/c_extractor_preprocessing_spec.rb @@ -0,0 +1,497 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'strscan' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_preprocessing' + +describe CExtractorPreprocessing do + + before(:each) do + code_text = CExtractorCodeText.new + @preprocessing = described_class.new( { c_extractor_code_text: code_text } ) + end + + # Helper: scan `text` for FOO and BAR macro calls + def scan(text, names = ['FOO', 'BAR']) + @preprocessing.try_extract_macro_calls( StringScanner.new(text), names ) + end + + context "#try_extract_macro_calls" do + + it "returns empty array for empty input" do + expect( scan('') ).to eq [] + end + + it "returns empty array when no matching macros are present" do + expect( scan('int x = UNRELATED(42);') ).to eq [] + end + + # --- Basic single-macro extraction --- + + it "extracts a single-param macro call" do + expect( scan('FOO(bar)') ).to eq ['FOO(bar)'] + end + + it "extracts a multi-param macro call" do + expect( scan('FOO(a, b, c)') ).to eq ['FOO(a, b, c)'] + end + + it "extracts macros from both names in the list" do + expect( scan('FOO(x) BAR(y)') ).to eq ['FOO(x)', 'BAR(y)'] + end + + it "extracts multiple calls of the same macro in order" do + expect( scan('FOO(a) FOO(b)') ).to eq ['FOO(a)', 'FOO(b)'] + end + + it "extracts macro surrounded by unrelated C code" do + input = "int x = 1;\nFOO(calc)\nvoid f(void) {}" + expect( scan(input) ).to eq ['FOO(calc)'] + end + + # --- Whitespace normalisation --- + + it "collapses embedded newlines in a multiline macro call" do + input = "FOO(a,\n b,\n c)" + expect( scan(input) ).to eq ['FOO(a, b, c)'] + end + + it "collapses tabs and multiple spaces" do + expect( scan("FOO(\t\ta\t\t)") ).to eq ['FOO( a )'] + end + + # --- Comment suppression (outer scan) --- + + it "does not extract a macro call inside a line comment" do + input = "// FOO(ignored)\nBAR(found)" + expect( scan(input) ).to eq ['BAR(found)'] + end + + it "does not extract a macro call inside a block comment" do + input = "/* FOO(ignored) */ BAR(found)" + expect( scan(input) ).to eq ['BAR(found)'] + end + + it "does not extract any macros when all input is inside a block comment" do + expect( scan('/* FOO(a) BAR(b) */') ).to eq [] + end + + it "resumes extraction after a block comment ends" do + input = "/* FOO(skip) */ FOO(keep)" + expect( scan(input) ).to eq ['FOO(keep)'] + end + + # --- String literal suppression (outer scan) --- + + it "does not extract a macro name appearing inside a double-quoted string literal" do + input = 'const char *s = "FOO(not_a_call)"; BAR(real)' + expect( scan(input) ).to eq ['BAR(real)'] + end + + it "does not extract a macro name appearing inside a single-quoted char literal" do + # 'F' followed immediately by OO(... — the single char literal ends after 'F' + # so this test uses a longer string that resembles a macro name in char context + input = "char c = 'x'; FOO(real)" + expect( scan(input) ).to eq ['FOO(real)'] + end + + # --- String literals as macro parameters --- + + it "treats a string literal containing a comma as a single argument" do + input = 'FOO("hello, world")' + expect( scan(input) ).to eq ['FOO("hello, world")'] + end + + it "treats a string literal containing parentheses as a single argument" do + input = 'FOO("f(x)")' + expect( scan(input) ).to eq ['FOO("f(x)")'] + end + + it "treats a string literal containing a macro name as a single argument" do + input = 'FOO("FOO(inner)")' + expect( scan(input) ).to eq ['FOO("FOO(inner)")'] + end + + it "handles a string literal containing an escaped quote" do + input = 'FOO("say \\"hi\\"")' + expect( scan(input) ).to eq ['FOO("say \\"hi\\"")'] + end + + it "extracts a macro whose arguments mix string literals and plain args" do + input = 'FOO(calc, "a,b", second)' + expect( scan(input) ).to eq ['FOO(calc, "a,b", second)'] + end + + # --- Comments inside argument lists --- + + it "removes a block comment inside an argument list and collapses to space" do + input = "FOO(a /* ignored */, b)" + expect( scan(input) ).to eq ['FOO(a , b)'] + end + + it "removes a line comment inside an argument list" do + input = "FOO(a, // comment\nb)" + expect( scan(input) ).to eq ['FOO(a, b)'] + end + + # --- Nested parentheses --- + + it "handles nested parentheses in arguments" do + input = 'FOO(func(x, y), z)' + expect( scan(input) ).to eq ['FOO(func(x, y), z)'] + end + + it "handles multiply-nested parentheses" do + input = 'FOO(f(g(h(1))))' + expect( scan(input) ).to eq ['FOO(f(g(h(1))))'] + end + + # --- Mixed complexity --- + + it "handles a mix of string literals, comments, and nested parens in one call" do + input = 'FOO("str(with,parens)", func(/* note */ x), y)' + expect( scan(input) ).to eq ['FOO("str(with,parens)", func( x), y)'] + end + + # --- Malformed / edge cases --- + + it "returns empty array for an unbalanced macro call" do + expect( scan('FOO(unbalanced') ).to eq [] + end + + it "does not confuse a word that ends with a macro name suffix" do + # 'NOTFOO(x)' should not match 'FOO' due to word boundary + expect( scan('NOTFOO(x)') ).to eq [] + end + + end + + context "#parse_macro_call" do + + def parse(str) + @preprocessing.parse_macro_call(str) + end + + it "returns macro name and single param" do + expect( parse('FOO(bar)') ).to eq ['FOO', ['bar']] + end + + it "returns macro name and multiple comma-separated params" do + expect( parse('FOO(a, b, c)') ).to eq ['FOO', ['a', 'b', 'c']] + end + + it "returns macro name and empty params array when no arguments" do + expect( parse('FOO()') ).to eq ['FOO', []] + end + + it "trims leading and trailing whitespace from each param" do + expect( parse('FOO( a , b )') ).to eq ['FOO', ['a', 'b']] + end + + it "treats nested parens as a single param unit — inner comma is not a separator" do + expect( parse('FOO(func(x, y), z)') ).to eq ['FOO', ['func(x, y)', 'z']] + end + + it "treats square brackets as a single param unit — inner comma is not a separator" do + expect( parse('FOO(calc, [add, subtract])') ).to eq ['FOO', ['calc', '[add, subtract]']] + end + + it "treats curly braces as a single param unit — inner comma is not a separator" do + expect( parse('FOO(name, {a, b})') ).to eq ['FOO', ['name', '{a, b}']] + end + + it "treats a string literal as a single param unit — inner comma is not a separator" do + expect( parse('FOO("hello, world")') ).to eq ['FOO', ['"hello, world"']] + end + + it "handles a mix of string literals, brackets, and nested parens" do + expect( parse('TEST_PARTIAL_CONFIG(calculator, test_public, [add, subtract])') ).to eq [ + 'TEST_PARTIAL_CONFIG', ['calculator', 'test_public', '[add, subtract]'] + ] + end + + it "returns [nil, []] for malformed input with no opening paren" do + expect( parse('FOO_no_parens') ).to eq [nil, []] + end + + it "returns [nil, []] for empty input" do + expect( parse('') ).to eq [nil, []] + end + + end + + context "#try_extract_directive" do + + def try_directive(text) + scanner = StringScanner.new(text) + result = @preprocessing.try_extract_directive(scanner) + [result, scanner.pos] + end + + it "returns [false, nil] when scanner is not at #" do + result, pos = try_directive('int x = 0;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 # scanner not advanced + end + + it "returns [false, nil] for empty input" do + result, pos = try_directive('') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "extracts a simple #pragma directive" do + result, pos = try_directive("#pragma once\n") + expect(result).to eq [true, "#pragma once"] + expect(pos).to eq "#pragma once\n".length + end + + it "extracts a simple #include directive" do + result, pos = try_directive("#include <stdio.h>\n") + expect(result).to eq [true, "#include <stdio.h>"] + expect(pos).to eq "#include <stdio.h>\n".length + end + + it "extracts a simple single-line #define macro" do + result, pos = try_directive("#define FOO 42\n") + expect(result).to eq [true, "#define FOO 42"] + expect(pos).to eq "#define FOO 42\n".length + end + + it "extracts a #define with whitespace after #" do + result, pos = try_directive("# define FOO\n") + expect(result).to eq [true, "# define FOO"] + expect(pos).to eq "# define FOO\n".length + end + + it "extracts a directive without trailing newline (EOS)" do + result, pos = try_directive("#define FOO") + expect(result).to eq [true, "#define FOO"] + expect(pos).to eq "#define FOO".length + end + + it "extracts a multiline #define with single backslash continuation" do + input = "#define MAX(a,b) \\\n ((a)>(b)?(a):(b))\n" + result, pos = try_directive(input) + expect(result).to eq [true, input.rstrip] + expect(pos).to eq input.length + end + + it "extracts a multiline #define with multiple backslash continuations" do + input = "#define MULTI \\\n line1 \\\n line2\n" + result, pos = try_directive(input) + expect(result).to eq [true, input.rstrip] + expect(pos).to eq input.length + end + + it "stops at end of directive and does not consume following code" do + input = "#define FOO 1\nint x = 0;" + result, pos = try_directive(input) + expect(result).to eq [true, "#define FOO 1"] + expect(pos).to eq "#define FOO 1\n".length + end + + it "leaves scanner position unchanged on failure" do + scanner = StringScanner.new("int x;") + scanner.pos = 0 + @preprocessing.try_extract_directive(scanner) + expect(scanner.pos).to eq 0 + end + + end + + context "#filter_directive" do + + it "returns the directive text for MACRO_DEFINITION when directive is #define" do + text = "#define FOO 42\n" + expect( @preprocessing.filter_directive(text, CExtractorPreprocessing::MACRO_DEFINITION) ).to eq text + end + + it "returns the directive text for MACRO_DEFINITION when directive is multiline #define" do + text = "#define MAX(a,b) \\\n ((a)>(b)?(a):(b))\n" + expect( @preprocessing.filter_directive(text, CExtractorPreprocessing::MACRO_DEFINITION) ).to eq text + end + + it "returns the directive text for MACRO_DEFINITION when # define has whitespace after #" do + text = "# define FOO\n" + expect( @preprocessing.filter_directive(text, CExtractorPreprocessing::MACRO_DEFINITION) ).to eq text + end + + it "returns nil for MACRO_DEFINITION when directive is #pragma" do + expect( @preprocessing.filter_directive("#pragma once\n", CExtractorPreprocessing::MACRO_DEFINITION) ).to be_nil + end + + it "returns nil for MACRO_DEFINITION when directive is #include" do + expect( @preprocessing.filter_directive("#include <stdio.h>\n", CExtractorPreprocessing::MACRO_DEFINITION) ).to be_nil + end + + it "returns nil for unknown type symbols" do + expect( @preprocessing.filter_directive("#define FOO\n", :unknown_type) ).to be_nil + end + + end + + context "#try_extract_static_assert" do + + def try_static_assert(text) + scanner = StringScanner.new(text) + result = @preprocessing.try_extract_static_assert(scanner) + [result, scanner.pos] + end + + # --- Failure cases --- + + it "returns [false, nil] when scanner is not at a static assert keyword" do + result, pos = try_static_assert('int x = 0;') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] for empty input" do + result, pos = try_static_assert('') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "does not advance scanner on failure" do + scanner = StringScanner.new('int x;') + @preprocessing.try_extract_static_assert(scanner) + expect(scanner.pos).to eq 0 + end + + it "does not match a longer identifier that ends with the keyword" do + result, pos = try_static_assert('not_static_assert(1 == 1, "msg");') + expect(result).to eq [false, nil] + expect(pos).to eq 0 + end + + it "returns [false, nil] when the argument list is missing its closing ')'" do + result, _pos = try_static_assert('_Static_assert(sizeof(int) == 4') + expect(result).to eq [false, nil] + end + + it "returns [false, nil] when the terminating ';' is missing" do + result, _pos = try_static_assert('_Static_assert(sizeof(int) == 4, "msg")') + expect(result).to eq [false, nil] + end + + # --- C11 _Static_assert --- + + it "extracts a two-argument C11 _Static_assert with trailing newline" do + input = "_Static_assert(sizeof(int) == 4, \"int must be 32-bit\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "extracts _Static_assert without trailing newline (EOS)" do + input = "_Static_assert(1 == 1, \"always true\");" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + # --- C23 static_assert --- + + it "extracts a one-argument C23 static_assert" do + input = "static_assert(sizeof(int) == 4);\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "extracts a two-argument C23 static_assert" do + input = "static_assert(sizeof(int) == 4, \"int must be 32-bit\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + # --- Complex / nested expressions --- + + it "handles sizeof with a struct type in the expression" do + input = "_Static_assert(sizeof(struct Point) == 8, \"Point must be 8 bytes\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "handles offsetof with nested parens in the expression" do + input = "_Static_assert(offsetof(struct S, field) == sizeof(int), \"layout\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "handles deeply nested parentheses in the expression" do + input = "_Static_assert(sizeof(int[sizeof(char)]) == 4, \"nested sizeof\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "handles a boolean expression with multiple parenthesised sub-expressions" do + input = "static_assert((sizeof(int) == 4) && (sizeof(long) >= 4));\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + # --- String literal in message argument --- + + it "handles a ')' inside the message string without terminating early" do + input = "_Static_assert(1, \"message with ) paren inside\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + # --- Comments inside the assertion --- + + it "handles a block comment inside the expression (comment replaced with space)" do + input = "_Static_assert(/* condition */ 1 == 1, \"always\");\n" + result, pos = try_static_assert(input) + expect(result).to be_a Array + expect(result[0]).to be true # text content varies (comment→space); just verify success + expect(pos).to eq input.length + end + + # --- Whitespace variants --- + + it "handles whitespace between keyword and '('" do + input = "_Static_assert (sizeof(int) == 4, \"msg\");\n" + result, pos = try_static_assert(input) + expect(result).to eq [true, input] + expect(pos).to eq input.length + end + + it "handles a multiline static assert" do + input = <<~'C' + _Static_assert( + sizeof(struct BigThing) == 128, + "BigThing must be exactly 128 bytes" + ); + C + result, pos = try_static_assert(input) + expect(result[0]).to be true + expect(pos).to eq input.length + end + + # --- Boundary behaviour --- + + it "stops at the ';' and does not consume following code" do + input = "_Static_assert(1 == 1, \"ok\");\nint x = 0;" + result, pos = try_static_assert(input) + expect(result[0]).to be true + expect(pos).to eq "_Static_assert(1 == 1, \"ok\");\n".length + end + + end + +end diff --git a/spec/units/c_extractor/c_extractor_spec.rb b/spec/units/c_extractor/c_extractor_spec.rb new file mode 100644 index 000000000..74fc7a098 --- /dev/null +++ b/spec/units/c_extractor/c_extractor_spec.rb @@ -0,0 +1,356 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/c_extractor/c_extractor' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_functions' +require 'ceedling/c_extractor/c_extractor_declarations' +require 'ceedling/c_extractor/c_extractor_preprocessing' +require 'ceedling/c_extractor/c_extractor_definitions' +require 'stringio' + +## +## These unit tests exercise the CExtractor class's individual methods. +## A separate set of integration tests exercise the composition of all CExtractor* objects +## in extracting features from C source code. +## +describe CExtractor do + + ### + ### extract_next_feature() + ### + describe "#extract_next_feature (private method testing)" do + # Helper to create a simple extractor that looks for a specific pattern + # NOTE: `scanner.scan()` expects pattern to match from the current position + let(:create_pattern_extractor) do + ->(pattern) do + ->(scanner) do + if scanner.scan(pattern) + matched = scanner.matched + return [true, matched] + end + return [false, nil] + end + end + end + + # Helper to build a fully-wired CExtractor via DI + let(:build_extractor) do + ->() do + code_text = CExtractorCodeText.new + declarations = CExtractorDeclarations.new({ c_extractor_code_text: code_text }) + functions = CExtractorFunctions.new({ c_extractor_code_text: code_text }) + preprocessing = CExtractorPreprocessing.new({ c_extractor_code_text: code_text }) + definitions = CExtractorDefinitions.new({ c_extractor_code_text: code_text }) + declarations.setup() + functions.setup() + extractor = CExtractor.new( + { + c_extractor_code_text: code_text, + c_extractor_functions: functions, + c_extractor_declarations: declarations, + c_extractor_preprocessing: preprocessing, + c_extractor_definitions: definitions + } + ) + extractor.setup() + extractor + end + end + + # Helper to access private method — unwraps the [feature, start_pos] tuple and returns just the feature + let(:extract_feature) do + ->(io, max_length, extractor_lambda, chunk_size=10) do + obj = build_extractor.call() + obj.chunk_size = chunk_size + feature, _start = obj.send(:extract_next_feature, io: io, max_length: max_length, extractor: extractor_lambda) + feature + end + end + + context "basic extraction" do + it "extracts a simple pattern within first chunk" do + content = "HELLO // comment" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/HELLO/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("HELLO") + # End of IO chunk + handling of possible trailing orphaned semicolon & deadspace + expect(io.pos).to eq(10) + end + + it "returns nil when pattern is not found before EOF" do + content = "// no content in these chunks" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/NOTFOUND/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to be_nil + expect(io.pos).to eq(0) + end + + it "advances scanner position on success" do + content = "PREFIX:DATA:SUFFIX" + io = StringIO.new(content) + + extractor = ->(scanner) do + # Look for pattern like "PREFIX:DATA:" + if scanner.scan(/PREFIX:(\w+):/) + return [true, scanner[1]] # Return just the captured DATA part + end + [false, nil] + end + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("DATA") + expect(io.pos).to eq(12) # After "PREFIX:DATA:" + end end + + context "multiple extractions" do + it "extracts multiple features sequentially from same IO" do + content = "FIRST SECOND THIRD" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/\w+/) + + result1 = extract_feature.call(io, 1000, extractor) + result2 = extract_feature.call(io, 1000, extractor) + result3 = extract_feature.call(io, 1000, extractor) + result4 = extract_feature.call(io, 1000, extractor) + + expect(result1).to eq("FIRST") + expect(result2).to eq("SECOND") + expect(result3).to eq("THIRD") + expect(result4).to be_nil + end + + it "positions IO correctly after each extraction" do + content = "AAA BBB CCC" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/\w+/) + + extract_feature.call(io, 1000, extractor) + pos_after_first = io.pos + + extract_feature.call(io, 1000, extractor) + pos_after_second = io.pos + + expect(pos_after_first).to eq(3) # After "AAA" + expect(pos_after_second).to eq(7) # After "AAA BBB" + end + end + + context "whitespace and deadspace handling" do + it "skips whitespace before pattern" do + content = " \n\t PATTERN" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("PATTERN") + end + + it "skips comments before pattern" do + content = "// comment\n/* block */PATTERN" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("PATTERN") + end + + it "does not skip preprocessor directives — they are features, not deadspace" do + content = "#include <stdio.h>\n#define FOO 123\nPATTERN" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to be_nil + end + end + + context "IO access and buffer usage" do + it "extracts pattern that spans multiple chunks" do + content = "/*pre*/ LOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOONG_PATTERN /*post*/" + io = StringIO.new(content) + # Chunk size is 10, so "LONG_PATTERN" will span chunks + extractor = create_pattern_extractor.call(/L(O)+NG_PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("LOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOONG_PATTERN") + end + + it "grows buffer across many chunks until pattern is found" do + # Create content where pattern appears after several chunks + content = "\t" * 100 + "TARGET" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/TARGET/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("TARGET") + end + + it "raises error when buffer exceeds max_length" do + content = "x" * 200 # Long string + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/NOTFOUND/) + + expect { + extract_feature.call(io, 100, extractor) + }.to raise_error(CeedlingException, /exceeded maximum length/) + end + + it "extracts multiple features from same chunk" do + # Other test cases deal with growing the internal buffer with multiple chunk reads from IO. + # This test case ensures we can extract multiple features from the same large chunk. + + content = "FIRST" + (' ' * 500) + "SECOND" + (' ' * 500) + "THIRD" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/\w+/) + + extractor_obj = build_extractor.call() + extractor_obj.chunk_size = 2000 + + result1, _ = extractor_obj.send(:extract_next_feature, io: io, max_length: 1200, extractor: extractor) + result2, _ = extractor_obj.send(:extract_next_feature, io: io, max_length: 1200, extractor: extractor) + result3, _ = extractor_obj.send(:extract_next_feature, io: io, max_length: 1200, extractor: extractor) + result4, _ = extractor_obj.send(:extract_next_feature, io: io, max_length: 1200, extractor: extractor) + + expect(result1).to eq("FIRST") + expect(result2).to eq("SECOND") + expect(result3).to eq("THIRD") + expect(result4).to be_nil + end + end + + context "edge cases" do + it "handles empty IO" do + io = StringIO.new("") + extractor = create_pattern_extractor.call(/ANYTHING/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to be_nil + end + + it "handles IO with only whitespace and comments" do + content = " \n\t // comment\n/* block */ \n" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to be_nil + end + + it "handles pattern at very end of IO" do + content = "/*prefix*/ PATTERN" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("PATTERN") + expect(io.eof?).to be true + end + + it "handles pattern at very beginning of IO" do + content = "PATTERN /*suffix*/" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("PATTERN") + # End of IO chunk + handling of possible trailing orphaned semicolon & deadspace + expect(io.pos).to eq(10) + end + + it "allows extraction when pattern exactly matches chunk size" do + content = "FOUND" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/FOUND/) + + result = extract_feature.call(io, 100, extractor, 5) + + expect(result).to eq("FOUND") + end + + it "allows extraction when exactly at max_length" do + content = "\n" * 95 + "FOUND" # 100 characters + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/FOUND/) + + result = extract_feature.call(io, 100, extractor) + + expect(result).to eq("FOUND") + end + + it "handles pattern split exactly at chunk boundary" do + # With chunk_size=10, "/*012345*/" fills first chunk exactly + content = "/*012345*/PATTERN" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("PATTERN") + end + + it "handles comment spanning chunk boundaries" do + content = "/* comment across\nchunk boundary */PATTERN" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/PATTERN/) + + result = extract_feature.call(io, 1000, extractor) + + expect(result).to eq("PATTERN") + end + end + + context "performance and safety" do + it "stops reading when max_length is reached" do + # Create content larger than max_length + large_content = "x" * 500 + io = StringIO.new(large_content) + extractor = create_pattern_extractor.call(/NOTFOUND/) + + expect { + extract_feature.call(io, 200, extractor) + }.to raise_error(CeedlingException, /exceeded maximum length/) + + # IO should not have read entire content + expect(io.pos).to be < large_content.length + end + + it "handles rapid successive extractions" do + content = "A B C D E F G H I J" + io = StringIO.new(content) + extractor = create_pattern_extractor.call(/\w/) + + results = [] + 10.times do + result = extract_feature.call(io, 1000, extractor) + break unless result + results << result + end + + expect(results).to eq(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]) + end + end + end + +end \ No newline at end of file diff --git a/spec/config_walkinator_spec.rb b/spec/units/config_walkinator_spec.rb similarity index 97% rename from spec/config_walkinator_spec.rb rename to spec/units/config_walkinator_spec.rb index 5fae29901..f68369e29 100644 --- a/spec/config_walkinator_spec.rb +++ b/spec/units/config_walkinator_spec.rb @@ -6,7 +6,7 @@ # ========================================================================= require 'spec_helper' -require 'ceedling/config_walkinator' +require 'ceedling/config/config_walkinator' describe ConfigWalkinator do before(:each) do diff --git a/spec/configurator_builder_spec.rb b/spec/units/configurator_builder_spec.rb similarity index 91% rename from spec/configurator_builder_spec.rb rename to spec/units/configurator_builder_spec.rb index 5f271d1b7..224d81f36 100644 --- a/spec/configurator_builder_spec.rb +++ b/spec/units/configurator_builder_spec.rb @@ -9,7 +9,7 @@ #derived from test_graveyard/unit/busted/configurator_builder_test.rb require 'spec_helper' -require 'ceedling/configurator_builder' +require 'ceedling/config/configurator_builder' describe ConfiguratorBuilder do xit "is scary" diff --git a/spec/configurator_helper_spec.rb b/spec/units/configurator_helper_spec.rb similarity index 100% rename from spec/configurator_helper_spec.rb rename to spec/units/configurator_helper_spec.rb diff --git a/spec/configurator_spec.rb b/spec/units/configurator_spec.rb similarity index 93% rename from spec/configurator_spec.rb rename to spec/units/configurator_spec.rb index a7c3ef35b..f8bad697d 100644 --- a/spec/configurator_spec.rb +++ b/spec/units/configurator_spec.rb @@ -6,7 +6,7 @@ # ========================================================================= require 'spec_helper' -require 'ceedling/configurator' +require 'ceedling/config/configurator' describe Configurator do describe "#standardize_paths" do diff --git a/spec/file_finder_helper_spec.rb b/spec/units/file_finder_helper_spec.rb similarity index 100% rename from spec/file_finder_helper_spec.rb rename to spec/units/file_finder_helper_spec.rb diff --git a/spec/units/generators/generator_partials_spec.rb b/spec/units/generators/generator_partials_spec.rb new file mode 100644 index 000000000..51a36e6c8 --- /dev/null +++ b/spec/units/generators/generator_partials_spec.rb @@ -0,0 +1,795 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/generators/generator_partials' +require 'ceedling/partials/partials' +require 'ceedling/includes/includes' +require 'ceedling/c_extractor/c_extractor_types' +require 'stringio' + +describe GeneratorPartials do + before(:each) do + @file_wrapper = double( "FileWrapper" ) + @file_path_utils = double( "FilePathUtils" ) + @loginator = double( "Loginator" ).as_null_object + + @generator = described_class.new( + { + :file_wrapper => @file_wrapper, + :file_path_utils => @file_path_utils, + :loginator => @loginator, + } + ) + end + + # Helper to create CVariableDeclaration structs for testing + def make_var(name:, type:, text:, decorators: [], line_num: nil) + CExtractorTypes::CVariableDeclaration.new( + name: name, type: type, decorators: decorators, + text: text, original: text, line_num: line_num + ) + end + + # Helper to create CStatement structs for testing + def make_stmt(text:, line_num: nil) + CExtractorTypes::CStatement.new(text: text, line_num: line_num) + end + + # Helper to create a CModule whose element_sequence is exactly `items` (in the given order). + # The typed arrays are populated from the items for completeness but generation uses element_sequence. + def make_module(*items) + vars = items.select { |i| i.is_a?(CExtractorTypes::CVariableDeclaration) } + macros = items.select { |i| i.is_a?(CExtractorTypes::CStatement) } + fdefs = items.select { |i| i.is_a?(CExtractorTypes::CFunctionDefinition) } + fdecls = items.select { |i| i.is_a?(CExtractorTypes::CFunctionDeclaration) } + CExtractorTypes::CModule.new( + variable_declarations: vars, + macro_definitions: macros, + function_definitions: fdefs, + function_declarations: fdecls, + element_sequence: items + ) + end + + # Empty CModule — used when tests only care about function_list or includes + def empty_module + CExtractorTypes::CModule.new() + end + + context "#generate_implementation" do + it "should call generate_header() and generate_source() with correct parameters" do + # Setup + output_path = '/path/to/output' + name = 'my_implementation' + source_filename = 'my_implementation_impl.c' + header_filename = 'my_implementation_impl.h' + expected_source_filepath = File.join(output_path, source_filename) + expected_header_filepath = File.join(output_path, header_filename) + + # Mock FilePathUtils + allow(@file_path_utils).to receive(:form_partial_implementation_source_filename) + .with(name) + .and_return(source_filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with(name) + .and_return(header_filename) + + # Mock FileWrapper.open to yield file handles + header_file_handle = double('header_file_handle') + source_file_handle = double('source_file_handle') + + allow(@file_wrapper).to receive(:open) + .with(expected_header_filepath, 'w') + .and_yield(header_file_handle) + + allow(@file_wrapper).to receive(:open) + .with(expected_source_filepath, 'w') + .and_yield(source_file_handle) + + # Spy on generate_header and generate_source -- allow them to be called but track the calls + allow(@generator).to receive(:generate_header) + allow(@generator).to receive(:generate_source) + + # Define test data + defns = [ + Partials.manufacture_function_definition( + name: 'initialize', + signature: 'void initialize(void)', + code_block: "void initialize(void) {\n // implementation\n}" + ) + ] + source_includes = [UserInclude.new('types.h'), UserInclude.new('config.h')] + header_includes = [SystemInclude.new('stdint.h'), SystemInclude.new('stdbool.h')] + c_module = make_module( + make_var(name: 'my_var', type: 'uint8_t', text: 'uint8_t my_var;'), + make_stmt(text: "#define MAX_SIZE 100\n", line_num: 5) + ) + + # Execute + result = @generator.generate_implementation( + test: 'test_my_implementation', + name: name, + function_definitions: defns, + source_includes: source_includes, + header_includes: header_includes, + c_module: c_module, + output_path: output_path + ) + + # Verify generate_header was called with correct parameters + expect(@generator).to have_received(:generate_header).with( + header_file_handle, + header_filename, + header_includes, + defns, + c_module, + true + ) + + # Verify generate_source was called with correct parameters + expect(@generator).to have_received(:generate_source).with( + source_file_handle, + source_includes, + defns, + c_module + ) + + # Verify file path utilities were called + expect(@file_path_utils).to have_received(:form_partial_implementation_source_filename).with(name) + expect(@file_path_utils).to have_received(:form_partial_implementation_header_filename).with(name) + + # Verify file operations + expect(@file_wrapper).to have_received(:open).with(expected_header_filepath, 'w') + expect(@file_wrapper).to have_received(:open).with(expected_source_filepath, 'w') + + # Verify return value is the source filepath + expect(result).to eq(expected_source_filepath) + end + end + + context "#generate_interface" do + it "should call generate_header() with correct parameters" do + # Setup + output_path = '/path/to/output' + name = 'my_interface' + header_filename = 'my_interface_interface.h' + expected_filepath = File.join(output_path, header_filename) + + # Mock FilePathUtils + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with(name) + .and_return(header_filename) + + # Mock FileWrapper.open to yield a file handle + file_handle = double('file_handle') + allow(@file_wrapper).to receive(:open) + .with(expected_filepath, 'w') + .and_yield(file_handle) + + # Spy on generate_header -- allow it to be called but track the call + allow(@generator).to receive(:generate_header) + + # Define test data + decls = [ + Partials.manufacture_function_declaration( + name: 'initilalize', + signature: 'void initialize(void)' + ) + ] + includes = [UserInclude.new('types.h'), UserInclude.new('config.h')] + c_module = make_module( + make_stmt(text: "typedef uint8_t Byte;\n", line_num: 3) + ) + + # Execute + result = @generator.generate_interface( + test: 'test_my_interface', + function_declarations: decls, + name: name, + includes: includes, + c_module: c_module, + output_path: output_path + ) + + # Verify generate_header was called with correct parameters + expect(@generator).to have_received(:generate_header).with( + file_handle, + header_filename, + includes, + decls, + c_module, + false + ) + + # Verify file path utilities were called + expect(@file_path_utils).to have_received(:form_partial_interface_header_filename).with(name) + + # Verify file operations + expect(@file_wrapper).to have_received(:open).with(expected_filepath, 'w') + + # Verify return value is the header filepath + expect(result).to eq(expected_filepath) + end + end + + context "#generate_header (private method)" do + # Define common StringIO buffer + let(:buf) { StringIO.new() } + + it "should generate a nearly empty header file" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_FOO_BAR_H__ + #define __CEEDLING_GENERATED_FOO_BAR_H__ + + #endif // __CEEDLING_GENERATED_FOO_BAR_H__ + + CONTENTS + + @generator.send(:generate_header, buf, 'foo_bar', [], [], empty_module, false) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should generate a header file with #include statements but nothing else" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_APPLES_AND_BANANAS_H__ + #define __CEEDLING_GENERATED_APPLES_AND_BANANAS_H__ + + #include "foo.h" + #include "bar.h" + + #endif // __CEEDLING_GENERATED_APPLES_AND_BANANAS_H__ + + CONTENTS + + @generator.send( + :generate_header, + buf, + 'Apples-and-Bananas', + [UserInclude.new('foo.h'), UserInclude.new('bar.h')], + [], empty_module, false + ) + + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should generate a header file with variable declarations (extern prefix added automatically)" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_PB_AND_J_H__ + #define __CEEDLING_GENERATED_PB_AND_J_H__ + + extern unsigned int slices_of_bread; + extern char crumbs; + + #endif // __CEEDLING_GENERATED_PB_AND_J_H__ + + CONTENTS + + c_module = make_module( + make_var(name: 'slices_of_bread', type: 'unsigned int', text: 'unsigned int slices_of_bread = 10;'), + make_var(name: 'crumbs', type: 'char', text: 'char crumbs[10];') + ) + @generator.send(:generate_header, buf, 'pb-and-j', [], [], c_module, true) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should generate a header file with #include statements, variable declarations, and function signatures" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_APPLES_AND_BANANAS_H__ + #define __CEEDLING_GENERATED_APPLES_AND_BANANAS_H__ + + #include "Eeny.h" + #include "Meeny.h" + + extern signed long int apples; + extern double bananas; + + void foobarbaz(int x, int y); + + int razzleDazzle(void* ptr); + + #endif // __CEEDLING_GENERATED_APPLES_AND_BANANAS_H__ + + CONTENTS + + decls = [] + + decls << Partials.manufacture_function_declaration( + name: 'foobarbaz', + signature: 'void foobarbaz(int x, int y)' + ) + + decls << Partials.manufacture_function_declaration( + name: 'razzleDazzle', + signature: 'int razzleDazzle(void* ptr)' + ) + + # Raw CExtractor stubs for the lookup by name — only :name must match decls + foobarbaz_raw = CExtractorTypes::CFunctionDeclaration.new(name: 'foobarbaz') + razzledazzle_raw = CExtractorTypes::CFunctionDeclaration.new(name: 'razzleDazzle') + + c_module = make_module( + make_var(name: 'apples', type: 'signed long int', text: 'signed long int apples;'), + make_var(name: 'bananas', type: 'double', text: 'double bananas;'), + foobarbaz_raw, + razzledazzle_raw + ) + + @generator.send( + :generate_header, + buf, + 'Apples-and-Bananas', + [UserInclude.new('Eeny.h'), UserInclude.new('Meeny.h')], + decls, + c_module, + true + ) + + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should emit CStatement text as-is in the header" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_DEFS_H__ + #define __CEEDLING_GENERATED_DEFS_H__ + + #define MAX_SIZE 100 + typedef uint8_t Byte; + struct Point { int x; int y; }; + + #endif // __CEEDLING_GENERATED_DEFS_H__ + + CONTENTS + + c_module = make_module( + make_stmt(text: "#define MAX_SIZE 100"), + make_stmt(text: "typedef uint8_t Byte;"), + make_stmt(text: "struct Point { int x; int y; };") + ) + + @generator.send(:generate_header, buf, 'defs', [], [], c_module, false) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should emit all four injectable statement categories correctly in element_sequence order" do + # One item of each category that can be injected into a generated Partial header: + # macro_definitions → CStatement emitted as-is + # type_definitions → CStatement emitted as-is + # aggregate_definitions → CStatement emitted as-is + # variable_declarations → CVariableDeclaration emitted as extern declaration + # + # Ordering is intentionally interleaved (not grouped by category) to confirm that + # element_sequence — not typed-array membership — governs emit order. + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_ALL_STATEMENTS_H__ + #define __CEEDLING_GENERATED_ALL_STATEMENTS_H__ + + #define MAX_ITEMS 16 + typedef uint8_t Byte; + extern int item_count; + struct Config { int id; int flags; }; + + #endif // __CEEDLING_GENERATED_ALL_STATEMENTS_H__ + + CONTENTS + + macro_stmt = CExtractorTypes::CStatement.new(text: "#define MAX_ITEMS 16", line_num: 1) + typedef_stmt = CExtractorTypes::CStatement.new(text: "typedef uint8_t Byte;", line_num: 2) + var_decl = make_var(name: 'item_count', type: 'int', text: 'int item_count;', line_num: 3) + aggregate_stmt = CExtractorTypes::CStatement.new(text: "struct Config { int id; int flags; };", line_num: 4) + + c_module = CExtractorTypes::CModule.new( + macro_definitions: [macro_stmt], + type_definitions: [typedef_stmt], + aggregate_definitions: [aggregate_stmt], + variable_declarations: [var_decl], + element_sequence: [macro_stmt, typedef_stmt, var_decl, aggregate_stmt] + ) + + @generator.send(:generate_header, buf, 'all_statements', [], [], c_module, true) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should emit mixed CStatement and CVariableDeclaration items in element_sequence order" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_MIXED_H__ + #define __CEEDLING_GENERATED_MIXED_H__ + + #define FOO 1 + extern int counter; + typedef uint8_t Byte; + + #endif // __CEEDLING_GENERATED_MIXED_H__ + + CONTENTS + + # element_sequence dictates the order; line_num is informational only + c_module = make_module( + make_stmt(text: "#define FOO 1", line_num: 1), + make_var( name: 'counter', type: 'int', text: 'int counter;', line_num: 2), + make_stmt(text: "typedef uint8_t Byte;", line_num: 3) + ) + + @generator.send(:generate_header, buf, 'mixed', [], [], c_module, true) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should not emit variable declarations when include_variables is false" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_INTERFACE_H__ + #define __CEEDLING_GENERATED_INTERFACE_H__ + + #define FOO 1 + + #endif // __CEEDLING_GENERATED_INTERFACE_H__ + + CONTENTS + + c_module = make_module( + make_stmt(text: "#define FOO 1", line_num: 1), + make_var( name: 'counter', type: 'int', text: 'int counter;', line_num: 2) + ) + + @generator.send(:generate_header, buf, 'interface', [], [], c_module, false) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should interleave functions with other elements in element_sequence order" do + file_contents = <<~CONTENTS + #ifndef __CEEDLING_GENERATED_INTERLEAVED_H__ + #define __CEEDLING_GENERATED_INTERLEAVED_H__ + + typedef uint8_t Byte; + + void foo(void); + + extern int counter; + + int bar(int x); + + #endif // __CEEDLING_GENERATED_INTERLEAVED_H__ + + CONTENTS + + typedef_stmt = make_stmt(text: "typedef uint8_t Byte;", line_num: 1) + var_decl = make_var( name: 'counter', type: 'int', text: 'int counter;', line_num: 5) + + foo_raw = CExtractorTypes::CFunctionDeclaration.new( + name: 'foo', signature: 'void foo(void)', line_num: 3 + ) + bar_raw = CExtractorTypes::CFunctionDeclaration.new( + name: 'bar', signature: 'int bar(int x)', line_num: 7 + ) + + foo_decl = Partials.manufacture_function_declaration(name: 'foo', signature: 'void foo(void)') + bar_decl = Partials.manufacture_function_declaration(name: 'bar', signature: 'int bar(int x)') + + # element_sequence captures original file order across all types + c_module = CExtractorTypes::CModule.new( + variable_declarations: [var_decl], + function_declarations: [foo_raw, bar_raw], + type_definitions: [typedef_stmt], + element_sequence: [typedef_stmt, foo_raw, var_decl, bar_raw] + ) + + @generator.send(:generate_header, buf, 'interleaved', [], [foo_decl, bar_decl], c_module, true) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "emits function signature with __declspec(dllexport) verbatim in header" do + decl = Partials.manufacture_function_declaration( + name: 'exported_func', + signature: '__declspec(dllexport) void exported_func(void)' + ) + raw = CExtractorTypes::CFunctionDeclaration.new(name: 'exported_func') + c_module = make_module(raw) + + @generator.send(:generate_header, buf, 'mymod', [], [decl], c_module, false) + + expect(buf.string).to include('__declspec(dllexport) void exported_func(void);') + end + + it "emits extern variable declaration using clean type and name, not raw text with __attribute__" do + c_module = make_module( + make_var(name: 'counter', type: 'int', text: 'int counter __attribute__((aligned(16)));') + ) + + @generator.send(:generate_header, buf, 'mymod', [], [], c_module, true) + + expect(buf.string).to include('extern int counter;') + expect(buf.string).not_to include('__attribute__') + end + + end + + context "#generate_source (private method)" do + # Define common StringIO buffer + let(:buf) { StringIO.new() } + + it "should generate a nearly empty source file" do + @generator.send(:generate_source, buf, [], [], empty_module) + expect( buf.string.strip() ).to eq '// Ceeding generated file' + end + + it "should generate a source file with #include directives" do + file_contents = <<~CONTENTS + // Ceeding generated file + #include "foo.h" + #include <bar.h> + + CONTENTS + + @generator.send( + :generate_source, + buf, + [UserInclude.new('foo.h'), SystemInclude.new('bar.h')], + [], empty_module + ) + + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should preserve internal whitespace of function code block for coverage instrumentation line mapping" do + file_contents = <<~CONTENTS + // Ceeding generated file + + void foobar(int x, int y) + + { + + int z = x+y; + + + } + + CONTENTS + + defns = [] + + defns << Partials.manufacture_function_definition( + name: 'foobar', + signature: 'void foobar(int x, int y)', + code_block: "void foobar(int x, int y)\n\n{\n\n int z = x+y;\n\n\n}" + ) + + # Raw CExtractor stub for the lookup by name + c_module = make_module( CExtractorTypes::CFunctionDefinition.new(name: 'foobar') ) + + @generator.send(:generate_source, buf, [], defns, c_module) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should generate a source file with include directives, variable declarations, and functions" do + file_contents = <<~CONTENTS + // Ceeding generated file + #include "foobar.h" + #include "baz.h" + + int abc = 123; + char str[] = "Hello, World!"; + + void foobar(int x, int y) { + int z = x+y; + } + + CONTENTS + + defn = Partials.manufacture_function_definition( + name: 'foobar', + signature: 'void foobar(int x, int y)', + code_block: "void foobar(int x, int y) {\n int z = x+y;\n}" + ) + + var_abc = make_var(name: 'abc', type: 'int', text: 'int abc = 123;') + var_str = make_var(name: 'str', type: 'char', text: 'char str[] = "Hello, World!";') + # Raw CExtractor stub for the lookup by name + foobar_raw = CExtractorTypes::CFunctionDefinition.new(name: 'foobar') + + c_module = make_module(var_abc, var_str, foobar_raw) + + @generator.send( + :generate_source, + buf, + [UserInclude.new('foobar.h'), UserInclude.new('baz.h')], + [defn], + c_module + ) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should generate a source file with functions and #line directives" do + file_contents = <<~CONTENTS + // Ceeding generated file + + #line 9 "../foo/bar/fubar.c" + void foobarbaz(int x, int y) { + int z = x+y; + } + + #line 123 "src/code/ABC.c" + int + razzleDazzle(void* ptr) + { + global_var = ptr; + return 42; + } + + CONTENTS + + defns = [] + + defns << Partials.manufacture_function_definition( + line_num: 9, + source_filepath: '../foo/bar/fubar.c', + name: 'foobarbaz', + signature: 'void foobarbaz(int x, int y)', + code_block: "void foobarbaz(int x, int y) {\n int z = x+y;\n}" + ) + + defns << Partials.manufacture_function_definition( + line_num: 123, + source_filepath: 'src/code/ABC.c', + name: 'razzleDazzle', + signature: 'int razzleDazzle(void* ptr)', + code_block: "int\nrazzleDazzle(void* ptr)\n{\n global_var = ptr;\n return 42;\n}" + ) + + # Raw CExtractor stubs for the lookup by name — in extraction order + c_module = make_module( + CExtractorTypes::CFunctionDefinition.new(name: 'foobarbaz'), + CExtractorTypes::CFunctionDefinition.new(name: 'razzleDazzle') + ) + + @generator.send(:generate_source, buf, [], defns, c_module) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should emit only variable declarations and function definitions from all four element_sequence categories" do + # Source generation emits two categories and silently skips two others: + # CVariableDeclaration → emitted as-is + # CFunctionDefinition → emitted (via filtered Partials lookup) + # CStatement → skipped (macros, typedefs, aggregates belong in headers) + # CFunctionDeclaration → skipped (forward declarations belong in headers) + # + # All four categories are present in element_sequence to confirm the full + # filtering and emission behavior in a single test. + file_contents = <<~CONTENTS + // Ceeding generated file + + int counter = 0; + + void compute(int x) { + return x; + } + + CONTENTS + + macro_stmt = CExtractorTypes::CStatement.new(text: "#define MAX 100", line_num: 1) + var_decl = make_var(name: 'counter', type: 'int', text: 'int counter = 0;', line_num: 2) + func_decl = CExtractorTypes::CFunctionDeclaration.new(name: 'compute', line_num: 3) + func_def = CExtractorTypes::CFunctionDefinition.new( name: 'compute', line_num: 4) + + c_module = CExtractorTypes::CModule.new( + macro_definitions: [macro_stmt], + variable_declarations: [var_decl], + function_declarations: [func_decl], + function_definitions: [func_def], + element_sequence: [macro_stmt, var_decl, func_decl, func_def] + ) + + defn = Partials.manufacture_function_definition( + name: 'compute', + signature: 'void compute(int x)', + code_block: "void compute(int x) {\n return x;\n}" + ) + + @generator.send(:generate_source, buf, [], [defn], c_module) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should emit only CVariableDeclaration items from element_sequence, skipping CStatement items" do + file_contents = <<~CONTENTS + // Ceeding generated file + + int counter = 0; + + CONTENTS + + # Mixed collection: one variable and two CStatements (macro + typedef) + c_module = make_module( + make_stmt(text: "#define MAX 100\n", line_num: 1), + make_var( name: 'counter', type: 'int', text: 'int counter = 0;'), + make_stmt(text: "typedef uint8_t Byte;\n", line_num: 3) + ) + + @generator.send(:generate_source, buf, [], [], c_module) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "should interleave variables and functions in element_sequence order" do + file_contents = <<~CONTENTS + // Ceeding generated file + + int x = 1; + + void foo(void) { + x++; + } + + int y = 2; + + void bar(void) { + y++; + } + + CONTENTS + + var_x = make_var(name: 'x', type: 'int', text: 'int x = 1;', line_num: 1) + var_y = make_var(name: 'y', type: 'int', text: 'int y = 2;', line_num: 5) + + foo_raw = CExtractorTypes::CFunctionDefinition.new( + name: 'foo', signature: 'void foo(void)', line_num: 3, + code_block: "void foo(void) {\n x++;\n}", body: "{\n x++;\n}" + ) + bar_raw = CExtractorTypes::CFunctionDefinition.new( + name: 'bar', signature: 'void bar(void)', line_num: 7, + code_block: "void bar(void) {\n y++;\n}", body: "{\n y++;\n}" + ) + + foo_defn = Partials.manufacture_function_definition( + name: 'foo', signature: 'void foo(void)', + code_block: "void foo(void) {\n x++;\n}" + ) + bar_defn = Partials.manufacture_function_definition( + name: 'bar', signature: 'void bar(void)', + code_block: "void bar(void) {\n y++;\n}" + ) + + c_module = CExtractorTypes::CModule.new( + variable_declarations: [var_x, var_y], + function_definitions: [foo_raw, bar_raw], + element_sequence: [var_x, foo_raw, var_y, bar_raw] + ) + + @generator.send(:generate_source, buf, [], [foo_defn, bar_defn], c_module) + expect( buf.string.strip() ).to eq file_contents.strip() + end + + it "emits function code_block with __attribute__((noreturn)) verbatim in source" do + defn = Partials.manufacture_function_definition( + name: 'fatal_error', + signature: '__attribute__((noreturn)) void fatal_error(const char* msg)', + code_block: "__attribute__((noreturn)) void fatal_error(const char* msg)\n{\n exit(1);\n}" + ) + raw = CExtractorTypes::CFunctionDefinition.new( + name: 'fatal_error', + signature: '__attribute__((noreturn)) void fatal_error(const char* msg)', + code_block: "__attribute__((noreturn)) void fatal_error(const char* msg)\n{\n exit(1);\n}", + body: "{\n exit(1);\n}" + ) + c_module = make_module(raw) + + @generator.send(:generate_source, buf, [], [defn], c_module) + + expect(buf.string).to include('__attribute__((noreturn)) void fatal_error(const char* msg)') + expect(buf.string).to include('exit(1);') + end + + it "emits variable .text with __attribute__((aligned)) verbatim in source" do + c_module = make_module( + make_var(name: 'counter', type: 'int', text: 'int counter __attribute__((aligned(16)));') + ) + + @generator.send(:generate_source, buf, [], [], c_module) + + expect(buf.string).to include('int counter __attribute__((aligned(16)));') + end + + end + +end diff --git a/spec/generator_test_results_sanity_checker_spec.rb b/spec/units/generators/generator_test_results_sanity_checker_spec.rb similarity index 97% rename from spec/generator_test_results_sanity_checker_spec.rb rename to spec/units/generators/generator_test_results_sanity_checker_spec.rb index 1c402d940..a13cfaf40 100644 --- a/spec/generator_test_results_sanity_checker_spec.rb +++ b/spec/units/generators/generator_test_results_sanity_checker_spec.rb @@ -6,10 +6,10 @@ # ========================================================================= require 'spec_helper' -require 'ceedling/generator_test_results_sanity_checker' +require 'ceedling/generators/generator_test_results_sanity_checker' require 'ceedling/constants' require 'ceedling/loginator' -require 'ceedling/configurator' +require 'ceedling/config/configurator' describe GeneratorTestResultsSanityChecker do before(:each) do diff --git a/spec/generator_test_results_spec.rb b/spec/units/generators/generator_test_results_spec.rb similarity index 96% rename from spec/generator_test_results_spec.rb rename to spec/units/generators/generator_test_results_spec.rb index 947e11f94..051ed2761 100644 --- a/spec/generator_test_results_spec.rb +++ b/spec/units/generators/generator_test_results_spec.rb @@ -6,12 +6,12 @@ # ========================================================================= require 'spec_helper' -require 'ceedling/generator_test_results_sanity_checker' -require 'ceedling/generator_test_results' +require 'ceedling/generators/generator_test_results_sanity_checker' +require 'ceedling/generators/generator_test_results' require 'ceedling/yaml_wrapper' require 'ceedling/constants' require 'ceedling/loginator' -require 'ceedling/configurator' +require 'ceedling/config/configurator' NORMAL_OUTPUT = "Verbose output one\n" + diff --git a/spec/units/includes/include_spec.rb b/spec/units/includes/include_spec.rb new file mode 100644 index 000000000..cbed6cf43 --- /dev/null +++ b/spec/units/includes/include_spec.rb @@ -0,0 +1,716 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'set' +require 'spec_helper' +require 'ceedling/includes/includes' + + +describe UserInclude do + describe "Essential uses" do + it "creates a UserInclude with a simple header file" do + include_obj = UserInclude.new("header.h") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include "header.h"') + expect(include_obj).to eq('header.h') + end + + it "creates a UserInclude with a path but does not provide path in string expansion" do + include_obj = UserInclude.new("path/to/header.h") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("path/to/header.h") + expect("#{include_obj}").to eq('#include "header.h"') + expect(include_obj).to eq('header.h') + end + + it "creates a UserInclude with a path and used path in string expansion" do + include_obj = UserInclude.new("path/to/header.h", use_path: true) + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("path/to/header.h") + expect("#{include_obj}").to eq('#include "path/to/header.h"') + expect(include_obj).to eq('path/to/header.h') + end + end + + describe "Graceful handling of input" do + it "creates a UserInclude removing #include directive syntax" do + include_obj = UserInclude.new("#include \"header.h\"") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include "header.h"') + expect(include_obj).to eq('header.h') + end + + it "creates a UserInclude removing quotes and whitespace" do + include_obj = UserInclude.new("\"header.h \"") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include "header.h"') + expect(include_obj).to eq('header.h') + end + + it "creates a UserInclude from a system include" do + include_obj = UserInclude.new("<header.h>") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include "header.h"') + expect(include_obj).to eq('header.h') + end + end + + describe "edge cases" do + it "raises an exception for empty string" do + expect { UserInclude.new(" ") }.to raise_error(ArgumentError) + end + + it "handles include with spaces" do + include_obj = UserInclude.new("my header.h") + + expect("#{include_obj}").to eq('#include "my header.h"') + expect(include_obj).to eq('my header.h') + end + + it "handles include with special characters" do + include_obj = UserInclude.new("header-v1.2.h") + + expect("#{include_obj}").to eq('#include "header-v1.2.h"') + expect(include_obj).to eq('header-v1.2.h') + end + end +end + +describe SystemInclude do + describe "Essential uses" do + it "creates a SystemInclude with a simple header file" do + include_obj = SystemInclude.new("header.h") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include <header.h>') + expect(include_obj).to eq('header.h') + end + + it "creates a SystemInclude with a path but does not provide path in string expansion" do + include_obj = SystemInclude.new("path/to/header.h") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("path/to/header.h") + expect("#{include_obj}").to eq('#include <header.h>') + expect(include_obj).to eq('header.h') + end + + it "creates a SystemInclude with a path and used path in string expansion" do + include_obj = SystemInclude.new("path/to/header.h", use_path: true) + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("path/to/header.h") + expect("#{include_obj}").to eq('#include <path/to/header.h>') + expect(include_obj).to eq('path/to/header.h') + end + end + + describe "Graceful handling of input" do + it "creates a SystemInclude from #include directive" do + include_obj = SystemInclude.new("#include <header.h>") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include <header.h>') + expect(include_obj).to eq('header.h') + end + + it "creates a SystemInclude removing quotes and whitespace" do + include_obj = SystemInclude.new("\"header.h \"") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include <header.h>') + expect(include_obj).to eq('header.h') + end + + it "creates a SystemInclude from a user include" do + include_obj = SystemInclude.new("\"<header.h\"") + + expect(include_obj.filename).to eq("header.h") + expect(include_obj.filepath).to eq("header.h") + expect("#{include_obj}").to eq('#include <header.h>') + expect(include_obj).to eq('header.h') + end + end + + describe "edge cases" do + it "raises an exception for empty string" do + expect { SystemInclude.new(" ") }.to raise_error(ArgumentError) + end + + it "handles include with spaces" do + include_obj = SystemInclude.new("my header.h") + + expect("#{include_obj}").to eq('#include <my header.h>') + expect(include_obj).to eq('my header.h') + end + + it "handles include with special characters" do + include_obj = SystemInclude.new("<header-v1.2.h>") + + expect("#{include_obj}").to eq("#include <header-v1.2.h>") + expect(include_obj).to eq('header-v1.2.h') + end + end +end + +describe "Include equality" do + describe "UserInclude equality" do + it "compares equal to another UserInclude with same filename" do + include1 = UserInclude.new("header.h") + include2 = UserInclude.new("header.h") + + expect(include1).to eq(include2) + expect(include2).to eq(include1) + end + + it "compares equal to another UserInclude with same filename but different paths" do + include1 = UserInclude.new("path1/header.h") + include2 = UserInclude.new("path2/header.h") + + expect(include1).to eq(include2) + expect(include2).to eq(include1) + end + + it "compares not equal to another UserInclude with same filename but different paths" do + include1 = UserInclude.new("path1/header.h", use_path: true) + include2 = UserInclude.new("path2/header.h", use_path: true) + + expect(include1).not_to eq(include2) + expect(include2).not_to eq(include1) + end + + it "compares not equal to another UserInclude with different filename" do + include1 = UserInclude.new("header1.h") + include2 = UserInclude.new("header2.h") + + expect(include1).not_to eq(include2) + expect(include2).not_to eq(include1) + end + + it "compares equal to a string with matching filename" do + include_obj = UserInclude.new("header.h") + + expect(include_obj).to eq("header.h") + end + + it "compares not equal to a string with different filename" do + include_obj = UserInclude.new("header.h") + + expect(include_obj).not_to eq("other.h") + end + + it "compares not equal to a SystemInclude with same filename" do + user_include = UserInclude.new("header.h") + system_include = SystemInclude.new("header.h") + + expect(user_include).not_to eq(system_include) + expect(system_include).not_to eq(user_include) + end + + # This should never really happen in actual use, but logically a MockInclude is basically a UserInclude + it "compares equal to a MockInclude with same filename" do + user_include = UserInclude.new("header.h") + mock_include = MockInclude.new("header.h") + + expect(user_include).to eq(mock_include) + expect(mock_include).to eq(user_include) + end + + it "compares not equal to nil" do + include_obj = UserInclude.new("header.h") + + expect(include_obj).not_to eq(nil) + end + + it "compares not equal to other object types" do + include_obj = UserInclude.new("header.h") + + expect(include_obj).not_to eq(42) + expect(include_obj).not_to eq([]) + expect(include_obj).not_to eq({}) + end + end + + describe "SystemInclude equality" do + it "compares equal to another SystemInclude with same filename" do + include1 = SystemInclude.new("stdio.h") + include2 = SystemInclude.new("stdio.h") + + expect(include1).to eq(include2) + expect(include2).to eq(include1) + end + + it "compares equal to another SystemInclude with same filename but different paths" do + include1 = SystemInclude.new("sys/stdio.h") + include2 = SystemInclude.new("other/stdio.h") + + expect(include1).to eq(include2) + expect(include2).to eq(include1) + end + + it "compares not equal to another SystemInclude with same filename but different paths" do + include1 = SystemInclude.new("sys/stdio.h", use_path: true) + include2 = SystemInclude.new("other/stdio.h", use_path: true) + + expect(include1).not_to eq(include2) + expect(include2).not_to eq(include1) + end + + it "compares not equal to another SystemInclude with different filename" do + include1 = SystemInclude.new("stdio.h") + include2 = SystemInclude.new("stdlib.h") + + expect(include1).not_to eq(include2) + expect(include2).not_to eq(include1) + end + + it "compares equal to a string with matching filename" do + include_obj = SystemInclude.new("stdio.h") + + expect(include_obj).to eq("stdio.h") + end + + it "compares not equal to a string with different filename" do + include_obj = SystemInclude.new("stdio.h") + + expect(include_obj).not_to eq("stdlib.h") + end + + it "compares not equal to a UserInclude with same filename" do + system_include = SystemInclude.new("header.h") + user_include = UserInclude.new("header.h") + + expect(system_include).not_to eq(user_include) + expect(user_include).not_to eq(system_include) + end + + it "compares not equal to a MockInclude with same filename" do + system_include = SystemInclude.new("header.h") + mock_include = MockInclude.new("header.h") + + expect(system_include).not_to eq(mock_include) + expect(mock_include).not_to eq(system_include) + end + + it "compares not equal to nil" do + include_obj = SystemInclude.new("stdio.h") + + expect(include_obj).not_to eq(nil) + end + + it "compares not equal to other object types" do + include_obj = SystemInclude.new("stdio.h") + + expect(include_obj).not_to eq(42) + expect(include_obj).not_to eq([]) + expect(include_obj).not_to eq({}) + end + end + + describe "MockInclude equality" do + it "compares equal to another MockInclude with same filename" do + include1 = MockInclude.new("mock_module.h") + include2 = MockInclude.new("mock_module.h") + + expect(include1).to eq(include2) + expect(include2).to eq(include1) + end + + it "compares equal to another MockInclude with same filename but different paths" do + include1 = MockInclude.new("mocks/mock_module.h") + include2 = MockInclude.new("test/mocks/mock_module.h") + + expect(include1).to eq(include2) + expect(include2).to eq(include1) + end + + it "compares not equal to another MockInclude with same filename but different paths" do + include1 = MockInclude.new("mocks/mock_module.h", use_path: true) + include2 = MockInclude.new("test/mocks/mock_module.h", use_path: true) + + expect(include1).not_to eq(include2) + expect(include2).not_to eq(include1) + end + + it "compares not equal to another MockInclude with different filename" do + include1 = MockInclude.new("mock_module1.h") + include2 = MockInclude.new("mock_module2.h") + + expect(include1).not_to eq(include2) + expect(include2).not_to eq(include1) + end + + it "compares equal to a string with matching filename" do + include_obj = MockInclude.new("mock_module.h") + + expect(include_obj).to eq("mock_module.h") + end + + it "compares not equal to a string with different filename" do + include_obj = MockInclude.new("mock_module.h") + + expect(include_obj).not_to eq("other_mock.h") + end + + # This should never really happen in actual use, but logically a MockInclude is basically a UserInclude + it "compares equal to a UserInclude with same filename" do + mock_include = MockInclude.new("header.h") + user_include = UserInclude.new("header.h") + + expect(mock_include).to eq(user_include) + expect(user_include).to eq(mock_include) + end + + it "compares not equal to a SystemInclude with same filename" do + mock_include = MockInclude.new("header.h") + system_include = SystemInclude.new("header.h") + + expect(mock_include).not_to eq(system_include) + expect(system_include).not_to eq(mock_include) + end + + it "compares not equal to nil" do + include_obj = MockInclude.new("mock_module.h") + + expect(include_obj).not_to eq(nil) + end + + it "compares not equal to other object types" do + include_obj = MockInclude.new("mock_module.h") + + expect(include_obj).not_to eq(42) + expect(include_obj).not_to eq([]) + expect(include_obj).not_to eq({}) + end + end + + describe "hash and eql? for use in sets and hashes" do + it "allows UserInclude objects with same filename to be deduplicated in arrays" do + include1 = UserInclude.new("header.h") + include2 = UserInclude.new("header.h") + include3 = UserInclude.new("other.h") + + array = [include1, include2, include3] + unique = array.uniq + + expect(unique.length).to eq(2) + expect(unique).to include(include1) + expect(unique).to include(include3) + end + + it "allows SystemInclude objects with same filename to be deduplicated in arrays" do + include1 = SystemInclude.new("stdio.h") + include2 = SystemInclude.new("stdio.h") + include3 = SystemInclude.new("stdlib.h") + + array = [include1, include2, include3] + unique = array.uniq + + expect(unique.length).to eq(2) + expect(unique).to include(include1) + expect(unique).to include(include3) + end + + it "allows MockInclude objects with same filename to be deduplicated in arrays" do + include1 = MockInclude.new("mock_module.h") + include2 = MockInclude.new("mock_module.h") + include3 = MockInclude.new("mock_other.h") + + array = [include1, include2, include3] + unique = array.uniq + + expect(unique.length).to eq(2) + expect(unique).to include(include1) + expect(unique).to include(include3) + end + + it "treats UserInclude and SystemInclude with same filename as different in arrays" do + user_include = UserInclude.new("header.h") + system_include = SystemInclude.new("header.h") + + array = [user_include, system_include] + unique = array.uniq + + expect(unique.length).to eq(2) + expect(unique).to include(user_include) + expect(unique).to include(system_include) + end + + # This should never really happen in actual use, but logically a MockInclude is basically a UserInclude + it "treats UserInclude and MockInclude with same filename as same in arrays" do + user_include = UserInclude.new("header.h") + mock_include = MockInclude.new("header.h") + + array = [user_include, mock_include] + unique = array.uniq + + expect(unique.length).to eq(1) + expect(unique[0]).to eq(user_include) + end + + it "treats MockInclude and SystemInclude with same filename as different in arrays" do + mock_include = MockInclude.new("header.h") + system_include = SystemInclude.new("header.h") + + array = [mock_include, system_include] + unique = array.uniq + + expect(unique.length).to eq(2) + expect(unique).to include(mock_include) + expect(unique).to include(system_include) + end + + it "allows Include objects to be used as hash keys" do + include1 = UserInclude.new("header.h") + include2 = UserInclude.new("header.h") + include3 = SystemInclude.new("header.h") + + hash = {} + hash[include1] = "value1" + hash[include2] = "value2" # Should overwrite value1 + hash[include3] = "value3" # Different type, different key + + expect(hash.length).to eq(2) + expect(hash[include1]).to eq("value2") + expect(hash[include3]).to eq("value3") + end + + it "allows Include objects to be used in sets" do + include1 = UserInclude.new("header.h") + include2 = UserInclude.new("header.h") + include3 = SystemInclude.new("header.h") + + set = Set.new + set << include1 + set << include2 # Should not add duplicate + set << include3 # Different type, should add + + expect(set.size).to eq(2) + expect(set).to include(include1) + expect(set).to include(include3) + end + end +end + +describe "Include string coercion (to_str)" do + # to_str is the Ruby implicit-coercion protocol. Any object that defines to_str + # is treated as a String by contexts that require one — string concatenation, + # File.join, Rake FileList#pathmap, etc. The base Include class defines to_str + # to return @filename; subclasses do NOT override it, so implicit coercion always + # yields the plain filename regardless of the subclass's to_s include directive. + + describe "UserInclude" do + it "responds to to_str" do + expect(UserInclude.new("header.h")).to respond_to(:to_str) + end + + it "returns the filename (not the include directive) for implicit coercion" do + include_obj = UserInclude.new("header.h") + expect(include_obj.to_str).to eq("header.h") + end + + it "to_str and to_s return different values — filename vs. include directive" do + include_obj = UserInclude.new("header.h") + expect(include_obj.to_str).to eq("header.h") + expect(include_obj.to_s).to eq('#include "header.h"') + end + + it "can be implicitly concatenated with a string prefix" do + include_obj = UserInclude.new("header.h") + expect("path/" + include_obj).to eq("path/header.h") + end + + it "can be passed to File.join as an implicit string" do + include_obj = UserInclude.new("header.h") + expect(File.join("build", include_obj)).to eq("build/header.h") + end + + it "returns the filename without the directory component for a path-bearing include" do + include_obj = UserInclude.new("sub/dir/header.h") + expect(include_obj.to_str).to eq("header.h") + end + + it "returns the full filepath when constructed with use_path: true" do + include_obj = UserInclude.new("sub/dir/header.h", use_path: true) + # to_str always returns @filename (the basename) regardless of use_path + expect(include_obj.to_str).to eq("header.h") + end + end + + describe "SystemInclude" do + it "responds to to_str" do + expect(SystemInclude.new("stdio.h")).to respond_to(:to_str) + end + + it "returns the filename (not the include directive) for implicit coercion" do + include_obj = SystemInclude.new("stdio.h") + expect(include_obj.to_str).to eq("stdio.h") + end + + it "to_str and to_s return different values — filename vs. include directive" do + include_obj = SystemInclude.new("stdio.h") + expect(include_obj.to_str).to eq("stdio.h") + expect(include_obj.to_s).to eq("#include <stdio.h>") + end + + it "can be implicitly concatenated with a string prefix" do + include_obj = SystemInclude.new("stdio.h") + expect("sys/" + include_obj).to eq("sys/stdio.h") + end + + it "can be passed to File.join as an implicit string" do + include_obj = SystemInclude.new("stdio.h") + expect(File.join("build", include_obj)).to eq("build/stdio.h") + end + end + + describe "MockInclude" do + it "responds to to_str" do + expect(MockInclude.new("mock_sensor.h")).to respond_to(:to_str) + end + + it "returns the filename (not the include directive) for implicit coercion" do + include_obj = MockInclude.new("mock_sensor.h") + expect(include_obj.to_str).to eq("mock_sensor.h") + end + + it "to_str and to_s return different values — filename vs. include directive" do + include_obj = MockInclude.new("mock_sensor.h") + expect(include_obj.to_str).to eq("mock_sensor.h") + expect(include_obj.to_s).to eq('#include "mock_sensor.h"') + end + + it "can be implicitly concatenated with a string prefix" do + include_obj = MockInclude.new("mock_sensor.h") + expect("mocks/" + include_obj).to eq("mocks/mock_sensor.h") + end + + it "returns filename from a path-bearing mock include" do + include_obj = MockInclude.new("mocks/subdir/mock_sensor.h") + expect(include_obj.to_str).to eq("mock_sensor.h") + end + end +end + +describe "Include regex matching" do + describe "=~ operator" do + it "returns a truthy match position when the pattern matches the filename" do + include_obj = UserInclude.new("mock_module.h") + + expect(include_obj =~ /mock_/).not_to be_nil + end + + it "returns nil when the pattern does not match the filename" do + include_obj = UserInclude.new("header.h") + + expect(include_obj =~ /mock_/).to be_nil + end + + it "matches against filename only, not the full path" do + include_obj = UserInclude.new("path/to/mock_module.h") + + expect(include_obj =~ /mock_/).not_to be_nil + expect(include_obj =~ /path\/to/).to be_nil + end + + it "works with SystemInclude" do + include_obj = SystemInclude.new("stdio.h") + + expect(include_obj =~ /stdio/).not_to be_nil + expect(include_obj =~ /stdlib/).to be_nil + end + + it "works with MockInclude" do + include_obj = MockInclude.new("mock_sensor.h") + + expect(include_obj =~ /mock_/).not_to be_nil + expect(include_obj =~ /mock_sensor/).not_to be_nil + end + + it "supports anchored patterns" do + include_obj = UserInclude.new("mock_module.h") + + expect(include_obj =~ /\Amock_/).not_to be_nil + expect(include_obj =~ /\Amodule/).to be_nil + end + + it "can be used in Array#select via block" do + includes = [ + UserInclude.new("mock_sensor.h"), + UserInclude.new("module.h"), + UserInclude.new("mock_driver.h") + ] + + mocks = includes.select { |inc| inc =~ /\Amock_/ } + + expect(mocks.length).to eq(2) + expect(mocks).to include(UserInclude.new("mock_sensor.h")) + expect(mocks).to include(UserInclude.new("mock_driver.h")) + end + end + + describe "!~ operator" do + it "returns true when the pattern does not match the filename" do + include_obj = UserInclude.new("header.h") + + expect(include_obj !~ /mock_/).to be true + end + + it "returns false when the pattern matches the filename" do + include_obj = UserInclude.new("mock_module.h") + + expect(include_obj !~ /mock_/).to be false + end + + it "matches against filename only, not the full path" do + include_obj = UserInclude.new("path/to/header.h") + + expect(include_obj !~ /path\/to/).to be true + expect(include_obj !~ /header/).to be false + end + + it "works with SystemInclude" do + include_obj = SystemInclude.new("stdio.h") + + expect(include_obj !~ /stdlib/).to be true + expect(include_obj !~ /stdio/).to be false + end + + it "works with MockInclude" do + include_obj = MockInclude.new("mock_sensor.h") + + expect(include_obj !~ /driver/).to be true + expect(include_obj !~ /mock_/).to be false + end + + it "can be used in Array#reject via block" do + includes = [ + UserInclude.new("mock_sensor.h"), + UserInclude.new("module.h"), + UserInclude.new("mock_driver.h") + ] + + non_mocks = includes.reject { |inc| inc =~ /\Amock_/ } + + expect(non_mocks.length).to eq(1) + expect(non_mocks).to include(UserInclude.new("module.h")) + end + end +end diff --git a/spec/units/includes/includes_spec.rb b/spec/units/includes/includes_spec.rb new file mode 100644 index 000000000..389647102 --- /dev/null +++ b/spec/units/includes/includes_spec.rb @@ -0,0 +1,1069 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'set' +require 'spec_helper' +require 'ceedling/includes/includes' + +describe "Includes serialization" do + it "produces valid hash structure" do + original = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h") + ] + + hashes = Includes.to_hashes(original) + + expect(hashes.length).to eq(3) + + expect(hashes[0]).to have_key('type') + expect(hashes[0]).to have_key('filepath') + expect(hashes[0]['type']).to eq('user') + expect(hashes[0]['filepath']).to eq('header.h') + + expect(hashes[1]['type']).to eq('system') + expect(hashes[1]['filepath']).to eq('stdio.h') + + expect(hashes[2]['type']).to eq('mock') + expect(hashes[2]['filepath']).to eq('mock_module.h') + end + + describe "round-trip serialization" do + it "serializes and deserializes a single UserInclude" do + original = [UserInclude.new("header.h")] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(restored.length).to eq(1) + expect(restored[0]).to eq(original[0]) + expect(restored[0].filename).to eq(original[0].filename) + expect(restored[0].filepath).to eq(original[0].filepath) + expect("#{restored[0]}").to eq("#{original[0]}") + end + + it "serializes and deserializes a single SystemInclude" do + original = [SystemInclude.new("stdio.h")] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(restored.length).to eq(1) + expect(restored[0]).to eq(original[0]) + expect(restored[0].filename).to eq(original[0].filename) + expect(restored[0].filepath).to eq(original[0].filepath) + expect("#{restored[0]}").to eq("#{original[0]}") + end + + it "serializes and deserializes a single MockInclude" do + original = [MockInclude.new("mock_module.h")] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(restored.length).to eq(1) + expect(restored[0]).to eq(original[0]) + expect(restored[0].filename).to eq(original[0].filename) + expect(restored[0].filepath).to eq(original[0].filepath) + expect("#{restored[0]}").to eq("#{original[0]}") + end + + it "serializes and deserializes mixed include types" do + original = [ + SystemInclude.new("stdio.h"), + UserInclude.new("header.h"), + MockInclude.new("mock_module.h"), + SystemInclude.new("stdlib.h"), + UserInclude.new("config.h") + ] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(restored.length).to eq(5) + + # Verify each include matches original + original.each_with_index do |orig, idx| + expect(restored[idx]).to eq(orig) + expect(restored[idx].class).to eq(orig.class) + expect(restored[idx].filename).to eq(orig.filename) + expect(restored[idx].filepath).to eq(orig.filepath) + expect("#{restored[idx]}").to eq("#{orig}") + end + end + + it "preserves order during serialization round-trip" do + original = [ + UserInclude.new("first.h"), + SystemInclude.new("second.h"), + MockInclude.new("third.h"), + UserInclude.new("fourth.h") + ] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(restored.map(&:filename)).to eq(["first.h", "second.h", "third.h", "fourth.h"]) + end + + it "handles includes with paths during serialization" do + original = [ + UserInclude.new("path/to/header.h", use_path: true), + SystemInclude.new("sys/stdio.h", use_path: true), + MockInclude.new("mocks/mock_module.h", use_path: true) + ] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(restored.length).to eq(3) + expect(restored[0].filepath).to eq("path/to/header.h") + expect(restored[1].filepath).to eq("sys/stdio.h") + expect(restored[2].filepath).to eq("mocks/mock_module.h") + end + + it "handles empty array" do + original = [] + + hashes = Includes.to_hashes(original) + restored = Includes.from_hashes(hashes) + + expect(hashes).to eq([]) + expect(restored).to eq([]) + end + end + + describe "error handling" do + it "raises error for invalid include type in to_hashes" do + invalid_include = Object.new + + expect { + Includes.to_hashes([invalid_include]) + }.to raise_error(ArgumentError, /Unknown Include type/) + end + + it "raises error for hash missing type key in from_hashes" do + invalid_hash = [{ 'filepath' => 'header.h' }] + + expect { + Includes.from_hashes(invalid_hash) + }.to raise_error(ArgumentError, /Hash missing 'type' key/) + end + + it "raises error for hash missing filepath key in from_hashes" do + invalid_hash = [{ 'type' => 'user' }] + + expect { + Includes.from_hashes(invalid_hash) + }.to raise_error(ArgumentError, /Hash missing 'filepath' key/) + end + + it "raises error for invalid type value in from_hashes" do + invalid_hash = [{ 'type' => 'invalid', 'filepath' => 'header.h' }] + + expect { + Includes.from_hashes(invalid_hash) + }.to raise_error(ArgumentError, /Invalid include type: invalid/) + end + end +end + +describe "Includes filtering" do + describe "system()" do + it "extracts only SystemInclude objects from mixed list" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h"), + SystemInclude.new("stdlib.h"), + UserInclude.new("config.h") + ] + + system_includes = Includes.system(includes) + + expect(system_includes.length).to eq(2) + expect(system_includes[0]).to be_a(SystemInclude) + expect(system_includes[0].filename).to eq("stdio.h") + expect(system_includes[1]).to be_a(SystemInclude) + expect(system_includes[1].filename).to eq("stdlib.h") + end + + it "returns empty array when no SystemInclude objects present" do + includes = [ + UserInclude.new("header.h"), + MockInclude.new("mock_module.h"), + UserInclude.new("config.h") + ] + + system_includes = Includes.system(includes) + + expect(system_includes).to eq([]) + end + + it "returns all includes when all are SystemInclude objects" do + includes = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h"), + SystemInclude.new("string.h") + ] + + system_includes = Includes.system(includes) + + expect(system_includes.length).to eq(3) + expect(system_includes).to eq(includes) + end + + it "handles empty array" do + includes = [] + + system_includes = Includes.system(includes) + + expect(system_includes).to eq([]) + end + end + + describe "user()" do + it "extracts only UserInclude objects from mixed list" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h"), + SystemInclude.new("stdlib.h"), + UserInclude.new("config.h") + ] + + user_includes = Includes.user(includes) + + expect(user_includes.length).to eq(3) + expect(user_includes[0]).to be_a(UserInclude) + expect(user_includes[0].filename).to eq("header.h") + expect(user_includes[1]).to be_a(MockInclude) + expect(user_includes[1].filename).to eq("mock_module.h") + expect(user_includes[2]).to be_a(UserInclude) + expect(user_includes[2].filename).to eq("config.h") + end + + it "includes MockInclude objects as they are subclass of UserInclude" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h"), + UserInclude.new("config.h") + ] + + user_includes = Includes.user(includes) + + expect(user_includes.length).to eq(3) + expect(user_includes[0]).to be_a(UserInclude) + expect(user_includes[0].filename).to eq("header.h") + expect(user_includes[1]).to be_a(MockInclude) + expect(user_includes[1].filename).to eq("mock_module.h") + expect(user_includes[2]).to be_a(UserInclude) + expect(user_includes[2].filename).to eq("config.h") + end + + it "returns empty array when no UserInclude objects present" do + includes = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h") + ] + + user_includes = Includes.user(includes) + + expect(user_includes).to eq([]) + end + + it "returns all includes when all are UserInclude or MockInclude objects" do + includes = [ + UserInclude.new("header.h"), + MockInclude.new("mock_module.h"), + UserInclude.new("config.h") + ] + + user_includes = Includes.user(includes) + + expect(user_includes.length).to eq(3) + expect(user_includes).to eq(includes) + end + + it "handles empty array" do + includes = [] + + user_includes = Includes.user(includes) + + expect(user_includes).to eq([]) + end + end + + describe "system() and user() together" do + it "partition includes into system and user groups" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h"), + SystemInclude.new("stdlib.h"), + UserInclude.new("config.h") + ] + + system_includes = Includes.system(includes) + user_includes = Includes.user(includes) + + expect(system_includes.length).to eq(2) + expect(user_includes.length).to eq(3) + expect(system_includes.length + user_includes.length).to eq(includes.length) + end + + it "ensures no overlap between system and user results" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h") + ] + + system_includes = Includes.system(includes) + user_includes = Includes.user(includes) + + system_filenames = system_includes.map(&:filename) + user_filenames = user_includes.map(&:filename) + + expect(system_filenames & user_filenames).to eq([]) + end + end +end + +describe "Includes sorting" do + describe "sort!()" do + it "moves SystemInclude objects to the beginning" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h"), + SystemInclude.new("stdlib.h"), + UserInclude.new("config.h") + ] + + result = Includes.sort!(includes) + + expect(result.length).to eq(5) + expect(result[0]).to be_a(SystemInclude) + expect(result[1]).to be_a(SystemInclude) + expect(result[2]).not_to be_a(SystemInclude) + expect(result[3]).not_to be_a(SystemInclude) + expect(result[4]).not_to be_a(SystemInclude) + end + + it "mutates the original array" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("config.h") + ] + + original_object_id = includes.object_id + result = Includes.sort!(includes) + + expect(result.object_id).to eq(original_object_id) + expect(includes[0]).to be_a(SystemInclude) + expect(includes[1]).not_to be_a(SystemInclude) + expect(includes[2]).not_to be_a(SystemInclude) + end + + it "handles array with only SystemInclude objects" do + includes = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h"), + SystemInclude.new("string.h") + ] + + Includes.sort!(includes) + + expect(includes.length).to eq(3) + expect(includes[0]).to be_a(SystemInclude) + expect(includes[1]).to be_a(SystemInclude) + expect(includes[2]).to be_a(SystemInclude) + end + + it "handles array with only UserInclude objects" do + includes = [ + UserInclude.new("header.h"), + MockInclude.new("mock_module.h"), + UserInclude.new("config.h") + ] + + Includes.sort!(includes) + + expect(includes.length).to eq(3) + expect(includes[0]).to be_a(UserInclude) + expect(includes[1]).to be_a(UserInclude) + expect(includes[2]).to be_a(UserInclude) + end + + it "handles empty array" do + includes = [] + + result = Includes.sort!(includes) + + expect(result).to eq([]) + expect(includes).to eq([]) + end + + it "handles single element array" do + includes = [UserInclude.new("header.h")] + + Includes.sort!(includes) + + expect(includes.length).to eq(1) + expect(includes[0].filename).to eq("header.h") + end + + it "returns the modified array" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h") + ] + + result = Includes.sort!(includes) + + expect(result).to be(includes) + expect(result[0]).to be_a(SystemInclude) + end + + it "treats UserInclude derivative as UserInclude for sorting" do + includes = [ + MockInclude.new("mock_first.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("header.h"), + MockInclude.new("mock_second.h") + ] + + Includes.sort!(includes) + + expect(includes[0]).to be_a(SystemInclude) + expect(includes[1]).to be_a(UserInclude) + expect(includes[2]).to be_a(UserInclude) + expect(includes[3]).to be_a(UserInclude) + end + end + + describe "sort()" do + it "returns a new sorted array without mutating original" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("config.h") + ] + + original_order = includes.map(&:filename) + result = Includes.sort(includes) + + expect(result.object_id).not_to eq(includes.object_id) + expect(includes.map(&:filename)).to eq(original_order) + expect(result[0]).to be_a(SystemInclude) + expect(result[1]).to be_a(UserInclude) + expect(result[2]).to be_a(UserInclude) + end + + it "handles empty array" do + includes = [] + + result = Includes.sort(includes) + + expect(result).to eq([]) + expect(result.object_id).not_to eq(includes.object_id) + end + end +end + +describe "Includes sanitization" do + describe "sanitize!()" do + it "removes duplicate includes" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("header.h"), + SystemInclude.new("stdio.h") + ] + + result = Includes.sanitize!(includes) + + expect(result.length).to eq(2) + expect(result[0]).to be_a(SystemInclude) + expect(result[0].filename).to eq("stdio.h") + expect(result[1]).to be_a(UserInclude) + expect(result[1].filename).to eq("header.h") + end + + it "mutates the original array" do + includes = [ + UserInclude.new("header.h"), + UserInclude.new("header.h") + ] + + original_object_id = includes.object_id + result = Includes.sanitize!(includes) + + expect(result.object_id).to eq(original_object_id) + expect(includes.length).to eq(1) + end + + # `sort()` test cases thoroughly cover this handling + it "moves system includes to the beginning" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_module.h"), + SystemInclude.new("stdlib.h") + ] + + Includes.sanitize!(includes) + + expect(includes[0]).to be_a(SystemInclude) + expect(includes[1]).to be_a(SystemInclude) + expect(includes[2]).to be_a(UserInclude) + expect(includes[3]).to be_a(UserInclude) + end + + it "handles empty array" do + includes = [] + + result = Includes.sanitize!(includes) + + expect(result).to eq([]) + end + + it "handles array with single element" do + includes = [UserInclude.new("header.h")] + + result = Includes.sanitize!(includes) + + expect(result.length).to eq(1) + expect(result[0].filename).to eq("header.h") + end + + it "preserves MockInclude as UserInclude subclass" do + includes = [ + MockInclude.new("mock_first.h"), + SystemInclude.new("stdio.h"), + MockInclude.new("mock_first.h") + ] + + Includes.sanitize!(includes) + + expect(includes.length).to eq(2) + expect(includes[0]).to be_a(SystemInclude) + expect(includes[1]).to be_a(MockInclude) + end + + it "applies custom rejection block" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("config.h"), + SystemInclude.new("stdlib.h") + ] + + # Reject all includes with 'std' in the filename + result = Includes.sanitize!(includes) do |include, all| + include.filename.include?('std') + end + + expect(result.length).to eq(2) + expect(includes[0]).to be_a(UserInclude) + expect(includes[1]).to be_a(UserInclude) + end + + it "handles duplicates and custom rejection together" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("header.h"), + UserInclude.new("test.h"), + SystemInclude.new("stdio.h") + ] + + result = Includes.sanitize!(includes) do |include, all| + include.filename.include?('test') + end + + expect(result.length).to eq(2) + expect(result[0]).to be_a(SystemInclude) + expect(result[0].filename).to eq("stdio.h") + expect(result[1]).to be_a(UserInclude) + expect(result[1].filename).to eq("header.h") + end + + it "returns the modified array" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h") + ] + + result = Includes.sanitize!(includes) + + expect(result).to be(includes) + end + + it "handles all duplicates scenario" do + includes = [ + UserInclude.new("header.h"), + UserInclude.new("header.h"), + UserInclude.new("header.h") + ] + + result = Includes.sanitize!(includes) + + expect(result.length).to eq(1) + expect(result[0].filename).to eq("header.h") + end + + it "handles custom rejection that removes all includes" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h") + ] + + result = Includes.sanitize!(includes) { |include, all| true } + + expect(result).to eq([]) + end + + it "handles custom rejection that removes no includes" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("config.h") + ] + + result = Includes.sanitize!(includes) { |include, all| false } + + expect(result.length).to eq(3) + expect(result[0]).to be_a(SystemInclude) + end + end + + # Simple validation of sanitize() as a variant of sanitize!() + describe "sanitize()" do + it "returns a new array without mutating original" do + includes = [ + UserInclude.new("header.h"), + SystemInclude.new("stdio.h"), + UserInclude.new("header.h") + ] + + original_order = includes.map(&:filename) + original_length = includes.length + result = Includes.sanitize(includes) + + expect(result.object_id).not_to eq(includes.object_id) + expect(includes.length).to eq(original_length) + expect(includes.map(&:filename)).to eq(original_order) + expect(result.length).to eq(2) + end + end +end + +describe "Includes reconciliation" do + describe "reconcile()" do + it "returns intersection of bare and system includes" do + bare = [ + Include.new("header.h"), + Include.new("stdio.h"), + Include.new("stdlib.h") + ] + + user = [] + + system = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h"), + SystemInclude.new("string.h") + ] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(2) + expect(result[0]).to be_a(SystemInclude) + expect(result[0].filename).to eq("stdio.h") + expect(result[1]).to be_a(SystemInclude) + expect(result[1].filename).to eq("stdlib.h") + end + + it "returns intersection of bare and user includes" do + bare = [ + Include.new("header.h"), + Include.new("config.h"), + Include.new("stdio.h") + ] + + user = [ + UserInclude.new("header.h"), + UserInclude.new("config.h"), + UserInclude.new("extra.h") + ] + + system = [] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(2) + expect(result[0]).to be_a(UserInclude) + expect(result[0].filename).to eq("header.h") + expect(result[1]).to be_a(UserInclude) + expect(result[1].filename).to eq("config.h") + end + + it "returns combined intersection of bare with both user and system includes" do + bare = [ + Include.new("header.h"), + Include.new("stdio.h"), + Include.new("config.h"), + Include.new("stdlib.h") + ] + + user = [ + UserInclude.new("header.h"), + UserInclude.new("config.h"), + UserInclude.new("extra.h") + ] + + system = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h"), + SystemInclude.new("string.h") + ] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(4) + expect(result[0]).to be_a(SystemInclude) + expect(result[0].filename).to eq("stdio.h") + expect(result[1]).to be_a(SystemInclude) + expect(result[1].filename).to eq("stdlib.h") + expect(result[2]).to be_a(UserInclude) + expect(result[2].filename).to eq("header.h") + expect(result[3]).to be_a(UserInclude) + expect(result[3].filename).to eq("config.h") + end + + it "places system includes before user includes" do + bare = [ + Include.new("header.h"), + Include.new("stdio.h") + ] + + user = [UserInclude.new("header.h")] + system = [SystemInclude.new("stdio.h")] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result[0]).to be_a(SystemInclude) + expect(result[1]).to be_a(UserInclude) + end + + it "handles MockInclude as UserInclude subclass" do + bare = [ + Include.new("mock_module.h"), + Include.new("stdio.h") + ] + + user = [ + MockInclude.new("mock_module.h"), + UserInclude.new("header.h") + ] + + system = [SystemInclude.new("stdio.h")] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(2) + expect(result[0]).to be_a(SystemInclude) + expect(result[1]).to be_a(MockInclude) + expect(result[1].filename).to eq("mock_module.h") + end + + it "returns empty array when bare is empty" do + bare = [] + user = [UserInclude.new("header.h")] + system = [SystemInclude.new("stdio.h")] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result).to eq([]) + end + + it "returns empty array when no intersections exist" do + bare = [ + Include.new("header.h"), + Include.new("stdio.h") + ] + + user = [UserInclude.new("config.h")] + system = [SystemInclude.new("stdlib.h")] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result).to eq([]) + end + + it "handles empty user includes" do + bare = [ + Include.new("stdio.h"), + Include.new("stdlib.h") + ] + + user = [] + + system = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h") + ] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(2) + expect(result.all? { |inc| inc.is_a?(SystemInclude) }).to be true + end + + it "handles empty system includes" do + bare = [ + Include.new("header.h"), + Include.new("config.h") + ] + + user = [ + UserInclude.new("header.h"), + UserInclude.new("config.h") + ] + + system = [] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(2) + expect(result.all? { |inc| inc.is_a?(UserInclude) }).to be true + end + + it "handles all empty arrays" do + result = Includes.reconcile(bare: [], user: [], system: []) + + expect(result).to eq([]) + end + + it "filters out user includes not in bare list" do + bare = [ + Include.new("header.h") + ] + + user = [ + UserInclude.new("header.h"), + UserInclude.new("config.h"), + UserInclude.new("extra.h") + ] + + system = [] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(1) + expect(result[0].filename).to eq("header.h") + end + + it "filters out system includes not in bare list" do + bare = [ + Include.new("stdio.h") + ] + + user = [] + + system = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h"), + SystemInclude.new("string.h") + ] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(1) + expect(result[0].filename).to eq("stdio.h") + end + + it "handles duplicate filenames in bare list" do + bare = [ + Include.new("header.h"), + Include.new("header.h"), + Include.new("stdio.h") + ] + + user = [UserInclude.new("header.h")] + system = [SystemInclude.new("stdio.h")] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + # Should still only return one of each + expect(result.length).to eq(2) + expect(result[0].filename).to eq("stdio.h") + expect(result[1].filename).to eq("header.h") + end + + it "raises ArgumentError when bare is not an array" do + expect { + Includes.reconcile(bare: "not an array", user: [], system: []) + }.to raise_error(ArgumentError, "`bare` must be an Array of Include objects") + end + + it "raises ArgumentError when bare contains non-Include objects" do + expect { + Includes.reconcile(bare: ["string"], user: [], system: []) + }.to raise_error(ArgumentError, "`bare` must be an Array of Include objects") + end + + it "raises ArgumentError when user is not an array" do + expect { + Includes.reconcile(bare: [], user: "not an array", system: []) + }.to raise_error(ArgumentError, "`user` must be an Array of UserInclude objects") + end + + it "raises ArgumentError when user contains non-UserInclude objects" do + expect { + Includes.reconcile(bare: [], user: [SystemInclude.new("stdio.h")], system: []) + }.to raise_error(ArgumentError, "`user` must be an Array of UserInclude objects") + end + + it "raises ArgumentError when system is not an array" do + expect { + Includes.reconcile(bare: [], user: [], system: "not an array") + }.to raise_error(ArgumentError, "`system` must be an Array of SystemInclude objects") + end + + it "raises ArgumentError when system contains non-SystemInclude objects" do + expect { + Includes.reconcile(bare: [], user: [], system: [UserInclude.new("header.h")]) + }.to raise_error(ArgumentError, "`system` must be an Array of SystemInclude objects") + end + + it "accepts MockInclude objects in user array" do + bare = [Include.new("mock_module.h")] + user = [MockInclude.new("mock_module.h")] + system = [] + + expect { + result = Includes.reconcile(bare: bare, user: user, system: system) + expect(result.length).to eq(1) + expect(result[0]).to be_a(MockInclude) + }.not_to raise_error + end + + it "handles complex mixed scenario with all include types" do + bare = [ + Include.new("stdio.h"), + Include.new("header.h"), + Include.new("mock_module.h"), + Include.new("stdlib.h"), + Include.new("config.h"), + Include.new("string.h"), + Include.new("utils.h") + ] + + user = [ + UserInclude.new("header.h"), + MockInclude.new("mock_module.h"), + UserInclude.new("config.h"), + UserInclude.new("extra.h") + ] + + system = [ + SystemInclude.new("stdio.h"), + SystemInclude.new("stdlib.h"), + SystemInclude.new("math.h") + ] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(5) + + # Verify system includes come first + expect(result[0]).to be_a(SystemInclude) + expect(result[0].filename).to eq("stdio.h") + expect(result[1]).to be_a(SystemInclude) + expect(result[1].filename).to eq("stdlib.h") + + # Verify user includes come after + expect(result[2]).to be_a(UserInclude) + expect(result[2].filename).to eq("header.h") + expect(result[3]).to be_a(MockInclude) + expect(result[3].filename).to eq("mock_module.h") + expect(result[4]).to be_a(UserInclude) + expect(result[4].filename).to eq("config.h") + + # Verify excluded includes + result_filenames = result.map(&:filename) + expect(result_filenames).not_to include("string.h") + expect(result_filenames).not_to include("utils.h") + expect(result_filenames).not_to include("extra.h") + expect(result_filenames).not_to include("math.h") + end + + it "returns new array without mutating input arrays" do + bare = [ + Include.new("header.h"), + Include.new("stdio.h") + ] + + user = [UserInclude.new("header.h")] + system = [SystemInclude.new("stdio.h")] + + bare_original = bare.dup + user_original = user.dup + system_original = system.dup + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(bare).to eq(bare_original) + expect(user).to eq(user_original) + expect(system).to eq(system_original) + expect(result.object_id).not_to eq(bare.object_id) + end + + it "handles large lists efficiently" do + bare = (1..100).map { |i| Include.new("header#{i}.h") } + + (1..100).map { |i| Include.new("sys#{i}.h") } + + user = (1..50).map { |i| UserInclude.new("header#{i}.h") } + system = (1..50).map { |i| SystemInclude.new("sys#{i}.h") } + + result = Includes.reconcile(bare: bare, user: user, system: system) + + expect(result.length).to eq(100) + + # Verify system includes come first + system_count = result.take_while { |inc| inc.is_a?(SystemInclude) }.count + expect(system_count).to eq(50) + + # Verify user includes come after + user_count = result.drop(50).count { |inc| inc.is_a?(UserInclude) } + expect(user_count).to eq(50) + end + + it "handles includes with path separators in filenames" do + bare = [ + Include.new("subdir/header.h"), + Include.new("sys/types.h") + ] + + user = [UserInclude.new("header.h")] + system = [SystemInclude.new("types.h")] + + result = Includes.reconcile(bare: bare, user: user, system: system) + + # Should match by filename only + expect(result.length).to eq(2) + expect(result[0].filename).to eq("types.h") + expect(result[1].filename).to eq("header.h") + end + end +end diff --git a/spec/parsing_parcels_spec.rb b/spec/units/parsing_parcels_spec.rb similarity index 61% rename from spec/parsing_parcels_spec.rb rename to spec/units/parsing_parcels_spec.rb index 19e1f1dbb..a8f7d71f6 100644 --- a/spec/parsing_parcels_spec.rb +++ b/spec/units/parsing_parcels_spec.rb @@ -60,7 +60,37 @@ expect( got ).to eq expected end - end + context "#code_lines_with_num" do + it "should clean code of encoding problems and comments" do + file_contents = <<~CONTENTS + /* TEST_SOURCE_FILE("foo.c") */ // Eliminate single line comment block + // TEST_SOURCE_FILE("bar.c") // Eliminate single line comment + Some text⛔️ + /* // /* // Eliminate tricky comment block enclosing comments + TEST_SOURCE_FILE("boom.c") + */ // // Eliminate trailing single line comment following block comment + More text + #define STR1 "/* comment " // Strip out (single line) C string containing block comment + #define STR2 " /* comment " // Strip out (single line) C string containing block comment + CONTENTS + + got = [] + + @parsing_parcels.code_lines_with_num( StringIO.new( file_contents ) ) do |line, num| + line.strip! + got << {:text => line, :num => num} if !line.empty? + end + + expected = [ + {:text => 'Some text', :num => 3}, # ⛔️ removed with encoding sanitizing + {:text => 'More text', :num => 7}, + {:text => "#define STR1", :num => 8}, + {:text => "#define STR2", :num => 9} + ] + + expect( got ).to eq expected + end + end end diff --git a/spec/units/partials/partializer_config_spec.rb b/spec/units/partials/partializer_config_spec.rb new file mode 100644 index 000000000..cc771e280 --- /dev/null +++ b/spec/units/partials/partializer_config_spec.rb @@ -0,0 +1,389 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_preprocessing' +require 'ceedling/partials/partials' +require 'ceedling/partials/partializer_config' + +describe PartializerConfig do + + it "defines MACRO_NAMES with the expected 10 macro name strings" do + expect( PartializerConfig::MACRO_NAMES ).to include( + 'TEST_PARTIAL_PUBLIC_MODULE', + 'TEST_PARTIAL_PRIVATE_MODULE', + 'MOCK_PARTIAL_PUBLIC_MODULE', + 'MOCK_PARTIAL_PRIVATE_MODULE', + 'TEST_PARTIAL_MODULE', + 'MOCK_PARTIAL_MODULE', + 'TEST_PARTIAL_ALL_MODULE', + 'MOCK_PARTIAL_ALL_MODULE', + 'TEST_PARTIAL_CONFIG', + 'MOCK_PARTIAL_CONFIG' + ) + expect( PartializerConfig::MACRO_NAMES.size ).to eq 10 + end + + context "#extract_configs" do + before(:each) do + code_text = CExtractorCodeText.new + c_extractor_preprocessing = CExtractorPreprocessing.new({ c_extractor_code_text: code_text }) + @config = described_class.new({ c_extractor_preprocessing: c_extractor_preprocessing }) + end + + it "extracts configs from a raw IO object" do + io = StringIO.new('TEST_PARTIAL_PUBLIC_MODULE(widget)') + result = @config.extract_configs(io) + expect( result['widget'].tests.type ).to eq Partials::PUBLIC + end + end + + context "#extract_configs_from_string" do + + before(:each) do + code_text = CExtractorCodeText.new + c_extractor_preprocessing = CExtractorPreprocessing.new({ c_extractor_code_text: code_text }) + @config = described_class.new({ c_extractor_preprocessing: c_extractor_preprocessing }) + end + + def extract(str) + @config.extract_configs_from_string(str) + end + + # --- Empty / no-match --- + + it "returns empty hash for empty input" do + expect( extract('') ).to eq({}) + end + + it "returns empty hash when no matching macros are present" do + expect( extract('int x = UNRELATED(42);') ).to eq({}) + end + + # --- MODULE macros: type --- + + it "extracts TEST_PARTIAL_PUBLIC_MODULE and sets tests.type to PUBLIC" do + result = extract('TEST_PARTIAL_PUBLIC_MODULE(calculator)') + expect( result.keys ).to eq ['calculator'] + expect( result['calculator'].tests.type ).to eq Partials::PUBLIC + expect( result['calculator'].mocks.type ).to be_nil + end + + it "extracts TEST_PARTIAL_PRIVATE_MODULE and sets tests.type to PRIVATE" do + result = extract('TEST_PARTIAL_PRIVATE_MODULE(calculator)') + expect( result['calculator'].tests.type ).to eq Partials::PRIVATE + expect( result['calculator'].mocks.type ).to be_nil + end + + it "extracts MOCK_PARTIAL_PUBLIC_MODULE and sets mocks.type to PUBLIC" do + result = extract('MOCK_PARTIAL_PUBLIC_MODULE(calculator)') + expect( result['calculator'].mocks.type ).to eq Partials::PUBLIC + expect( result['calculator'].tests.type ).to be_nil + end + + it "extracts MOCK_PARTIAL_PRIVATE_MODULE and sets mocks.type to PRIVATE" do + result = extract('MOCK_PARTIAL_PRIVATE_MODULE(calculator)') + expect( result['calculator'].mocks.type ).to eq Partials::PRIVATE + expect( result['calculator'].tests.type ).to be_nil + end + + # --- TEST/MOCK_PARTIAL_MODULE: ACCUMULATE --- + + it "extracts TEST_PARTIAL_MODULE and sets tests.type to ACCUMULATE" do + input = <<~C + TEST_PARTIAL_MODULE(calculator) + TEST_PARTIAL_CONFIG(calculator, add, subtract) + C + result = extract(input) + expect( result['calculator'].tests.type ).to eq Partials::ACCUMULATE + expect( result['calculator'].tests.additions ).to eq ['add', 'subtract'] + end + + it "extracts MOCK_PARTIAL_MODULE and sets mocks.type to ACCUMULATE" do + input = <<~C + MOCK_PARTIAL_MODULE(calculator) + MOCK_PARTIAL_CONFIG(calculator, +multiply) + C + result = extract(input) + expect( result['calculator'].mocks.type ).to eq Partials::ACCUMULATE + expect( result['calculator'].mocks.additions ).to eq ['multiply'] + end + + it "raises when TEST_PARTIAL_MODULE is used alone without any CONFIG additions" do + expect { + extract('TEST_PARTIAL_MODULE(calculator)') + }.to raise_error(CeedlingException, /TEST_PARTIAL_MODULE/) + end + + # --- TEST/MOCK_PARTIAL_ALL_MODULE: DEDUCT --- + + it "extracts TEST_PARTIAL_ALL_MODULE and sets tests.type to DEDUCT" do + result = extract('TEST_PARTIAL_ALL_MODULE(calculator)') + expect( result['calculator'].tests.type ).to eq Partials::DEDUCT + expect( result['calculator'].mocks.type ).to be_nil + end + + it "extracts MOCK_PARTIAL_ALL_MODULE and sets mocks.type to DEDUCT" do + result = extract('MOCK_PARTIAL_ALL_MODULE(calculator)') + expect( result['calculator'].mocks.type ).to eq Partials::DEDUCT + expect( result['calculator'].tests.type ).to be_nil + end + + it "does not raise when TEST_PARTIAL_ALL_MODULE is used alone without CONFIG (means all functions)" do + expect { extract('TEST_PARTIAL_ALL_MODULE(calculator)') }.not_to raise_error + end + + it "does not raise when MOCK_PARTIAL_ALL_MODULE is used alone without CONFIG (means all functions)" do + expect { extract('MOCK_PARTIAL_ALL_MODULE(calculator)') }.not_to raise_error + end + + it "extracts TEST_PARTIAL_ALL_MODULE with CONFIG subtractions" do + input = <<~C + TEST_PARTIAL_ALL_MODULE(calculator) + TEST_PARTIAL_CONFIG(calculator, -internal_helper, -debug_only) + C + result = extract(input) + expect( result['calculator'].tests.type ).to eq Partials::DEDUCT + expect( result['calculator'].tests.subtractions ).to contain_exactly('internal_helper', 'debug_only') + expect( result['calculator'].tests.additions ).to eq [] + end + + it "extracts MOCK_PARTIAL_ALL_MODULE with CONFIG subtractions" do + input = <<~C + MOCK_PARTIAL_ALL_MODULE(driver) + MOCK_PARTIAL_CONFIG(driver, -write) + C + result = extract(input) + expect( result['driver'].mocks.type ).to eq Partials::DEDUCT + expect( result['driver'].mocks.subtractions ).to eq ['write'] + expect( result['driver'].mocks.additions ).to eq [] + end + + it "raises when additions are used with TEST DEDUCT" do + input = <<~C + TEST_PARTIAL_ALL_MODULE("foo") + TEST_PARTIAL_CONFIG("foo", "+bar") + C + expect { extract(input) }.to raise_error(CeedlingException, /foo/) + end + + it "raises when additions are used with MOCK DEDUCT" do + input = <<~C + MOCK_PARTIAL_ALL_MODULE("foo") + MOCK_PARTIAL_CONFIG("foo", "baz", "-bar") + C + expect { extract(input) }.to raise_error(CeedlingException, /foo/) + end + + it "does not raise when only subtractions are used with DEDUCT" do + input = <<~C + TEST_PARTIAL_ALL_MODULE("foo") + TEST_PARTIAL_CONFIG("foo", "-a", "-b") + C + expect { extract(input) }.not_to raise_error + end + + it "does not raise when CONFIG is present but has no function names (empty subtractions)" do + # A CONFIG macro with only the module name and no function args is a degenerate but valid case + input = <<~C + TEST_PARTIAL_ALL_MODULE("foo") + TEST_PARTIAL_CONFIG("foo") + C + expect { extract(input) }.not_to raise_error + end + + it "allows TEST_PARTIAL_ALL_MODULE and MOCK_PARTIAL_PUBLIC_MODULE together for the same module" do + input = <<~C + TEST_PARTIAL_ALL_MODULE(widget) + MOCK_PARTIAL_PUBLIC_MODULE(widget) + C + result = extract(input) + expect( result['widget'].tests.type ).to eq Partials::DEDUCT + expect( result['widget'].mocks.type ).to eq Partials::PUBLIC + end + + # --- MODULE macro overwrite raises (extended to ALL_MODULE variants) --- + + it "raises when TEST_PARTIAL_ALL_MODULE and TEST_PARTIAL_MODULE both target the same module" do + input = "TEST_PARTIAL_ALL_MODULE(calc) TEST_PARTIAL_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + it "raises when TEST_PARTIAL_ALL_MODULE and TEST_PARTIAL_PUBLIC_MODULE both target the same module" do + input = "TEST_PARTIAL_ALL_MODULE(calc) TEST_PARTIAL_PUBLIC_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + it "raises when TEST_PARTIAL_ALL_MODULE and TEST_PARTIAL_PRIVATE_MODULE both target the same module" do + input = "TEST_PARTIAL_ALL_MODULE(calc) TEST_PARTIAL_PRIVATE_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + it "raises when MOCK_PARTIAL_ALL_MODULE and MOCK_PARTIAL_MODULE both target the same module" do + input = "MOCK_PARTIAL_ALL_MODULE(calc) MOCK_PARTIAL_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + it "raises when MOCK_PARTIAL_ALL_MODULE and MOCK_PARTIAL_PUBLIC_MODULE both target the same module" do + input = "MOCK_PARTIAL_ALL_MODULE(calc) MOCK_PARTIAL_PUBLIC_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + it "raises when MOCK_PARTIAL_ALL_MODULE and MOCK_PARTIAL_PRIVATE_MODULE both target the same module" do + input = "MOCK_PARTIAL_ALL_MODULE(calc) MOCK_PARTIAL_PRIVATE_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + # --- MODULE macro overwrite raises --- + + it "raises when the same MODULE macro is used twice for the same module" do + input = "TEST_PARTIAL_PUBLIC_MODULE(calc) TEST_PARTIAL_PUBLIC_MODULE(calc)" + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + it "raises when multiple MODULE macros target the same tests entry for a module" do + input = <<~C + TEST_PARTIAL_PUBLIC_MODULE(calc) + TEST_PARTIAL_PRIVATE_MODULE(calc) + C + expect { extract(input) }.to raise_error(CeedlingException, /calc/) + end + + # --- Multiple modules --- + + it "creates separate Config entries for different modules" do + input = <<~C + TEST_PARTIAL_PUBLIC_MODULE(module_a) + MOCK_PARTIAL_PUBLIC_MODULE(module_b) + C + result = extract(input) + expect( result.keys ).to contain_exactly('module_a', 'module_b') + expect( result['module_a'].tests.type ).to eq Partials::PUBLIC + expect( result['module_b'].mocks.type ).to eq Partials::PUBLIC + end + + # --- CONFIG: additions and subtractions --- + + it "splits bare, +, and - function names into additions and subtractions" do + input = <<~C + TEST_PARTIAL_PUBLIC_MODULE(calculator) + TEST_PARTIAL_CONFIG(calculator, add, +subtract, -multiply) + C + result = extract(input) + expect( result['calculator'].tests.additions ).to contain_exactly('add', 'subtract') + expect( result['calculator'].tests.subtractions ).to eq ['multiply'] + end + + it "populates mocks.additions and mocks.subtractions from MOCK_PARTIAL_CONFIG" do + input = <<~C + MOCK_PARTIAL_PUBLIC_MODULE(driver) + MOCK_PARTIAL_CONFIG(driver, +read, -write) + C + result = extract(input) + expect( result['driver'].mocks.additions ).to eq ['read'] + expect( result['driver'].mocks.subtractions ).to eq ['write'] + end + + # --- Quoted function names --- + + it "strips double-quotes from quoted function names and applies prefix logic" do + input = <<~C + TEST_PARTIAL_PUBLIC_MODULE(calculator) + TEST_PARTIAL_CONFIG(calculator, "add", "+subtract", "-multiply") + C + result = extract(input) + expect( result['calculator'].tests.additions ).to contain_exactly('add', 'subtract') + expect( result['calculator'].tests.subtractions ).to eq ['multiply'] + end + + # --- CONFIG referencing unknown module --- + + it "raises an exception when CONFIG macro references a module not declared by a MODULE macro" do + expect { + extract('TEST_PARTIAL_CONFIG(unknown_module, add)') + }.to raise_error(CeedlingException, /TEST_PARTIAL_CONFIG.*unknown_module/) + end + + it "includes the CONFIG macro name in the exception message" do + expect { + extract('MOCK_PARTIAL_CONFIG(ghost, +func)') + }.to raise_error(CeedlingException, /MOCK_PARTIAL_CONFIG/) + end + + it "raises when tests are ACCUMULATE without CONFIG additions (even when mocks are PUBLIC)" do + input = 'TEST_PARTIAL_MODULE("foo") MOCK_PARTIAL_PUBLIC_MODULE("foo")' + expect { extract(input) }.to raise_error(CeedlingException, /TEST_PARTIAL_MODULE/) + end + + it "does not raise when tests are PUBLIC and mocks are PRIVATE" do + input = 'TEST_PARTIAL_PUBLIC_MODULE("foo") MOCK_PARTIAL_PRIVATE_MODULE("foo")' + expect { extract(input) }.not_to raise_error + end + + # --- Validation: subtractions illegal with ACCUMULATE --- + + it "raises when subtractions are used with TEST ACCUMULATE" do + input = <<~C + TEST_PARTIAL_MODULE("foo") + TEST_PARTIAL_CONFIG("foo", "-bar") + C + expect { extract(input) }.to raise_error(CeedlingException, /foo/) + end + + it "raises when subtractions are used with MOCK ACCUMULATE" do + input = <<~C + MOCK_PARTIAL_MODULE("foo") + MOCK_PARTIAL_CONFIG("foo", "+baz", "-bar") + C + expect { extract(input) }.to raise_error(CeedlingException, /foo/) + end + + it "does not raise when only additions are used with ACCUMULATE" do + input = <<~C + TEST_PARTIAL_MODULE("foo") + TEST_PARTIAL_CONFIG("foo", "a", "+b") + C + expect { extract(input) }.not_to raise_error + end + + # --- Complete round-trip --- + + it "handles a complete source snippet with MODULE and CONFIG macros together" do + input = <<~C + #include "calculator.h" + + TEST_PARTIAL_PUBLIC_MODULE(calculator) + TEST_PARTIAL_CONFIG(calculator, +add, -internal_helper) + + MOCK_PARTIAL_PRIVATE_MODULE(driver) + MOCK_PARTIAL_CONFIG(driver, write) + + void some_function(void) {} + C + result = extract(input) + + expect( result.keys ).to contain_exactly('calculator', 'driver') + + expect( result['calculator'].module ).to eq 'calculator' + expect( result['calculator'].tests.type ).to eq Partials::PUBLIC + expect( result['calculator'].tests.additions ).to eq ['add'] + expect( result['calculator'].tests.subtractions ).to eq ['internal_helper'] + expect( result['calculator'].mocks.type ).to be_nil + expect( result['calculator'].mocks.additions ).to eq [] + expect( result['calculator'].mocks.subtractions ).to eq [] + + expect( result['driver'].module ).to eq 'driver' + expect( result['driver'].mocks.type ).to eq Partials::PRIVATE + expect( result['driver'].mocks.additions ).to eq ['write'] + expect( result['driver'].mocks.subtractions ).to eq [] + expect( result['driver'].tests.type ).to be_nil + end + + end + +end diff --git a/spec/units/partials/partializer_helper_spec.rb b/spec/units/partials/partializer_helper_spec.rb new file mode 100644 index 000000000..3b66b1a99 --- /dev/null +++ b/spec/units/partials/partializer_helper_spec.rb @@ -0,0 +1,992 @@ + +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'set' +require 'spec_helper' +require 'ceedling/partials/partializer_helper' +require 'ceedling/partials/partializer_utils' +require 'ceedling/partials/partials' +require 'ceedling/c_extractor/c_extractor_declarations' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ostruct' + +describe PartializerHelper do + before(:each) do + @partializer_utils = double("PartializerUtils") + @c_extractor = double("CExtractor") + @c_extractor_declarations = double("CExtractorDeclarations") + @file_path_utils = double("FilePathUtils") + @loginator = double("Loginator").as_null_object + + @helper = described_class.new( + { + :partializer_utils => @partializer_utils, + :c_extractor => @c_extractor, + :c_extractor_declarations => @c_extractor_declarations, + :file_path_utils => @file_path_utils, + :loginator => @loginator + } + ) + end + + context "#filter_and_transform_funcs" do + before(:each) do + @mock_func1 = double('func1', + name: 'publicFunc', + decorators: [], + signature_stripped: 'void publicFunc(void)' + ) + @mock_func2 = double('func2', + name: 'staticFunc', + decorators: ['static'], + signature_stripped: 'void staticFunc(void)' + ) + @mock_func3 = double('func3', + name: 'inlineFunc', + decorators: ['inline'], + signature_stripped: 'int inlineFunc(int x)' + ) + end + + it "returns empty array when functions list is empty" do + result = @helper.filter_and_transform_funcs([], Partials::PUBLIC, :impl) + expect(result).to eq([]) + end + + it "filters out public function when :private visibility requested" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PRIVATE) + .and_return(false) + + result = @helper.filter_and_transform_funcs([@mock_func1], Partials::PRIVATE, :impl) + expect(result).to eq([]) + end + + it "filters out private function when :public visibility requested" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with(['static'], Partials::PUBLIC) + .and_return(false) + + result = @helper.filter_and_transform_funcs([@mock_func2], Partials::PUBLIC, :impl) + expect(result).to eq([]) + end + + it "transforms a matching public function to :impl type" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PUBLIC) + .and_return(true) + + mock_impl = double('FunctionDefinition') + expect(@partializer_utils).to receive(:transform_function) + .with(@mock_func1, 'void publicFunc(void)', :impl) + .and_return(mock_impl) + + result = @helper.filter_and_transform_funcs([@mock_func1], Partials::PUBLIC, :impl) + expect(result).to eq([mock_impl]) + end + + it "transforms a matching public function to :interface type" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PUBLIC) + .and_return(true) + + mock_interface = double('FunctionDeclaration') + expect(@partializer_utils).to receive(:transform_function) + .with(@mock_func1, 'void publicFunc(void)', :interface) + .and_return(mock_interface) + + result = @helper.filter_and_transform_funcs([@mock_func1], Partials::PUBLIC, :interface) + expect(result).to eq([mock_interface]) + end + + it "returns only the matching function when mixed public/private present" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PUBLIC) + .and_return(true) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(['static'], Partials::PUBLIC) + .and_return(false) + + mock_transformed = double('transformed_func') + allow(@partializer_utils).to receive(:transform_function) + .with(@mock_func1, 'void publicFunc(void)', :impl) + .and_return(mock_transformed) + + result = @helper.filter_and_transform_funcs([@mock_func1, @mock_func2], Partials::PUBLIC, :impl) + expect(result).to eq([mock_transformed]) + end + + it "filters to only the private function when :private visibility requested" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PRIVATE) + .and_return(false) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(['static'], Partials::PRIVATE) + .and_return(true) + + mock_transformed = double('transformed_func') + allow(@partializer_utils).to receive(:transform_function) + .with(@mock_func2, 'void staticFunc(void)', :impl) + .and_return(mock_transformed) + + result = @helper.filter_and_transform_funcs([@mock_func1, @mock_func2], Partials::PRIVATE, :impl) + expect(result).to eq([mock_transformed]) + end + + it "processes multiple matching functions preserving order" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PUBLIC) + .and_return(true) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(['static'], Partials::PUBLIC) + .and_return(false) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(['inline'], Partials::PUBLIC) + .and_return(true) + + mock_result1 = double('result1') + mock_result3 = double('result3') + + allow(@partializer_utils).to receive(:transform_function) + .with(@mock_func1, 'void publicFunc(void)', :impl) + .and_return(mock_result1) + allow(@partializer_utils).to receive(:transform_function) + .with(@mock_func3, 'int inlineFunc(int x)', :impl) + .and_return(mock_result3) + + result = @helper.filter_and_transform_funcs([@mock_func1, @mock_func2, @mock_func3], Partials::PUBLIC, :impl) + expect(result).to eq([mock_result1, mock_result3]) + end + + it "returns empty array when no functions match visibility" do + allow(@partializer_utils).to receive(:matches_visibility?) + .with([], Partials::PRIVATE) + .and_return(false) + + result = @helper.filter_and_transform_funcs([@mock_func1], Partials::PRIVATE, :impl) + expect(result).to eq([]) + end + end + + context "#find_and_transform_func" do + before(:each) do + @prim_func = OpenStruct.new(name: 'primary_func', signature_stripped: 'int primary_func(void)') + @sec_func = OpenStruct.new(name: 'secondary_func', signature_stripped: 'void secondary_func(void)') + @transformed = double('transformed') + end + + it "returns transformed result from primary_funcs when found there" do + expect(@partializer_utils).to receive(:transform_function) + .with(@prim_func, 'int primary_func(void)', :impl) + .and_return(@transformed) + + result = @helper.find_and_transform_func( + name: 'primary_func', + primary_funcs: [@prim_func], + secondary_funcs: [@sec_func], + output_type: :impl + ) + expect(result).to eq(@transformed) + end + + it "falls back to secondary_funcs when name not in primary" do + expect(@partializer_utils).to receive(:transform_function) + .with(@sec_func, 'void secondary_func(void)', :interface) + .and_return(@transformed) + + result = @helper.find_and_transform_func( + name: 'secondary_func', + primary_funcs: [@prim_func], + secondary_funcs: [@sec_func], + output_type: :interface + ) + expect(result).to eq(@transformed) + end + + it "returns nil when name not found in either list" do + expect(@partializer_utils).not_to receive(:transform_function) + + result = @helper.find_and_transform_func( + name: 'missing', + primary_funcs: [@prim_func], + secondary_funcs: [@sec_func], + output_type: :impl + ) + expect(result).to be_nil + end + + it "does not search secondary when name found in primary" do + expect(@partializer_utils).to receive(:transform_function) + .with(@prim_func, 'int primary_func(void)', :interface) + .and_return(@transformed) + .once + + # A duplicate of prim_func in secondary — should never be reached + sec_dup = OpenStruct.new(name: 'primary_func', signature_stripped: 'WRONG') + + result = @helper.find_and_transform_func( + name: 'primary_func', + primary_funcs: [@prim_func], + secondary_funcs: [sec_dup], + output_type: :interface + ) + expect(result).to eq(@transformed) + end + end + + context "#subtract_funcs" do + before(:each) do + @fa = OpenStruct.new(name: 'foo') + @fb = OpenStruct.new(name: 'bar') + @fc = OpenStruct.new(name: 'baz') + end + + it "removes a named function from the list" do + result = @helper.subtract_funcs(funcs: [@fa, @fb, @fc], names: ['bar']) + expect(result.map(&:name)).to eq(['foo', 'baz']) + end + + it "removes multiple named functions" do + result = @helper.subtract_funcs(funcs: [@fa, @fb, @fc], names: ['foo', 'baz']) + expect(result.map(&:name)).to eq(['bar']) + end + + it "returns original list unchanged when names is empty" do + result = @helper.subtract_funcs(funcs: [@fa, @fb], names: []) + expect(result).to eq([@fa, @fb]) + end + + it "has no effect when named function is not in the list" do + result = @helper.subtract_funcs(funcs: [@fa, @fb], names: ['nonexistent']) + expect(result.map(&:name)).to eq(['foo', 'bar']) + end + + it "returns empty array when all functions are subtracted" do + result = @helper.subtract_funcs(funcs: [@fa, @fb], names: ['foo', 'bar']) + expect(result).to eq([]) + end + end + + context "#associate_function_line_numbers" do + before(:each) do + @name = 'TestModule' + @filepath = '/path/to/module.c' + @preprocessed_filepath = '/build/preproc/module_TestModule.i' + + allow(@file_path_utils).to receive(:form_preprocessed_file_raw_directives_only_filepath) + .with(@filepath, @name) + .and_return(@preprocessed_filepath) + + allow(@partializer_utils).to receive(:stamp_source_filepaths) + allow(@partializer_utils).to receive(:format_line_number_list).and_return([]) + end + + it "calls stamp_source_filepaths for empty funcs with fallback: false" do + expect(@partializer_utils).to receive(:stamp_source_filepaths).with([], @filepath) + + @helper.associate_function_line_numbers(name: @name, funcs: [], filepath: @filepath, fallback: false) + end + + it "calls stamp_source_filepaths for empty funcs with fallback: true" do + expect(@partializer_utils).to receive(:stamp_source_filepaths).with([], @filepath) + + @helper.associate_function_line_numbers(name: @name, funcs: [], filepath: @filepath, fallback: true) + end + + it "makes no locate calls when funcs is empty" do + expect(@partializer_utils).not_to receive(:locate_function_in_source) + expect(@partializer_utils).not_to receive(:locate_function_via_preprocessed) + + @helper.associate_function_line_numbers(name: @name, funcs: [], filepath: @filepath, fallback: false) + end + + it "constructs preprocessed filepath from name and filepath" do + expect(@file_path_utils).to receive(:form_preprocessed_file_raw_directives_only_filepath) + .with(@filepath, @name) + .and_return(@preprocessed_filepath) + + @helper.associate_function_line_numbers(name: @name, funcs: [], filepath: @filepath, fallback: false) + end + + it "uses locate_function_in_source for each func when fallback: true" do + func1 = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil) + func2 = OpenStruct.new(code_block: 'int bar(int x) {}', line_num: nil) + + expect(@partializer_utils).to receive(:locate_function_in_source) + .with(code_block: func1.code_block, filepath: @filepath) + .and_return(10) + expect(@partializer_utils).to receive(:locate_function_in_source) + .with(code_block: func2.code_block, filepath: @filepath) + .and_return(25) + + @helper.associate_function_line_numbers(name: @name, funcs: [func1, func2], filepath: @filepath, fallback: true) + + expect(func1.line_num).to eq(10) + expect(func2.line_num).to eq(25) + end + + it "uses locate_function_via_preprocessed for each func when fallback: false" do + func1 = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil) + func2 = OpenStruct.new(code_block: 'int bar(int x) {}', line_num: nil) + + expect(@partializer_utils).to receive(:locate_function_via_preprocessed) + .with(code_block: func1.code_block, filepath: @filepath, preprocessed_filepath: @preprocessed_filepath) + .and_return(10) + expect(@partializer_utils).to receive(:locate_function_via_preprocessed) + .with(code_block: func2.code_block, filepath: @filepath, preprocessed_filepath: @preprocessed_filepath) + .and_return(25) + + @helper.associate_function_line_numbers(name: @name, funcs: [func1, func2], filepath: @filepath, fallback: false) + + expect(func1.line_num).to eq(10) + expect(func2.line_num).to eq(25) + end + + it "sets line_num to nil when locate_function_via_preprocessed returns nil" do + func = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil) + + allow(@partializer_utils).to receive(:locate_function_via_preprocessed).and_return(nil) + + @helper.associate_function_line_numbers(name: @name, funcs: [func], filepath: @filepath, fallback: false) + + expect(func.line_num).to be_nil + end + + it "does not call locate_function_via_preprocessed when fallback: true" do + func = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil) + + expect(@partializer_utils).not_to receive(:locate_function_via_preprocessed) + allow(@partializer_utils).to receive(:locate_function_in_source).and_return(1) + + @helper.associate_function_line_numbers(name: @name, funcs: [func], filepath: @filepath, fallback: true) + end + + it "does not call locate_function_in_source when fallback: false" do + func = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil) + + expect(@partializer_utils).not_to receive(:locate_function_in_source) + allow(@partializer_utils).to receive(:locate_function_via_preprocessed).and_return(1) + + @helper.associate_function_line_numbers(name: @name, funcs: [func], filepath: @filepath, fallback: false) + end + + it "calls format_line_number_list with funcs and passes result to loginator" do + func = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil, name: 'foo') + + allow(@partializer_utils).to receive(:locate_function_via_preprocessed).and_return(5) + + expect(@partializer_utils).to receive(:format_line_number_list).with([func]).and_return(["foo(): 5"]) + + @helper.associate_function_line_numbers(name: @name, funcs: [func], filepath: @filepath, fallback: false) + end + + it "forwards the constructed preprocessed_filepath to locate_function_via_preprocessed" do + func = OpenStruct.new(code_block: 'void foo(void) {}', line_num: nil) + + custom_preproc = '/custom/preproc/output.i' + allow(@file_path_utils).to receive(:form_preprocessed_file_raw_directives_only_filepath) + .with(@filepath, @name) + .and_return(custom_preproc) + + expect(@partializer_utils).to receive(:locate_function_via_preprocessed) + .with(hash_including(preprocessed_filepath: custom_preproc)) + .and_return(nil) + + @helper.associate_function_line_numbers(name: @name, funcs: [func], filepath: @filepath, fallback: false) + end + end + + context "#extract_function_scope_static_vars" do + context "mock-based delegation tests" do + it "returns empty array and makes no calls when funcs is empty" do + expect(@c_extractor_declarations).not_to receive(:try_extract_variable) + + result = @helper.extract_function_scope_static_vars([], name: 'test', module_name: 'mod', file_type: 'source') + expect(result).to eq([]) + end + + it "returns empty array when function body has no declarations (scanner returns false immediately)" do + func = OpenStruct.new( + name: 'simple', + code_block: 'void simple(void) { return; }', + body: '{ return; }' + ) + + allow(@c_extractor_declarations).to receive(:try_extract_variable) + .and_return([false, nil]) + + expect(@partializer_utils).not_to receive(:replace_declaration_with_noop) + expect(@partializer_utils).not_to receive(:rename_c_identifier) + + result = @helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + expect(result).to eq([]) + end + + it "returns empty array when variable is found but is non-static" do + func = OpenStruct.new( + name: 'simple', + code_block: 'void simple(void) { int x; return x; }', + body: '{ int x; return x; }' + ) + + non_static_var = OpenStruct.new( + original: 'int x', + name: 'x', + decorators: [], + text: 'int x;' + ) + + allow(@c_extractor_declarations).to receive(:try_extract_variable) + .and_return([true, [non_static_var]], [false, nil]) + + expect(@partializer_utils).not_to receive(:replace_declaration_with_noop) + expect(@partializer_utils).not_to receive(:rename_c_identifier) + + result = @helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + expect(result).to eq([]) + end + + it "delegates noop and rename calls to utils for a static variable" do + func = OpenStruct.new( + name: 'process', + code_block: 'void process(void) { static int count; count = 0; }', + body: '{ static int count; count = 0; }' + ) + + var = OpenStruct.new( + original: 'static int count', + name: 'count', + decorators: ['static'], + text: 'int count;' + ) + + allow(@c_extractor_declarations).to receive(:try_extract_variable) + .and_return([true, [var]], [false, nil]) + + placeholder = '__CEEDLING_NOOP_PROCESS_COUNT__' + noop_text = "(void)0; /* `#{placeholder}` ... */" + + allow(@partializer_utils).to receive(:replace_declaration_with_noop) + .and_return(noop_text) + allow(@partializer_utils).to receive(:rename_c_identifier) + .and_return('') + + result = @helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + + expect(@partializer_utils).to have_received(:replace_declaration_with_noop).exactly(2).times + expect(@partializer_utils).to have_received(:rename_c_identifier).exactly(3).times + expect(result.first.name).to eq('partial_process_count') + end + + it "delegates compound noop and rename calls to utils for a compound static declaration" do + func = OpenStruct.new( + name: 'calc', + code_block: 'void calc(void) { static int a, b; a = 0; b = 1; }', + body: '{ static int a, b; a = 0; b = 1; }' + ) + + shared_original = 'static int a, b;' + var_a = OpenStruct.new( + original: shared_original, + name: 'a', + decorators: ['static'], + text: 'int a;' + ) + var_b = OpenStruct.new( + original: shared_original, + name: 'b', + decorators: ['static'], + text: 'int b;' + ) + + # Scanner returns both vars in one call, then fails + allow(@c_extractor_declarations).to receive(:try_extract_variable) + .and_return([true, [var_a, var_b]], [false, nil]) + + placeholder = '__CEEDLING_NOOP_CALC_A__' + noops = "(void)0; (void)0; /* `#{placeholder}` ... */" + + allow(@partializer_utils).to receive(:replace_compound_declaration_with_noops) + .and_return(noops) + allow(@partializer_utils).to receive(:rename_c_identifier) + .and_return('') + + result = @helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + + expect(@partializer_utils).to have_received(:replace_compound_declaration_with_noops) + .with(anything, shared_original, placeholder, 2) + .exactly(2).times + expect(@partializer_utils).to have_received(:rename_c_identifier).exactly(6).times + expect(result.map(&:name)).to contain_exactly('partial_calc_a', 'partial_calc_b') + end + end + + context "end-to-end happy day tests" do + let(:real_utils) do + PartializerUtils.new( + { + :preprocessinator_code_finder => double("CodeFinder"), + :loginator => double("Loginator").as_null_object + } + ) + end + + let(:real_helper) do + PartializerHelper.new( + { + :partializer_utils => real_utils, + :c_extractor => double("CExtractor").as_null_object, + :c_extractor_declarations => CExtractorDeclarations.new({ c_extractor_code_text: CExtractorCodeText.new() }).tap(&:setup), + :file_path_utils => double("FilePathUtils"), + :loginator => double("Loginator").as_null_object + } + ) + end + + it "excises a single static variable declaration and renames references" do + func = OpenStruct.new( + name: 'process', + body: "{\n static int count;\n return count;\n}", + code_block: "void process(void) {\n static int count;\n return count;\n}" + ) + + result = real_helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + + # One var returned with renamed identifier + expect(result.length).to eq(1) + expect(result.first.name).to eq('partial_process_count') + + # No-op placeholder present in code_block + expect(func.code_block).to include('(void)0;') + + # References renamed throughout code_block + expect(func.code_block).to include('partial_process_count') + + # Original declaration text preserved inside comment + expect(func.code_block).to include('static int count') + end + + it "does not modify code_block when function body has only non-static variables" do + original_code_block = "void simple(void) {\n int x = 0;\n return x;\n}" + + func = OpenStruct.new( + name: 'simple', + body: "{\n int x = 0;\n return x;\n}", + code_block: original_code_block.dup + ) + + result = real_helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + + expect(result).to eq([]) + expect(func.code_block).to eq(original_code_block) + end + + it "returns empty array when function has no variable declarations at all" do + func = OpenStruct.new( + name: 'empty_func', + body: "{\n return;\n}", + code_block: "void empty_func(void) {\n return;\n}" + ) + + result = real_helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + expect(result).to eq([]) + end + + it "correctly handles a compound static declaration without corrupting comments" do + func = OpenStruct.new( + name: 'calc', + body: "{\n static int a, b;\n a = 0;\n b = 1;\n}", + code_block: "void calc(void) {\n static int a, b;\n a = 0;\n b = 1;\n}" + ) + + result = real_helper.extract_function_scope_static_vars([func], name: 'test', module_name: 'mod', file_type: 'source') + + # Two vars returned, both renamed + expect(result.length).to eq(2) + expect(result.map(&:name)).to contain_exactly('partial_calc_a', 'partial_calc_b') + + # Exactly two no-ops replace the one compound declaration + expect(func.code_block.scan('(void)0;').length).to eq(2) + + # Renamed references present in code_block + expect(func.code_block).to include('partial_calc_a') + expect(func.code_block).to include('partial_calc_b') + + # Replacement no-op with (incomplete) comment + expect(func.code_block).to include('(void)0; (void)0; /* `static int a, b;` replaced with no-op') + end + end + end + + ### + ### Validation helpers shared fixtures + ### + + def make_func(name, decorators: []) + OpenStruct.new(name: name, decorators: decorators) + end + + def make_c_module(funcs) + OpenStruct.new(function_definitions: funcs) + end + + def make_pf(type: nil, additions: [], subtractions: []) + OpenStruct.new(type: type, additions: additions, subtractions: subtractions) + end + + def make_config(mod, tests:, mocks:) + OpenStruct.new(module: mod, tests: tests, mocks: mocks) + end + + ### + ### validate_function_names_exist() + ### + + context "#validate_function_names_exist" do + it "does not raise when all additions and subtractions name existing functions" do + name = "test_mod" + c_module = make_c_module([make_func('foo'), make_func('bar')]) + config = make_config('mod', + tests: make_pf(additions: ['foo'], subtractions: ['bar']), + mocks: make_pf + ) + expect { @helper.validate_function_names_exist(c_module, config, name) }.not_to raise_error + end + + it "raises when a tests.additions name is not in function_definitions" do + name = "test_mod" + c_module = make_c_module([make_func('foo')]) + config = make_config('mod', + tests: make_pf(additions: ['missing']), + mocks: make_pf + ) + expect { @helper.validate_function_names_exist(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*missing/) + end + + it "raises when a tests.subtractions name is not in function_definitions" do + name = "test_mod" + c_module = make_c_module([make_func('foo')]) + config = make_config('mod', + tests: make_pf(subtractions: ['ghost']), + mocks: make_pf + ) + expect { @helper.validate_function_names_exist(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*ghost/) + end + + it "raises when a mocks.additions name is not in function_definitions" do + name = "test_mod" + c_module = make_c_module([make_func('foo')]) + config = make_config('mod', + tests: make_pf, + mocks: make_pf(additions: ['unknown']) + ) + expect { @helper.validate_function_names_exist(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*unknown/) + end + + it "raises when a mocks.subtractions name is not in function_definitions" do + name = "test_mod" + c_module = make_c_module([make_func('foo')]) + config = make_config('mod', + tests: make_pf, + mocks: make_pf(subtractions: ['gone']) + ) + expect { @helper.validate_function_names_exist(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*gone/) + end + + it "raises a case-mismatch exception when name differs only by case from a known function" do + name = "test_mod" + c_module = make_c_module([make_func('FooBar')]) + config = make_config('mod', + tests: make_pf(additions: ['foobar']), + mocks: make_pf + ) + expect { @helper.validate_function_names_exist(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*case/) + end + end + + ### + ### validate_no_additions_subtractions_overlap() + ### + + context "#validate_no_additions_subtractions_overlap" do + it "does not raise when tests additions and subtractions are disjoint" do + name = "test_mod" + config = make_config('mod', + tests: make_pf(additions: ['foo'], subtractions: ['bar']), + mocks: make_pf + ) + expect { @helper.validate_no_additions_subtractions_overlap(config, name) }.not_to raise_error + end + + it "does not raise when mocks additions and subtractions are disjoint" do + name = "test_mod" + config = make_config('mod', + tests: make_pf, + mocks: make_pf(additions: ['read'], subtractions: ['write']) + ) + expect { @helper.validate_no_additions_subtractions_overlap(config, name) }.not_to raise_error + end + + it "raises when a function appears in both tests.additions and tests.subtractions" do + name = "test_mod" + config = make_config('mod', + tests: make_pf(additions: ['foo'], subtractions: ['foo']), + mocks: make_pf + ) + expect { @helper.validate_no_additions_subtractions_overlap(config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*foo/) + end + + it "raises when a function appears in both mocks.additions and mocks.subtractions" do + name = "test_mod" + config = make_config('mod', + tests: make_pf, + mocks: make_pf(additions: ['bar'], subtractions: ['bar']) + ) + expect { @helper.validate_no_additions_subtractions_overlap(config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*bar/) + end + end + + ### + ### validate_additions_subtractions_visibility() + ### + + context "#validate_additions_subtractions_visibility" do + it "does not raise when PUBLIC subtractions are public functions" do + name = "test_mod" + func = make_func('pub') + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::PUBLIC, subtractions: ['pub']), + mocks: make_pf + ) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(func.decorators, Partials::PUBLIC).and_return(true) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .not_to raise_error + end + + it "raises when PUBLIC subtractions include a private function" do + name = "test_mod" + func = make_func('priv', decorators: ['static']) + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::PUBLIC, subtractions: ['priv']), + mocks: make_pf + ) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(func.decorators, Partials::PUBLIC).and_return(false) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*priv/) + end + + it "does not raise when PRIVATE subtractions are private functions" do + name = "test_mod" + func = make_func('priv', decorators: ['static']) + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::PRIVATE, subtractions: ['priv']), + mocks: make_pf + ) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(func.decorators, Partials::PRIVATE).and_return(true) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .not_to raise_error + end + + it "raises when PRIVATE subtractions include a public function" do + name = "test_mod" + func = make_func('pub') + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::PRIVATE, subtractions: ['pub']), + mocks: make_pf + ) + allow(@partializer_utils).to receive(:matches_visibility?) + .with(func.decorators, Partials::PRIVATE).and_return(false) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .to raise_error(CeedlingException, /test_mod.*mod.*pub/) + end + + it "does not raise when PUBLIC additions include a public function (redundant but harmless)" do + name = "test_mod" + func = make_func('pub') + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::PUBLIC, additions: ['pub']), + mocks: make_pf + ) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .not_to raise_error + end + + it "does not raise when PRIVATE additions include a private function (redundant but harmless)" do + name = "test_mod" + func = make_func('priv', decorators: ['static']) + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::PRIVATE, additions: ['priv']), + mocks: make_pf + ) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .not_to raise_error + end + + it "skips visibility check for ACCUMULATE type" do + name = "test_mod" + func = make_func('any') + c_module = make_c_module([func]) + config = make_config('mod', + tests: make_pf(type: Partials::ACCUMULATE, additions: ['any']), + mocks: make_pf + ) + expect(@partializer_utils).not_to receive(:matches_visibility?) + expect { @helper.validate_additions_subtractions_visibility(c_module, config, name) } + .not_to raise_error + end + end + + ### + ### update_signatures_from_full_expansion() + ### + + context "#update_signatures_from_full_expansion" do + require 'ceedling/c_extractor/c_extractor_types' + + def make_expanded_func(name:, signature:, decorators:, signature_stripped:) + CExtractorTypes::CFunctionDefinition.new( + name: name, + signature: signature, + decorators: decorators, + signature_stripped: signature_stripped + ) + end + + it "replaces signature fields from expanded function when name matches" do + func = double('func', + name: 'helper', + signature: 'MY_STATIC int helper(void)', + decorators: [], + signature_stripped: 'int helper(void)' + ) + allow(func).to receive(:signature=) + allow(func).to receive(:decorators=) + allow(func).to receive(:signature_stripped=) + + expanded_func = make_expanded_func( + name: 'helper', + signature: 'static int helper(void)', + decorators: ['static'], + signature_stripped: 'int helper(void)' + ) + expanded_module = CExtractorTypes::CModule.new(function_definitions: [expanded_func]) + + allow(@c_extractor).to receive(:from_file) + .with('/build/full_expansion/mod.c') + .and_return(expanded_module) + + expect(func).to receive(:signature=).with('static int helper(void)') + expect(func).to receive(:decorators=).with(['static']) + expect(func).to receive(:signature_stripped=).with('int helper(void)') + + @helper.update_signatures_from_full_expansion( + funcs: [func], + full_expansion_filepath: '/build/full_expansion/mod.c', + name: 'TestMod', + module_name: 'mod', + file_type: 'source' + ) + end + + it "leaves function unchanged when name is not found in expanded module" do + func = double('func', + name: 'missing', + signature: 'MACRO int missing(void)', + decorators: [], + signature_stripped: 'int missing(void)' + ) + + expanded_module = CExtractorTypes::CModule.new(function_definitions: []) + + allow(@c_extractor).to receive(:from_file).and_return(expanded_module) + + expect(func).not_to receive(:signature=) + expect(func).not_to receive(:decorators=) + expect(func).not_to receive(:signature_stripped=) + + @helper.update_signatures_from_full_expansion( + funcs: [func], + full_expansion_filepath: '/build/full_expansion/mod.c', + name: 'TestMod', + module_name: 'mod', + file_type: 'source' + ) + end + + it "updates only matched functions and skips unmatched ones" do + func_match = double('matched', + name: 'private_func', + signature: 'MY_STATIC void private_func(void)', + decorators: [], + signature_stripped: 'void private_func(void)' + ) + allow(func_match).to receive(:signature=) + allow(func_match).to receive(:decorators=) + allow(func_match).to receive(:signature_stripped=) + + func_no_match = double('unmatched', name: 'orphan') + + expanded_func = make_expanded_func( + name: 'private_func', + signature: 'static void private_func(void)', + decorators: ['static'], + signature_stripped: 'void private_func(void)' + ) + expanded_module = CExtractorTypes::CModule.new(function_definitions: [expanded_func]) + + allow(@c_extractor).to receive(:from_file).and_return(expanded_module) + + expect(func_match).to receive(:signature=).with('static void private_func(void)') + expect(func_match).to receive(:decorators=).with(['static']) + expect(func_match).to receive(:signature_stripped=).with('void private_func(void)') + expect(func_no_match).not_to receive(:signature=) + expect(func_no_match).not_to receive(:decorators=) + expect(func_no_match).not_to receive(:signature_stripped=) + + @helper.update_signatures_from_full_expansion( + funcs: [func_match, func_no_match], + full_expansion_filepath: '/build/full_expansion/mod.c', + name: 'TestMod', + module_name: 'mod', + file_type: 'source' + ) + end + + it "handles an empty funcs list without error" do + expanded_module = CExtractorTypes::CModule.new(function_definitions: []) + allow(@c_extractor).to receive(:from_file).and_return(expanded_module) + + expect { + @helper.update_signatures_from_full_expansion( + funcs: [], + full_expansion_filepath: '/build/full_expansion/mod.c', + name: 'TestMod', + module_name: 'mod', + file_type: 'source' + ) + }.not_to raise_error + end + end + +end diff --git a/spec/units/partials/partializer_spec.rb b/spec/units/partials/partializer_spec.rb new file mode 100644 index 000000000..454772cf4 --- /dev/null +++ b/spec/units/partials/partializer_spec.rb @@ -0,0 +1,1629 @@ + +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/includes/includes' +require 'ceedling/partials/partializer' +require 'ceedling/partials/partials' +require 'ceedling/reportinator' +require 'ceedling/c_extractor/c_extractor_types' +require 'ostruct' + +describe Partializer do + before(:each) do + @partializer_helper = double("PartializerHelper") + @file_finder = double("FileFinder") + @c_extractor = double("CExtractor") + @file_path_utils = double("FilePathUtils") + @reportinator = Reportinator.new + @loginator = double("Loginator").as_null_object + + @partializer = described_class.new( + { + :partializer_helper => @partializer_helper, + :file_finder => @file_finder, + :c_extractor => @c_extractor, + :file_path_utils => @file_path_utils, + :reportinator => @reportinator, + :loginator => @loginator + } + ) + + # Logging happens in production use only and is not directly tested. + # Silence all private logging helpers so that test doubles for extracted + # functions and variable declarations do not need .signature / .text stubs. + allow(@partializer).to receive(:_log_module_contents) + allow(@partializer).to receive(:_log_impl_functions) + allow(@partializer).to receive(:_log_interface_functions) + end + + ### + ### populate_filepaths() + ### + + context "#populate_filepaths" do + def make_tests(present:) + OpenStruct.new(present?: present) + end + + def make_mocks(present:, type: nil) + OpenStruct.new(present?: present, type: type) + end + + def make_config(tests:, mocks:) + OpenStruct.new( + tests: tests, + mocks: mocks, + header: OpenStruct.new(filepath: nil), + source: OpenStruct.new(filepath: nil) + ) + end + + it "returns the configs hash unchanged when empty" do + result = @partializer.populate_filepaths({}) + expect(result).to eq({}) + end + + it "populates header and source for a test config" do + configs = { 'mod' => make_config(tests: make_tests(present: true), mocks: make_mocks(present: false)) } + + allow(@file_finder).to receive(:find_header_file).with('mod', :ignore).and_return('mod.h') + allow(@file_finder).to receive(:find_source_file).with('mod', :ignore).and_return('mod.c') + + @partializer.populate_filepaths(configs) + + expect(configs['mod'].header.filepath).to eq('mod.h') + expect(configs['mod'].source.filepath).to eq('mod.c') + end + + it "populates header only for mock-public config" do + configs = { 'mod' => make_config(tests: make_tests(present: false), mocks: make_mocks(present: true, type: Partials::PUBLIC)) } + + allow(@file_finder).to receive(:find_header_file).with('mod', :ignore).and_return('mod.h') + expect(@file_finder).not_to receive(:find_source_file) + + @partializer.populate_filepaths(configs) + + expect(configs['mod'].header.filepath).to eq('mod.h') + expect(configs['mod'].source.filepath).to be_nil + end + + it "populates header and source for mock-private config" do + configs = { 'mod' => make_config(tests: make_tests(present: false), mocks: make_mocks(present: true, type: Partials::PRIVATE)) } + + allow(@file_finder).to receive(:find_header_file).with('mod', :ignore).and_return('mod.h') + allow(@file_finder).to receive(:find_source_file).with('mod', :ignore).and_return('mod.c') + + @partializer.populate_filepaths(configs) + + expect(configs['mod'].header.filepath).to eq('mod.h') + expect(configs['mod'].source.filepath).to eq('mod.c') + end + + it "populates header and source when mocks.type is nil" do + configs = { 'mod' => make_config(tests: make_tests(present: false), mocks: make_mocks(present: true, type: nil)) } + + allow(@file_finder).to receive(:find_header_file).with('mod', :ignore).and_return('mod.h') + allow(@file_finder).to receive(:find_source_file).with('mod', :ignore).and_return('mod.c') + + @partializer.populate_filepaths(configs) + + expect(configs['mod'].header.filepath).to eq('mod.h') + expect(configs['mod'].source.filepath).to eq('mod.c') + end + + it "handles multiple modules independently" do + configs = { + 'a' => make_config(tests: make_tests(present: true), mocks: make_mocks(present: false)), + 'b' => make_config(tests: make_tests(present: false), mocks: make_mocks(present: true, type: Partials::PUBLIC)) + } + + allow(@file_finder).to receive(:find_header_file).with('a', :ignore).and_return('a.h') + allow(@file_finder).to receive(:find_source_file).with('a', :ignore).and_return('a.c') + allow(@file_finder).to receive(:find_header_file).with('b', :ignore).and_return('b.h') + expect(@file_finder).not_to receive(:find_source_file).with('b', :ignore) + + @partializer.populate_filepaths(configs) + + expect(configs['a'].header.filepath).to eq('a.h') + expect(configs['a'].source.filepath).to eq('a.c') + expect(configs['b'].header.filepath).to eq('b.h') + expect(configs['b'].source.filepath).to be_nil + end + end + + ### + ### validate_config() + ### + + context "#validate_config" do + it "delegates to three validation helpers in order" do + c_module = double("CModule") + config = double("Config") + name = "test_foo" + + expect(@partializer_helper).to receive(:validate_function_names_exist) + .with(c_module, config, name).ordered + expect(@partializer_helper).to receive(:validate_no_additions_subtractions_overlap) + .with(config, name).ordered + expect(@partializer_helper).to receive(:validate_additions_subtractions_visibility) + .with(c_module, config, name).ordered + + @partializer.validate_config(c_module: c_module, config: config, name: name) + end + end + + ### + ### sanitize() + ### + + context "#sanitize" do + def make_macro(text) + CExtractorTypes::CStatement.new(text: text, line_num: 1) + end + + def make_module(macros: [], sequence: nil) + m = CExtractorTypes::CModule.new + m.macro_definitions = macros.dup + m.element_sequence = sequence || macros.dup + m + end + + it "leaves macro_definitions unchanged when no macros contain CEEDLING_GENERATED" do + macro = make_macro('#define MY_GUARD_H') + mod = make_module(macros: [macro]) + + @partializer.sanitize(mod) + + expect(mod.macro_definitions).to eq([macro]) + end + + it "leaves element_sequence unchanged when no macros contain CEEDLING_GENERATED" do + macro = make_macro('#define MY_GUARD_H') + mod = make_module(macros: [macro]) + + @partializer.sanitize(mod) + + expect(mod.element_sequence).to eq([macro]) + end + + it "removes macros whose text includes CEEDLING_GENERATED from macro_definitions" do + generated = make_macro('#ifndef CEEDLING_GENERATED_FOO_H') + kept = make_macro('#define MY_DEFINE 1') + mod = make_module(macros: [generated, kept]) + + @partializer.sanitize(mod) + + expect(mod.macro_definitions).to eq([kept]) + expect(mod.macro_definitions).not_to include(generated) + end + + it "removes the same macro objects from element_sequence" do + generated = make_macro('#ifndef CEEDLING_GENERATED_BAR_H') + kept = make_macro('#define MY_DEFINE 2') + mod = make_module(macros: [generated, kept]) + + @partializer.sanitize(mod) + + expect(mod.element_sequence).not_to include(generated) + expect(mod.element_sequence).to include(kept) + end + + it "removes all macros when every macro contains CEEDLING_GENERATED" do + m1 = make_macro('#ifndef CEEDLING_GENERATED_A_H') + m2 = make_macro('#define CEEDLING_GENERATED_A_H') + mod = make_module(macros: [m1, m2]) + + @partializer.sanitize(mod) + + expect(mod.macro_definitions).to be_empty + expect(mod.element_sequence).to be_empty + end + + it "does not remove non-macro elements from element_sequence" do + generated = make_macro('#ifndef CEEDLING_GENERATED_C_H') + other_elem = double('CFunction', text: 'void foo(void){}') + mod = CExtractorTypes::CModule.new + mod.macro_definitions = [generated] + mod.element_sequence = [generated, other_elem] + + @partializer.sanitize(mod) + + expect(mod.element_sequence).to include(other_elem) + expect(mod.element_sequence).not_to include(generated) + end + + it "handles empty macro_definitions without error" do + mod = make_module(macros: []) + + expect { @partializer.sanitize(mod) }.not_to raise_error + expect(mod.macro_definitions).to be_empty + expect(mod.element_sequence).to be_empty + end + end + + ### + ### validate_extracted_functions() + ### + + context "#validate_extracted_functions" do + def make_impl(name) + Partials.manufacture_function_definition( + name: name, signature: "void #{name}(void)", code_block: "void #{name}(void) {}" + ) + end + + def make_iface(name) + Partials.manufacture_function_declaration(name: name, signature: "void #{name}(void)") + end + + it "does not raise when impl is nil" do + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: nil, interface: [make_iface('foo')] + ) + }.not_to raise_error + end + + it "does not raise when interface is nil" do + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: [make_impl('foo')], interface: nil + ) + }.not_to raise_error + end + + it "does not raise when impl is empty" do + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: [], interface: [make_iface('foo')] + ) + }.not_to raise_error + end + + it "does not raise when interface is empty" do + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: [make_impl('foo')], interface: [] + ) + }.not_to raise_error + end + + it "does not raise when impl and interface have no overlapping functions" do + impl = [make_impl('foo'), make_impl('bar')] + interface = [make_iface('baz'), make_iface('qux')] + + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: impl, interface: interface + ) + }.not_to raise_error + end + + it "raises CeedlingException when one function appears in both impl and interface" do + impl = [make_impl('foo'), make_impl('shared')] + interface = [make_iface('shared'), make_iface('bar')] + + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: impl, interface: interface + ) + }.to raise_error(CeedlingException, /shared/) + end + + it "raises for each overlapping function when multiple functions overlap" do + impl = [make_impl('alpha'), make_impl('beta')] + interface = [make_iface('alpha'), make_iface('beta')] + + expect { + @partializer.validate_extracted_functions( + name: 'test_mod', partial: 'mod', impl: impl, interface: interface + ) + }.to raise_error(CeedlingException) + end + end + + ### + ### remap_implementation_header_includes() + ### + + context "#remap_implementation_header_includes" do + it "returns empty array when input is empty and no partials" do + includes = [] + partials = {} + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to eq([]) + end + + it "removes module's own header from includes" do + includes = [UserInclude.new('header1.h'), UserInclude.new('module.h'), UserInclude.new('header2.h')] + partials = {} + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('module.h')) + end + + it "removes partialized module headers from includes" do + includes = [UserInclude.new('header1.h'), UserInclude.new('partial_module.h'), UserInclude.new('header2.h')] + partials = { + 'partial_module' => { types: [:test_public] } + } + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('partial_module.h')) + end + + it "removes multiple partialized module headers" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial1.h'), + UserInclude.new('partial2.h'), + UserInclude.new('header2.h') + ] + partials = { + 'partial1' => nil, + 'partial2' => nil + } + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('partial1.h')) + expect(result).not_to include(UserInclude.new('partial2.h')) + end + + it "preserves includes that are not partialized" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial_module.h'), + UserInclude.new('header2.h'), + UserInclude.new('header3.h') + ] + partials = { + 'partial_module' => nil + } + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array( + [ + UserInclude.new('header1.h'), + UserInclude.new('header2.h'), + UserInclude.new('header3.h') + ] + ) + end + + it "removes duplicates after removing partialized headers" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial_module.h'), + UserInclude.new('header1.h'), + UserInclude.new('header2.h') + ] + partials = { + 'partial_module' => nil + } + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + end + + it "handles case-insensitive module header removal" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('module.h'), + UserInclude.new('MODULE.H'), + UserInclude.new('header2.h') + ] + partials = {} + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('module.h')) + expect(result).not_to include(UserInclude.new('MODULE.H')) + end + + it "handles partials with different configuration types" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial1.h'), + UserInclude.new('partial2.h'), + UserInclude.new('partial3.h') + ] + partials = { + 'partial1' => nil, + 'partial2' => nil, + 'partial3' => nil + } + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h')]) + end + + it "handles empty partials hash" do + includes = [UserInclude.new('header1.h'), UserInclude.new('header2.h'), UserInclude.new('module.h')] + partials = {} + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + end + + it "handles complex scenario with module header, partials, and duplicates" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('module.h'), + UserInclude.new('partial1.h'), + UserInclude.new('header2.h'), + UserInclude.new('partial2.h'), + UserInclude.new('header1.h'), + UserInclude.new('header3.h') + ] + partials = { + 'partial1' => nil, + 'partial2' => nil + } + result = @partializer.remap_implementation_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to match_array( + [ + UserInclude.new('header1.h'), + UserInclude.new('header2.h'), + UserInclude.new('header3.h') + ] + ) + expect(result).not_to include(UserInclude.new('module.h')) + expect(result).not_to include(UserInclude.new('partial1.h')) + expect(result).not_to include(UserInclude.new('partial2.h')) + end + end + + ### + ### remap_interface_header_includes() + ### + + context "#remap_interface_header_includes" do + it "returns empty array when input is empty and no partials" do + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: [], + partials: {} + ) + expect(result).to eq([]) + end + + it "removes module's own header from includes" do + includes = [UserInclude.new('header1.h'), UserInclude.new('module.h'), UserInclude.new('header2.h')] + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: {} + ) + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('module.h')) + end + + it "removes partialized module headers from includes" do + includes = [UserInclude.new('header1.h'), UserInclude.new('partial_module.h'), UserInclude.new('header2.h')] + partials = { 'partial_module' => nil } + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('partial_module.h')) + end + + it "removes multiple partialized module headers" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial1.h'), + UserInclude.new('partial2.h'), + UserInclude.new('header2.h') + ] + partials = { 'partial1' => nil, 'partial2' => nil } + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('partial1.h')) + expect(result).not_to include(UserInclude.new('partial2.h')) + end + + it "preserves includes that are not partialized" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial_module.h'), + UserInclude.new('header2.h'), + UserInclude.new('header3.h') + ] + partials = { 'partial_module' => nil } + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + expect(result).to match_array([ + UserInclude.new('header1.h'), + UserInclude.new('header2.h'), + UserInclude.new('header3.h') + ]) + end + + it "removes duplicates after removing partialized headers" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial_module.h'), + UserInclude.new('header1.h'), + UserInclude.new('header2.h') + ] + partials = { 'partial_module' => nil } + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + end + + it "handles case-insensitive module header removal" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('module.h'), + UserInclude.new('MODULE.H'), + UserInclude.new('header2.h') + ] + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: {} + ) + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + expect(result).not_to include(UserInclude.new('module.h')) + expect(result).not_to include(UserInclude.new('MODULE.H')) + end + + it "handles partials with different configuration types" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial1.h'), + UserInclude.new('partial2.h'), + UserInclude.new('partial3.h') + ] + partials = { 'partial1' => nil, 'partial2' => nil, 'partial3' => nil } + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + expect(result).to match_array([UserInclude.new('header1.h')]) + end + + it "handles empty partials hash" do + includes = [UserInclude.new('header1.h'), UserInclude.new('header2.h'), UserInclude.new('module.h')] + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: {} + ) + expect(result).to match_array([UserInclude.new('header1.h'), UserInclude.new('header2.h')]) + end + + it "handles complex scenario with module header, partials, and duplicates" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('module.h'), + UserInclude.new('partial1.h'), + UserInclude.new('header2.h'), + UserInclude.new('partial2.h'), + UserInclude.new('header1.h'), + UserInclude.new('header3.h') + ] + partials = { 'partial1' => nil, 'partial2' => nil } + result = @partializer.remap_interface_header_includes( + name: 'module', + includes: includes, + partials: partials + ) + expect(result).to match_array([ + UserInclude.new('header1.h'), + UserInclude.new('header2.h'), + UserInclude.new('header3.h') + ]) + expect(result).not_to include(UserInclude.new('module.h')) + expect(result).not_to include(UserInclude.new('partial1.h')) + expect(result).not_to include(UserInclude.new('partial2.h')) + end + end + + ### + ### remap_implementation_source_includes() + ### + + def make_partial_config(mocks_type: nil, tests_type: nil) + OpenStruct.new( + mocks: OpenStruct.new(type: mocks_type), + tests: OpenStruct.new(type: tests_type) + ) + end + + context "#remap_implementation_source_includes" do + it "returns implementation header when input is empty and no partials" do + includes = [] + partials = {} + filename = 'module_impl.h' + impl_header = UserInclude.new(filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to eq([impl_header]) + end + + it "adds implementation header to includes" do + includes = [UserInclude.new('header1.h'), UserInclude.new('header2.h')] + partials = {} + filename = 'module_impl.h' + impl_header = UserInclude.new(filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + end + + it "removes module's own header from includes" do + includes = [UserInclude.new('header1.h'), UserInclude.new('module.h'), UserInclude.new('header2.h')] + partials = {} + filename = 'module_impl.h' + impl_header = UserInclude.new(filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('module.h')) + end + + it "remaps mockable public partial to interface header" do + includes = [UserInclude.new('header1.h'), UserInclude.new('partial_module.h'), UserInclude.new('header2.h')] + partials = { + 'partial_module' => make_partial_config(mocks_type: Partials::PUBLIC) + } + impl_filename = 'module_impl.h' + interface_filename = 'partial_module_interface.h' + impl_header = UserInclude.new(impl_filename) + interface_header = UserInclude.new(interface_filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(impl_filename) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial_module') + .and_return(interface_filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(interface_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('partial_module.h')) + end + + it "remaps mockable private partial to interface header" do + includes = [UserInclude.new('header1.h'), UserInclude.new('partial_module.h'), UserInclude.new('header2.h')] + partials = { + 'partial_module' => make_partial_config(mocks_type: Partials::PRIVATE) + } + impl_filename = 'module_impl.h' + interface_filename = 'partial_module_interface.h' + impl_header = UserInclude.new(impl_filename) + interface_header = UserInclude.new(interface_filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(impl_filename) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial_module') + .and_return(interface_filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(interface_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('partial_module.h')) + end + + it "remaps multiple mockable partials to interface headers" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial1.h'), + UserInclude.new('partial2.h'), + UserInclude.new('header2.h') + ] + partials = { + 'partial1' => make_partial_config(mocks_type: Partials::PUBLIC), + 'partial2' => make_partial_config(mocks_type: Partials::PRIVATE) + } + impl_filename = 'module_impl.h' + interface_filename1 = 'partial1_module_interface.h' + interface_filename2 = 'partial2_module_interface.h' + impl_header = UserInclude.new(impl_filename) + interface_header1 = UserInclude.new(interface_filename1) + interface_header2 = UserInclude.new(interface_filename2) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(impl_filename) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial1') + .and_return(interface_filename1) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial2') + .and_return(interface_filename2) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(interface_header1) + expect(result).to include(interface_header2) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('partial1.h')) + expect(result).not_to include(UserInclude.new('partial2.h')) + end + + it "remaps testable partial to implementation header" do + includes = [UserInclude.new('header1.h'), UserInclude.new('partial_module.h'), UserInclude.new('header2.h')] + partials = { + 'partial_module' => make_partial_config(tests_type: Partials::PUBLIC) + } + filename = 'module_impl.h' + impl_header = UserInclude.new(filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('partial_module.h')) + expect(result).to include(UserInclude.new('header2.h')) + end + + it "remaps mix of mockable and testable partials" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial1.h'), + UserInclude.new('partial2.h'), + UserInclude.new('header2.h') + ] + partials = { + 'partial1' => make_partial_config(mocks_type: Partials::PUBLIC), + 'partial2' => make_partial_config(tests_type: Partials::PRIVATE) + } + impl_filename = 'module_impl.h' + interface1_filename = 'partial1_module_interface.h' + impl_header = UserInclude.new(impl_filename) + interface_header1 = UserInclude.new(interface1_filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(impl_filename) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial1') + .and_return(interface1_filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(interface_header1) + expect(result).to include(UserInclude.new('partial2.h')) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('partial1.h')) + end + + it "removes duplicates after remapping" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial_module.h'), + UserInclude.new('header1.h'), + UserInclude.new('header2.h') + ] + partials = { + 'partial_module' => make_partial_config(mocks_type: Partials::PUBLIC) + } + impl_filename = 'module_impl.h' + interface_filename = 'partial_module_interface.h' + impl_header = UserInclude.new(impl_filename) + interface_header = UserInclude.new(interface_filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(impl_filename) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial_module') + .and_return(interface_filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result.count(UserInclude.new('header1.h'))).to eq(1) + expect(result).to include(impl_header) + expect(result).to include(interface_header) + expect(result).to include(UserInclude.new('header2.h')) + end + + it "handles case-insensitive module header removal" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('module.h'), + UserInclude.new('MODULE.H'), + UserInclude.new('header2.h') + ] + partials = {} + filename ='module_impl.h' + impl_header = UserInclude.new(filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('module.h')) + expect(result).not_to include(UserInclude.new('MODULE.H')) + end + + it "handles case-insensitive partial module remapping" do + includes = [ + UserInclude.new('header1.h'), + UserInclude.new('partial_module.h'), + UserInclude.new('PARTIAL_MODULE.H'), + UserInclude.new('header2.h') + ] + partials = { + 'partial_module' => make_partial_config(mocks_type: Partials::PUBLIC) + } + impl_filename = 'module_impl.h' + interface_filename = 'partial_module_interface.h' + impl_header = UserInclude.new(impl_filename) + interface_header = UserInclude.new(interface_filename) + + allow(@file_path_utils).to receive(:form_partial_implementation_header_filename) + .with('module') + .and_return(impl_filename) + + allow(@file_path_utils).to receive(:form_partial_interface_header_filename) + .with('partial_module') + .and_return(interface_filename) + + result = @partializer.remap_implementation_source_includes( + name: 'module', + includes: includes, + partials: partials + ) + + expect(result).to include(impl_header) + expect(result).to include(interface_header) + expect(result).to include(UserInclude.new('header1.h')) + expect(result).to include(UserInclude.new('header2.h')) + expect(result).not_to include(UserInclude.new('partial_module.h')) + expect(result).not_to include(UserInclude.new('PARTIAL_MODULE.H')) + end + end + + ### + ### extract_module_contents() + ### + + context "#extract_module_contents" do + before(:each) do + @name = 'TestModule' + + # associate_function_line_numbers is called for every file processed; stub by default + allow(@partializer_helper).to receive(:associate_function_line_numbers) + # static var extraction/promotion called for every file processed; stub by default + allow(@partializer_helper).to receive(:extract_function_scope_static_vars).and_return([]) + end + + it "returns empty CModule when both directives_only_filepaths are nil" do + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: '/path/to/header.h', directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new(filepath: '/path/to/source.c', directives_only_filepath: nil) + ) + + expect(@c_extractor).not_to receive(:from_file) + expect(@partializer_helper).not_to receive(:associate_function_line_numbers) + expect(@partializer_helper).not_to receive(:extract_function_scope_static_vars) + + result = @partializer.extract_module_contents(@name, config, false) + + expect(result.function_definitions).to eq([]) + expect(result.variable_declarations).to eq([]) + end + + it "extracts contents from header preprocessed file only" do + header_funcs = [ + double('func1', name: 'func1', signature: 'void func1(void)'), + double('func2', name: 'func2', signature: 'int func2(int x)') + ] + header_contents = CExtractorTypes::CModule.new(function_definitions: header_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: '/path/to/header.h', directives_only_filepath: '/build/preproc/header.i'), + source: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil) + ) + + expect(@c_extractor).to receive(:from_file).with('/build/preproc/header.i').and_return(header_contents) + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: @name, funcs: header_funcs, filepath: '/path/to/header.h', fallback: false + ) + + result = @partializer.extract_module_contents(@name, config, false) + + expect(result.function_definitions).to eq(header_funcs) + expect(result.variable_declarations).to eq([]) + end + + it "extracts contents from source preprocessed file only" do + source_funcs = [ + double('func1', name: 'func1', signature: 'static void func1(void)'), + double('func2', name: 'func2', signature: 'static int func2(int x)') + ] + source_contents = CExtractorTypes::CModule.new(function_definitions: source_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new(filepath: '/path/to/source.c', directives_only_filepath: '/build/preproc/source.i') + ) + + expect(@c_extractor).to receive(:from_file).with('/build/preproc/source.i').and_return(source_contents) + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: @name, funcs: source_funcs, filepath: '/path/to/source.c', fallback: false + ) + + result = @partializer.extract_module_contents(@name, config, false) + + expect(result.function_definitions).to eq(source_funcs) + expect(result.variable_declarations).to eq([]) + end + + it "extracts and merges contents from both source and header preprocessed files" do + source_funcs = [ + double('func1', name: 'func1', signature: 'static void func1(void)'), + double('func2', name: 'func2', signature: 'static int func2(int x)') + ] + header_funcs = [ + double('func3', name: 'func3', signature: 'void func3(void)'), + double('func4', name: 'func4', signature: 'int func4(int x)') + ] + source_contents = CExtractorTypes::CModule.new(function_definitions: source_funcs, variable_declarations: [double('var1')]) + header_contents = CExtractorTypes::CModule.new(function_definitions: header_funcs, variable_declarations: [double('var2')]) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: '/path/to/header.h', directives_only_filepath: '/build/preproc/header.i'), + source: Partials::ConfigFileInfo.new(filepath: '/path/to/source.c', directives_only_filepath: '/build/preproc/source.i') + ) + + allow(@c_extractor).to receive(:from_file).with('/build/preproc/source.i').and_return(source_contents) + allow(@c_extractor).to receive(:from_file).with('/build/preproc/header.i').and_return(header_contents) + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: @name, funcs: source_funcs, filepath: '/path/to/source.c', fallback: false + ) + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: @name, funcs: header_funcs, filepath: '/path/to/header.h', fallback: false + ) + + result = @partializer.extract_module_contents(@name, config, false) + + expect(result.function_definitions).to eq(header_funcs + source_funcs) + expect(result.variable_declarations).to eq(header_contents.variable_declarations + source_contents.variable_declarations) + end + + it "calls associate_function_line_numbers with the preprocessed expansion filepath, not the preprocessed filepath" do + source_funcs = [double('func1', name: 'func1')] + source_contents = CExtractorTypes::CModule.new(function_definitions: source_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new(filepath: '/src/module1.c', directives_only_filepath: '/build/preproc/module1.i') + ) + + allow(@c_extractor).to receive(:from_file).and_return(source_contents) + + # associate_function_line_numbers must receive the original filepath, not the preprocessed one + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: @name, funcs: source_funcs, filepath: '/src/module1.c', fallback: false + ) + + @partializer.extract_module_contents(@name, config, false) + end + + it "passes extraction name through to associate_function_line_numbers" do + source_funcs = [double('func1', name: 'func1')] + source_contents = CExtractorTypes::CModule.new(function_definitions: source_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new(filepath: '/src/module1.c', directives_only_filepath: '/build/preproc/module1.i') + ) + + allow(@c_extractor).to receive(:from_file).and_return(source_contents) + + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: 'SpecificTestName', funcs: source_funcs, filepath: '/src/module1.c', fallback: false + ) + + @partializer.extract_module_contents('SpecificTestName', config, false) + end + + it "returns empty CModule when a preprocessed file has no contents" do + empty_contents = CExtractorTypes::CModule.new(function_definitions: [], variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: '/path/to/header.h', directives_only_filepath: '/build/preproc/header.i'), + source: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil) + ) + + expect(@c_extractor).to receive(:from_file).and_return(empty_contents) + expect(@partializer_helper).to receive(:associate_function_line_numbers).with( + name: @name, funcs: [], filepath: '/path/to/header.h', fallback: false + ) + + result = @partializer.extract_module_contents(@name, config, false) + + expect(result.function_definitions).to eq([]) + expect(result.variable_declarations).to eq([]) + end + + it "appends promoted function-scope static variables to element_sequence" do + promoted_var1 = CExtractorTypes::CVariableDeclaration.new(text: 'static int counter = 0;', type: 'int', name: 'counter') + promoted_var2 = CExtractorTypes::CVariableDeclaration.new(text: 'static bool flag = false;', type: 'bool', name: 'flag') + + source_contents = CExtractorTypes::CModule.new(function_definitions: [], variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new(filepath: '/src/module1.c', directives_only_filepath: '/build/preproc/module1.i') + ) + + allow(@c_extractor).to receive(:from_file).and_return(source_contents) + allow(@partializer_helper).to receive(:extract_function_scope_static_vars).and_return([promoted_var1, promoted_var2]) + + result = @partializer.extract_module_contents(@name, config, false) + + expect(result.element_sequence).to eq([promoted_var1, promoted_var2]) + expect(result.element_sequence[0]).to equal(promoted_var1) + expect(result.element_sequence[1]).to equal(promoted_var2) + end + + it "calls update_signatures_from_full_expansion when full_expansion_filepath is set on source" do + source_funcs = [double('func1', name: 'func1')] + source_contents = CExtractorTypes::CModule.new(function_definitions: source_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new( + filepath: '/src/module1.c', + directives_only_filepath: '/build/preproc/module1.i', + full_expansion_filepath: '/build/preproc/full_expansion/module1.c' + ) + ) + + allow(@c_extractor).to receive(:from_file).with('/build/preproc/module1.i').and_return(source_contents) + allow(@partializer_helper).to receive(:associate_function_line_numbers) + + expect(@partializer_helper).to receive(:update_signatures_from_full_expansion).with( + funcs: source_funcs, + full_expansion_filepath: '/build/preproc/full_expansion/module1.c', + name: @name, + module_name: 'module1', + file_type: 'source' + ) + + @partializer.extract_module_contents(@name, config, false) + end + + it "calls update_signatures_from_full_expansion when full_expansion_filepath is set on header" do + header_funcs = [double('func1', name: 'func1')] + header_contents = CExtractorTypes::CModule.new(function_definitions: header_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new( + filepath: '/path/to/header.h', + directives_only_filepath: '/build/preproc/header.h', + full_expansion_filepath: '/build/preproc/full_expansion/header.h' + ), + source: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil) + ) + + allow(@c_extractor).to receive(:from_file).with('/build/preproc/header.h').and_return(header_contents) + allow(@partializer_helper).to receive(:associate_function_line_numbers) + + expect(@partializer_helper).to receive(:update_signatures_from_full_expansion).with( + funcs: header_funcs, + full_expansion_filepath: '/build/preproc/full_expansion/header.h', + name: @name, + module_name: 'module1', + file_type: 'header' + ) + + @partializer.extract_module_contents(@name, config, false) + end + + it "does not call update_signatures_from_full_expansion when full_expansion_filepath is nil" do + source_funcs = [double('func1', name: 'func1')] + source_contents = CExtractorTypes::CModule.new(function_definitions: source_funcs, variable_declarations: []) + + config = Partials::Config.new( + module: 'module1', + header: Partials::ConfigFileInfo.new(filepath: nil, directives_only_filepath: nil), + source: Partials::ConfigFileInfo.new( + filepath: '/src/module1.c', + directives_only_filepath: '/build/preproc/module1.i', + full_expansion_filepath: nil + ) + ) + + allow(@c_extractor).to receive(:from_file).with('/build/preproc/module1.i').and_return(source_contents) + allow(@partializer_helper).to receive(:associate_function_line_numbers) + + expect(@partializer_helper).not_to receive(:update_signatures_from_full_expansion) + + @partializer.extract_module_contents(@name, config, false) + end + end + + ### + ### extract_implementation_functions() + ### + + def make_pf(type: nil, additions: [], subtractions: []) + OpenStruct.new(type: type, additions: additions, subtractions: subtractions) + end + + def make_config(tests: make_pf, mocks: make_pf) + OpenStruct.new(tests: tests, mocks: mocks) + end + + context "#extract_implementation_functions" do + it "returns nil when config tests type is nil" do + defs = [] + pf = make_pf + + expect(@partializer_helper).not_to receive(:find_and_transform_func) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, nil, :impl).and_return([]) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [], names: []).and_return([]) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to be_nil + end + + it "delegates initial list to filter_and_transform_funcs for PUBLIC type" do + defs = [double('func1', name: 'pub')] + filtered = [double('impl_func')] + pf = make_pf(type: Partials::PUBLIC) + + expect(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :impl).and_return(filtered) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to eq(filtered) + end + + it "delegates initial list to filter_and_transform_funcs for PRIVATE type" do + defs = [double('func1', name: 'priv')] + filtered = [double('impl_func')] + pf = make_pf(type: Partials::PRIVATE) + + expect(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PRIVATE, :impl).and_return(filtered) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to eq(filtered) + end + + it "fills list from additions only for ACCUMULATE type" do + defs = [double('func1', name: 'named')] + found = double('impl_func', name: 'named') + pf = make_pf(type: Partials::ACCUMULATE, additions: ['named']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::ACCUMULATE, :impl).and_return([]) + expect(@partializer_helper).to receive(:find_and_transform_func) + .with(name: 'named', primary_funcs: defs, secondary_funcs: [], output_type: :impl) + .and_return(found) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [found], names: []).and_return([found]) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to eq([found]) + end + + it "adds cross-visibility function from additions to initial list" do + pub_func = double('pub', name: 'pub') + priv_func = double('priv', name: 'priv') + defs = [pub_func, priv_func] + filtered = [double('impl_pub', name: 'pub')] + added = double('impl_priv', name: 'priv') + pf = make_pf(type: Partials::PUBLIC, additions: ['priv']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :impl).and_return(filtered) + expect(@partializer_helper).to receive(:find_and_transform_func) + .with(name: 'priv', primary_funcs: defs, secondary_funcs: [], output_type: :impl) + .and_return(added) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [filtered[0], added], names: []).and_return([filtered[0], added]) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to include(filtered[0], added) + end + + it "skips addition when function already in initial list (dedup)" do + func = double('impl_pub', name: 'pub') + defs = [double('def', name: 'pub')] + filtered = [func] + pf = make_pf(type: Partials::PUBLIC, additions: ['pub']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :impl).and_return(filtered) + expect(@partializer_helper).not_to receive(:find_and_transform_func) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to eq(filtered) + end + + it "delegates subtractions to subtract_funcs" do + defs = [double('func', name: 'pub')] + filtered = [double('impl_pub', name: 'pub')] + pf = make_pf(type: Partials::PUBLIC, subtractions: ['pub']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :impl).and_return(filtered) + expect(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: ['pub']).and_return([]) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [], names: []).and_return([]) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, config: make_config(tests: pf) + ) + expect(result).to eq([]) + end + + it "subtracts mocks.additions from the impl result" do + defs = [double('func', name: 'pub')] + filtered = [double('impl_pub', name: 'pub')] + test_pf = make_pf(type: Partials::PUBLIC) + mock_pf = make_pf(additions: ['pub']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :impl).and_return(filtered) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + expect(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: ['pub']).and_return([]) + + result = @partializer.extract_implementation_functions( + test: 'test_mod', partial: 'mod', definitions: defs, + config: make_config(tests: test_pf, mocks: mock_pf) + ) + expect(result).to eq([]) + end + end + + ### + ### extract_interface_functions() + ### + + context "#extract_interface_functions" do + it "returns nil when config mocks type is nil" do + defs = [] + decls = [] + pf = make_pf + + expect(@partializer_helper).not_to receive(:find_and_transform_func) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, nil, :interface).and_return([]) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [], names: []).and_return([]) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to be_nil + end + + it "delegates initial list to filter_and_transform_funcs for PUBLIC type" do + defs = [double('func1', name: 'pub')] + decls = [] + filtered = [double('iface_func')] + pf = make_pf(type: Partials::PUBLIC) + + expect(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :interface).and_return(filtered) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to eq(filtered) + end + + it "delegates initial list to filter_and_transform_funcs for PRIVATE type" do + defs = [double('func1', name: 'priv')] + decls = [] + filtered = [double('iface_func')] + pf = make_pf(type: Partials::PRIVATE) + + expect(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PRIVATE, :interface).and_return(filtered) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to eq(filtered) + end + + it "fills list from additions only for ACCUMULATE type" do + defs = [double('def', name: 'named')] + decls = [] + found = double('iface_func', name: 'named') + pf = make_pf(type: Partials::ACCUMULATE, additions: ['named']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::ACCUMULATE, :interface).and_return([]) + expect(@partializer_helper).to receive(:find_and_transform_func) + .with(name: 'named', primary_funcs: defs, secondary_funcs: decls, output_type: :interface) + .and_return(found) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [found], names: []).and_return([found]) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to eq([found]) + end + + it "searches definitions then declarations for additions" do + defs = [] + decls = [double('decl', name: 'decl_only')] + found = double('iface_func', name: 'decl_only') + pf = make_pf(type: Partials::ACCUMULATE, additions: ['decl_only']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::ACCUMULATE, :interface).and_return([]) + expect(@partializer_helper).to receive(:find_and_transform_func) + .with(name: 'decl_only', primary_funcs: defs, secondary_funcs: decls, output_type: :interface) + .and_return(found) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [found], names: []).and_return([found]) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to include(found) + end + + it "skips addition when function already in initial list (dedup)" do + func = double('iface_pub', name: 'pub') + defs = [double('def', name: 'pub')] + decls = [] + filtered = [func] + pf = make_pf(type: Partials::PUBLIC, additions: ['pub']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :interface).and_return(filtered) + expect(@partializer_helper).not_to receive(:find_and_transform_func) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to eq(filtered) + end + + it "delegates subtractions to subtract_funcs" do + defs = [double('func', name: 'pub')] + decls = [] + filtered = [double('iface_pub', name: 'pub')] + pf = make_pf(type: Partials::PUBLIC, subtractions: ['pub']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :interface).and_return(filtered) + expect(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: ['pub']).and_return([]) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: [], names: []).and_return([]) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, config: make_config(mocks: pf) + ) + expect(result).to eq([]) + end + + it "subtracts tests.additions from the interface result" do + defs = [double('func', name: 'pub')] + decls = [] + filtered = [double('iface_pub', name: 'pub')] + mock_pf = make_pf(type: Partials::PUBLIC) + test_pf = make_pf(additions: ['pub']) + + allow(@partializer_helper).to receive(:filter_and_transform_funcs) + .with(defs, Partials::PUBLIC, :interface).and_return(filtered) + allow(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: []).and_return(filtered) + expect(@partializer_helper).to receive(:subtract_funcs) + .with(funcs: filtered, names: ['pub']).and_return([]) + + result = @partializer.extract_interface_functions( + test: 'test_mod', partial: 'mod', + definitions: defs, declarations: decls, + config: make_config(tests: test_pf, mocks: mock_pf) + ) + expect(result).to eq([]) + end + end + +end \ No newline at end of file diff --git a/spec/units/partials/partializer_utils_spec.rb b/spec/units/partials/partializer_utils_spec.rb new file mode 100644 index 000000000..cbbf8ac1c --- /dev/null +++ b/spec/units/partials/partializer_utils_spec.rb @@ -0,0 +1,587 @@ + +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ostruct' +require 'ceedling/constants' +require 'ceedling/partials/partials' +require 'ceedling/partials/partializer_utils' + +describe PartializerUtils do + before(:each) do + @code_finder = double("PreprocessinatorCodeFinder") + @loginator = double("Loginator").as_null_object + + @utils = described_class.new( + { + :preprocessinator_code_finder => @code_finder, + :loginator => @loginator + } + ) + end + + context "#matches_visibility?" do + context ":private visibility" do + it "returns false when decorators array is empty" do + result = @utils.matches_visibility?([], Partials::PRIVATE) + expect(result).to be false + end + + it "returns true when decorators contain 'static'" do + result = @utils.matches_visibility?(['static'], Partials::PRIVATE) + expect(result).to be true + end + + it "returns true when decorators contain 'inline'" do + result = @utils.matches_visibility?(['inline'], Partials::PRIVATE) + expect(result).to be true + end + + it "returns true when decorators contain '__inline'" do + result = @utils.matches_visibility?(['__inline'], Partials::PRIVATE) + expect(result).to be true + end + + it "returns true when decorators contain '__inline__'" do + result = @utils.matches_visibility?(['__inline__'], Partials::PRIVATE) + expect(result).to be true + end + + it "returns true when decorators contain multiple private keywords" do + result = @utils.matches_visibility?(['static', 'inline'], Partials::PRIVATE) + expect(result).to be true + end + + it "returns false when decorators contain only 'extern'" do + result = @utils.matches_visibility?(['extern'], Partials::PRIVATE) + expect(result).to be false + end + + it "returns false when decorators contain only 'const'" do + result = @utils.matches_visibility?(['const'], Partials::PRIVATE) + expect(result).to be false + end + + it "returns true when mixed with non-private decorators" do + result = @utils.matches_visibility?(['extern', 'static', 'const'], Partials::PRIVATE) + expect(result).to be true + end + end + + context ":public visibility" do + it "returns true when decorators array is empty" do + result = @utils.matches_visibility?([], Partials::PUBLIC) + expect(result).to be true + end + + it "returns false when decorators contain 'static'" do + result = @utils.matches_visibility?(['static'], Partials::PUBLIC) + expect(result).to be false + end + + it "returns false when decorators contain 'inline'" do + result = @utils.matches_visibility?(['inline'], Partials::PUBLIC) + expect(result).to be false + end + + it "returns false when decorators contain '__inline'" do + result = @utils.matches_visibility?(['__inline'], Partials::PUBLIC) + expect(result).to be false + end + + it "returns false when decorators contain '__inline__'" do + result = @utils.matches_visibility?(['__inline__'], Partials::PUBLIC) + expect(result).to be false + end + + it "returns false when decorators contain multiple private keywords" do + result = @utils.matches_visibility?(['static', 'inline'], Partials::PUBLIC) + expect(result).to be false + end + + it "returns true when decorators contain only 'extern'" do + result = @utils.matches_visibility?(['extern'], Partials::PUBLIC) + expect(result).to be true + end + + it "returns true when decorators contain only 'const'" do + result = @utils.matches_visibility?(['const'], Partials::PUBLIC) + expect(result).to be true + end + + it "returns false when mixed with private decorators" do + result = @utils.matches_visibility?(['extern', 'static', 'const'], Partials::PUBLIC) + expect(result).to be false + end + end + + context "when `visibility` parameter is invalid" do + # Non-exhaustive validation -- just a spot check`` + it "raises for invalid symbol" do + expect { + @utils.matches_visibility?(['static'], :invalid) + }.to raise_error(ArgumentError, /Invalid.*:invalid/) + end + end + end + + context "#transform_function" do + let(:mock_func) do + double('function', + name: 'testFunc', + signature: 'void testFunc(int x)', + source_filepath: 'src/code/myfile.c', + line_num: 34, + decorators: ['__pragma__', 'static'], + code_block: "__pragma__ static void testFunc(int x) {\n return x * 2;\n}" + ) + end + + context "when output_type is :impl" do + it "returns a FunctionDefinition with source filepath, line number, signature, and code block" do + signature = 'void testFunc(int x)' + + result = @utils.transform_function(mock_func, signature, :impl) + + expect(result).to be_a(Partials::FunctionDefinition) + expect(result.signature).to eq(signature) + expect(result.code_block).to eq("void testFunc(int x) {\n return x * 2;\n}") + # Decorators are inline (no leading newlines), so line_num is unchanged + expect(result.line_num).to eq(34) + end + end + + context "when output_type is :interface" do + it "returns a FunctionDeclaration with only signature" do + signature = 'void testFunc(int x)' + + result = @utils.transform_function(mock_func, signature, :interface) + + expect(result).to be_a(Partials::FunctionDeclaration) + expect(result.signature).to eq(signature) + expect(result).not_to respond_to(:code_block) + end + end + + context "when output_type is invalid" do + it "raises exception for unknown output type" do + signature = 'void testFunc(void)' + + expect { + @utils.transform_function(mock_func, signature, :unknown) + }.to raise_error(ArgumentError, /unknown/i) + end + end + end + + # Test private method for code block manipulation used by `transform_function()` + context "#extract_code_block" do + it "strips a single inline decorator and returns 0 leading newlines" do + code_block = "static void foo(void) {\n return;\n}" + result, newlines = @utils.send(:extract_code_block, code_block, ['static']) + + expect(result).to eq("void foo(void) {\n return;\n}") + expect(newlines).to eq(0) + end + + it "strips multiple inline decorators and returns 0 leading newlines" do + code_block = "__pragma__ static void foo(void) { return; }" + result, newlines = @utils.send(:extract_code_block, code_block, ['__pragma__', 'static']) + + expect(result).to eq("void foo(void) { return; }") + expect(newlines).to eq(0) + end + + it "handles empty decorators array — no stripping, 0 leading newlines" do + code_block = "void foo(void) { return; }" + result, newlines = @utils.send(:extract_code_block, code_block, []) + + expect(result).to eq(code_block) + expect(newlines).to eq(0) + end + + it "counts 1 leading newline when decorator occupies its own line" do + code_block = "static\nint foo(void) { return 0; }" + result, newlines = @utils.send(:extract_code_block, code_block, ['static']) + + expect(result).to eq("int foo(void) { return 0; }") + expect(newlines).to eq(1) + end + + it "counts 2 leading newlines when two decorators each occupy their own line" do + code_block = "static\ninline\nint foo(void) { return 0; }" + result, newlines = @utils.send(:extract_code_block, code_block, ['static', 'inline']) + + expect(result).to eq("int foo(void) { return 0; }") + expect(newlines).to eq(2) + end + + it "preserves body indentation and internal newlines" do + code_block = "static void indented(void) {\n int x = 1;\n return x;\n}" + result, _ = @utils.send(:extract_code_block, code_block, ['static']) + + expect(result).to include(" int x = 1;") + expect(result.count("\n")).to eq(3) + end + + it "handles multi-line parameter list without relying on signature substring match" do + code_block = "static int multiline(\n int a,\n int b) {\n return a + b;\n}" + result, newlines = @utils.send(:extract_code_block, code_block, ['static']) + + expect(result).to eq("int multiline(\n int a,\n int b) {\n return a + b;\n}") + expect(result).not_to include("static") + expect(newlines).to eq(0) + end + + it "is a no-op when decorator is not present in code_block" do + code_block = "void foo(void) { }" + result, newlines = @utils.send(:extract_code_block, code_block, ['extern']) + + expect(result).to eq(code_block) + expect(newlines).to eq(0) + end + + it "removes only the first occurrence of a decorator that appears in the body too" do + # 'static' in the body (e.g., a local static var) must not be touched + code_block = "static int foo(void) {\n static int x = 0;\n return x;\n}" + result, _ = @utils.send(:extract_code_block, code_block, ['static']) + + expect(result).to start_with("int foo(void)") + expect(result).to include("static int x = 0;") + end + + it "strips multiple decorators and counts newlines for inline+multiline mix" do + code_block = "extern\nstatic int bar(int x) { return x; }" + result, newlines = @utils.send(:extract_code_block, code_block, ['extern', 'static']) + + expect(result).to eq("int bar(int x) { return x; }") + expect(newlines).to eq(1) + end + end + + context "#adjust_line_num" do + it "returns nil when line_num is nil" do + expect(@utils.send(:adjust_line_num, nil, 3)).to be_nil + end + + it "returns line_num unchanged when stripped_newlines is 0" do + expect(@utils.send(:adjust_line_num, 10, 0)).to eq(10) + end + + it "adds stripped_newlines to line_num" do + expect(@utils.send(:adjust_line_num, 5, 2)).to eq(7) + end + + it "handles a single stripped newline" do + expect(@utils.send(:adjust_line_num, 1, 1)).to eq(2) + end + end + + context "#replace_declaration_with_noop" do + it "replaces first occurrence of declaration with noop containing placeholder in comment" do + text = " static int count;\n count = 0;" + result = @utils.replace_declaration_with_noop(text, "static int count", "MYPLACEHOLDER") + + expect(result).to include("(void)0;") + expect(result).to include("`MYPLACEHOLDER`") + expect(result).not_to start_with(" static int count;") + end + + it "only replaces the first occurrence (sub, not gsub)" do + text = "static int x; static int x;" + result = @utils.replace_declaration_with_noop(text, "static int x", "PH") + + expect(result.scan("(void)0;").length).to eq(1) + # Second occurrence is preserved + expect(result).to include("static int x") + end + + it "works when declaration appears mid-string with surrounding context" do + text = "/* leading comment */\n static int val;\n val = 5;" + result = @utils.replace_declaration_with_noop(text, "static int val", "TOKEN") + + expect(result).to include("(void)0;") + expect(result).to include("/* leading comment */") + end + + it "returns unchanged text when declaration is not found" do + text = "int x = 0;" + result = @utils.replace_declaration_with_noop(text, "static int y", "PH") + + expect(result).to eq(text) + end + + it "handles declaration text that ends with a semicolon" do + text = "static int count;" + result = @utils.replace_declaration_with_noop(text, "static int count;", "PH") + + expect(result).to include("(void)0;") + end + end + + context "#replace_compound_declaration_with_noops" do + it "produces a single noop with comment for count=1" do + text = " static int count;\n count = 0;" + result = @utils.replace_compound_declaration_with_noops(text, "static int count", "PH_COUNT", 1) + + expect(result.scan("(void)0;").length).to eq(1) + expect(result).to include("`PH_COUNT`") + end + + it "produces two noops for count=2 with single comment containing placeholder" do + text = "void f(void) { static int a, b; a = 0; b = 1; }" + result = @utils.replace_compound_declaration_with_noops(text, "static int a, b;", "PH_A", 2) + + expect(result.scan("(void)0;").length).to eq(2) + expect(result).to include("`PH_A`") + # Only one comment + expect(result.scan("/*").length).to eq(1) + end + + it "produces three noops for count=3 with a single comment" do + text = "static int x, y, z;" + result = @utils.replace_compound_declaration_with_noops(text, "static int x, y, z;", "PH_X", 3) + + expect(result.scan("(void)0;").length).to eq(3) + expect(result).to include("`PH_X`") + expect(result.scan("/*").length).to eq(1) + end + + it "replaces only the first occurrence of the declaration (sub, not gsub)" do + text = "static int a, b; static int a, b;" + result = @utils.replace_compound_declaration_with_noops(text, "static int a, b;", "PH_A", 2) + + expect(result.scan("(void)0;").length).to eq(2) # two noops from one replacement + expect(result).to include("static int a, b;") # second occurrence unchanged + end + + it "returns unchanged text when declaration is not found" do + text = "int x = 0;" + result = @utils.replace_compound_declaration_with_noops(text, "static int y, z;", "PH_Y", 2) + + expect(result).to eq(text) + end + + it "placeholder token appears verbatim in the single comment" do + text = "static int val1, val2;" + result = @utils.replace_compound_declaration_with_noops(text, "static int val1, val2;", "TOKEN_1", 2) + + expect(result).to include("`TOKEN_1`") + expect(result).not_to include("static int val1, val2;") + end + end + + context "#rename_c_identifier" do + it "replaces a single token-bounded occurrence" do + result = @utils.rename_c_identifier("count = 0;", "count", "partial_foo_count") + expect(result).to eq("partial_foo_count = 0;") + end + + it "replaces all occurrences throughout the text (gsub)" do + result = @utils.rename_c_identifier("count = count + 1;", "count", "partial_foo_count") + expect(result).to eq("partial_foo_count = partial_foo_count + 1;") + end + + it "does not replace left-bounded substrings" do + result = @utils.rename_c_identifier("recount = 0;", "count", "partial_foo_count") + expect(result).to eq("recount = 0;") + end + + it "does not replace right-bounded substrings" do + result = @utils.rename_c_identifier("count_down = 0;", "count", "partial_foo_count") + expect(result).to eq("count_down = 0;") + end + + it "does not replace interior substrings" do + result = @utils.rename_c_identifier("recount_down = 0;", "count", "partial_foo_count") + expect(result).to eq("recount_down = 0;") + end + + it "replaces identifier in parentheses context" do + result = @utils.rename_c_identifier("foo(count)", "count", "partial_foo_count") + expect(result).to eq("foo(partial_foo_count)") + end + + it "replaces pointer dereference context" do + result = @utils.rename_c_identifier("*count = 0;", "count", "partial_foo_count") + expect(result).to eq("*partial_foo_count = 0;") + end + + it "replaces array subscript context" do + result = @utils.rename_c_identifier("count[0]", "count", "partial_foo_count") + expect(result).to eq("partial_foo_count[0]") + end + + it "replaces comparison context without spaces" do + result = @utils.rename_c_identifier("count==5", "count", "partial_foo_count") + expect(result).to eq("partial_foo_count==5") + end + + it "replaces compound assignment without spaces" do + result = @utils.rename_c_identifier("count+=1", "count", "partial_foo_count") + expect(result).to eq("partial_foo_count+=1") + end + + it "replaces address-of context" do + result = @utils.rename_c_identifier("&count", "count", "partial_foo_count") + expect(result).to eq("&partial_foo_count") + end + + it "returns empty string unchanged" do + result = @utils.rename_c_identifier("", "count", "partial_foo_count") + expect(result).to eq("") + end + + it "returns unchanged text when old_name not present" do + text = "value = 5;" + result = @utils.rename_c_identifier(text, "count", "partial_foo_count") + expect(result).to eq(text) + end + end + + context "#stamp_source_filepaths" do + it "does nothing for empty array without error" do + expect { @utils.stamp_source_filepaths([], '/path/to/file.c') }.not_to raise_error + end + + it "sets source_filepath on a single func" do + func = double('func') + expect(func).to receive(:source_filepath=).with('/path/to/file.c') + @utils.stamp_source_filepaths([func], '/path/to/file.c') + end + + it "sets source_filepath on all funcs in a collection" do + funcs = [double('f1'), double('f2'), double('f3')] + funcs.each { |f| expect(f).to receive(:source_filepath=).with('/path/to/file.c') } + @utils.stamp_source_filepaths(funcs, '/path/to/file.c') + end + + it "overwrites a pre-existing source_filepath" do + func = OpenStruct.new(source_filepath: '/old/path.c') + @utils.stamp_source_filepaths([func], '/new/path.c') + expect(func.source_filepath).to eq('/new/path.c') + end + end + + context "#locate_function_in_source" do + it "returns the line number when function is found" do + expect(@code_finder).to receive(:find_in_c_file) + .with('/path/to/file.c', 'void foo(void) {}') + .and_return(42) + + result = @utils.locate_function_in_source(code_block: 'void foo(void) {}', filepath: '/path/to/file.c') + expect(result).to eq(42) + end + + it "returns nil when function is not found" do + allow(@code_finder).to receive(:find_in_c_file).and_return(nil) + + result = @utils.locate_function_in_source(code_block: 'void missing(void) {}', filepath: '/path/to/file.c') + expect(result).to be_nil + end + + it "forwards exact arguments to code_finder" do + expect(@code_finder).to receive(:find_in_c_file) + .with('/exact/path.c', 'exact code block') + .and_return(nil) + + @utils.locate_function_in_source(code_block: 'exact code block', filepath: '/exact/path.c') + end + end + + context "#locate_function_via_preprocessed" do + let(:filepath) { '/path/to/source.c' } + let(:preprocessed_filepath) { '/build/preproc/source.i' } + let(:code_block) { 'void foo(void) {}' } + + it "returns line number from preprocessed file when found there" do + expect(@code_finder).to receive(:find_in_preprpocessed_file) + .with(preprocessed_filepath, code_block) + .and_return(10) + expect(@code_finder).not_to receive(:find_in_c_file) + + result = @utils.locate_function_via_preprocessed( + code_block: code_block, + filepath: filepath, + preprocessed_filepath: preprocessed_filepath + ) + expect(result).to eq(10) + end + + it "falls back to C source when not found in preprocessed file" do + allow(@code_finder).to receive(:find_in_preprpocessed_file).and_return(nil) + expect(@code_finder).to receive(:find_in_c_file).with(filepath, code_block).and_return(25) + + result = @utils.locate_function_via_preprocessed( + code_block: code_block, + filepath: filepath, + preprocessed_filepath: preprocessed_filepath + ) + expect(result).to eq(25) + end + + it "returns nil when found in neither preprocessed nor C source" do + allow(@code_finder).to receive(:find_in_preprpocessed_file).and_return(nil) + allow(@code_finder).to receive(:find_in_c_file).and_return(nil) + + result = @utils.locate_function_via_preprocessed( + code_block: code_block, + filepath: filepath, + preprocessed_filepath: preprocessed_filepath + ) + expect(result).to be_nil + end + + it "forwards correct arguments to both code_finder methods" do + expect(@code_finder).to receive(:find_in_preprpocessed_file) + .with('/exact/preproc.i', 'exact block') + .and_return(nil) + expect(@code_finder).to receive(:find_in_c_file) + .with('/exact/source.c', 'exact block') + .and_return(nil) + + @utils.locate_function_via_preprocessed( + code_block: 'exact block', + filepath: '/exact/source.c', + preprocessed_filepath: '/exact/preproc.i' + ) + end + end + + context "#format_line_number_list" do + it "returns empty array for empty input" do + result = @utils.format_line_number_list([]) + expect(result).to eq([]) + end + + it "formats functions with resolved line numbers correctly" do + funcs = [ + OpenStruct.new(name: 'foo', line_num: 5), + OpenStruct.new(name: 'bar', line_num: 12) + ] + result = @utils.format_line_number_list(funcs) + expect(result).to eq(["foo(): 5", "bar(): 12"]) + end + + it "renders N/A for function with nil line_num" do + funcs = [OpenStruct.new(name: 'baz', line_num: nil)] + result = @utils.format_line_number_list(funcs) + expect(result).to eq(["baz(): N/A"]) + end + + it "handles mix of found and missing line numbers" do + funcs = [ + OpenStruct.new(name: 'found', line_num: 42), + OpenStruct.new(name: 'missing', line_num: nil) + ] + result = @utils.format_line_number_list(funcs) + expect(result).to eq(["found(): 42", "missing(): N/A"]) + end + end + +end diff --git a/spec/units/preprocess/c_comment_scanner_spec.rb b/spec/units/preprocess/c_comment_scanner_spec.rb new file mode 100644 index 000000000..86a0056de --- /dev/null +++ b/spec/units/preprocess/c_comment_scanner_spec.rb @@ -0,0 +1,409 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'stringio' +require 'ceedling/preprocess/c_comment_scanner' + +RSpec.describe CCommentScanner do + + subject(:scanner) { CCommentScanner.new } + + # --------------------------------------------------------------------------- + # Helper: extract the matched text for every CommentInfo in content + # --------------------------------------------------------------------------- + def comment_texts(content, infos) + infos.map { |info| content[info.position, info.length] } + end + + + # =========================================================================== + describe '#scan' do + # =========================================================================== + + # ------------------------------------------------------------------------- + context 'with empty or comment-free content' do + # ------------------------------------------------------------------------- + + it 'returns empty array for an empty string' do + expect(scanner.scan(io: StringIO.new(''))).to eq([]) + end + + it 'returns empty array for realistic C code that contains no comments' do + content = <<~C + #include <stdint.h> + #include <stdbool.h> + + typedef struct { + uint32_t count; + uint8_t data[16]; + } Buffer; + + static int buffer_init(Buffer *b, uint32_t size); + static bool buffer_full(const Buffer *b); + static void buffer_clear(Buffer *b); + C + expect(scanner.scan(io: StringIO.new(content))).to eq([]) + end + + end + + + # ------------------------------------------------------------------------- + context 'with // single-line comments' do + # ------------------------------------------------------------------------- + + it 'finds an inline // comment and reports correct position, length, and lines_removed' do + content = "int x = 5; // assign x\nint y = 6;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(infos[0].position).to eq(11) + expect(content[infos[0].position, infos[0].length]).to eq('// assign x') + expect(infos[0].lines_removed).to eq(0) + end + + it 'finds a // comment that extends to end of file with no trailing newline' do + content = "int x = 5; // eof comment" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq('// eof comment') + expect(infos[0].lines_removed).to eq(0) + end + + it 'does not consume the terminating newline of a // comment' do + content = "// comment\nint x;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + # The \n at position 10 must remain available; only the comment is captured + expect(content[infos[0].position, infos[0].length]).to eq('// comment') + expect(infos[0].lines_removed).to eq(0) + end + + it 'handles a backslash continuation (no trailing whitespace) spanning one extra line' do + # A backslash immediately before the newline continues the line comment. + content = "// first line \\\nsecond line still comment\nint x;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq("// first line \\\nsecond line still comment") + expect(infos[0].lines_removed).to eq(1) + end + + it 'handles a backslash continuation with trailing spaces between backslash and newline' do + # GCC accepts (with a warning) whitespace between the \ and the newline. + content = "// first line \\ \nsecond line still comment\nint x;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq("// first line \\ \nsecond line still comment") + expect(infos[0].lines_removed).to eq(1) + end + + it 'handles a backslash continuation with trailing tabs between backslash and newline' do + # \\\t\t\n in the Ruby string literal: one backslash, two tabs, then a newline. + # The scanner must treat \<TAB><TAB><NEWLINE> as a continuation sequence. + content = "// comment\\\t\t\ncontinuation\ncode;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq("// comment\\\t\t\ncontinuation") + expect(infos[0].lines_removed).to eq(1) + end + + it 'handles a // comment with multiple continuation lines' do + content = "// line one \\\nline two \\\nline three\ncode;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq("// line one \\\nline two \\\nline three") + expect(infos[0].lines_removed).to eq(2) + end + + end + + + # ------------------------------------------------------------------------- + context 'with /* */ block comments' do + # ------------------------------------------------------------------------- + + it 'finds an inline /* */ comment on a single line with lines_removed = 0' do + content = "int x = /* the value */ 5;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq('/* the value */') + expect(infos[0].lines_removed).to eq(0) + end + + it 'finds a multi-line /* */ block comment with correct lines_removed' do + content = <<~C + /* + * Module: sensor + * Version: 2.1 + */ + #include <stdint.h> + C + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(infos[0].position).to eq(0) + expect(content[infos[0].position, infos[0].length]).to eq("/*\n * Module: sensor\n * Version: 2.1\n */") + expect(infos[0].lines_removed).to eq(3) + end + + it 'records an unterminated /* comment that extends to EOF' do + content = "int x = 5;\n/* unterminated block\nstill in comment\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq("/* unterminated block\nstill in comment\n") + expect(infos[0].lines_removed).to eq(2) + end + + it 'treats // sequences inside /* */ as non-special (not a nested comment)' do + content = "/* block with // inside\nstill block */\ncode;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq("/* block with // inside\nstill block */") + expect(infos[0].lines_removed).to eq(1) + end + + end + + + # ------------------------------------------------------------------------- + context 'with string and character literals' do + # ------------------------------------------------------------------------- + + it 'does not detect // inside a double-quoted string literal as a comment' do + content = "const char *s = \"http://example.com\";\n" + infos = scanner.scan(io: StringIO.new(content)) + expect(infos).to be_empty + end + + it 'does not detect /* */ inside a double-quoted string literal as a comment' do + content = "const char *msg = \"/* not a comment */\";\n" + infos = scanner.scan(io: StringIO.new(content)) + expect(infos).to be_empty + end + + it 'does not detect // inside a single-quoted character literal as a comment' do + content = "char a = '/';\nchar b = '/';\n// real comment\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq('// real comment') + end + + it 'handles an escaped quote inside a string literal before a real comment' do + # The \" inside the string must not prematurely close the literal. + content = "char *s = \"he said \\\"hi\\\"\";\n// real comment\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq('// real comment') + end + + end + + + # ------------------------------------------------------------------------- + context 'with comment interaction edge cases' do + # ------------------------------------------------------------------------- + + it 'does not treat /* appearing after // as a block comment start' do + content = "// line comment /* not a block\ncode;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + expect(content[infos[0].position, infos[0].length]).to eq('// line comment /* not a block') + expect(infos[0].lines_removed).to eq(0) + end + + it 'finds two adjacent comments with no code between them' do + content = "/* block *//* another block */\ncode;\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(2) + expect(content[infos[0].position, infos[0].length]).to eq('/* block */') + expect(content[infos[1].position, infos[1].length]).to eq('/* another block */') + end + + it 'finds all comments in realistic mixed-comment C code in correct order' do + content = <<~C + /* Module header */ + #include <stdint.h> // Standard integer types + + typedef struct { // The buffer type + uint32_t count; /* Element count */ + uint8_t data[16]; + } Buffer; + C + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(4) + expect(infos.map(&:lines_removed)).to all(eq(0)) + expect(comment_texts(content, infos)).to eq([ + '/* Module header */', + '// Standard integer types', + '// The buffer type', + '/* Element count */' + ]) + end + + it 'finds all comments in realistic GCC preprocessor output with line markers' do + content = <<~PREPROCESSED + # 1 "sensor.c" + /* Sensor driver */ + # 3 "sensor.c" + #define SENSOR_MAX 16 // hardware maximum + # 4 "sensor.c" + #define SCALE_FACTOR 0.001f /* mV per LSB */ + PREPROCESSED + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(3) + expect(comment_texts(content, infos)).to eq([ + '/* Sensor driver */', + '// hardware maximum', + '/* mV per LSB */' + ]) + expect(infos[0].lines_removed).to eq(0) + expect(infos[1].lines_removed).to eq(0) + expect(infos[2].lines_removed).to eq(0) + end + + it 'returns comments in ascending byte position order' do + content = "/* first */ code; /* second */\nmore; // third\n" + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(3) + positions = infos.map(&:position) + expect(positions).to eq(positions.sort) + end + + end + + end + + + # =========================================================================== + describe '#remove' do + # =========================================================================== + + it 'replaces each comment with a single space and leaves all other content intact' do + content = "int x = 5; // inline comment\nint y = /* value */ 6;\n" + infos = scanner.scan(io: StringIO.new(content)) + result = scanner.remove(content, infos) + + expect(result).to eq("int x = 5; \nint y = 6;\n") + end + + it 'preserves the original string (returns a modified copy)' do + content = "/* comment */ code;\n" + original = content.dup + infos = scanner.scan(io: StringIO.new(content)) + scanner.remove(content, infos) + + expect(content).to eq(original) + end + + it 'returns the original content unchanged when comment_infos is empty' do + content = "int x = 5;\n" + result = scanner.remove(content, []) + expect(result).to eq(content) + end + + it 'removes a multi-line block comment (replacing with space reduces newline count)' do + content = "before\n/* multi\nline\ncomment */\nafter" + infos = scanner.scan(io: StringIO.new(content)) + result = scanner.remove(content, infos) + + original_lines = content.count("\n") + result_lines = result.count("\n") + expect(original_lines - result_lines).to eq(infos[0].lines_removed) + expect(result).to eq("before\n \nafter") + end + + it 'correctly strips comments from realistic mixed C code' do + content = <<~C + /* File: sensor.c + * Author: firmware team + */ + #include <stdint.h> // integer types + + static uint32_t read_raw(uint8_t ch) /* channel 0-15 */ { + return ch * 256; // placeholder + } + C + infos = scanner.scan(io: StringIO.new(content)) + result = scanner.remove(content, infos) + + # No comment delimiters should remain + expect(result).not_to include('//') + expect(result).not_to include('/*') + expect(result).not_to include('*/') + # Structure keywords must still be present + expect(result).to include('#include') + expect(result).to include('static uint32_t read_raw') + expect(result).to include('return ch * 256;') + end + + it 'with mode: :preserve_lines replaces multi-line comments with equivalent newlines' do + content = "before\n/* multi\nline\ncomment */\nafter" + infos = scanner.scan(io: StringIO.new(content)) + result = scanner.remove(content, infos, mode: :preserve_lines) + + # Two internal newlines → replaced by \n\n; total line count preserved. + expect(result.count("\n")).to eq(content.count("\n")) + expect(result).to eq("before\n\n\n\nafter") + end + + it 'with mode: :preserve_lines replaces single-line comments with a single space' do + content = "int x; // inline\nint y;\n" + infos = scanner.scan(io: StringIO.new(content)) + result = scanner.remove(content, infos, mode: :preserve_lines) + + # lines_removed == 0 → same space replacement as :compact mode. + expect(result).to eq("int x; \nint y;\n") + end + + it 'with mode: :preserve_lines handles mixed comment types without changing line count' do + # // comment (lines_removed=0) → space; /* two\nlines */ (lines_removed=1) → \n. + content = "int x; // comment\n/* two\nlines */\nint y;\n" + infos = scanner.scan(io: StringIO.new(content)) + result = scanner.remove(content, infos, mode: :preserve_lines) + + expect(result.count("\n")).to eq(content.count("\n")) + expect(result).to eq("int x; \n\n\nint y;\n") + end + + it 'lines_removed equals exactly the newlines eliminated by replace-with-space' do + content = <<~C + /* header comment + spanning four + physical lines + */ + void foo(void) {} + C + infos = scanner.scan(io: StringIO.new(content)) + + expect(infos.length).to eq(1) + result = scanner.remove(content, infos) + lines_before = content.count("\n") + lines_after = result.count("\n") + + expect(lines_before - lines_after).to eq(infos[0].lines_removed) + end + + end + +end diff --git a/spec/units/preprocess/preprocessinator_code_finder_spec.rb b/spec/units/preprocess/preprocessinator_code_finder_spec.rb new file mode 100644 index 000000000..698b6442a --- /dev/null +++ b/spec/units/preprocess/preprocessinator_code_finder_spec.rb @@ -0,0 +1,361 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/preprocess/preprocessinator_code_finder' + +describe PreprocessinatorCodeFinder do + + before(:each) do + @finder = described_class.new + end + + context "#find_in_preprpocessed_string" do + + # ----------------------------------------------------------------------- + # nil cases + # ----------------------------------------------------------------------- + + it "returns nil for empty content" do + expect( @finder.find_in_preprpocessed_string( "", "int foo(void);" ) ).to be_nil + end + + it "returns nil when the search string is not present in the content" do + content = <<~PREPROCESSED + # 1 "source.c" + int foo(void) { return 0; } + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "int bar(void);" ) ).to be_nil + end + + it "returns nil when no line marker precedes the match" do + # A marker exists but only after the match — it must not be used + content = <<~PREPROCESSED + int foo(void) { return 0; } + # 5 "source.c" + int bar(void) { return 1; } + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "int foo(void) { return 0; }" ) ).to be_nil + end + + # ----------------------------------------------------------------------- + # Single line marker + # ----------------------------------------------------------------------- + + it "returns the marker line number when code immediately follows the marker" do + content = <<~PREPROCESSED + # 5 "source.c" + int foo(void) { return 0; } + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "int foo(void) { return 0; }" ) ).to eq 5 + end + + it "returns the marker line number when code immediately follows the marker" do + content = <<~PREPROCESSED + # 5 "source.c" + int foo(void) { return 0; } + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "int foo(void) { return 0; }" ) ).to eq 5 + end + + it "handles a line marker carrying a single flag" do + content = <<~PREPROCESSED + # 7 "source.c" 2 + void process(void); + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "void process(void);" ) ).to eq 7 + end + + it "handles a line marker carrying multiple flags" do + content = <<~PREPROCESSED + # 13 "system/types.h" 3 4 + typedef unsigned int uint32_t; + # 4 "source.c" + void init(uint32_t value); + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "void init(uint32_t value);" ) ).to eq 4 + end + + # ----------------------------------------------------------------------- + # Whitespace-insensitive match + # ----------------------------------------------------------------------- + + it "returns marker linenum plus line offset when whitespace expanded code follows after other lines" do + # Marker says line 10; two lines of other declarations precede the match, + # placing the target at source line 12. + content = <<~PREPROCESSED + # 10 "source.c" + void setup(void); + void teardown(void); + int compute(int x) + { + + return x * 2; + } + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "int compute(int x) { return x * 2; }" ) ).to eq 12 + end + + # ----------------------------------------------------------------------- + # Multiple line markers + # ----------------------------------------------------------------------- + + it "uses the closest preceding marker when multiple markers exist before the match" do + content = <<~PREPROCESSED + # 1 "header.h" 1 + extern int global_var; + # 20 "source.c" 2 + int foo(void) { return 0; } + PREPROCESSED + + # The second marker (line 20) is the last one before the match + expect( @finder.find_in_preprpocessed_string( content, "int foo(void) { return 0; }" ) ).to eq 20 + end + + it "ignores line markers that appear after the match position" do + content = <<~PREPROCESSED + # 3 "source.c" + int found_here(void); + # 10 "source.c" + int not_here(void); + PREPROCESSED + + # The # 10 marker follows the match and must not influence the result + expect( @finder.find_in_preprpocessed_string( content, "int found_here(void);" ) ).to eq 3 + end + + it "handles large line numbers correctly" do + content = <<~PREPROCESSED + # 1 "source.c" + /* file preamble */ + # 247 "source.c" + static void internal_helper(void) {} + PREPROCESSED + + expect( @finder.find_in_preprpocessed_string( content, "static void internal_helper(void) {}" ) ).to eq 247 + end + + # ----------------------------------------------------------------------- + # Multiline search string + # ----------------------------------------------------------------------- + + it "finds a multiline function body and reports the line of its opening signature" do + content = <<~PREPROCESSED + # 15 "module.c" + int add(int a, int b) + { + return a + b; + } + PREPROCESSED + + func = <<~FUNCTION + int add(int a, int b) + { + return a + b; + } + FUNCTION + + expect( @finder.find_in_preprpocessed_string( content, func ) ).to eq 15 + end + + it "reports the correct offset for a multiline match several lines after the marker" do + # Marker at line 30; two preceding declarations push the function to line 32. + content = <<~PREPROCESSED + # 30 "driver.c" + #define ENABLE 1 + #define DISABLE 0 + void driver_init(int mode) + { + if (mode == ENABLE) { setup(); } + } + PREPROCESSED + + func = <<~FUNCTION + void driver_init(int mode) + { + if (mode == ENABLE) { setup(); } + } + FUNCTION + + expect( @finder.find_in_preprpocessed_string( content, func ) ).to eq 32 + end + + # ----------------------------------------------------------------------- + # Realistic preprocessor output + # ----------------------------------------------------------------------- + + it "correctly identifies the source line amid interspersed system header content" do + # Models real GCC -E output: built-in markers, a system include expansion, + # a return marker, and then the actual source file content. + content = <<~PREPROCESSED + # 1 "source.c" + # 1 "<built-in>" 1 + # 1 "<command-line>" 1 + # 1 "source.c" + # 1 "/usr/include/stdint.h" 1 3 + typedef unsigned int uint32_t; + typedef unsigned char uint8_t; + # 5 "source.c" 2 + #include <stdint.h> + uint32_t counter; + void increment(uint32_t *p) { (*p)++; } + PREPROCESSED + + # After "# 5 "source.c" 2": line 5 = #include, line 6 = counter, line 7 = increment + expect( @finder.find_in_preprpocessed_string( content, "void increment(uint32_t *p) { (*p)++; }" ) ).to eq 7 + end + + end + + + context "#find_in_c_string" do + + # ----------------------------------------------------------------------- + # nil cases + # ----------------------------------------------------------------------- + + it "returns nil for empty content" do + expect( @finder.find_in_c_string( "", "int foo(void);" ) ).to be_nil + end + + it "returns nil when the search string is not present in the content" do + content = "int foo(void);\nint bar(void);\n" + expect( @finder.find_in_c_string( content, "int baz(void);" ) ).to be_nil + end + + # ----------------------------------------------------------------------- + # Line number arithmetic — no GCC markers, pure newline count + # ----------------------------------------------------------------------- + + it "returns 1 when the match is at the very start of the content" do + content = "uint32_t sensor_val;\nint x;\n" + expect( @finder.find_in_c_string( content, "uint32_t sensor_val;" ) ).to eq 1 + end + + it "returns the correct 1-indexed line number for a match on a later line" do + content = "int a;\nint b;\nint c;\n" + expect( @finder.find_in_c_string( content, "int c;" ) ).to eq 3 + end + + it "returns the correct line number for a match on the last line with no trailing newline" do + content = "void foo(void);\nvoid bar(void);\nvoid baz(void);" + expect( @finder.find_in_c_string( content, "void baz(void);" ) ).to eq 3 + end + + it "locates a declaration several lines into a realistic C header block" do + content = <<~C + #include <stdint.h> + #include <stdbool.h> + #define MAX_CHANNELS 16 + typedef struct { uint32_t count; } Buffer; + void buffer_init(Buffer *b); + bool buffer_full(const Buffer *b); + C + + expect( @finder.find_in_c_string( content, "void buffer_init(Buffer *b);" ) ).to eq 5 + expect( @finder.find_in_c_string( content, "bool buffer_full(const Buffer *b);" ) ).to eq 6 + end + + # ----------------------------------------------------------------------- + # Multiline search string + # ----------------------------------------------------------------------- + + it "finds a multiline function body and reports the line of its opening signature" do + content = <<~C + void setup(void); + void teardown(void); + int add(int a, int b) + { + return a + b; + } + C + + func = <<~FUNCTION + int add(int a, int b) + { + return a + b; + } + FUNCTION + + expect( @finder.find_in_c_string( content, func ) ).to eq 3 + end + + it "reports the correct line for a multiline match preceded by declarations and macros" do + content = <<~C + #include <stdint.h> + #define ENABLE 1 + #define DISABLE 0 + void driver_init(int mode) + { + if (mode == ENABLE) { setup(); } + } + C + + func = <<~FUNCTION + void driver_init(int mode) + { + if (mode == ENABLE) { setup(); } + } + FUNCTION + + expect( @finder.find_in_c_string( content, func ) ).to eq 4 + end + + # ----------------------------------------------------------------------- + # Whitespace-insensitive match + # ----------------------------------------------------------------------- + + it "finds a multiline whitespace expanded function body and reports the line of its opening signature" do + content = <<~C + void setup(void); + void teardown(void); + int add(int a, int b) + { + return a + b; + + + } + C + + func = <<~FUNCTION + int add(int a, int b) + { + return a + b; + } + FUNCTION + + expect( @finder.find_in_c_string( content, func ) ).to eq 3 + end + + # ----------------------------------------------------------------------- + # Realistic C source + # ----------------------------------------------------------------------- + + it "correctly locates multiple functions in a realistic stripped C source file" do + content = <<~C + #include <stdint.h> + static uint32_t counter = 0; + void increment(void) { counter++; } + uint32_t get_count(void) { return counter; } + void reset(void) { counter = 0; } + C + + expect( @finder.find_in_c_string( content, "void increment(void) { counter++; }" ) ).to eq 3 + expect( @finder.find_in_c_string( content, "uint32_t get_count(void) { return counter; }" ) ).to eq 4 + expect( @finder.find_in_c_string( content, "void reset(void) { counter = 0; }" ) ).to eq 5 + end + + end + +end diff --git a/spec/units/preprocess/preprocessinator_comment_stripper_spec.rb b/spec/units/preprocess/preprocessinator_comment_stripper_spec.rb new file mode 100644 index 000000000..5dc79c00a --- /dev/null +++ b/spec/units/preprocess/preprocessinator_comment_stripper_spec.rb @@ -0,0 +1,519 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/preprocess/c_comment_scanner' +require 'ceedling/preprocess/preprocessinator_comment_stripper' +require 'ceedling/preprocess/preprocessinator_code_finder' + +RSpec.describe PreprocessinatorCommentStripper do + + before(:each) do + @stripper = PreprocessinatorCommentStripper.new( + { + c_comment_scanner: CCommentScanner.new + } + ) + + # PreprocessinatorCodeFinder is used to verify that stripped output preserves + # correct source-line mapping via the marker-relative arithmetic it implements. + @finder = PreprocessinatorCodeFinder.new + end + + + # =========================================================================== + describe '#strip_string' do + # =========================================================================== + + # ------------------------------------------------------------------------- + context 'with comment-free preprocessor output' do + # ------------------------------------------------------------------------- + + it 'returns content unchanged when there are no comments' do + content = <<~PREPROCESSED + # 1 "module.c" + #define MODULE_H + # 2 "module.c" + #define MAX_SIZE 256 + # 3 "module.c" + #include "types.h" + PREPROCESSED + + result = @stripper.strip_string(content) + expect(result).to eq(content) + end + + end + + + # ------------------------------------------------------------------------- + context 'with single-line // comments' do + # ------------------------------------------------------------------------- + + it 'replaces inline // comments with spaces and leaves markers and directives intact' do + content = <<~PREPROCESSED + # 1 "module.c" + #define FOO 1 // enable feature + # 2 "module.c" + #define BAR 2 // another setting + # 3 "module.c" + #define BAZ 3 + PREPROCESSED + + result = @stripper.strip_string(content) + + # Comments replaced with spaces + expect(result).not_to include('//') + # Directives and markers intact + expect(result).to include('# 1 "module.c"') + expect(result).to include('#define FOO 1') + expect(result).to include('#define BAR 2') + expect(result).to include('# 3 "module.c"') + expect(result).to include('#define BAZ 3') + # Marker count unchanged + expect(result.scan(/^#\s+\d+\s+"[^"]+"/).length).to eq( + content.scan(/^#\s+\d+\s+"[^"]+"/).length + ) + end + + end + + + # ------------------------------------------------------------------------- + context 'with multi-line /* */ comments' do + # ------------------------------------------------------------------------- + + it 'replaces a multi-line block comment with blank lines and leaves markers unchanged' do + content = <<~PREPROCESSED + # 1 "sensor.c" + /* + * Sensor module + * Platform: RTOS + */ + #define SENSOR_H + # 7 "sensor.c" + #define MAX_CHANNELS 16 + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + expect(result).not_to include('*/') + # The # 7 "sensor.c" marker is left unchanged (blank-line replacement preserves line count) + expect(result).to include('# 7 "sensor.c"') + expect(result).to include('#define SENSOR_H') + expect(result).to include('#define MAX_CHANNELS 16') + end + + it 'preserves source line numbers via blank-line replacement so code finder is correct' do + # Comment /*...*/ spans source lines 1-4 (3 internal newlines → lines_removed=3). + # Replaced by \n\n\n, preserving line count. # 7 "sensor.c" is unchanged. + # Code finder: # 1 + 4 newlines (\n\n\n replacement + original \n after */) = 5. + # MAX_CHANNELS: unchanged # 7 + 0 newlines = 7. + content = <<~PREPROCESSED + # 1 "sensor.c" + /* + * Sensor module + * Platform: RTOS + */ + #define SENSOR_H + # 7 "sensor.c" + #define MAX_CHANNELS 16 + PREPROCESSED + + result = @stripper.strip_string(content) + + # Verify source-line mapping via PreprocessinatorCodeFinder round-trip. + # #define SENSOR_H: marker # 1 + 4 newlines = line 5. + expect(@finder.find_in_preprpocessed_string(result, '#define SENSOR_H')).to eq(5) + # #define MAX_CHANNELS 16: unchanged marker # 7 + 0 newlines = line 7. + expect(@finder.find_in_preprpocessed_string(result, '#define MAX_CHANNELS 16')).to eq(7) + end + + it 'handles a large realistic module header with multi-line block comment' do + content = <<~PREPROCESSED + # 1 "buffer.h" + /* + * buffer.h -- Ring buffer implementation + * + * Copyright (c) 2025 ThrowTheSwitch + * SPDX-License-Identifier: MIT + */ + # 8 "buffer.h" + #ifndef BUFFER_H + # 9 "buffer.h" + #define BUFFER_H + # 10 "buffer.h" + #define BUFFER_MAX_SIZE 512 /* hardware constraint */ + # 11 "buffer.h" + typedef struct { + # 12 "buffer.h" + } Buffer; + # 13 "buffer.h" + void buffer_init(Buffer *b); // Initialize buffer + # 14 "buffer.h" + #endif + PREPROCESSED + + result = @stripper.strip_string(content) + + # All comments stripped + expect(result).not_to include('//') + expect(result).not_to include('/*') + expect(result).not_to include('*/') + + # Source-line mapping must be correct via round-trip. + # The 6-line header comment (lines_removed=5) is replaced by \n\n\n\n\n. + # All markers # 8 through # 14 are unchanged. + # Each # N "file" marker is immediately followed by its directive (0 newlines), + # so code finder returns N+0=N for each. + expect(@finder.find_in_preprpocessed_string(result, '#ifndef BUFFER_H')).to eq(8) + expect(@finder.find_in_preprpocessed_string(result, '#define BUFFER_H')).to eq(9) + expect(@finder.find_in_preprpocessed_string(result, '#define BUFFER_MAX_SIZE 512')).to eq(10) + expect(@finder.find_in_preprpocessed_string(result, 'void buffer_init(Buffer *b);')).to eq(13) + end + + it 'replaces multiple multi-line comments with blank lines preserving line markers' do + content = <<~PREPROCESSED + # 1 "multi.c" + /* first comment + two lines */ + #define FIRST 1 + # 5 "multi.c" + /* second comment + also two lines */ + #define SECOND 2 + # 9 "multi.c" + #define THIRD 3 + PREPROCESSED + + result = @stripper.strip_string(content) + + # Both comments stripped + expect(result).not_to include('/*') + expect(result).not_to include('*/') + + # All markers unchanged. + expect(result).to include('# 5 "multi.c"') + expect(result).to include('# 9 "multi.c"') + + # Source-line mapping via round-trip for all three defines. + # Each comment (lines_removed=1) is replaced by \n, preserving line count. + # FIRST: # 1 + 2 newlines (\n replacement + original \n after */) = 3. + # SECOND: # 5 + 2 newlines = 7. + # THIRD: # 9 + 0 newlines = 9. + expect(@finder.find_in_preprpocessed_string(result, '#define FIRST 1')).to eq(3) + expect(@finder.find_in_preprpocessed_string(result, '#define SECOND 2')).to eq(7) + expect(@finder.find_in_preprpocessed_string(result, '#define THIRD 3')).to eq(9) + end + + it 'replaces a multi-line comment before any line marker with equivalent blank lines' do + content = <<~PREPROCESSED + /* file header spanning + two lines */ + # 3 "foo.c" + #define FOO 1 + PREPROCESSED + + result = @stripper.strip_string(content) + expect(result).not_to include('/*') + # Marker unchanged; code finder: # 3 + 0 newlines = 3. + expect(result).to include('# 3 "foo.c"') + expect(@finder.find_in_preprpocessed_string(result, '#define FOO 1')).to eq(3) + end + + it 'handles an inline single-line /* */ block comment without changing markers' do + content = <<~PREPROCESSED + # 1 "opts.c" + #define TIMEOUT 100 /* milliseconds */ + # 2 "opts.c" + #define RETRIES 3 + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + # Marker count unchanged + expect(result.scan(/^#\s+\d+\s+"[^"]+"/).length).to eq( + content.scan(/^#\s+\d+\s+"[^"]+"/).length + ) + # Content correct + expect(result).to include('#define TIMEOUT 100') + expect(result).to include('#define RETRIES 3') + end + + end + + + # ------------------------------------------------------------------------- + context 'with C code preceding multi-line comments' do + # ------------------------------------------------------------------------- + + it 'maps code before a comment and code after when no subsequent marker exists' do + # Pattern: marker → code → multi-line comment → more code (no following marker). + # Comment (lines_removed=1) replaced by \n; line count preserved; no markers change. + content = <<~PREPROCESSED + # 1 "calc.c" + int result = 0; + /* initial + value */ + result += 1; + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + # int result = 0: marker # 1 + 0 newlines = 1. + # result += 1: marker # 1 + 3 newlines (code \n + \n replacement + original \n after */) = 4. + expect(@finder.find_in_preprpocessed_string(result, 'int result = 0;')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'result += 1;')).to eq(4) + end + + it 'maps code before a comment and code after when a subsequent marker exists' do + # Pattern: marker → code → multi-line comment → more code → marker → code. + # Subsequent marker is unchanged; code after comment maps to original source line. + content = <<~PREPROCESSED + # 1 "calc.c" + int result = 0; + /* initial + value */ + result += 1; + # 6 "calc.c" + return result; + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + # int result = 0: marker # 1 + 0 newlines = 1. + # result += 1: marker # 1 + 3 newlines = 4 (original source line 4). + # return result: unchanged marker # 6 + 0 newlines = 6. + expect(@finder.find_in_preprpocessed_string(result, 'int result = 0;')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'result += 1;')).to eq(4) + expect(@finder.find_in_preprpocessed_string(result, 'return result;')).to eq(6) + end + + it 'maps multiple code lines before a comment and code after' do + # Pattern: marker → several code lines → multi-line comment → code → marker → code. + # Blank-line replacement preserves newline count; subsequent marker unchanged. + content = <<~PREPROCESSED + # 1 "gpio.c" + void gpio_init(void) { + int port = 0; + /* configure + port + registers */ + port = GPIO_BASE; + # 8 "gpio.c" + gpio_write(port); + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + # void gpio_init: marker # 1 + 0 = 1. + # int port = 0: marker # 1 + 1 newline = 2. + # port = GPIO_BASE: marker # 1 + 5 newlines (void, int port, \n\n replacement, original \n) = 6. + # gpio_write: unchanged marker # 8 + 0 = 8. + expect(@finder.find_in_preprpocessed_string(result, 'void gpio_init(void) {')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'int port = 0;')).to eq(2) + expect(@finder.find_in_preprpocessed_string(result, 'port = GPIO_BASE;')).to eq(6) + expect(@finder.find_in_preprpocessed_string(result, 'gpio_write(port);')).to eq(8) + end + + it 'maps code correctly when blank lines surround a comment within code' do + # Blank lines before and after the comment remain in the output as-is; + # the comment is replaced by equivalent newlines, preserving the total line count. + content = <<~PREPROCESSED + # 1 "sensor.c" + uint8_t data; + + /* multi + line + comment */ + + uint32_t result; + # 9 "sensor.c" + process(result); + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + # uint8_t data: marker # 1 + 0 = 1. + # uint32_t result: marker # 1 + 6 newlines + # (data \n, blank \n, \n\n replacement, original \n after */, blank \n) = 7. + # process: unchanged marker # 9 + 0 = 9. + expect(@finder.find_in_preprpocessed_string(result, 'uint8_t data;')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'uint32_t result;')).to eq(7) + expect(@finder.find_in_preprpocessed_string(result, 'process(result);')).to eq(9) + end + + it 'accumulates correct newline count for two comments under the same marker' do + # Two multi-line comments follow code under # 1 with no intermediate marker. + # Each comment (lines_removed=1) is replaced by \n; total line count preserved. + content = <<~PREPROCESSED + # 1 "shared.c" + int x = 0; + /* first + comment */ + int y = 0; + /* second + comment */ + int z = 0; + # 9 "shared.c" + int w = 0; + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('/*') + # int x: marker # 1 + 0 = 1. + # int y: marker # 1 + 3 newlines (x \n, \n replacement, original \n) = 4. + # int z: marker # 1 + 6 newlines (x, repl, orig, y \n, repl, orig) = 7. + # int w: unchanged marker # 9 + 0 = 9. + expect(@finder.find_in_preprpocessed_string(result, 'int x = 0;')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'int y = 0;')).to eq(4) + expect(@finder.find_in_preprpocessed_string(result, 'int z = 0;')).to eq(7) + expect(@finder.find_in_preprpocessed_string(result, 'int w = 0;')).to eq(9) + end + + it 'handles // and /* */ comments interleaved with code under the same marker' do + # Single-line // comments → space replacement (line count unchanged). + # Multi-line block comment → blank-line replacement (line count unchanged). + # All markers preserved as-is. + content = <<~PREPROCESSED + # 1 "mixed.c" + int x; // x value + /* two + lines */ + int y; // y value + # 6 "mixed.c" + int z; + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('//') + expect(result).not_to include('/*') + # int x: marker # 1 + 0 = 1. + # int y: marker # 1 + 3 newlines (x-line \n, \n replacement, original \n) = 4. + # int z: unchanged marker # 6 + 0 = 6. + expect(@finder.find_in_preprpocessed_string(result, 'int x;')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'int y;')).to eq(4) + expect(@finder.find_in_preprpocessed_string(result, 'int z;')).to eq(6) + end + + it 'maps all code correctly in realistic function-level output with multiple comment types' do + # Realistic preprocessed output: directives, a block comment immediately after + # a marker, C code under a marker, an inline single-line block comment, and + # then another multi-line block comment after code. All markers unchanged. + content = <<~PREPROCESSED + # 1 "module.c" + #include <stdint.h> + # 2 "module.c" + /* Module initialization + functions */ + # 5 "module.c" + static int initialized = 0; + # 6 "module.c" + void module_init(void) { + # 7 "module.c" + int status = 0; /* pending */ + /* retry + loop */ + status = do_init(); + # 12 "module.c" + initialized = status; + # 13 "module.c" + } + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('//') + expect(result).not_to include('/*') + expect(result).not_to include('*/') + + # All markers unchanged. + # Comment 1 ("Module initialization\n functions"): lines_removed=1, replaced by \n. + # Comment 2 ("pending"): lines_removed=0, replaced by space. + # Comment 3 ("retry\n loop"): lines_removed=1, replaced by \n. + # + # #include <stdint.h>: marker # 1 + 0 = 1. + # static int initialized: unchanged marker # 5 + 0 = 5. + # void module_init: unchanged marker # 6 + 0 = 6. + # int status = 0: unchanged marker # 7 + 0 = 7. + # status = do_init(): marker # 7 + 3 newlines + # (status-line \n + \n replacement + original \n after retry comment) = 10. + # initialized = status: unchanged marker # 12 + 0 = 12. + # }: unchanged marker # 13 + 0 = 13. + expect(@finder.find_in_preprpocessed_string(result, '#include <stdint.h>')).to eq(1) + expect(@finder.find_in_preprpocessed_string(result, 'static int initialized = 0;')).to eq(5) + expect(@finder.find_in_preprpocessed_string(result, 'void module_init(void) {')).to eq(6) + expect(@finder.find_in_preprpocessed_string(result, 'int status = 0;')).to eq(7) + expect(@finder.find_in_preprpocessed_string(result, 'status = do_init();')).to eq(10) + expect(@finder.find_in_preprpocessed_string(result, 'initialized = status;')).to eq(12) + expect(@finder.find_in_preprpocessed_string(result, '}')).to eq(13) + end + + end + + + # ------------------------------------------------------------------------- + context 'with mixed comment types' do + # ------------------------------------------------------------------------- + + it 'strips a comprehensive realistic directives-only preprocessor output' do + content = <<~PREPROCESSED + # 1 "platform.h" + /* + * platform.h -- Hardware abstraction layer + * + * Target: STM32F4 + */ + # 7 "platform.h" + #ifndef PLATFORM_H // include guard check + # 8 "platform.h" + #define PLATFORM_H + # 9 "platform.h" + #define CPU_FREQ_HZ 168000000UL /* 168 MHz */ + # 10 "platform.h" + #define FLASH_SIZE 0x100000UL /* 1 MB */ + # 11 "platform.h" + #define RAM_SIZE 0x020000UL /* 128 KB */ + # 12 "platform.h" + typedef unsigned int uint32_t; // fundamental integer type + # 16 "platform.h" + #endif /* PLATFORM_H */ + PREPROCESSED + + result = @stripper.strip_string(content) + + expect(result).not_to include('//') + expect(result).not_to include('/*') + expect(result).not_to include('*/') + + # Source-line mapping must be preserved for all key directives. + # The 5-line block comment (lines_removed=4) is replaced by \n\n\n\n. + # All markers # 7 through # 16 are unchanged. + # Directives immediately follow their respective # N "file" markers (0 newlines + # between), so code finder returns N+0=N for each. + expect(@finder.find_in_preprpocessed_string(result, '#ifndef PLATFORM_H')).to eq(7) + expect(@finder.find_in_preprpocessed_string(result, '#define PLATFORM_H')).to eq(8) + expect(@finder.find_in_preprpocessed_string(result, '#define CPU_FREQ_HZ 168000000UL')).to eq(9) + expect(@finder.find_in_preprpocessed_string(result, '#define FLASH_SIZE 0x100000UL')).to eq(10) + expect(@finder.find_in_preprpocessed_string(result, '#define RAM_SIZE 0x020000UL')).to eq(11) + expect(@finder.find_in_preprpocessed_string(result, 'typedef unsigned int uint32_t;')).to eq(12) + expect(@finder.find_in_preprpocessed_string(result, '#endif')).to eq(16) + end + + end + + end + +end diff --git a/spec/units/preprocess/preprocessinator_includes_handler_spec.rb b/spec/units/preprocess/preprocessinator_includes_handler_spec.rb new file mode 100644 index 000000000..28fa7945d --- /dev/null +++ b/spec/units/preprocess/preprocessinator_includes_handler_spec.rb @@ -0,0 +1,42 @@ +# ========================================================================= +# Ceedling - Test-Centered Build System for C +# ThrowTheSwitch.org +# Copyright (c) 2010-25 Mike Karlesky, Mark VanderVoord, & Greg Williams +# SPDX-License-Identifier: MIT +# ========================================================================= + +require 'spec_helper' +require 'ceedling/preprocess/preprocessinator_includes_handler' + +describe PreprocessinatorIncludesHandler do + before :each do + @configurator = double('configurator') + @preprocessinator_line_marker_includes_extractor = double('preprocessinator_line_marker_includes_extractor') + @include_factory = double('include_factory') + @tool_executor = double('tool_executor') + @file_wrapper = double('file_wrapper') + @yaml_wrapper = double('yaml_wrapper') + @parsing_parcels = double('parsing_parcels') + @loginator = double('loginator') + @reportinator = double('reportinator') + end + + subject do + PreprocessinatorIncludesHandler.new( + { + :configurator => @configurator, + :include_factory => @include_factory, + :preprocessinator_line_marker_includes_extractor => @preprocessinator_line_marker_includes_extractor, + :tool_executor => @tool_executor, + :file_wrapper => @file_wrapper, + :yaml_wrapper => @yaml_wrapper, + :parsing_parcels => @parsing_parcels, + :loginator => @loginator, + :reportinator => @reportinator + } + ) + end + + # TODO: Test coverage + +end diff --git a/spec/preprocessinator_extractor_spec.rb b/spec/units/preprocess/preprocessinator_reconstructor_spec.rb similarity index 75% rename from spec/preprocessinator_extractor_spec.rb rename to spec/units/preprocess/preprocessinator_reconstructor_spec.rb index 265867e7f..9a4a8e50d 100644 --- a/spec/preprocessinator_extractor_spec.rb +++ b/spec/units/preprocess/preprocessinator_reconstructor_spec.rb @@ -6,10 +6,10 @@ # ========================================================================= require 'spec_helper' -require 'ceedling/preprocessinator_extractor' +require 'ceedling/preprocess/preprocessinator_reconstructor' require 'ceedling/parsing_parcels' -describe PreprocessinatorExtractor do +describe PreprocessinatorReconstructor do before(:each) do @parsing_parcels = ParsingParcels.new() @extractor = described_class.new( @@ -62,7 +62,6 @@ ] expected = [ - '', '#pragma yo sup', '#define FOO(...)', 'void some_function(void) {', @@ -160,8 +159,69 @@ expect( @extractor.extract_file_as_array_from_expansion(input, filepath) ).to eq expected end - end + it "should extract text of original file from staggered system include expansions" do + filepath = "path/system_header_expansion.c" + + file_contents = [ + '# 9 "path/system_header_expansion.c" 2', + '', + '', + 'uint16_t var1;', + '', + 'static ', + '# 13 "path/system_header_expansion.c" 3 4', + ' _Bool ', + '# 13 "path/system_header_expansion.c"', + ' var1;', + '', + 'static ', + '# 15 "path/system_header_expansion.c" 3 4', + ' _Bool ', + '# 15 "path/system_header_expansion.c"', + ' ecs_foo__init_structure(void);', + '', + '', + '', + 'void ecs_foo_init(void) {', + ' var1 = ecs_foo__init_structure();', + '}', + '', + 'static ', + '# 23 "path/system_header_expansion.c" 3 4', + ' _Bool ', + '# 23 "path/system_header_expansion.c"', + ' ecs_foo__init_structure(void) {', + ' return ', + '# 24 "path/system_header_expansion.c" 3 4', + ' 1', + '# 24 "path/system_header_expansion.c"', + ' ;', + '}', + ] + + expected = [ + 'uint16_t var1;', + '', + 'static _Bool var1;', + '', + 'static _Bool ecs_foo__init_structure(void);', + '', + 'void ecs_foo_init(void) {', + ' var1 = ecs_foo__init_structure();', + '}', + '', + 'static _Bool ecs_foo__init_structure(void) {', + ' return 1;', + '}', + ] + + input = StringIO.new( file_contents.join( "\n" ) ) + + expect( @extractor.extract_file_as_array_from_expansion( input, filepath ) ).to eq expected + end + end + context "#extract_file_as_string_from_expansion" do it "should simply extract text of original file from preprocessed expansion" do filepath = "path/do/WANT.c" @@ -190,6 +250,94 @@ end end + context "#compact_from_expansion" do + it "should write only lines from source filepath to output IO, excluding other included files" do + filepath = "path/do/WANT.c" + + file_contents = [ + '# 1 "some/file/we/do/not/care/about.c" 5', + 'some_text_we_do_not_want();', + '# 11 "path/do/WANT.c" 99999', # Beginning of block to extract + 'some_text_we_do_want();', # Line to write + '', # Blank line to write + 'some_awesome_text_we_want_so_hard();', # Line to write + 'holy_crepes_more_awesome_text();', # Line to write + '# 3 "some/other/file/we/ignore.c" 5', # End of block to extract + ] + + expected_lines = [ + 'some_text_we_do_want();', + '', + 'some_awesome_text_we_want_so_hard();', + 'holy_crepes_more_awesome_text();' + ] + + input = StringIO.new( file_contents.join( "\n" ) ) + output = StringIO.new + + @extractor.compact_from_expansion( input: input, filepath: filepath, output: output ) + + # `puts` appends "\n" to each line, so join with "\n" + trailing "\n" + expect( output.string ).to eq( expected_lines.join( "\n" ) + "\n" ) + end + + it "should produce empty output when source filepath has no matching line markers" do + filepath = "path/not/in/expansion.c" + + file_contents = [ + '# 1 "some/other/file.c" 5', + 'text_we_do_not_want();', + '# 5 "another/file.c" 1', + 'more_unwanted_text();', + ] + + input = StringIO.new( file_contents.join( "\n" ) ) + output = StringIO.new + + @extractor.compact_from_expansion( input: input, filepath: filepath, output: output ) + + expect( output.string ).to eq '' + end + + it "should produce content matching extract_file_as_string_from_expansion (modulo trailing newline)" do + filepath = "dir/our_file.c" + + file_contents = [ + '# 1 "dir/our_file.c" 123', + 'some_text_we_do_want();', + 'some_awesome_text_we_want_so_hard();', + '# 3 "some preprocessor directive"', + '', + 'some_text_we_do_not_want();', + '# 15 "dir/our_file.c" 9', + 'more_text_we_want();', + 'void some_function(void) { func(); }', + '# 9 "dir/our_file.c" 77', + 'some code', + 'test statements', + '# 6 "dir/our_file.c" 19', + 'some_additional_awesomely_wanted_text();' + ] + + joined = file_contents.join( "\n" ) + + string_result = @extractor.extract_file_as_string_from_expansion( + StringIO.new( joined ), filepath + ) + + output = StringIO.new + @extractor.compact_from_expansion( + input: StringIO.new( joined ), + filepath: filepath, + output: output + ) + + # compact_from_expansion uses `puts` which appends "\n" to each line; + # extract_file_as_string_from_expansion uses join("\n") with no trailing newline + expect( output.string ).to eq( string_result + "\n" ) + end + end + context "#extract_test_directive_macro_calls" do it "should extract any and all test directive macro calls from test file text" do file_text = <<~FILE_TEXT diff --git a/spec/reportinator_spec.rb b/spec/units/reportinator_spec.rb similarity index 100% rename from spec/reportinator_spec.rb rename to spec/units/reportinator_spec.rb diff --git a/spec/system_utils_spec.rb b/spec/units/system_utils_spec.rb similarity index 99% rename from spec/system_utils_spec.rb rename to spec/units/system_utils_spec.rb index 64f62f55e..b468a860a 100644 --- a/spec/system_utils_spec.rb +++ b/spec/units/system_utils_spec.rb @@ -23,18 +23,18 @@ describe '#setup' do it 'sets tcsh_shell to nil' do - expect(@sys_utils.instance_variable_get(:@tcsh_shell)).to eq(nil) + expect(@sys_utils.instance_variable_get(:@tcsh_shell)).to be_nil end it 'sets tcsh_shell to nil after being set' do - expect(@sys_utils.instance_variable_get(:@tcsh_shell)).to eq(nil) + expect(@sys_utils.instance_variable_get(:@tcsh_shell)).to be_nil allow(@loginator).to receive(:shell_backticks).with(@echo_test_cmd).and_return({:exit_code => 0, :output =>'tcsh 1234567890'}) @sys_utils.tcsh_shell? @sys_utils.setup - expect(@sys_utils.instance_variable_get(:@tcsh_shell)).to eq(nil) + expect(@sys_utils.instance_variable_get(:@tcsh_shell)).to be_nil end end diff --git a/spec/test_context_extractor_spec.rb b/spec/units/test_context_extractor_spec.rb similarity index 52% rename from spec/test_context_extractor_spec.rb rename to spec/units/test_context_extractor_spec.rb index b8ceb411b..50bb11845 100644 --- a/spec/test_context_extractor_spec.rb +++ b/spec/units/test_context_extractor_spec.rb @@ -7,19 +7,39 @@ require 'spec_helper' require 'ceedling/test_context_extractor' +require 'ceedling/includes/includes' +require 'ceedling/includes/include_factory' require 'ceedling/parsing_parcels' require 'ceedling/exceptions' +require 'ceedling/c_extractor/c_extractor_code_text' +require 'ceedling/c_extractor/c_extractor_preprocessing' +require 'ceedling/partials/partializer_config' +require 'ceedling/partials/partials' + describe TestContextExtractor do before(:each) do - # Mock injected dependencies - @parsing_parcels = ParsingParcels.new() - @configurator = double( "Configurator" ) # Use double() so we can mock needed methods that are added dynamically at startup - @file_wrapper = double( "FileWrapper" ) # Not actually exercised in these test cases - loginator = instance_double( "Loginator" ) + + ## Mock injected dependencies + + # Use double() so we can mock needed methods that are added dynamically at startup + @configurator = double( "Configurator" ) + # Not actually exercised in these test cases + @file_wrapper = double( "FileWrapper" ) # Ignore all logging calls + loginator = instance_double( "Loginator" ) allow(loginator).to receive(:log) + allow(loginator).to receive(:log_list) + + ## Concrete injected dependencies + @parsing_parcels = ParsingParcels.new() + @include_factory = IncludeFactory.new( {:configurator => @configurator} ) + @file_path_utils = FilePathUtils.new( {:configurator => @configurator, :file_wrapper => @file_wrapper } ) + + code_text = CExtractorCodeText.new + c_extractor_preprocessing = CExtractorPreprocessing.new({ c_extractor_code_text: code_text }) + partializer_config = PartializerConfig.new({ c_extractor_preprocessing: c_extractor_preprocessing }) # Provide configurations mock_prefix = 'mock_' @@ -35,29 +55,33 @@ } allow(@configurator).to receive(:cmock_mock_prefix).and_return( mock_prefix ) + allow(@configurator).to receive(:cmock_mock_path).and_return( 'build/mocks' ) allow(@configurator).to receive(:extension_header).and_return( '.h' ) allow(@configurator).to receive(:extension_source).and_return( '.c' ) allow(@configurator).to receive(:get_runner_config).and_return( test_runner_config ) @extractor = described_class.new( { - :configurator => @configurator, - :file_wrapper => @file_wrapper, - :parsing_parcels => @parsing_parcels, - :loginator => loginator + :configurator => @configurator, + :parsing_parcels => @parsing_parcels, + :include_factory => @include_factory, + :partializer_config => partializer_config, + :file_path_utils => @file_path_utils, + :file_wrapper => @file_wrapper, + :loginator => loginator } ) end - context "#lookup_full_header_includes_list" do + context "#lookup_all_header_includes_list" do it "should provide empty list when no context extraction has occurred" do - expect( @extractor.lookup_full_header_includes_list( "path" ) ).to eq [] + expect( @extractor.lookup_all_header_includes_list( "path" ) ).to eq [] end end - context "#lookup_header_includes_list" do + context "#lookup_all_header_includes_list" do it "should provide empty list when no context extraction has occurred" do - expect( @extractor.lookup_header_includes_list( "path" ) ).to eq [] + expect( @extractor.lookup_all_header_includes_list( "path" ) ).to eq [] end end @@ -91,14 +115,21 @@ end end - context "#lookup_raw_mock_list" do + context "#lookup_mock_header_includes_list" do it "should provide empty list when no context extraction has occurred" do - expect( @extractor.lookup_raw_mock_list( "path" ) ).to eq [] + expect( @extractor.lookup_mock_header_includes_list( "path" ) ).to eq [] end end - context "#extract_includes" do - it "should extract #include directives from code" do + context "#collect_context" do + it "should raise an execption for unknown symbol argument" do + expect{ @extractor.collect_context( "path", StringIO.new(), :bad ) }.to raise_error( CeedlingException ) + end + + # collect_context() + lookup_all_header_includes_list() + lookup_mock_header_includes_list() + it "should extract contents of #include directives" do + filepath = "path/tests/test_file.c" + # Complex comments tested in `clean_code_line()` test case file_contents = <<~CONTENTS #include "some_source.h" @@ -109,77 +140,121 @@ #include "unity.h" #include "mock_File.h" - #include "mock_another-file.h" - #include " mock_another-file.h " // Duplicate to be ignored + #include "mock_another_file.h" + #include " mock_another_file.h " // Duplicate to be ignored CONTENTS input = StringIO.new( file_contents ) - expected = [ - 'some_source.h', - 'more_source.h', - 'unity.h', - 'mock_File.h', - 'mock_another-file.h' - ] - - expect( @extractor.extract_includes( input ) ).to eq expected - end - end - - context "#collect_simple_context" do - it "should raise an execption for unknown symbol argument" do - expect{ @extractor.collect_simple_context( "path", StringIO.new(), :bad ) }.to raise_error( CeedlingException ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::INCLUDES ) + + result = @extractor.lookup_all_header_includes_list( filepath ) + expect( result.length ).to eq 5 + expect( result ).to match_array( + [ + UserInclude.new('some_source.h'), + UserInclude.new('more_source.h'), + UserInclude.new('unity.h'), + MockInclude.new('mock_File.h'), + MockInclude.new('mock_another_file.h'), + ] + ) + + result = @extractor.lookup_mock_header_includes_list( filepath ) + expect( result ).to match_array( + [ + MockInclude.new('mock_File.h'), + MockInclude.new('mock_another_file.h'), + ] + ) end - # collect_simple_context() + lookup_full_header_includes_list() + lookup_header_includes_list() + lookup_raw_mock_list() - it "should extract contents of #include directives" do - filepath = "path/tests/test_file.c" + # collect_context() + lookup_all_header_includes_list() + lookup_mock_header_includes_list() + it "should extract contents of partials configurations as #include directives" do + filepath = "path/tests/test_file_with_partials.c" # Complex comments tested in `clean_code_line()` test case file_contents = <<~CONTENTS #include "some_source.h" - #include "more_source.h" - - #include "some_source.h" // Duplicate to be ignored - - #include "unity.h" + // Partial confgurations + #include TEST_PARTIAL_PUBLIC_MODULE(foo) + #include TEST_PARTIAL_PRIVATE_MODULE(bar) + #include MOCK_PARTIAL_PRIVATE_MODULE(noo) + #include MOCK_PARTIAL_PUBLIC_MODULE(doo) - #include "mock_File.h" - #include "mock_another_file.h" - #include " mock_another_file.h " // Duplicate to be ignored CONTENTS input = StringIO.new( file_contents ) - @extractor.collect_simple_context( filepath, input, :includes ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::PARTIALS_CONFIGURATION ) expected_full = [ - 'some_source.h', - 'more_source.h', - 'unity.h', - 'mock_File.h', - 'mock_another_file.h' - ] - - expected_trim = [ - 'some_source.h', - 'more_source.h' + UserInclude.new('ceedling_partial_foo_impl.h'), + UserInclude.new('ceedling_partial_bar_impl.h'), + MockInclude.new('mock_ceedling_partial_noo_interface.h'), + MockInclude.new('mock_ceedling_partial_doo_interface.h') ] expected_mocks = [ - 'mock_File', - 'mock_another_file' + MockInclude.new('mock_ceedling_partial_noo_interface.h'), + MockInclude.new('mock_ceedling_partial_doo_interface.h') ] - expect( @extractor.lookup_full_header_includes_list( filepath ) ).to eq expected_full + result = @extractor.lookup_all_header_includes_list( filepath ) + expect( result ).to match_array( expected_full ) + + result = @extractor.lookup_mock_header_includes_list( filepath ) + expect( result ).to match_array( expected_mocks ) + end + + # collect_context() + lookup_partials_config() + it "should extract contents of partials configurations" do + filepath = "path/tests/test_file_with_partials.c" + + file_contents = <<~CONTENTS + // Partial configurations + #include TEST_PARTIAL_PUBLIC_MODULE(foo) + #include TEST_PARTIAL_PUBLIC_MODULE(foobar) + #include TEST_PARTIAL_PRIVATE_MODULE(baz) + #include TEST_PARTIAL_PRIVATE_MODULE(razmataz) + #include MOCK_PARTIAL_PRIVATE_MODULE(foobar) + #include MOCK_PARTIAL_PRIVATE_MODULE(hardyharhar) + #include MOCK_PARTIAL_PUBLIC_MODULE(abc) + #include MOCK_PARTIAL_PUBLIC_MODULE(abc_xyz) + TEST_PARTIAL_CONFIG(foo, +add, -internal_helper) + MOCK_PARTIAL_CONFIG(foobar, write, -debug_write) + + CONTENTS + + input = StringIO.new( file_contents ) + + @extractor.collect_context( filepath, input, TestContextExtractor::Context::PARTIALS_CONFIGURATION ) + + result = @extractor.lookup_partials_config( filepath ) + + expect( result ).to be_a( Hash ) + expect( result.keys ).to contain_exactly('foo', 'foobar', 'baz', 'razmataz', 'hardyharhar', 'abc', 'abc_xyz') + + # Test-only modules + expect( result['foo'].tests.type ).to eq Partials::PUBLIC + expect( result['foo'].tests.additions ).to eq ['add'] + expect( result['foo'].tests.subtractions ).to eq ['internal_helper'] + expect( result['baz'].tests.type ).to eq Partials::PRIVATE + expect( result['razmataz'].tests.type ).to eq Partials::PRIVATE - expect( @extractor.lookup_header_includes_list( filepath ) ).to eq expected_trim + # Module with both test and mock config + expect( result['foobar'].tests.type ).to eq Partials::PUBLIC + expect( result['foobar'].mocks.type ).to eq Partials::PRIVATE + expect( result['foobar'].mocks.additions ).to eq ['write'] + expect( result['foobar'].mocks.subtractions ).to eq ['debug_write'] - expect( @extractor.lookup_raw_mock_list( filepath ) ).to eq expected_mocks + # Mock-only modules + expect( result['hardyharhar'].mocks.type ).to eq Partials::PRIVATE + expect( result['abc'].mocks.type ).to eq Partials::PUBLIC + expect( result['abc_xyz'].mocks.type ).to eq Partials::PUBLIC end - # collect_simple_context() + lookup_build_directive_sources_list() + # collect_context() + lookup_build_directive_sources_list() it "should extract extra source files by build directive macros" do filepath = "path/tests/testfile.c" @@ -197,7 +272,7 @@ input = StringIO.new( file_contents ) - @extractor.collect_simple_context( filepath, input, :build_directive_source_files ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::BUILD_DIRECTIVE_SOURCE_FILES ) expected = [ 'a.c', @@ -209,7 +284,7 @@ expect( @extractor.lookup_build_directive_sources_list( filepath ) ).to eq expected end - # collect_simple_context() + lookup_include_paths_list() + # collect_context() + lookup_include_paths_list() it "should extract extra header search paths by build directive macros" do filepath = "path/tests/testfile.c" @@ -227,7 +302,7 @@ input = StringIO.new( file_contents ) - @extractor.collect_simple_context( filepath, input, :build_directive_include_paths ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::BUILD_DIRECTIVE_INCLUDE_PATHS ) expected = [ 'a', @@ -239,7 +314,7 @@ expect( @extractor.lookup_include_paths_list( filepath ) ).to eq expected end - # collect_simple_context() + lookup_all_include_paths() + # collect_context() + lookup_all_include_paths() it "should extract extra header search paths for multiple files" do # First File filepath = "path/tests/testfile.c" @@ -252,7 +327,7 @@ input = StringIO.new( file_contents ) - @extractor.collect_simple_context( filepath, input, :build_directive_include_paths ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::BUILD_DIRECTIVE_INCLUDE_PATHS ) # Second File filepath = "anotherfile.c" @@ -266,7 +341,7 @@ input = StringIO.new( file_contents ) - @extractor.collect_simple_context( filepath, input, :build_directive_include_paths ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::BUILD_DIRECTIVE_INCLUDE_PATHS ) expected = [ 'this/path', @@ -278,7 +353,7 @@ expect( @extractor.lookup_all_include_paths() ).to eq expected end - # collect_simple_context() + lookup_test_cases() + # collect_context() + lookup_test_cases() it "should extract test case names with line numbers" do filepath = "path/tests/testfile.c" @@ -313,7 +388,7 @@ input = StringIO.new( file_contents ) - @extractor.collect_simple_context( filepath, input, :test_runner_details ) + @extractor.collect_context( filepath, input, TestContextExtractor::Context::TEST_RUNNER_DETAILS ) expected = [ {:line_number => 2, :test => 'test_this_function'}, diff --git a/spec/tool_executor_helper_spec.rb b/spec/units/tool_executor_helper_spec.rb similarity index 89% rename from spec/tool_executor_helper_spec.rb rename to spec/units/tool_executor_helper_spec.rb index 5f04d2f3e..d26ecf62f 100644 --- a/spec/tool_executor_helper_spec.rb +++ b/spec/units/tool_executor_helper_spec.rb @@ -106,11 +106,11 @@ @shell_result[:stderr] = '' @shell_result[:stdout] = '' - message = "\n" + - "> Shell executed command:\n" + + message = + "> Shell executed::\n" + "`gcc ab.c`\n" + - "> With $stdout: <empty>\n" + - "> With $stderr: <empty>\n" + + "> With $stdout:: <empty>\n" + + "> With $stderr:: <empty>\n" + "> And terminated with status: <status>\n\n\n" expect(@loginator).to receive(:log).with(message, Verbosity::DEBUG) @@ -122,11 +122,11 @@ @shell_result[:stderr] = "error output\n\n\n" @shell_result[:stdout] = '' - message = "\n" + - "> Shell executed command:\n" + + message = + "> Shell executed::\n" + "`test.exe`\n" + - "> With $stdout: <empty>\n" + - "> With $stderr: \nerror output\n" + + "> With $stdout:: <empty>\n" + + "> With $stderr:: \nerror output\n" + "> And terminated with status: <status>\n\n\n" expect(@loginator).to receive(:log).with(message, Verbosity::DEBUG) @@ -138,11 +138,11 @@ @shell_result[:stderr] = '' @shell_result[:stdout] = "output\n\n\n" - message = "\n" + - "> Shell executed command:\n" + + message = + "> Shell executed::\n" + "`utility --flag`\n" + - "> With $stdout: \noutput\n" + - "> With $stderr: <empty>\n" + + "> With $stdout:: \noutput\n" + + "> With $stderr:: <empty>\n" + "> And terminated with status: <status>\n\n\n" expect(@loginator).to receive(:log).with(message, Verbosity::DEBUG) @@ -162,8 +162,8 @@ @shell_result[:output] = '' @shell_result[:exit_code] = nil - message = "\n" + - "> Shell executed command:\n" + + message = + "> Shell executed::\n" + "`gcc ab.c`\n" + "> And exited prematurely\n\n\n" @@ -176,10 +176,9 @@ @shell_result[:output] = 'some output' @shell_result[:exit_code] = 0 - message = "\n" + - "> Shell executed command:\n" + + message = + "> Shell executed::\n" + "`test.exe --a_flag`\n" + - "> Produced output: \nsome output\n" + "> And terminated with exit code: [0]\n\n\n" expect(@loginator).to receive(:log).with(message, Verbosity::OBNOXIOUS) @@ -191,10 +190,9 @@ @shell_result[:output] = 'some more output' @shell_result[:exit_code] = 37 - message = "\n" + - "> Shell executed command:\n" + + message = + "> Shell executed::\n" + "`utility.out args`\n" + - "> Produced output: \nsome more output\n" + "> And terminated with exit code: [37]\n\n\n" expect(@loginator).to receive(:log).with(message, Verbosity::OBNOXIOUS) diff --git a/spec/uncategorized_specs_spec.rb b/spec/units/uncategorized_specs_spec.rb similarity index 100% rename from spec/uncategorized_specs_spec.rb rename to spec/units/uncategorized_specs_spec.rb