From a19d793a82ddb7464f51cbe13506f0f81cfa0a53 Mon Sep 17 00:00:00 2001 From: roeglinj Date: Fri, 24 Apr 2026 12:07:13 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=B1=20Add=20test-specific=20mock=20inc?= =?UTF-8?q?lude=20directives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global CMock include options apply to all generated mocks, which can introduce unwanted dependencies or conflicts in unrelated tests. Add test-file build directives for injecting headers into a specific generated mock for the current test executable only. This allows tests to provide mock-specific types, macros, or configuration headers without changing production headers or global CMock configuration. The generic TEST_MOCK_INCLUDE directive maps to the existing CMock pre-original-header include location. Location-specific variants are also supported for finer control. Add parser coverage for the new directives and document their usage. --- docs/CeedlingPacket.md | 87 ++++++++++++++ lib/ceedling/constants.rb | 5 + lib/ceedling/generator.rb | 38 +++++- lib/ceedling/generator_mocks.rb | 23 +++- lib/ceedling/test_context_extractor.rb | 153 +++++++++++++++++++++++++ lib/ceedling/test_invoker.rb | 2 +- spec/test_context_extractor_spec.rb | 19 +++ 7 files changed, 324 insertions(+), 3 deletions(-) diff --git a/docs/CeedlingPacket.md b/docs/CeedlingPacket.md index 65c97b83..217b90f2 100644 --- a/docs/CeedlingPacket.md +++ b/docs/CeedlingPacket.md @@ -1393,6 +1393,93 @@ _**Notes:**_ order of any Mixins. Paths specified with Mixins will be added to path lists in your project configuration in the order of merging. +## Test-specific mock include directives + +Ceedling supports build directive macros that can be placed in test files to provide additional build context. These macros do not affect the compiled test code directly, but Ceedling scans them and uses them while preparing the test executable. + +In addition to `TEST_INCLUDE_PATH(...)` and `TEST_SOURCE_FILE(...)`, test files can request additional headers to be injected into generated mocks for that specific test executable. + +### `TEST_MOCK_INCLUDE(...)` + +```c +TEST_MOCK_INCLUDE("mock_driver.h", "test_driver_types.h") +``` + +This injects `test_driver_types.h` into the generated mock for `mock_driver.h` for the current test executable only. + +This is useful when a mock needs additional test-specific types, macros, or configuration headers, but those headers should not be added globally to every generated mock. + +Example: + +```c +#include "unity.h" +#include "mock_driver.h" + +TEST_MOCK_INCLUDE("mock_driver.h", "test_driver_types.h") + +void test_driver_initializes(void) +{ + driver_init_Expect(); + system_init(); +} +``` + +The directive above affects only the generated mock for `mock_driver.h` in this test executable. + +### Location-specific variants + +For finer control, the include location can be selected explicitly: + +```c +TEST_MOCK_INCLUDE_H_PRE_ORIG_HEADER("mock_driver.h", "test_driver_types.h") +TEST_MOCK_INCLUDE_H_POST_ORIG_HEADER("mock_driver.h", "test_driver_types.h") +TEST_MOCK_INCLUDE_C_PRE_HEADER("mock_driver.h", "test_driver_config.h") +TEST_MOCK_INCLUDE_C_POST_HEADER("mock_driver.h", "test_driver_config.h") +``` + +The generic form: + +```c +TEST_MOCK_INCLUDE("mock_driver.h", "test_driver_types.h") +``` + +is equivalent to: + +```c +TEST_MOCK_INCLUDE_H_PRE_ORIG_HEADER("mock_driver.h", "test_driver_types.h") +``` + +### Difference from global CMock include options + +CMock also supports global include configuration options such as: + +```yaml +:cmock: + :includes_h_pre_orig_header: + - some_global_header.h + :includes_h_post_orig_header: [] + :includes_c_pre_header: [] + :includes_c_post_header: [] +``` + +Those options apply globally to generated mocks. + +`TEST_MOCK_INCLUDE(...)` is different: it is scoped to the test file and mock where it is declared. This prevents unrelated mocks from receiving unnecessary or conflicting includes. + +### Notes + +The mock argument should be written as a quoted mock header or mock name: + +```c +TEST_MOCK_INCLUDE("mock_driver.h", "test_driver_types.h") +TEST_MOCK_INCLUDE("mock_driver", "test_driver_types.h") +``` + +Both forms refer to the same generated mock. + +Header paths are interpreted the same way as normal include paths used by the test build. + + ## Search Paths for Release Builds Unlike test builds, release builds are relatively straightforward. Each diff --git a/lib/ceedling/constants.rb b/lib/ceedling/constants.rb index 9bbb40d3..696e917b 100644 --- a/lib/ceedling/constants.rb +++ b/lib/ceedling/constants.rb @@ -75,6 +75,11 @@ class PATTERNS TEST_STDOUT_STATISTICS = /\n-+\s*(\d+)\s+Tests\s+(\d+)\s+Failures\s+(\d+)\s+Ignored\s+(OK|FAIL)\s*/i TEST_SOURCE_FILE = /TEST_SOURCE_FILE\s*\(\s*\"\s*([^"]+)\s*\"\s*\)/ TEST_INCLUDE_PATH = /TEST_INCLUDE_PATH\s*\(\s*\"\s*([^"]+)\s*\"\s*\)/ + TEST_MOCK_INCLUDE = /TEST_MOCK_INCLUDE\s*\(\s*\"\s*([^"]+)\s*\"\s*,\s*\"\s*([^"]+)\s*\"\s*\)/ + TEST_MOCK_INCLUDE_H_PRE_ORIG_HEADER = /TEST_MOCK_INCLUDE_H_PRE_ORIG_HEADER\s*\(\s*\"\s*([^"]+)\s*\"\s*,\s*\"\s*([^"]+)\s*\"\s*\)/ + TEST_MOCK_INCLUDE_H_POST_ORIG_HEADER = /TEST_MOCK_INCLUDE_H_POST_ORIG_HEADER\s*\(\s*\"\s*([^"]+)\s*\"\s*,\s*\"\s*([^"]+)\s*\"\s*\)/ + TEST_MOCK_INCLUDE_C_PRE_HEADER = /TEST_MOCK_INCLUDE_C_PRE_HEADER\s*\(\s*\"\s*([^"]+)\s*\"\s*,\s*\"\s*([^"]+)\s*\"\s*\)/ + TEST_MOCK_INCLUDE_C_POST_HEADER = /TEST_MOCK_INCLUDE_C_POST_HEADER\s*\(\s*\"\s*([^"]+)\s*\"\s*,\s*\"\s*([^"]+)\s*\"\s*\)/ end GIT_COMMIT_SHA_FILENAME = 'GIT_COMMIT_SHA' diff --git a/lib/ceedling/generator.rb b/lib/ceedling/generator.rb index ceb3d22a..3cb652b0 100644 --- a/lib/ceedling/generator.rb +++ b/lib/ceedling/generator.rb @@ -53,8 +53,26 @@ def generate_mock(context:, mock:, test:, input_filepath:, output_path:) # - Add option to CMock to generate mock to any destination path # - Make CMock thread-safe + #TODO: mock_key = normalize_mock_include_target( mock ) + # def normalize_mock_include_target(mock) + # mock + # .to_s + # .strip + # .sub(/^.*[\\\/]/, '') + # .sub(/#{Regexp.escape(@configurator.extension_header)}$/, '') + # end + + mock_include_config = @test_context_extractor.lookup_mock_includes_for_mock( test, mock ) + + if !mock_include_config_empty?(mock_include_config) + @loginator.log( + "Applying test-specific mock includes for #{mock_key} in #{test}\n", + Verbosity::DEBUG + ) + end + # Get default config created by Ceedling and customize it - config = @generator_mocks.build_configuration( output_path ) + config = @generator_mocks.build_configuration( output_path, mock_include_config ) # Generate mock msg = @reportinator.generate_module_progress( @@ -361,4 +379,22 @@ def generate_test_results(tool:, context:, test_name:, test_filepath:, executabl shell_result end + private + + def normalize_mock_include_target(mock) + mock + .to_s + .strip + .sub(/^.*[\\\/]/, '') + .sub(/#{Regexp.escape(@configurator.extension_header)}$/, '') + end + + def mock_include_config_empty?(mock_include_config) + return true if mock_include_config.nil? || mock_include_config.empty? + + mock_include_config.all? do |_key, headers| + headers.nil? || headers.empty? + end + end + end diff --git a/lib/ceedling/generator_mocks.rb b/lib/ceedling/generator_mocks.rb index 22f226b6..b2b502f6 100644 --- a/lib/ceedling/generator_mocks.rb +++ b/lib/ceedling/generator_mocks.rb @@ -15,10 +15,12 @@ def manufacture(config) return CMock.new(config) end - def build_configuration( output_path ) + def build_configuration( output_path, mock_include_config=nil ) config = @configurator.get_cmock_config config[:mock_path] = output_path + apply_mock_include_config( config, mock_include_config ) + # Verbosity management for logging messages verbosity = @configurator.project_verbosity @@ -37,5 +39,24 @@ def build_configuration( output_path ) return config end + + private + + def apply_mock_include_config(config, mock_include_config) + return if mock_include_config.nil? || mock_include_config.empty? + + [ + :includes_h_pre_orig_header, + :includes_h_post_orig_header, + :includes_c_pre_header, + :includes_c_post_header + ].each do |key| + next if mock_include_config[key].nil? || mock_include_config[key].empty? + + config[key] ||= [] + config[key] += mock_include_config[key] + config[key].uniq! + end + end end diff --git a/lib/ceedling/test_context_extractor.rb b/lib/ceedling/test_context_extractor.rb index 6101b565..af6e8d0e 100644 --- a/lib/ceedling/test_context_extractor.rb +++ b/lib/ceedling/test_context_extractor.rb @@ -22,6 +22,7 @@ def setup @source_extras = {} # C source files 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 + @mock_includes = {} # Additional mock includes added to a test build via TEST_MOCK_INCLUDE_*() @include_paths = {} # Additional search paths added to a test build via TEST_INCLUDE_PATH() # Arrays @@ -35,6 +36,7 @@ def collect_simple_context( filepath, input, *args ) all_options = [ :build_directive_include_paths, :build_directive_source_files, + :build_directive_mock_includes, :includes, :test_runner_details ] @@ -52,6 +54,7 @@ def collect_simple_context( filepath, input, *args ) include_paths = [] source_extras = [] includes = [] + mock_includes = {} @parsing_parcels.code_lines( input ) do |line| if args.include?( :build_directive_include_paths ) @@ -64,6 +67,14 @@ def collect_simple_context( filepath, input, *args ) source_extras += extract_build_directive_source_files( line ) end + if args.include?( :build_directive_mock_includes ) + # Scan for build directives: TEST_MOCK_INCLUDE_*() + mock_includes = merge_mock_includes( + mock_includes, + extract_build_directive_mock_includes( line ) + ) + end + if args.include?( :includes ) # Scan for contents of #include directives includes += _extract_includes( line ) @@ -72,6 +83,7 @@ def collect_simple_context( filepath, input, *args ) 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_build_directive_mock_includes( filepath, mock_includes ) if args.include?( :build_directive_mock_includes ) collect_includes( filepath, includes ) if args.include?( :includes ) # Different code processing pattern for test runner @@ -180,6 +192,27 @@ def lookup_raw_mock_list(filepath) return val end + # Mock-specific includes of test file specified with TEST_MOCK_INCLUDE_*() + def lookup_mock_includes(filepath) + val = nil + @lock.synchronize do + val = @mock_includes[form_file_key( filepath )] || {} + end + return val + end + + # Includes for one mock within one test file + def lookup_mock_includes_for_mock(filepath, mock) + val = nil + mock_key = normalize_mock_include_target( mock ) + + @lock.synchronize do + val = (@mock_includes[form_file_key( filepath )] || {})[mock_key] || empty_mock_include_config + end + + return val + end + def lookup_all_include_paths val = nil @lock.synchronize do @@ -280,6 +313,16 @@ def _collect_test_runner_details(filepath, test_content, input_content=nil) debug_log_list( "Test cases found ", filepath, test_cases ) end + def collect_build_directive_mock_includes(filepath, mock_includes) + ingest_build_directive_mock_includes( filepath, mock_includes ) + + debug_log_mock_includes( + "Mock includes found via TEST_MOCK_INCLUDE_*()", + filepath, + mock_includes + ) + end + def extract_build_directive_source_files(line) source_extras = [] @@ -304,6 +347,40 @@ def extract_build_directive_include_paths(line) return include_paths end + def extract_build_directive_mock_includes(line) + mock_includes = {} + + { + :includes_h_pre_orig_header => [ + PATTERNS::TEST_MOCK_INCLUDE, + PATTERNS::TEST_MOCK_INCLUDE_H_PRE_ORIG_HEADER + ], + :includes_h_post_orig_header => [ + PATTERNS::TEST_MOCK_INCLUDE_H_POST_ORIG_HEADER + ], + :includes_c_pre_header => [ + PATTERNS::TEST_MOCK_INCLUDE_C_PRE_HEADER + ], + :includes_c_post_header => [ + PATTERNS::TEST_MOCK_INCLUDE_C_POST_HEADER + ] + }.each do |include_location, patterns| + patterns.each do |pattern| + results = line.scan(pattern) + + results.each do |result| + mock = normalize_mock_include_target( result[0] ) + header = FilePathUtils.standardize( result[1] ) + + mock_includes[mock] ||= empty_mock_include_config + mock_includes[mock][include_location] << header + end + end + end + + return mock_includes + end + def _extract_includes(line) includes = [] @@ -353,6 +430,22 @@ def ingest_test_runner_details(filepath:, test_runner_generator:) end end + def ingest_build_directive_mock_includes(filepath, mock_includes) + return if mock_includes.empty? + + key = form_file_key( filepath ) + + mock_includes.each do |_mock, include_config| + include_config.each do |_include_location, headers| + headers.uniq! + end + end + + @lock.synchronize do + @mock_includes[key] = mock_includes + end + end + ## ## Utility methods ## @@ -361,6 +454,41 @@ def form_file_key( filepath ) return filepath.to_s.to_sym end + def empty_mock_include_config + { + :includes_h_pre_orig_header => [], + :includes_h_post_orig_header => [], + :includes_c_pre_header => [], + :includes_c_post_header => [] + } + end + + def merge_mock_includes(left, right) + merged = {} + + [left, right].each do |mock_includes| + mock_includes.each do |mock, include_config| + merged[mock] ||= empty_mock_include_config + + include_config.each do |include_location, headers| + merged[mock][include_location] ||= [] + merged[mock][include_location] += headers + merged[mock][include_location].uniq! + end + end + end + + return merged + end + + def normalize_mock_include_target(mock) + mock + .to_s + .strip + .sub(/^.*[\\\/]/, '') + .sub(/#{Regexp.escape(@configurator.extension_header)}$/, '') + end + def debug_log_list(message, filepath, list) msg = "#{message} in #{filepath}:" if list.empty? @@ -375,4 +503,29 @@ def debug_log_list(message, filepath, list) @loginator.log( "#{msg}\n\n", Verbosity::DEBUG ) end + def debug_log_mock_includes(message, filepath, mock_includes) + msg = "#{message} in #{filepath}:" + + if mock_includes.empty? + msg += " " + else + msg += "\n" + + mock_includes.each do |mock, include_config| + msg += " - #{mock}\n" + + include_config.each do |include_location, headers| + next if headers.empty? + + msg += " #{include_location}:\n" + headers.each do |header| + msg += " - #{header}\n" + end + end + end + end + + @loginator.log( "#{msg}\n\n", Verbosity::DEBUG ) + end + end diff --git a/lib/ceedling/test_invoker.rb b/lib/ceedling/test_invoker.rb index 75d01dfd..1d4b2b87 100644 --- a/lib/ceedling/test_invoker.rb +++ b/lib/ceedling/test_invoker.rb @@ -96,7 +96,7 @@ def setup_and_invoke(tests:, context:TEST_SYM, options:{}) # 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 ) + @context_extractor.collect_simple_context( filepath, input, :build_directive_include_paths, :build_directive_mock_includes ) end else msg = @reportinator.generate_progress( "Parsing #{File.basename(filepath)} for build directive macros, #includes, and test case names" ) diff --git a/spec/test_context_extractor_spec.rb b/spec/test_context_extractor_spec.rb index b8ceb411..c736aab8 100644 --- a/spec/test_context_extractor_spec.rb +++ b/spec/test_context_extractor_spec.rb @@ -97,6 +97,25 @@ end end + context "#lookup_mock_includes" do + it "should provide empty hash when no context extraction has occurred" do + expect( @extractor.lookup_mock_includes( "path" ) ).to eq({}) + end + end + + context "#lookup_mock_includes_for_mock" do + it "should provide empty mock include config when no context extraction has occurred" do + expected = { + :includes_h_pre_orig_header => [], + :includes_h_post_orig_header => [], + :includes_c_pre_header => [], + :includes_c_post_header => [] + } + + expect( @extractor.lookup_mock_includes_for_mock( "path", "mock_driver" ) ).to eq expected + end + end + context "#extract_includes" do it "should extract #include directives from code" do # Complex comments tested in `clean_code_line()` test case