Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion bin/objects.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@
#

file_wrapper:
compose:
- loginator
- verbosinator

yaml_wrapper:
compose:
- file_wrapper

ruby_expandinator:

Expand All @@ -29,7 +34,6 @@ rake_task_registry:
loginator:
compose:
- verbosinator
- file_wrapper
- system_wrapper

#
Expand Down
5 changes: 4 additions & 1 deletion docs/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ In Ceedling’s early history simplicity won out with the assumption that every
### Multiple file extensions per file type
Added support for multiple [file extensions](https://throwtheswitch.github.io/Ceedling/latest/configuration/reference/extension/) per type (e.g. `:extension` ↳ `:source` ⇒ `['.c', '.C']`) such as requested in [#947](https://github.com/ThrowTheSwitch/Ceedling/issues/947).

### Filepath limit checks
Platform filepath limits (especially Windows) can lead to mysterious build failures, especially in CI where deep project subdirectories can occur. To help track down funny business, filepaths are intercepted and their lengths logged if they are nearing or exceed the platform limit.

## ⚠️ Changed

### `#include` relative paths & duplicate filename disambiguation
Expand All @@ -39,7 +42,7 @@ Because of the support added for handling paths and distinguishing duplicated fi

---

# [1.1.4] — Prerelease
# [1.1.4] — 2026-08-13

## 💪 Fixed

Expand Down
4 changes: 2 additions & 2 deletions lib/ceedling/c_extractor/c_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class CExtractor
include CExtractorConstants
include CExtractorTypes

constructor :c_extractor_code_text, :c_extractor_functions, :c_extractor_declarations, :c_extractor_preprocessing, :c_extractor_definitions, :configurator, :loginator
constructor :c_extractor_code_text, :c_extractor_functions, :c_extractor_declarations, :c_extractor_preprocessing, :c_extractor_definitions, :configurator, :loginator, :file_wrapper

attr_writer :chunk_size, :max_buffer_length

Expand All @@ -45,7 +45,7 @@ def setup()
# CeedlingException: If file cannot be opened (permissions, doesn't exist, etc.)
def from_file(filepath)
begin
File.open(filepath, 'r') do |file|
@file_wrapper.open(filepath, 'r') do |file|
return extract_contents( file, filepath )
end
rescue => ex
Expand Down
5 changes: 4 additions & 1 deletion lib/ceedling/erb_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
require 'erb'

class ErbWrapper

constructor :file_wrapper

def generate_file(template, data, output_file)
File.open(output_file, "w") do |f|
@file_wrapper.open(output_file, "w") do |f|
f << ERB.new(template, trim_mode: "<>").result(binding)
end
end
Expand Down
9 changes: 6 additions & 3 deletions lib/ceedling/file_path_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,16 @@ def form_build_context_path(subdir, name: nil, context: nil)
parts << context.to_s if context
parts << subdir
parts << name if name
File.join( *parts )
path = File.join( *parts )
@file_wrapper.check_path_length( path, origin: 'FilePathUtils#form_build_context_path' )
return path
end

# Forms base/name[/subdir]
def form_named_path(base, name, subdir: nil)
return File.join( base, name, subdir ) if subdir
File.join( base, name )
path = subdir ? File.join( base, name, subdir ) : File.join( base, name )
@file_wrapper.check_path_length( path, origin: 'FilePathUtils#form_named_path' )
return path
end

# The mirrored-subdirectory portion of a test's own identity, excluding its own basename
Expand Down
63 changes: 62 additions & 1 deletion lib/ceedling/file_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,29 @@
require 'pathname'
require 'tmpdir'
require 'ceedling/constants'
require 'ceedling/system_wrapper'


class FileWrapper

constructor :loginator, :verbosinator

# Platform practical filepath length ceilings, used only as a diagnostic heuristic --
# neither Ruby nor the host OS exposes a portable, runtime-queryable API for this.
# POSIX pathconf(2) is the real mechanism (PATH_MAX can vary by filesystem/mount
# point, so it's a per-path query, not a system-wide sysconf() value), but Ruby's Etc
# module defines the PC_PATH_MAX/PC_NAME_MAX pathconf constants without exposing a
# pathconf method to use them. Windows has no Ruby-accessible equivalent at all
# without an FFI dependency. These are the stable, documented OS/libc defaults
# instead: Windows' legacy MAX_PATH, and macOS/Linux's PATH_MAX from their own libc
# headers (a project can opt into longer paths on some platforms, but that's not
# safely assumable here, so these stay conservative).
PATH_LENGTH_LIMITS = {
windows: 260,
macos: 1024,
linux: 4096,
}.freeze

def self.generate_include_guard(name)
# abc-XYZ.h --> _ABC_XYZ_H_
base = File.basename(name, '.*') # Remove any extension
Expand Down Expand Up @@ -72,6 +91,7 @@ def rm_rf(path, options={})
end

def cp(source, destination, options={})
check_path_length(destination, origin: 'FileWrapper#cp')
FileUtils.cp(source, destination, **options)
end

Expand All @@ -96,6 +116,9 @@ def newer?(filepathA, filepathB)
end

def open(filepath, flags)
# Only writing/creating/appending can run into a platform's filepath length ceiling --
# a read-mode open is against a path that (if it exists) already fit on disk.
check_path_length(filepath, origin: 'FileWrapper#open') if flags.to_s =~ /[wa+]/
File.open(filepath, flags) do |file|
yield(file)
end
Expand All @@ -120,12 +143,14 @@ def touch(filepath, options={})
end

def write_blank_file(filepath)
check_path_length(filepath, origin: 'FileWrapper#write_blank_file')
File.open(filepath, 'w') do |file|
file.write("// Ceedling intentionally blank file\n\n")
end
end

def write(filepath, contents, flags='w')
check_path_length(filepath, origin: 'FileWrapper#write')
File.open(filepath, flags) do |file|
file.write(contents)
end
Expand All @@ -140,14 +165,50 @@ def instantiate_file_list(files=[])
end

def mkdir(folder)
check_path_length(folder, origin: 'FileWrapper#mkdir')
return FileUtils.mkdir_p(folder)
end

# Creates a uniquely-named, empty directory nested inside `parent` and returns its path.
# `parent` must already exist. Collision-free even across concurrent callers targeting
# the same `parent` -- Dir.mktmpdir retries internally on name clash.
def mkdir_tmp(prefix, parent)
return Dir.mktmpdir(prefix, parent)
path = Dir.mktmpdir(prefix, parent)
check_path_length(path, origin: 'FileWrapper#mkdir_tmp')
return path
end

# Warn as a path's length approaches its platform's practical ceiling, and flag if it
# reaches or exceeds it. Logging only -- never raises or blocks the caller's own
# operation, which will surface its own real failure on its own if the OS actually
# rejects the path. The calling context (`origin:`) is only included at DEBUG
# verbosity -- otherwise the message stays plain, since the path itself is normally
# enough and repeated origin labels add noise at everyday verbosity levels.
def check_path_length(path, origin:)
limit = path_length_limit
length = File.expand_path(path).length

prefix = @verbosinator.should_output?(Verbosity::DEBUG) ? "#{origin} ⏩️ " : ''

if length >= limit
@loginator.log(
"#{prefix}Path length (#{length}) reaches or exceeds this platform's practical limit (#{limit}): #{path}",
Verbosity::ERRORS
)
elsif length >= (limit * 0.95)
@loginator.log(
"#{prefix}Path length (#{length}) is approaching this platform's practical limit (#{limit}): #{path}",
Verbosity::COMPLAIN
)
end
end

private

def path_length_limit
return PATH_LENGTH_LIMITS[:windows] if SystemWrapper.windows?
return PATH_LENGTH_LIMITS[:macos] if SystemWrapper.macos?
return PATH_LENGTH_LIMITS[:linux]
end

end
6 changes: 4 additions & 2 deletions lib/ceedling/loginator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Loginator
attr_reader :project_logging
attr_writer :decorators

constructor :verbosinator, :file_wrapper, :system_wrapper
constructor :verbosinator, :system_wrapper

def setup()
$loginator = self
Expand Down Expand Up @@ -419,7 +419,9 @@ def logfile(string, stream='')
# <IO:$stdout> May 1 22:20:40 2024 | Compiling TestUsartModel::unity.c...
# <IO:$stdout> May 1 22:20:40 2024 | Compiling TestUsartModel::cmock.c...

@file_wrapper.write( @log_filepath, output, 'a' )
# Raw File I/O, not FileWrapper#write -- FileWrapper's own path-length diagnostics log
# through this class, so depending on FileWrapper here would be circular.
File.open( @log_filepath, 'a' ) { |file| file.write( output ) }
end

end
Expand Down
13 changes: 12 additions & 1 deletion lib/ceedling/objects.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ application:
- system_wrapper

file_wrapper:
compose:
- loginator
- verbosinator

file_system_wrapper:

rake_wrapper:

yaml_wrapper:
compose:
- file_wrapper

ruby_expandinator:

Expand Down Expand Up @@ -113,7 +118,6 @@ configurator_builder:
loginator:
compose:
- verbosinator
- file_wrapper
- system_wrapper

setupinator:
Expand Down Expand Up @@ -265,12 +269,14 @@ dependinator:
preprocessinator_line_marker_includes_extractor:
compose:
- include_factory
- file_wrapper

c_comment_scanner:

preprocessinator_comment_stripper:
compose:
- c_comment_scanner
- file_wrapper

preprocessinator_reconstructor:
compose:
Expand Down Expand Up @@ -315,6 +321,8 @@ preprocessinator_file_assembler:
- reportinator

preprocessinator_code_finder:
compose:
- file_wrapper

c_extractor_code_text:

Expand Down Expand Up @@ -343,6 +351,7 @@ c_extractor:
- c_extractor_definitions
- configurator
- loginator
- file_wrapper

partializer_config:
compose:
Expand Down Expand Up @@ -507,3 +516,5 @@ dependency_tracker:
- dependency_differ

erb_wrapper:
compose:
- file_wrapper
6 changes: 4 additions & 2 deletions lib/ceedling/preprocess/preprocessinator_code_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

class PreprocessinatorCodeFinder

constructor :file_wrapper

LINE_MARKER_REGEX = /^#\s+(\d+)\s+"[^"]+"[^\n]*\n/ unless const_defined?(:LINE_MARKER_REGEX)

# Regex-special-character-immune string
Expand All @@ -18,7 +20,7 @@ class PreprocessinatorCodeFinder
# 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|
@file_wrapper.open( filepath, 'r' ) do |file|
return find_in_preprocessed_content( io: file, search: code )
end
end
Expand All @@ -36,7 +38,7 @@ def find_in_preprpocessed_string(content, 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|
@file_wrapper.open( filepath, 'r' ) do |file|
return find_in_c_code( io: file, search: code )
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/ceedling/preprocess/preprocessinator_comment_stripper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

class PreprocessinatorCommentStripper

constructor :c_comment_scanner
constructor :c_comment_scanner, :file_wrapper


# Strip all C comments from a file. The file is unchanged if no comments
Expand All @@ -25,7 +25,7 @@ def strip_file(filepath)
# Open in binary mode to avoid locale-dependent encoding failures.
# GCC preprocessor output may contain localized strings (e.g. <組み込み> under ja_JP locale).
# CCommentScanner already operates byte-accurately internally.
File.open(filepath, 'rb') do |buffer|
@file_wrapper.open(filepath, 'rb') do |buffer|
stripped = strip(buffer)
end
rescue => e
Expand All @@ -37,7 +37,7 @@ def strip_file(filepath)

begin
# Write in binary mode to match binary read — preserves original line endings exactly
File.write(filepath, stripped, mode: 'wb')
@file_wrapper.write(filepath, stripped, 'wb')
rescue => e
raise CeedlingException.new("Failed to rewrite '#{filepath}' after comment stripping ⏩️ #{e}")
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class PreprocessinatorLineMarkerIncludesExtractor
SYSTEM = :system unless const_defined?(:SYSTEM)
USER = :user unless const_defined?(:USER)

constructor :include_factory
constructor :include_factory, :file_wrapper

# Parse preprocessor output from a file (production use)
# @param filepath [String] Path to the preprocessor output file
Expand All @@ -107,7 +107,7 @@ def extract_includes_from_file(filepath, type, max_depth=nil, test: nil)
# their first byte is 0x3C — ASCII '<' — regardless of the surrounding encoding.
# NOTE: binary mode means \r\n line endings are NOT translated on Windows; the
# extract_includes method calls line.chomp! before regex matching to handle this.
File.open(filepath, 'rb') do |file|
@file_wrapper.open(filepath, 'rb') do |file|
includes = extract_includes(io: file, filepath: filepath, type: type, max_depth: max_depth, test: test)
end
rescue StandardError => e
Expand Down
12 changes: 12 additions & 0 deletions lib/ceedling/system_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ def self.windows?
@windows = (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|msys|ucrt/i) ? true : false
end

# Memoized: host_os is fixed for the process lifetime.
# Guard against nil? rather than ||= so false (non-macOS) is cached correctly.
def self.macos?
return @macos unless @macos.nil?
@macos = (RbConfig::CONFIG['host_os'] =~ /darwin/i) ? true : false
end

def self.time_stopwatch_s
# Wall clock time that can be adjusted for a variety of reasons and lead to
# unexpected negative durations -- only option on Windows.
Expand All @@ -36,6 +43,11 @@ def windows?
return SystemWrapper.windows?
end

# class method so as to be mockable for tests
def macos?
return SystemWrapper.macos?
end

def eval(string)
return eval(string)
end
Expand Down
Loading
Loading