In strict mode tabs in indentation are errors (§12), so no expansion + * is performed and the tab is left for {@link DecodeHelper#getDepth} to + * reject. A tab before a leading '#' has already kept the line out of the + * §5.1 comment pre-pass, so a tab-indented hash row is data, not a + * comment.
+ * + * @param line the stripped line to process + * @param options decode options (strict mode, indent size) + * @return the line with leading tabs expanded to indentSize spaces in + * non-strict mode, otherwise the unchanged line + */ + private static String expandLeadingTabs(final String line, final DecodeOptions options) { + if (options.strict()) { + return line; + } + int i = 0; + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + final String leading = line.substring(0, i); + if (leading.indexOf('\t') < 0) { + return line; + } + final String expanded = leading.replace("\t", " ".repeat(options.indent())); + return expanded + line.substring(i); + } + private static boolean isEmptyDocument(final String... lines) { for (final String line : lines) { if (!line.isBlank()) { diff --git a/src/test/java/dev/toonformat/jtoon/decoder/DecodeHelperTest.java b/src/test/java/dev/toonformat/jtoon/decoder/DecodeHelperTest.java index 05efc67..480dce3 100644 --- a/src/test/java/dev/toonformat/jtoon/decoder/DecodeHelperTest.java +++ b/src/test/java/dev/toonformat/jtoon/decoder/DecodeHelperTest.java @@ -710,7 +710,9 @@ void testLeadingSpacesStrictValidMultiple() throws Exception { @Test void testTabNonStrictStopsCounting() throws Exception { - // in non-strict mode, indentation stops at first non-space (including tab) + // Direct calls to computeLeadingSpaces stop at the first non-space. + // The §12 tab leniency is applied earlier, in the ValueDecoder + // pre-pass, where leading tabs are expanded to indentSize spaces. assertEquals(2, invokeCompute(" \t text", ctxNonStrict2)); } diff --git a/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderTest.java b/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderTest.java index 20a37db..a680ff5 100644 --- a/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderTest.java +++ b/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderTest.java @@ -117,6 +117,50 @@ void decode_discardsFullLineComments() { assertEquals("{name=Ada}", result.toString()); } + @Test + @DisplayName("throws on a tab-indented hash line in strict mode, which is not a comment") + void decode_throwsOnTabIndentedHashLineInStrictMode() { + // Given + // Spec §5.1: only U+0020 spaces may precede the '#', so a tab keeps the + // line out of the comment pre-pass; §12 then rejects the tab as + // indentation in strict mode. + final String input = "items[1]{tag}:\n\t#a"; + + // When + assertThrows(IllegalArgumentException.class, + () -> ValueDecoder.decode(input, DecodeOptions.DEFAULT)); + } + + @Test + @DisplayName("decodes a tab-indented hash row as data in non-strict mode") + void decode_tabIndentedHashRowInNonStrictMode() { + // Given + // Spec §12 non-strict: leading tabs are accepted as indentation and + // removed from the line's content before classification (§5.2). + final String input = "items[3]{id}:\n 1\n\t#x\n 2"; + + // When + final Object result = ValueDecoder.decode(input, DecodeOptions.withStrict(false)); + + // Then + assertEquals("{items=[{id=1}, {id=#x}, {id=2}]}", result.toString()); + } + + @Test + @DisplayName("expands a leading tab to one indentation level in non-strict mode") + void decode_expandsLeadingTabToOneIndentLevelInNonStrictMode() { + // Given + // Spec §12: depth computation for tabs is implementation-defined; JToon + // expands each leading tab to indentSize spaces (one level). + final String input = "outer:\n\tinner: 1"; + + // When + final Object result = ValueDecoder.decode(input, DecodeOptions.withStrict(false)); + + // Then + assertEquals("{outer={inner=1}}", result.toString()); + } + @Test @DisplayName("treats a hash not at line start as data") void decode_hashInsideLineIsData() { diff --git a/src/test/resources/conformance/decode/comments.json b/src/test/resources/conformance/decode/comments.json index ac6432d..e351082 100644 --- a/src/test/resources/conformance/decode/comments.json +++ b/src/test/resources/conformance/decode/comments.json @@ -139,6 +139,28 @@ "specSection": "5.1", "note": "Only spaces (U+0020) may precede the #; the tab makes this a regular line, and tabs in indentation error in strict mode (§12)" }, + { + "name": "decodes tab-indented hash row as data in non-strict mode", + "input": "items[3]{id}:\n 1\n\t#x\n 2", + "expected": { + "items": [ + { + "id": 1 + }, + { + "id": "#x" + }, + { + "id": 2 + } + ] + }, + "options": { + "strict": false + }, + "specSection": "5.1", + "note": "Comment removal precedes the §12 tab leniency, so the tab keeps the line out of the pre-pass" + }, { "name": "parses quoted hash-leading first cell as data, not comment", "input": "items[1]{tag}:\n \"#a\"",