Skip to content

Commit 0498190

Browse files
mkarleskyclaude
andcommitted
Add .. (parent-directory) support to relative path resolution
#include directives, TEST_SOURCE_FILE(), and CLI `test:` task names got proper relative-path handling and duplicate-filename disambiguation in a recent refactor, but none of it accounted for `..`. A `..`-containing query was silently treated as an ordinary path segment that could never match any real project file, degrading to a plain not-found rather than naming `..` as the actual problem. PathMatcher gains `resolve_relative(query, anchor:)`, collapsing a query's own `..` against an anchor directory (or raising a clear, specific error when no anchor exists, or when traversal would go outside the project). Six call sites needed it, not the three originally expected -- three distinct #include extraction pipelines (GCC's own directives-only output, the text-scan fallback, and TestContextExtractor's always-on scan) each independently construct Include objects from literal, unresolved directive text and needed their own anchoring. That repeated reconstruction logic is now consolidated into one `Include#anchored(anchor)` on the base class. CLI test: task names have no file of their own to anchor a `..` against, so one is deliberately left unsupported there, with a named error instead of a generic not-found. Also folds `Includes.paths_correspond?`'s own segment-tail-matching into `PathMatcher.correspond?`, removing a second, independent reimplementation of the same comparison PathMatcher already provided. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5316666 commit 0498190

19 files changed

Lines changed: 451 additions & 13 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
int extra_value(void)
2+
{
3+
return 222;
4+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include "helper.h"
2+
3+
int helper_value(void)
4+
{
5+
return 111;
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#ifndef HELPER_H
2+
#define HELPER_H
3+
4+
int helper_value(void);
5+
6+
#endif // HELPER_H
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#include "unity.h"
2+
#include "../common/helper.h"
3+
4+
void setUp(void) {}
5+
void tearDown(void) {}
6+
7+
void test_helper_value_via_parent_directory_include(void)
8+
{
9+
TEST_ASSERT_EQUAL(111, helper_value());
10+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#include "unity.h"
2+
3+
// extra.c has no corresponding header; this test file lives one directory
4+
// away from it, so resolving this directive exercises TEST_SOURCE_FILE()'s
5+
// own anchor-relative .. resolution rather than the #include machinery.
6+
TEST_SOURCE_FILE("../alpha/extra.c")
7+
8+
extern int extra_value(void);
9+
10+
void setUp(void) {}
11+
void tearDown(void) {}
12+
13+
void test_extra_value_via_parent_directory_test_source_file(void)
14+
{
15+
TEST_ASSERT_EQUAL(222, extra_value());
16+
}

lib/ceedling/file_finder.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ def resolve_mock(mock, collection: nil)
6565
# ambiguous, not have Ceedling silently guess which test they meant -- there's no
6666
# compilation step downstream to catch a wrong guess the way there is for a header
6767
# or source file resolved during a build.
68+
#
69+
# A task name has no file of its own to anchor a `..` against, unlike an #include or
70+
# TEST_SOURCE_FILE() entry -- resolving with no anchor surfaces that as a specific,
71+
# named error rather than falling through to a generic file-not-found further down.
6872
def find_test_file_from_name(name)
73+
name = PathMatcher.resolve_relative(name)
6974
return find_first_candidate(name, @configurator.extension_source, @configurator.collection_all_tests, :error, strict: true)
7075
end
7176

lib/ceedling/includes/includes.rb

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
require 'set'
99
require 'ceedling/exceptions'
10+
require 'ceedling/path_matcher'
1011

1112
class Includes
1213
# Class method to convert mixed list of Include objects into an order-preserving list of hashes
@@ -209,9 +210,12 @@ def self.sanitize!(includes, &block)
209210
# includes are never resolved against a real file under `-nostdinc`, so its bare text
210211
# is always exactly what was written.
211212
#
212-
# `test_filepath` plays no part in matching -- it's carried only so a caller's own
213-
# `on_ambiguous` block can name the one test file whose #include statement is worth a
214-
# closer look, since nothing further up the call stack adds that context on its own.
213+
# `test_filepath` is the anchor a bare entry's own `..` resolves against -- a bare
214+
# scan sees a directory-relative #include exactly as written, `..` and all, so it's
215+
# resolved here against the one file any such directive could possibly belong to:
216+
# the test file itself. It's also carried so a caller's own `on_ambiguous` block can
217+
# name the one test file whose #include statement is worth a closer look, since
218+
# nothing further up the call stack adds that context on its own.
215219
def self.reconcile(bare:, user:, system:, test_filepath: nil, &on_ambiguous)
216220
# Validate input types
217221

@@ -249,13 +253,20 @@ def self.reconcile(bare:, user:, system:, test_filepath: nil, &on_ambiguous)
249253
seen = Set.new
250254

251255
bare.each do |bare_include|
256+
# A directory-relative #include's own literal text is anchored to the one file
257+
# a bare scan could have found it in -- the test file itself, never a header,
258+
# since bare extraction never recurses into one. Resolving here, once, means
259+
# every candidate below is compared against an ordinary, ..-free path exactly
260+
# as if it had been written that way to begin with.
261+
bare_filepath = PathMatcher.resolve_relative(bare_include.filepath, anchor: test_filepath && File.dirname(test_filepath))
262+
252263
# Matching is deliberately bidirectional: a bare entry can carry either more path
253264
# than its candidate (literal, unresolved #include text against a bare-scanned
254265
# candidate from fallback preprocessing) or less (a pathless bare entry against a
255266
# candidate directives-only preprocessing resolved to a fuller real location) --
256267
# either side may be the more specific one, so whichever is shorter sets how many
257268
# of the longer one's trailing segments must match.
258-
matched = user_filepaths.select { |filepath| paths_correspond?(bare_include.filepath, filepath) }
269+
matched = user_filepaths.select { |filepath| paths_correspond?(bare_filepath, filepath) }
259270

260271
next if matched.empty?
261272

@@ -280,11 +291,10 @@ def self.reconcile(bare:, user:, system:, test_filepath: nil, &on_ambiguous)
280291
# Two filepaths correspond if the shorter one's path segments equal the longer one's
281292
# own trailing segments, exactly, in order -- checked without regard for which side is
282293
# shorter, since either a bare entry or its candidate may be the one carrying less path.
294+
# Delegates to PathMatcher's own version of this same comparison rather than
295+
# reimplementing segment-splitting a second time.
283296
def self.paths_correspond?(a, b)
284-
segments_a = a.split(/[\\\/]/).reject(&:empty?)
285-
segments_b = b.split(/[\\\/]/).reject(&:empty?)
286-
shorter, longer = segments_a.length <= segments_b.length ? [segments_a, segments_b] : [segments_b, segments_a]
287-
return longer.last(shorter.length) == shorter
297+
PathMatcher.correspond?(a, b)
288298
end
289299
private_class_method :paths_correspond?
290300

@@ -360,6 +370,18 @@ def initialize(statement, include_path: nil)
360370
@include_path = clean(include_path) if include_path
361371
end
362372

373+
# Resolves any `..` in this include's own filepath against `anchor` (a directory
374+
# path in the same representation `filepath` itself uses), returning a new instance
375+
# of this same class if resolution actually changed anything, or this instance
376+
# unchanged otherwise. A raw text scan -- unlike a real compiler, which resolves a
377+
# directory-relative #include on its own while opening it -- has no way to know
378+
# which real file its own literal directive text reaches until this runs.
379+
def anchored(anchor)
380+
resolved = PathMatcher.resolve_relative( @filepath, anchor: anchor )
381+
return self if resolved == @filepath
382+
return self.class.new( resolved, include_path: @include_path )
383+
end
384+
363385
# Method specialized by subclasses
364386
def to_s()
365387
# Simple string representation of class contents with no additional formatting or #include decoration

lib/ceedling/path_matcher.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,66 @@ def self.candidates(query, collection)
6060
collection.select { |candidate| tail_matches?(query_segments, segments(candidate)) }
6161
end
6262

63+
# Collapses a `..` segment in `query` against `anchor`, a directory path in the
64+
# same representation as every other path this class handles (e.g. a test file's
65+
# own `File.dirname`) -- one anchor segment is popped per leading `..`, so the
66+
# result is an ordinary, `..`-free query ready for `.match`/`.resolve`/`.candidates`
67+
# exactly as if it had been written that way to begin with. A query with no `..` at
68+
# all is returned completely untouched, so this is a no-op for the overwhelming
69+
# majority of callers.
70+
#
71+
# `anchor: nil` means no file context exists to resolve against (e.g. a bare CLI
72+
# task name, which names no file of its own) -- a `..` there can't mean anything,
73+
# so it raises rather than silently mismatching or falling through to a confusing
74+
# "not found" further down. `anchor: ''` is different: it means the query is
75+
# already a complete, self-contained path that merely needs its own internal `..`
76+
# collapsed, not prefixed onto anything else -- the shape GCC itself produces for a
77+
# directory-relative quoted include, which is a full project-root-relative path
78+
# left uncanonicalized.
79+
#
80+
# An absolute query (including a Windows drive letter) is returned unchanged --
81+
# `candidates()`'s own `File.expand_path` comparison above already resolves `..`
82+
# correctly for those, so reprocessing one here as anchor-relative would be wrong,
83+
# not merely redundant.
84+
def self.resolve_relative(query, anchor: nil)
85+
return query if absolute?(query)
86+
87+
query_segments = segments(query)
88+
return query unless query_segments.include?('..')
89+
90+
if anchor.nil?
91+
raise CeedlingException.new(
92+
"Relative path reference '..' in '#{query}' has no file context to resolve against here."
93+
)
94+
end
95+
96+
resolved = segments(anchor)
97+
98+
query_segments.each do |segment|
99+
if segment == '..'
100+
if resolved.empty?
101+
raise CeedlingException.new("Relative path reference '..' in '#{query}' goes outside the project.")
102+
end
103+
resolved.pop
104+
else
105+
resolved << segment
106+
end
107+
end
108+
109+
resolved.join('/')
110+
end
111+
112+
# Two filepaths correspond if the shorter one's path segments equal the longer
113+
# one's own trailing segments, exactly, in order -- checked without regard for
114+
# which side is shorter, since a query and a candidate can each be the more
115+
# specific one depending on how each was discovered.
116+
def self.correspond?(a, b)
117+
segments_a = segments(a)
118+
segments_b = segments(b)
119+
shorter, longer = segments_a.length <= segments_b.length ? [segments_a, segments_b] : [segments_b, segments_a]
120+
tail_matches?(shorter, longer)
121+
end
122+
63123
### Private ###
64124

65125
def self.absolute?(path)

lib/ceedling/preprocess/preprocessinator_includes_handler.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# =========================================================================
77

88
require 'ceedling/includes/includes'
9+
require 'ceedling/path_matcher'
910
require 'ceedling/preprocess/preprocessinator_bare_includes_extractor'
1011
require 'ceedling/preprocess/preprocessinator_line_marker_includes_extractor'
1112
require 'ceedling/preprocess/c_preprocessor_conditionals'
@@ -192,7 +193,7 @@ def extract_user_includes_from_text(name:, filepath:, defines: [])
192193
cond_tracker.process_directive( line )
193194
next unless cond_tracker.active?
194195
_include = @include_factory.user_include_from_directive( line )
195-
includes << _include if !_include.nil?
196+
includes << _include.anchored( File.dirname( filepath ) ) if !_include.nil?
196197
end
197198
end
198199

@@ -243,7 +244,7 @@ def extract_system_includes_from_text(name:, filepath:, defines: [])
243244
cond_tracker.process_directive( line )
244245
next unless cond_tracker.active?
245246
_include = @include_factory.system_include_from_directive( line )
246-
includes << _include if !_include.nil?
247+
includes << _include.anchored( File.dirname( filepath ) ) if !_include.nil?
247248
end
248249
end
249250

lib/ceedling/preprocess/preprocessinator_line_marker_includes_extractor.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# =========================================================================
77

88
require 'ceedling/exceptions'
9+
require 'ceedling/path_matcher'
910
require 'set'
1011

1112
##
@@ -164,6 +165,13 @@ def extract_includes(io:, filepath:, type:, max_depth:, test: nil)
164165
# Skip special markers like "<built-in>" and "<command-line>"
165166
next if filepath.start_with?('<')
166167

168+
# GCC forms a directory-relative quoted include's own line marker by
169+
# concatenating the including file's directory onto the literal include
170+
# text -- real and complete, but left uncanonicalized. Collapsing any ..
171+
# here, once, means this path can correspond to the project's own real,
172+
# ..-free file list the same way any other candidate already does.
173+
filepath = PathMatcher.resolve_relative( filepath, anchor: '' )
174+
167175
# Integer line number
168176
line_number = match[1].to_i
169177

0 commit comments

Comments
 (0)