Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/core/astBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,10 @@ export default class ASTBuilder extends YiniParserVisitor<any> {
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 =
Expand Down
18 changes: 17 additions & 1 deletion src/parsers/parseString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/7-string-literals/raw-strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:', () => {
Expand Down Expand Up @@ -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)
})
})
})
Loading