diff --git a/CHANGELOG.md b/CHANGELOG.md index f5946bc..efd5888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # CHANGELOG +## 1.6.1x - 2026 June + LATER +- **Fixed:** Raw single-line strings now reject literal control characters, such as tabs, and report a clearer string diagnostic. + ## 1.6.1 - 2026 June - **Fixed:** Aligned parser behavior with the external `yini-test` conformance suite for shebang-like comment lines, misplaced `@yini` directives, and triple-quoted string line endings. - **Fixed:** `#!` lines after an opening `@yini` marker are now treated as comment trivia, while `@yini` directives after document content are reported as syntax errors in both lenient and strict mode. diff --git a/src/core/astBuilder.ts b/src/core/astBuilder.ts index 702ee4f..8a926a4 100644 --- a/src/core/astBuilder.ts +++ b/src/core/astBuilder.ts @@ -456,6 +456,10 @@ export default class ASTBuilder extends YiniParserVisitor { msgWhat = 'Invalid escape sequence in string' msgHint = 'Use double backslashes (\\\\) in C-strings, or use a raw string if escapes are not needed.' + } else if (/control character/i.test(msg)) { + msgWhat = 'Control character in string' + msgHint = + 'Use a C-string escape such as \\t for tabs, or remove the raw control character.' } else if (/end of string/i.test(msg)) { msgWhat = 'Incomplete escape sequence in string' msgHint = diff --git a/src/parsers/parseString.ts b/src/parsers/parseString.ts index 2148ed1..00875f1 100644 --- a/src/parsers/parseString.ts +++ b/src/parsers/parseString.ts @@ -211,11 +211,27 @@ const parseClassicEscapes = ( const normalizeRealLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n') +const rejectRawSingleLineControlCharacters = (input: string): void => { + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i) + + if (code < 0x20) { + throw new CYiniStringParseError( + `Raw string contains control character U+${code + .toString(16) + .padStart(4, '0')}`, + ) + } + } +} + const parseStringLiteral = ({ strKind, value }: IParsedStringInput): string => { switch (strKind) { case 'raw': // Raw strings preserve content exactly as provided by the lexer. - // Single-line raw string constraints are enforced by the lexer. + // Line breaks are enforced by the lexer; other C0 controls are + // reported here so callers get a structured parser diagnostic. + rejectRawSingleLineControlCharacters(value) return value case 'triple-raw': diff --git a/tests/integration/7-string-literals/raw-strings.test.ts b/tests/integration/7-string-literals/raw-strings.test.ts index 7f1d9be..9c974bd 100644 --- a/tests/integration/7-string-literals/raw-strings.test.ts +++ b/tests/integration/7-string-literals/raw-strings.test.ts @@ -237,6 +237,20 @@ Line 2" }) }).toThrow() }) + + test('10. Should throw when a raw string contains a literal tab.', () => { + // Arrange. + const invalidYini = + '@yini\n\n^ Strings\nkey = "alpha\tbeta"\n' + + // Act & Assert. + expect(() => { + YINI.parse(invalidYini, { + strictMode: false, + failLevel: 'errors', + }) + }).toThrow(/control character/i) + }) }) describe('Strict mode:', () => { @@ -399,5 +413,20 @@ Line 2" }) }).toThrow() }) + + test('7. Should throw when a raw string contains a literal tab.', () => { + // Arrange. + const invalidYini = + '@yini strict\n\n^ App\ntext = "alpha\tbeta"\n\n/END\n' + + // Act & Assert. + expect(() => { + YINI.parse(invalidYini, { + strictMode: true, + requireDocTerminator: 'required', + failLevel: 'errors', + }) + }).toThrow(/control character/i) + }) }) })