This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new c59fc14fac TODO-135 - HOCON peekNoSkip/skipWhitespaceAndComments
invariant tightening
c59fc14fac is described below
commit c59fc14fac5e52e75bad268792c71b820476c35e
Author: James Bognar <[email protected]>
AuthorDate: Fri May 29 06:46:51 2026 -0400
TODO-135 - HOCON peekNoSkip/skipWhitespaceAndComments invariant tightening
---
.../apache/juneau/hocon/HoconParserSession.java | 27 +++++-----
.../org/apache/juneau/hocon/HoconTokenizer.java | 12 +++++
.../org/apache/juneau/hocon/HoconParser_Test.java | 63 ++++++++++++++++++++++
.../apache/juneau/hocon/HoconTokenizer_Test.java | 28 ++++++++++
juneau-utest/test-run-history.tsv | 1 +
5 files changed, 116 insertions(+), 15 deletions(-)
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconParserSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconParserSession.java
index 9d8eaff24d..bb91419746 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconParserSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconParserSession.java
@@ -352,11 +352,10 @@ public class HoconParserSession extends
ReaderParserSession {
} else {
throw new ParseException(this, "Expected =, :
or brace at line {0}", t.getLine());
}
- // Mirror the parseArray guard: peek-before-skip so
that a `}` cached as `peeked` by
- // parseValueOrConcat's concat loop doesn't cause
skipWhitespaceAndComments below to
- // chew through a newline that lives OUTSIDE this
object's scope.
- if (t.peek().type() == HoconTokenizer.TokenType.RBRACE
|| t.peek().type() == HoconTokenizer.TokenType.EOF)
- break;
+ // skipWhitespaceAndComments is a no-op while a token
is cached (see HoconTokenizer), so a
+ // closing `}` already peeked by parseValueOrConcat's
concat loop is preserved here and the
+ // loop terminates naturally via the while-condition
above rather than chewing through a
+ // newline that belongs to an enclosing scope.
t.skipWhitespaceAndComments();
if (t.peek().type() == HoconTokenizer.TokenType.COMMA
|| t.peek().type() == HoconTokenizer.TokenType.NEWLINE)
t.read();
@@ -378,16 +377,14 @@ public class HoconParserSession extends
ReaderParserSession {
// that point. Here we must add the element as-is so
nested arrays like
// `[[1,2,3], [4,5,6]]` (with separators) stay nested
rather than flattening.
arr.getElements().add(parseValueOrConcat(t));
- // Check for closing ] / EOF BEFORE calling
skipWhitespaceAndComments. parseValueOrConcat's
- // internal concat loop calls peekNoSkip(), which
eagerly consumes the closing-bracket char
- // from the underlying reader and stashes it as
peeked=RBRACKET. If we then called
- // skipWhitespaceAndComments here, it would read PAST
the cached `]` and eat any newline
- // that follows — but that newline is the in-array
separator for the NEXT element (when
- // we're nested inside an outer parseArray), or a
meaningful boundary for outer scopes.
- // Eating it would cause adjacent newline-separated
inner arrays like `[[1,2]\n[3,4]]` to
- // be silently re-merged via HOCON array-concatenation
in parseValueOrConcat above us.
- if (t.peek().type() ==
HoconTokenizer.TokenType.RBRACKET || t.peek().type() ==
HoconTokenizer.TokenType.EOF)
- break;
+ // parseValueOrConcat's internal concat loop calls
peekNoSkip(), which eagerly consumes the
+ // closing-bracket char from the underlying reader and
stashes it as peeked=RBRACKET.
+ // skipWhitespaceAndComments is a no-op while a token
is cached (see HoconTokenizer), so it
+ // will NOT read past the cached `]` and eat a
following newline — a newline that is either
+ // the in-array separator for the NEXT element of an
enclosing array or a meaningful boundary
+ // for an outer scope. This prevents adjacent
newline-separated inner arrays like
+ // `[[1,2]\n[3,4]]` from being silently re-merged via
HOCON array-concatenation above us.
+ // The loop terminates on the cached `]` via the
while-condition above.
t.skipWhitespaceAndComments();
if (t.peek().type() == HoconTokenizer.TokenType.COMMA
|| t.peek().type() == HoconTokenizer.TokenType.NEWLINE)
t.read();
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconTokenizer.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconTokenizer.java
index 75d1f0964b..7026cf9359 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconTokenizer.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconTokenizer.java
@@ -255,9 +255,21 @@ public class HoconTokenizer {
/**
* Skips whitespace and comments.
*
+ * <p>
+ * No-op when a token has already been peeked (i.e. {@link #peek()} or
{@link #peekNoSkip()}
+ * cached a token). Once a token is cached, the underlying reader is
positioned <em>after</em>
+ * that token, so skipping whitespace here would consume characters
that semantically come after
+ * the cached token rather than before it. The leading whitespace
before the cached token was
+ * already consumed when the token was produced, so this method has
nothing left to do until the
+ * cached token is read. This invariant keeps callers from accidentally
reading past a peeked
+ * structural token (such as a closing {@code ]} or {@code }}) and
swallowing a separator that
+ * belongs to an enclosing scope.
+ *
* @throws IOException If a read error occurs.
*/
public void skipWhitespaceAndComments() throws IOException {
+ if (peeked != null)
+ return;
while (true) {
var c = readChar();
if (c < 0)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconParser_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconParser_Test.java
index 4d17e9c35e..c94c152263 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconParser_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconParser_Test.java
@@ -259,4 +259,67 @@ class HoconParser_Test {
assertEquals(1, ((Number) obj.get("a")).intValue());
assertEquals(2, ((Number) obj.get("b")).intValue());
}
+
+ // Regression for the tokenizer peek/skip invariant (work item 135 /
FINISHED-57 OQ #13).
+ // Newline-separated inner arrays inside an outer array must stay
NESTED — the newline is an
+ // element separator, NOT a value-concatenation join. If
skipWhitespaceAndComments wrongly read
+ // past the closing ']' cached by parseValueOrConcat's peekNoSkip and
ate the following newline,
+ // the two inner arrays would silently flatten via HOCON
array-concatenation.
+ @Test
+ void b27_nestedArraysNewlineSeparatedStayNested() throws Exception {
+ var hocon = "outer = [\n [1, 2]\n [3, 4]\n]";
+ var m = (Map<String, Object>) HoconParser.DEFAULT.parse(hocon,
Map.class, String.class, Object.class);
+ var outer = (List<?>) m.get("outer");
+ assertNotNull(outer);
+ assertEquals(2, outer.size());
+ var first = (List<?>) outer.get(0);
+ var second = (List<?>) outer.get(1);
+ assertEquals(2, first.size());
+ assertEquals(2, second.size());
+ assertEquals(1, ((Number) first.get(0)).intValue());
+ assertEquals(2, ((Number) first.get(1)).intValue());
+ assertEquals(3, ((Number) second.get(0)).intValue());
+ assertEquals(4, ((Number) second.get(1)).intValue());
+ }
+
+ // Companion to b25: space-separated inner arrays on the SAME line
still concatenate per the
+ // HOCON array-concatenation rule. Confirms the structural fix did not
break that feature.
+ @Test
+ void b28_sameLineInnerArraysStillConcatenate() throws Exception {
+ var hocon = "outer = [\n [1, 2] [3, 4]\n]";
+ var m = (Map<String, Object>) HoconParser.DEFAULT.parse(hocon,
Map.class, String.class, Object.class);
+ var outer = (List<?>) m.get("outer");
+ assertNotNull(outer);
+ assertEquals(1, outer.size());
+ var inner = (List<?>) outer.get(0);
+ assertEquals(4, inner.size());
+ assertEquals(1, ((Number) inner.get(0)).intValue());
+ assertEquals(4, ((Number) inner.get(3)).intValue());
+ }
+
+ // Regression for the parseObject side of the invariant: when a nested
object's closing '}' is
+ // cached as a peeked token, the newline that separates the next
sibling key must survive so the
+ // sibling is parsed at the correct scope rather than being swallowed.
+ @Test
+ void b29_nestedObjectClosePreservesSiblingNewline() throws Exception {
+ var hocon = "a {\n b = 1\n}\nc = 2";
+ var m = (Map<String, Object>) HoconParser.DEFAULT.parse(hocon,
Map.class, String.class, Object.class);
+ var a = (Map<String, Object>) m.get("a");
+ assertNotNull(a);
+ assertEquals(1, ((Number) a.get("b")).intValue());
+ assertEquals(2, ((Number) m.get("c")).intValue());
+ }
+
+ // Newline-separated objects inside an array must remain distinct
elements rather than merging
+ // via HOCON object-concatenation (which only applies to same-line
adjacency).
+ @Test
+ void b30_arrayOfObjectsNewlineSeparatedStayDistinct() throws Exception {
+ var hocon = "items = [\n { id = 1 }\n { id = 2 }\n]";
+ var m = (Map<String, Object>) HoconParser.DEFAULT.parse(hocon,
Map.class, String.class, Object.class);
+ var items = (List<?>) m.get("items");
+ assertNotNull(items);
+ assertEquals(2, items.size());
+ assertEquals(1, ((Number) ((Map<String, Object>)
items.get(0)).get("id")).intValue());
+ assertEquals(2, ((Number) ((Map<String, Object>)
items.get(1)).get("id")).intValue());
+ }
}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconTokenizer_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconTokenizer_Test.java
index 9db659da43..f7cbd86320 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconTokenizer_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconTokenizer_Test.java
@@ -178,4 +178,32 @@ class HoconTokenizer_Test extends TestBase {
assertEquals(PLUS_EQUALS, t.read().type());
assertEquals(EOF, t.read().type());
}
+
+ // Structural invariant (work item 135): skipWhitespaceAndComments is a
no-op while a token is
+ // cached. peekNoSkip() reads a structural token and leaves the
underlying reader positioned
+ // AFTER it. A subsequent skipWhitespaceAndComments() must NOT advance
past the cached token and
+ // swallow the whitespace/newline that follows it — that whitespace
belongs to the enclosing
+ // scope (e.g. an in-array element separator).
+ @Test
+ void g15_skipIsNoopWhileTokenCached() throws Exception {
+ var t = tokenizer("]\nx");
+ // peekNoSkip caches RBRACKET; the reader is now positioned at
the '\n' that follows ']'.
+ assertEquals(RBRACKET, t.peekNoSkip().type());
+ // Must be a no-op: the trailing '\n' must survive.
+ t.skipWhitespaceAndComments();
+ // The cached RBRACKET is still returned first...
+ assertEquals(RBRACKET, t.read().type());
+ // ...and the '\n' that followed ']' was preserved, not
swallowed.
+ assertEquals(NEWLINE, t.peekNoSkip().type());
+ }
+
+ // Companion to g15: the no-op guard must not regress the normal
(nothing-cached) skip behavior.
+ @Test
+ void g16_skipStillWorksWhenNothingCached() throws Exception {
+ var t = tokenizer(" # comment\n z");
+ t.skipWhitespaceAndComments();
+ var tok = t.read();
+ assertEquals(UNQUOTED_STRING, tok.type());
+ assertEquals("z", tok.stringValue());
+ }
}
diff --git a/juneau-utest/test-run-history.tsv
b/juneau-utest/test-run-history.tsv
index e1cfd1da41..4818b939d6 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -51,3 +51,4 @@ timestamp git_sha branch tests_run failures
errors skipped surefire_sec wall_sec
2026-05-28T20:22:45Z 5e454b3a7988 master 125891 0 0 21
141
2026-05-28T20:45:00Z 007293082747 master 125891 0 0 21
146
2026-05-28T21:29:51Z b0dc55154f95 master 125891 0 0 21
143
+2026-05-29T10:45:54Z 3dc947b24d41 master 125897 0 0 21
151