const LUA_PATTERN_ESCAPES: Record = { a: '[A-Za-z]', A: '[^A-Za-z]', c: '[\\x00-\\x1F\\x7F]', C: '[^\\x00-\\x1F\\x7F]', d: '[0-9]', D: '[^0-9]', l: '[a-z]', L: '[^a-z]', p: '[!-/:-@[-`{-~]', P: '[^!-/:-@[-`{-~]', s: '\\s', S: '\\S', u: '[A-Z]', U: '[^A-Z]', w: '[A-Za-z0-9]', W: '[^A-Za-z0-9]', x: '[A-Fa-f0-9]', X: '[^A-Fa-f0-9]', z: '\\x00', Z: '[^\\x00]', } const LUA_CHAR_CLASS_ATOMS: Record = { a: '[A-Za-z]', A: '[^A-Za-z]', c: '[\\x00-\\x1F\\x7F]', C: '[^\\x00-\\x1F\\x7F]', d: '[0-9]', D: '[^0-9]', l: '[a-z]', L: '[^a-z]', p: '[!-/:-@\\[-`\\{-~]', P: '[^!-/:-@\\[-`\\{-~]', s: '\\s', S: '\\S', u: '[A-Z]', U: '[^A-Z]', w: '[A-Za-z0-9]', W: '[^A-Za-z0-9]', x: '[A-Fa-f0-9]', X: '[^A-Fa-f0-9]', z: '\\x00', Z: '[^\\x00]', } function escapeRegexLiteral(char: string): string { return /[\\^$.*+?()[\]{}|/]/.test(char) ? `\\${char}` : char } function escapeRegexClassLiteral(char: string): string { return char === '\\' || char === ']' || char === '^' || char === '-' ? `\\${char}` : char } function translateLuaCharClass(chars: string): string { let negate = false let source = chars if (source.startsWith('^')) { negate = true source = source.slice(1) } const atoms: string[] = [] for (let index = 0; index < source.length; ) { const current = parseLuaClassToken(source, index) const hyphen = source[index + current.length] ?? '' const nextIndex = index + current.length + 1 const canRange = hyphen === '-' && nextIndex < source.length && current.literal !== null && current.rangeable && source[nextIndex] !== ']' if (canRange) { const right = parseLuaClassToken(source, nextIndex) if (current.literal !== null && right.literal !== null && right.rangeable) { const leftLiteral = current.literal const rightLiteral = right.literal atoms.push(`[${escapeRegexClassLiteral(leftLiteral)}-${escapeRegexClassLiteral(rightLiteral)}]`) index = nextIndex + right.length continue } } atoms.push(current.atom) index += current.length } const union = atoms.length === 1 ? (atoms[0] ?? '') : `(?:${atoms.join('|')})` return negate ? `(?:(?!${union})[\\s\\S])` : union } function parseLuaClassToken( source: string, index: number, ): { atom: string; length: number; literal: string | null; rangeable: boolean } { const char = source[index] ?? '' if (char === '%') { const next = source[index + 1] ?? '' const mapped = LUA_CHAR_CLASS_ATOMS[next] if (mapped) { return { atom: mapped, length: 2, literal: null, rangeable: false } } return { atom: escapeRegexLiteral(next), length: 2, literal: next, rangeable: false } } return { atom: escapeRegexLiteral(char), length: 1, literal: char, rangeable: char !== '\\', } } function canApplyLuaQuantifier(parts: string[]): boolean { if (parts.length === 0) { return false } const last = parts[parts.length - 1] ?? '' return last !== '(' && last !== '^' && last !== '$' } function translateLuaPattern(pattern: string): RegExp { const parts: string[] = [] for (let i = 0; i < pattern.length; i++) { const char = pattern[i] ?? '' if (char === '%') { const next = pattern[i + 1] ?? '' if (next === '') { parts.push('%') continue } if (next === 'b' || next === 'f') { throw new Error('gsub(): unsupported Lua pattern feature') } i++ parts.push(LUA_PATTERN_ESCAPES[next] ?? escapeRegexLiteral(next)) continue } if (char === '[') { let classBody = '' let closed = false i++ if (i < pattern.length && pattern[i] === '^') { classBody += '^' i++ } if (i < pattern.length && pattern[i] === ']') { classBody += ']' i++ } while (i < pattern.length && pattern[i] !== ']') { classBody += pattern[i] ?? '' i++ } if (i < pattern.length && pattern[i] === ']') { closed = true } if (!closed) { throw new Error('gsub(): malformed Lua pattern') } parts.push(translateLuaCharClass(classBody)) continue } if (char === '.') { parts.push('[\\s\\S]') continue } if (char === '-' || char === '*' || char === '+' || char === '?') { if (canApplyLuaQuantifier(parts)) { parts[parts.length - 1] += char === '-' ? '*?' : char } else { parts.push(escapeRegexLiteral(char)) } continue } if (char === '^') { parts.push(i === 0 ? '^' : '\\^') continue } if (char === '$') { parts.push(i === pattern.length - 1 ? '$' : '\\$') continue } if ('()'.includes(char)) { parts.push(char) continue } parts.push(escapeRegexLiteral(char)) } return new RegExp(parts.join(''), 'g') } function applyLuaReplacement(template: string, match: string, captures: string[]): string { let out = '' for (let i = 0; i < template.length; i++) { const char = template[i] ?? '' if (char !== '%') { out += char continue } const next = template[i + 1] ?? '' if (next === '%') { out += '%' i++ continue } if (/\d/.test(next)) { i++ if (next === '0') { out += match } else { const capture = captures[Number(next) - 1] if (capture === undefined) { throw new Error(`invalid capture index %${next}`) } out += capture } continue } out += next i++ } return out } export function gsub(source: string, pattern: string, replacement: string, limit?: number): [string, number] { // discuss at: https://locutus.io/lua/gsub/ // parity verified: Lua 5.4 // original by: Kevin van Zonneveld (https://kvz.io) // note 1: Supports common Lua pattern classes and capture substitutions like %1 in replacement strings. // example 1: gsub('hello world', 'l', 'L') // returns 1: ['heLLo worLd', 3] // example 2: gsub('123-456', '(%d+)', '[%1]', 1) // returns 2: ['[123]-456', 1] // example 3: gsub('abc', '%a', 'x') // returns 3: ['xxx', 3] const value = String(source) const regex = translateLuaPattern(String(pattern)) const maxReplacements = limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.trunc(Number(limit))) let count = 0 if (maxReplacements === 0) { return [value, 0] } const out = value.replace(regex, (...args) => { const match = String(args[0] ?? '') if (count >= maxReplacements) { return match } count++ const captures = args.slice(1, -2).map((item) => String(item ?? '')) return applyLuaReplacement(String(replacement), match, captures) }) return [out, count] }