diff --git a/CHANGELOG.md b/CHANGELOG.md index f7203e5..6f41df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 0ae0b3d..701625b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 09c023b..c2a98a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "yini-parser", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yini-parser", - "version": "1.6.0", + "version": "1.6.1", "license": "Apache-2.0", "dependencies": { "antlr4": "^4.13.2" diff --git a/package.json b/package.json index 0480286..7baefc2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/YINI.ts b/src/YINI.ts index bc4eb0e..c7fdb01 100644 --- a/src/YINI.ts +++ b/src/YINI.ts @@ -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' } diff --git a/src/core/astBuilder.ts b/src/core/astBuilder.ts index 3423be7..702ee4f 100644 --- a/src/core/astBuilder.ts +++ b/src/core/astBuilder.ts @@ -756,8 +756,8 @@ export default class ASTBuilder extends YiniParserVisitor { 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.', ) diff --git a/src/core/runtime.ts b/src/core/runtime.ts index 59a0fe4..0714ca2 100644 --- a/src/core/runtime.ts +++ b/src/core/runtime.ts @@ -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' @@ -130,6 +131,7 @@ export class YiniRuntime { const originalContent = yiniContent yiniContent = stripBomAndValidShebang(yiniContent) + yiniContent = normalizeShebangCommentLines(yiniContent) if (originalContent.startsWith('\uFEFF')) { devPrint( diff --git a/src/parsers/parseString.ts b/src/parsers/parseString.ts index c07f6f9..2148ed1 100644 --- a/src/parsers/parseString.ts +++ b/src/parsers/parseString.ts @@ -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}`) diff --git a/src/parsers/validateShebangPlacement.ts b/src/parsers/validateShebangPlacement.ts index e31e3f5..093312b 100644 --- a/src/parsers/validateShebangPlacement.ts +++ b/src/parsers/validateShebangPlacement.ts @@ -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 => { + const text = input.startsWith('\uFEFF') ? input.slice(1) : input + const lines = text.split(/\r?\n/) + const commentLines = new Set() + 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] @@ -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.' @@ -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 diff --git a/tests/integration/10-special-and-validation-modes/mode-declarations.test.ts b/tests/integration/10-special-and-validation-modes/mode-declarations.test.ts index 705888c..7b61180 100644 --- a/tests/integration/10-special-and-validation-modes/mode-declarations.test.ts +++ b/tests/integration/10-special-and-validation-modes/mode-declarations.test.ts @@ -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 ` @@ -387,7 +386,7 @@ describe('Mode declaration tests:', () => { expect(() => { YINI.parse(invalidYini, { strictMode: false, - failLevel: 'warnings-and-errors', + failLevel: 'errors', }) }).toThrow() }) @@ -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() + }) }) }) diff --git a/tests/integration/11-public-api-and-parse-options/parse-for-tooling.test.ts b/tests/integration/11-public-api-and-parse-options/parse-for-tooling.test.ts index 0fcfbd9..757f24e 100644 --- a/tests/integration/11-public-api-and-parse-options/parse-for-tooling.test.ts +++ b/tests/integration/11-public-api-and-parse-options/parse-for-tooling.test.ts @@ -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( ` diff --git a/tests/integration/2-file-structure-and-error/handle shebang.test.ts b/tests/integration/2-file-structure-and-error/handle shebang.test.ts index 9a07d9f..aae35cf 100644 --- a/tests/integration/2-file-structure-and-error/handle shebang.test.ts +++ b/tests/integration/2-file-structure-and-error/handle shebang.test.ts @@ -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 diff --git a/tests/integration/7-string-literals/triple-raw-strings.test.ts b/tests/integration/7-string-literals/triple-raw-strings.test.ts index c023c2c..19d9d24 100644 --- a/tests/integration/7-string-literals/triple-raw-strings.test.ts +++ b/tests/integration/7-string-literals/triple-raw-strings.test.ts @@ -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 = ` @@ -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 = `