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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# CHANGELOG

## 1.6.1x - 2026 May +
## 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.
- **Fixed:** Triple-quoted raw and Classic strings now normalize physical `CRLF`/`CR` line endings to `LF`, producing stable output across platforms.
- **Fixed:** Diagnostic line numbers no longer shift by one after a valid shebang line; strict-mode trailing-comma errors now point to the actual physical source line.
- **Fixed:** Removed the outdated warning `Warning: Strict initialMode is not yet fully implemented.` Strict mode is now implemented against the latest YINI Specification RC 6.
- **Improved:** Parse errors now use a concise `YiniParseError`, and missing `/END` messages clearly explain that `/END` is required in strict mode but optional in the default lenient mode.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ When reporting parser behavior, it is helpful to include:

This parser is covered by smoke, integration, and regression tests across lenient, strict, and metadata-enabled modes.

It has also been run against `yini-test-suite` v0.3.0, the external YINI conformance test suite, with all TypeScript parser cases passing.

---

## Links
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "yini-parser",
"version": "1.6.0",
"version": "1.6.1",
"description": "Official Node.js (TypeScript) parser for YINI, an INI-inspired, indentation-insensitive configuration format with clear nested sections and explicit structure.",
"keywords": [
"yini",
Expand Down
4 changes: 4 additions & 0 deletions src/YINI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ export default class YINI {
return 'YINI_MODE_MISMATCH'
}

if (text.includes('directive') && text.includes('wrong place')) {
return 'misplaced-directive'
}

if (text.includes('invalid escape sequence')) {
return 'invalid-escape-sequence'
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/astBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,8 +756,8 @@ export default class ASTBuilder extends YiniParserVisitor<any> {
if (this.mapSectionNamePaths.size || this._numOfMembers) {
this.errorHandler!.pushOrBail(
toErrorLocation(ctx),
this.isStrict ? 'Syntax-Error' : 'Syntax-Warning',
`Found a directive statement in the wrong place ${this.isStrict ? '(strict mode)' : '(lenient mode)'}`,
'Syntax-Error',
'Found a directive statement in the wrong place',
`Directive '${rawText}' must appear only at the beginning of the document, before any sections or members.`,
'Move the directive to the top of the file, after a possible shebang, comments, or whitespace.',
)
Expand Down
2 changes: 2 additions & 0 deletions src/core/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'fs'
import { isDev } from '../config/env'
import {
getShebangPlacementIssue,
normalizeShebangCommentLines,
stripBomAndValidShebang,
} from '../parsers/validateShebangPlacement'
import { ParsedObject, ParseOptions, PreferredFailLevel } from '../types'
Expand Down Expand Up @@ -130,6 +131,7 @@ export class YiniRuntime {

const originalContent = yiniContent
yiniContent = stripBomAndValidShebang(yiniContent)
yiniContent = normalizeShebangCommentLines(yiniContent)

if (originalContent.startsWith('\uFEFF')) {
devPrint(
Expand Down
9 changes: 7 additions & 2 deletions src/parsers/parseString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,24 @@ const parseClassicEscapes = (
return result
}

const normalizeRealLineBreaks = (value: string): string =>
value.replace(/\r\n?/g, '\n')

const parseStringLiteral = ({ strKind, value }: IParsedStringInput): string => {
switch (strKind) {
case 'raw':
case 'triple-raw':
// Raw strings preserve content exactly as provided by the lexer.
// Single-line raw string constraints are enforced by the lexer.
return value

case 'triple-raw':
return normalizeRealLineBreaks(value)

case 'classic':
return parseClassicEscapes(value, false)

case 'triple-classic':
return parseClassicEscapes(value, true)
return parseClassicEscapes(normalizeRealLineBreaks(value), true)

default:
throw new CYiniStringParseError(`Unknown string kind: ${strKind}`)
Expand Down
85 changes: 85 additions & 0 deletions src/parsers/validateShebangPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,68 @@

import { IPreflightIssue } from '../core/internalTypes'

const isLineTriviaBeforeContent = (line: string): boolean => {
const trimmed = line.trimStart()

return (
trimmed === '' ||
trimmed.startsWith('//') ||
trimmed.startsWith(';') ||
trimmed.startsWith('--') ||
trimmed.startsWith('# ') ||
trimmed.startsWith('#\t')
)
}

const isYiniDirectiveLine = (line: string): boolean =>
/^\s*@yini(?:\s|$)/i.test(line)

const getShebangCommentLines = (input: string): Set<number> => {
const text = input.startsWith('\uFEFF') ? input.slice(1) : input
const lines = text.split(/\r?\n/)
const commentLines = new Set<number>()
let seenYiniDirective = false
let seenContent = false

for (let index = 0; index < lines.length; index++) {
const line = lines[index]

if (index === 0 && line.startsWith('#!')) {
continue
}

if (line.startsWith('#!')) {
if (seenYiniDirective && !seenContent) {
commentLines.add(index)
continue
}

seenContent = true
continue
}

if (isLineTriviaBeforeContent(line)) {
continue
}

if (!seenContent && isYiniDirectiveLine(line)) {
seenYiniDirective = true
continue
}

seenContent = true
}

return commentLines
}

export const getShebangPlacementIssue = (
input: string,
strictMode: boolean,
): IPreflightIssue | undefined => {
const text = input.startsWith('\uFEFF') ? input.slice(1) : input
const lines = text.split(/\r?\n/)
const shebangCommentLines = getShebangCommentLines(input)

for (let index = 0; index < lines.length; index++) {
const line = lines[index]
Expand All @@ -24,6 +80,10 @@ export const getShebangPlacementIssue = (
return undefined
}

if (shebangCommentLines.has(index)) {
continue
}

const message =
'Misplaced shebang-like sequence. A shebang is only recognized when #! is the first two non-BOM characters of the document.'

Expand All @@ -46,6 +106,31 @@ export const getShebangPlacementIssue = (
return undefined
}

export const normalizeShebangCommentLines = (input: string): string => {
const shebangCommentLines = getShebangCommentLines(input)

if (shebangCommentLines.size === 0) {
return input
}

const parts = input.split(/(\r\n|\n|\r)/)
const result: string[] = []
let lineIndex = 0

for (let index = 0; index < parts.length; index += 2) {
const line = parts[index] ?? ''
const eol = parts[index + 1] ?? ''

result.push(
shebangCommentLines.has(lineIndex) ? line.replace(/^#!/, '//') : line,
eol,
)
lineIndex++
}

return result.join('')
}

export const stripBomAndValidShebang = (input: string): string => {
let text = input

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,10 @@ describe('Mode declaration tests:', () => {
}).toThrow()
})

test('3. Should warn or throw when @yini appears after a section in lenient mode when warnings are fatal.', () => {
test('3. Should throw when @yini appears after a section in lenient mode.', () => {
// Arrange.
const invalidYini = `
^ App
name = "demo"

@yini lenient
`
Expand All @@ -387,7 +386,7 @@ describe('Mode declaration tests:', () => {
expect(() => {
YINI.parse(invalidYini, {
strictMode: false,
failLevel: 'warnings-and-errors',
failLevel: 'errors',
})
}).toThrow()
})
Expand All @@ -411,5 +410,23 @@ describe('Mode declaration tests:', () => {
})
}).toThrow()
})

test('5. Should throw when @yini appears after a member in lenient mode.', () => {
// Arrange.
const invalidYini = `
^ App
name = "demo"

@yini lenient
`

// Act & Assert.
expect(() => {
YINI.parse(invalidYini, {
strictMode: false,
failLevel: 'errors',
})
}).toThrow()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,30 @@ name = "Demo"
)
})

test('Returns a stable diagnostic code for misplaced directives.', () => {
const parsed = YINI.parseForTooling(
`
^ App
name = "Demo"

@yini
`,
{
strictMode: false,
},
)

expect(parsed.ok).toBe(false)
expect(parsed.result.App.name).toBe('Demo')

expect(parsed.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
code: 'misplaced-directive',
}),
)
})

test('Does not let callers override the tooling-safe parser behavior.', () => {
const parsed = YINI.parseForTooling(
`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ name = "Shebang-demo"`
}).toThrow()
})

test('A-4.b) Should treat a shebang-like line after @yini as a comment.', () => {
// Arrange.
const input = `@yini
#!/usr/bin/env yini

^ App
name = "Shebang-demo"`

// Act.
const result = YINI.parse(input, {
strictMode: false,
})

// Assert.
expect(result).toEqual(expected)
})

test("A-5) Should not treat '#!' inside a string value as a shebang.", () => {
// Arrange.
const input = `^ App
Expand Down
56 changes: 56 additions & 0 deletions tests/integration/7-string-literals/triple-raw-strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ Line B"""
expect(toPrettyJSON(result)).toEqual(toPrettyJSON(expected))
})

test('3.b. Should normalize CRLF line breaks in raw triple-quoted strings.', () => {
// Arrange.
const validYini =
'@yini\r\n\r\n' +
'^ Strings\r\n' +
'message = R"""Line one\r\n' +
'Line two with \\n kept raw\r\n' +
'Line three"""\r\n'

const expected = {
Strings: {
message:
'Line one\n' +
'Line two with \\n kept raw\n' +
'Line three',
},
}

// Act.
const result = YINI.parse(validYini, {
strictMode: false,
})

// Assert.
expect(toPrettyJSON(result)).toEqual(toPrettyJSON(expected))
})

test('4. Should preserve backslashes in raw triple-quoted strings.', () => {
// Arrange.
const validYini = `
Expand Down Expand Up @@ -315,6 +342,35 @@ Line B"""
expect(toPrettyJSON(result)).toEqual(toPrettyJSON(expected))
})

test('3.b. Should normalize CRLF line breaks in raw triple-quoted strings.', () => {
// Arrange.
const validYini =
'@yini strict\r\n\r\n' +
'^ Strings\r\n' +
'message = R"""Line one\r\n' +
'Line two with \\n kept raw\r\n' +
'Line three"""\r\n\r\n' +
'/END\r\n'

const expected = {
Strings: {
message:
'Line one\n' +
'Line two with \\n kept raw\n' +
'Line three',
},
}

// Act.
const result = YINI.parse(validYini, {
strictMode: true,
requireDocTerminator: 'required',
})

// Assert.
expect(toPrettyJSON(result)).toEqual(toPrettyJSON(expected))
})

test('4. Should not interpret escape sequences in raw triple-quoted strings.', () => {
// Arrange.
const validYini = `
Expand Down
Loading