test: add case for unquoted invalid numeric formats as strings

This commit is contained in:
Johann Schopplich
2025-10-29 13:05:42 +01:00
parent b034c4455e
commit ee31be3bdc
12 changed files with 292 additions and 364 deletions

View File

@@ -0,0 +1,28 @@
import { FALSE_LITERAL, NULL_LITERAL, TRUE_LITERAL } from '../constants'
/**
* Checks if a token is a boolean or null literal (`true`, `false`, `null`).
*/
export function isBooleanOrNullLiteral(token: string): boolean {
return token === TRUE_LITERAL || token === FALSE_LITERAL || token === NULL_LITERAL
}
/**
* Checks if a token represents a valid numeric literal.
*
* @remarks
* Rejects numbers with leading zeros (except `"0"` itself or decimals like `"0.5"`).
*/
export function isNumericLiteral(token: string): boolean {
if (!token)
return false
// Must not have leading zeros (except for `"0"` itself or decimals like `"0.5"`)
if (token.length > 1 && token[0] === '0' && token[1] !== '.') {
return false
}
// Check if it's a valid number
const num = Number(token)
return !Number.isNaN(num) && Number.isFinite(num)
}

127
src/shared/string-utils.ts Normal file
View File

@@ -0,0 +1,127 @@
import { BACKSLASH, CARRIAGE_RETURN, DOUBLE_QUOTE, NEWLINE, TAB } from '../constants'
/**
* Escapes special characters in a string for encoding.
*
* @remarks
* Handles backslashes, quotes, newlines, carriage returns, and tabs.
*/
export function escapeString(value: string): string {
return value
.replace(/\\/g, `${BACKSLASH}${BACKSLASH}`)
.replace(/"/g, `${BACKSLASH}${DOUBLE_QUOTE}`)
.replace(/\n/g, `${BACKSLASH}n`)
.replace(/\r/g, `${BACKSLASH}r`)
.replace(/\t/g, `${BACKSLASH}t`)
}
/**
* Unescapes a string by processing escape sequences.
*
* @remarks
* Handles `\n`, `\t`, `\r`, `\\`, and `\"` escape sequences.
*/
export function unescapeString(value: string): string {
let result = ''
let i = 0
while (i < value.length) {
if (value[i] === BACKSLASH) {
if (i + 1 >= value.length) {
throw new SyntaxError('Invalid escape sequence: backslash at end of string')
}
const next = value[i + 1]
if (next === 'n') {
result += NEWLINE
i += 2
continue
}
if (next === 't') {
result += TAB
i += 2
continue
}
if (next === 'r') {
result += CARRIAGE_RETURN
i += 2
continue
}
if (next === BACKSLASH) {
result += BACKSLASH
i += 2
continue
}
if (next === DOUBLE_QUOTE) {
result += DOUBLE_QUOTE
i += 2
continue
}
throw new SyntaxError(`Invalid escape sequence: \\${next}`)
}
result += value[i]
i++
}
return result
}
/**
* Finds the index of the closing double quote in a string, accounting for escape sequences.
*
* @param content The string to search in
* @param start The index of the opening quote
* @returns The index of the closing quote, or -1 if not found
*/
export function findClosingQuote(content: string, start: number): number {
let i = start + 1
while (i < content.length) {
if (content[i] === BACKSLASH && i + 1 < content.length) {
// Skip escaped character
i += 2
continue
}
if (content[i] === DOUBLE_QUOTE) {
return i
}
i++
}
return -1 // Not found
}
/**
* Finds the index of a specific character outside of quoted sections.
*
* @param content The string to search in
* @param char The character to look for
* @param start Optional starting index (defaults to 0)
* @returns The index of the character, or -1 if not found outside quotes
*/
export function findUnquotedChar(content: string, char: string, start = 0): number {
let inQuotes = false
let i = start
while (i < content.length) {
if (content[i] === BACKSLASH && i + 1 < content.length && inQuotes) {
// Skip escaped character
i += 2
continue
}
if (content[i] === DOUBLE_QUOTE) {
inQuotes = !inQuotes
i++
continue
}
if (content[i] === char && !inQuotes) {
return i
}
i++
}
return -1
}

84
src/shared/validation.ts Normal file
View File

@@ -0,0 +1,84 @@
import { COMMA, LIST_ITEM_MARKER } from '../constants'
import { isBooleanOrNullLiteral } from './literal-utils'
/**
* Checks if a key can be used without quotes.
*
* @remarks
* Valid unquoted keys must start with a letter or underscore,
* followed by letters, digits, underscores, or dots.
*/
export function isValidUnquotedKey(key: string): boolean {
return /^[A-Z_][\w.]*$/i.test(key)
}
/**
* Determines if a string value can be safely encoded without quotes.
*
* @remarks
* A string needs quoting if it:
* - Is empty
* - Has leading or trailing whitespace
* - Could be confused with a literal (boolean, null, number)
* - Contains structural characters (colons, brackets, braces)
* - Contains quotes or backslashes (need escaping)
* - Contains control characters (newlines, tabs, etc.)
* - Contains the active delimiter
* - Starts with a list marker (hyphen)
*/
export function isSafeUnquoted(value: string, delimiter: string = COMMA): boolean {
if (!value) {
return false
}
if (value !== value.trim()) {
return false
}
// Check if it looks like any literal value (boolean, null, or numeric)
if (isBooleanOrNullLiteral(value) || isNumericLike(value)) {
return false
}
// Check for colon (always structural)
if (value.includes(':')) {
return false
}
// Check for quotes and backslash (always need escaping)
if (value.includes('"') || value.includes('\\')) {
return false
}
// Check for brackets and braces (always structural)
if (/[[\]{}]/.test(value)) {
return false
}
// Check for control characters (newline, carriage return, tab - always need quoting/escaping)
if (/[\n\r\t]/.test(value)) {
return false
}
// Check for the active delimiter
if (value.includes(delimiter)) {
return false
}
// Check for hyphen at start (list marker)
if (value.startsWith(LIST_ITEM_MARKER)) {
return false
}
return true
}
/**
* Checks if a string looks like a number.
*
* @remarks
* Match numbers like `42`, `-3.14`, `1e-6`, `05`, etc.
*/
function isNumericLike(value: string): boolean {
return /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(value) || /^0\d+$/.test(value)
}