Skip to content

fix: honour a chosen separator, allocate explicitly-zero sections - #25

Merged
wrfly merged 1 commit into
masterfrom
fix/splitchar-and-section-allocation
Aug 19, 2026
Merged

fix: honour a chosen separator, allocate explicitly-zero sections#25
wrfly merged 1 commit into
masterfrom
fix/splitchar-and-section-allocation

Conversation

@wrfly

@wrfly wrfly commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Three bugs found while auditing v0.3.0. Two of them are mine from #24, one predates it.

1. A separator the caller chose was silently replaced — regression from #24

e := ecp.New()
e.Advance.SplitChar = "\t"
// value "a b\tc d"
// v0.2.5: ["a b" "c d"]        correct
// v0.3.0: ["a" "b" "c" "d"]    wrong

#24 made the default separator collapse repeats ("a b" should be two elements, not three). It did that by routing the value through strings.Fields whenever the separator looked like whitespace — so a deliberate "\t", "\n" or " " was thrown away and the value was split on every whitespace run instead.

The fix keys off is it the default separator, not is it whitespace, and collapses by dropping empty elements rather than re-splitting:

sep := e.Advance.SplitChar
if sep == "" {
	sep = space // an empty separator would cut between every rune
}
parts := strings.Split(v, sep)
if sep != space {
	return parts // taken literally, empty elements and all
}
collapsed := parts[:0]
for _, p := range parts {
	if p != "" {
		collapsed = append(collapsed, p)
	}
}
return collapsed

Splitting first and filtering after also fixes a second-order bug: a tab inside an element is no longer treated as a separator when the default is in use.

Worth checking: parts[:0] reuses the backing array. The write index never overtakes the read index, so it is the standard in-place filter, but it is the kind of thing worth a second pair of eyes.

2. Sections were allocated on the wrong signal — incomplete feature from #24

Pointer sections were allocated when the filled-in value came out non-zero. That cannot tell "nothing was said about this section" from "every value in it is zero":

P_PORT=0   ->   P == nil     // explicitly asked for, silently dropped

It also left List and Get disagreeing: List advertised P_PORT and P_NAME, and Get then answered key not found for both.

The walk now carries a filled *bool that is marked at each site that actually assigns something — the custom SetValue hook, setPointer, parseSlice, setValue, and a nested section that allocated — and rangeOverPointer allocates on that instead of on IsZero.

Worth checking:

  • a default tag counts as an assignment, so a section whose only default is default:"0" is now allocated where it previously was not. That reads as correct to me — an explicit default is a statement of intent — but it is a deliberate behaviour change and the one judgement call in this PR.
  • an untouched section still stays nil, covered by the Empty case in TestPointerSectionExplicitZero.

3. Errors blamed the wrong part of the value — predates #24

Any non-numeric value containing an e was reported as a failure to parse the fragment after the e:

input before after
not-a-number strconv.Atoi: parsing "r" strconv.ParseInt: parsing "not-a-number"
hello strconv.Atoi: parsing "llo" strconv.ParseInt: parsing "hello"

A non-numeric exponent means the value is not scientific notation at all, so parseScientific hands it back unchanged and lets the caller report it whole. 1e, 1E, 1e1e1e and 1e-3 still error inside parseScientific, and errors.As(err, &numErr) still unwraps to *strconv.NumError.

Verification

Regression tests added for all three (TestCustomWhitespaceSplitChar, TestPointerSectionExplicitZero, TestNonNumericValueErrorMessage). The section test also asserts that every key List advertises is readable back through Get, which is the invariant that was broken.

Coverage 94.2% -> 95.2%. vet, golint and gofmt clean. Passes -race -shuffle=on -count=2.

Checked against a v0.2.5 build: the tab separator now matches it again, and the comma separator was never affected.

Audited beyond these three and found nothing else wrong: named types and *time.Duration, exponent bounds (1e18 ok, 1e19 out of range for int64 but fine for uint64, 1e21 rejected, int64 max exact), negative/+/comma numbers, mutually recursive types terminating in both Parse and List, deep nested sections, sections under a prefix, - tags on sections, errors propagating out of sections, 32 concurrent Parse calls under -race, and Get with a prefix on a read-only config.

gua re-checked against this branch: tests pass and its example prints byte-identical output across all eight scenarios.

Not in this PR

Embedded unexported struct types are skipped entirely, in every version including v0.1.4 — type Conf struct { inner; ... } never fills inner's exported fields. It is fixable (the promoted field reports CanSet() == true; it is our IsExported() check that skips it) but it needs a naming decision first: prefix the keys like exported embedded structs do today (INNER_HOST), or flatten them like encoding/json (HOST). Flattening would be a breaking change for exported embedding, so I left it alone.

🤖 Generated with Claude Code

Three bugs found while auditing v0.3.0.

A separator the caller chose was silently replaced. v0.3.0 collapsed
repeated separators by routing the value through strings.Fields whenever
the separator looked like whitespace, so SplitChar = "\t" split on spaces
too: "a b\tc d" became four elements instead of two. v0.2.5 got this
right. Only the default separator collapses repeats now, and it does so by
dropping empty elements rather than re-splitting, which also stops a tab
inside an element from being treated as a separator.

An optional section was allocated based on the result being non-zero,
which cannot tell "nothing was said about this section" from "every value
in it is zero". PORT=0 left the section nil, and List advertised keys that
Get could not read back. The walk now reports whether a field was actually
assigned, from the environment or from a default tag, and the section is
allocated on that.

A value that merely contains an "e" was blamed on the fragment after it:
PORT=not-a-number reported `strconv.Atoi: parsing "r"` and PORT=hello
reported `parsing "llo"`. A non-numeric exponent means the value is not
scientific notation at all, so it is handed back unchanged and reported
whole. This one predates v0.3.0.

Also documents that the scientific and thousand-separator notation is
deliberately not applied to slice elements, where "1,2" is far more likely
to be the wrong separator than the number 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wrfly
wrfly merged commit 3247af7 into master Aug 19, 2026
5 checks passed
@wrfly
wrfly deleted the fix/splitchar-and-section-allocation branch August 19, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant