export interface FormattingOptions { assignment?: string; lineBreak?: "\n" | "\r\n"; commentChar?: "#" | ";" | "//"; pretty?: boolean; deduplicate?: boolean;}
type Formatting = Omit<FormattingOptions, "lineBreak" | "commentChar"> & { lineBreak?: string; commentChar?: string;};
export interface ParseOptions { assignment?: FormattingOptions["assignment"]; reviver?: ReviverFunction;}
export interface StringifyOptions extends FormattingOptions { replacer?: ReplacerFunction;}
export type ReplacerFunction = ( key: string, value: any, section?: string,) => string;
export type ReviverFunction = ( key: string, value: any, section?: string, ) => any;
export class IniMap { #global = new Map<string, LineValue>(); #sections = new Map<string, LineSection>(); #lines: Line[] = []; #comments: Comments = { clear: (): void => { this.#lines = this.#lines.filter((line) => line.type !== "comment"); for (const [i, line] of this.#lines.entries()) { if (line.type === "section") { line.end = line.end - line.num + i + 1; } line.num = i + 1; } }, deleteAtLine: (line: number): boolean => { const comment = this.#getComment(line); if (comment) { this.#appendOrDeleteLine(comment, LineOp.Del); return true; } return false; }, deleteAtKey: (keyOrSection: string, noneOrKey?: string): boolean => { const lineValue = this.#getValue(keyOrSection, noneOrKey); if (lineValue) { return this.comments.deleteAtLine(lineValue.num - 1); } return false; }, deleteAtSection: (sectionName: string): boolean => { const section = this.#sections.get(sectionName); if (section) { return this.comments.deleteAtLine(section.num - 1); } return false; }, getAtLine: (line: number): string | undefined => { return this.#getComment(line)?.val; }, getAtKey: ( keyOrSection: string, noneOrKey?: string, ): string | undefined => { const lineValue = this.#getValue(keyOrSection, noneOrKey); if (lineValue) { return this.comments.getAtLine(lineValue.num - 1); } }, getAtSection: (sectionName: string): string | undefined => { const section = this.#sections.get(sectionName); if (section) { return this.comments.getAtLine(section.num - 1); } }, setAtLine: (line: number, text: string): Comments => { const comment = this.#getComment(line); const mark = this.#formatting.commentChar ?? "#"; const formatted = text.startsWith(mark) || text === "" ? text : `${mark} ${text}`; if (comment) { comment.val = formatted; } else { if (line > this.#lines.length) { for (let i = this.#lines.length + 1; i < line; i += 1) { this.#appendOrDeleteLine({ type: "comment", num: i, val: "", }, LineOp.Add); } } this.#appendOrDeleteLine({ type: "comment", num: line, val: formatted, }, LineOp.Add); } return this.comments; }, setAtKey: ( keyOrSection: string, textOrKey: string, noneOrText?: string, ): Comments => { if (noneOrText !== undefined) { const lineValue = this.#getValue(keyOrSection, textOrKey); if (lineValue) { if (this.#getComment(lineValue.num - 1)) { this.comments.setAtLine(lineValue.num - 1, noneOrText); } else { this.comments.setAtLine(lineValue.num, noneOrText); } } } else { const lineValue = this.#getValue(keyOrSection); if (lineValue) { if (this.#getComment(lineValue.num - 1)) { this.comments.setAtLine(lineValue.num - 1, textOrKey); } else { this.comments.setAtLine(lineValue.num, textOrKey); } } } return this.comments; }, setAtSection: (sectionName: string, text: string): Comments => { const section = this.#sections.get(sectionName); if (section) { if (this.#getComment(section.num - 1)) { this.comments.setAtLine(section.num - 1, text); } else { this.comments.setAtLine(section.num, text); } } return this.comments; }, }; #formatting: Formatting;
constructor(formatting?: FormattingOptions) { this.#formatting = this.#cleanFormatting(formatting); }
get size(): number { let size = this.#global.size; for (const { map } of this.#sections.values()) { size += map.size; } return size; }
get formatting(): Formatting { return this.#formatting; }
get comments(): Comments { return this.#comments; }
clear(sectionName?: string): void { if (sectionName) { const section = this.#sections.get(sectionName);
if (section) { section.map.clear(); this.#sections.delete(sectionName); this.#lines.splice(section.num - 1, section.end - section.num); } } else { this.#global.clear(); this.#sections.clear(); this.#lines.length = 0; } }
delete(key: string): boolean; delete(section: string, key: string): boolean; delete(keyOrSection: string, noneOrKey?: string): boolean { const exists = this.#getValue(keyOrSection, noneOrKey); if (exists) { this.#appendOrDeleteLine(exists, LineOp.Del); if (exists.sec) { return this.#sections.get(exists.sec)!.map.delete(exists.key); } else { return this.#global.delete(exists.key); } }
return false; }
get(key: string): unknown; get(section: string, key: string): unknown; get(keyOrSection: string, noneOrKey?: string): unknown { return this.#getValue(keyOrSection, noneOrKey)?.val; }
has(key: string): boolean; has(section: string, key: string): boolean; has(keyOrSection: string, noneOrKey?: string): boolean { return this.#getValue(keyOrSection, noneOrKey) !== undefined; }
set(key: string, value: any): this; set(section: string, key: string, value: any): this; set(keyOrSection: string, valueOrKey: any, value?: any): this { if (typeof valueOrKey === "string" && value !== undefined) { const section = this.#getOrCreateSection(keyOrSection); const exists = section.map.get(valueOrKey);
if (exists) { exists.val = value; } else { section.end += 1; const lineValue: LineValue = { type: "value", num: section.end, sec: section.sec, key: valueOrKey, val: value, }; this.#appendValue(lineValue); section.map.set(valueOrKey, lineValue); } } else { const lineValue: LineValue = { type: "value", num: 0, key: keyOrSection, val: valueOrKey, }; this.#appendValue(lineValue); this.#global.set(keyOrSection, lineValue); }
return this; }
*entries(): Generator< [key: string, value: unknown, section?: string | undefined] > { for (const { key, val } of this.#global.values()) { yield [key, val]; } for (const { map } of this.#sections.values()) { for (const { key, val, sec } of map.values()) { yield [key, val, sec]; } } }
#getOrCreateSection(section: string): LineSection { const existing = this.#sections.get(section);
if (existing) { return existing; }
const lineSection: LineSection = { type: "section", num: this.#lines.length + 1, sec: section, map: new Map<string, LineValue>(), end: this.#lines.length + 1, }; this.#lines.push(lineSection); this.#sections.set(section, lineSection); return lineSection; }
#getValue(keyOrSection: string, noneOrKey?: string): LineValue | undefined { if (noneOrKey) { const section = this.#sections.get(keyOrSection);
return section?.map.get(noneOrKey); }
return this.#global.get(keyOrSection); }
#getComment(line: number): LineComment | undefined { const comment: Line | undefined = this.#lines[line - 1]; if (comment?.type === "comment") { return comment; } }
#appendValue(lineValue: LineValue): void { if (this.#lines.length === 0) { lineValue.num = 1; this.#lines.push(lineValue); } else if (lineValue.sec) { this.#appendOrDeleteLine(lineValue, LineOp.Add); } else { lineValue.num = this.#lines.length + 1; for (const [i, line] of this.#lines.entries()) { if (line.type === "section") { lineValue.num = i + 1; break; } } this.#appendOrDeleteLine(lineValue, LineOp.Add); } }
#appendOrDeleteLine(input: Line, op: LineOp) { if (op === LineOp.Add) { this.#lines.splice(input.num - 1, 0, input); } else { this.#lines.splice(input.num - 1, 1); } let updateSection = input.type === "comment"; const start = op === LineOp.Add ? input.num : input.num - 1; for (const line of this.#lines.slice(start)) { line.num += op; if (line.type === "section") { line.end += op; updateSection = false; } if (updateSection) { if (line.type === "value" && line.sec) { const section = this.#sections.get(line.sec);
if (section) { section.end += op; updateSection = false; } } } } }
*#readTextLines(text: string): Generator<string> { const lineBreak = "\r\n"; const { length } = text; let lineBreakLength = -1; let line = "";
for (let i = 0; i < length; i += 1) { const char = text[i]!;
if (lineBreak.includes(char)) { yield line; line = ""; if (lineBreakLength === -1) { const ahead = text[i + 1]; if ( ahead !== undefined && ahead !== char && lineBreak.includes(ahead) ) { if (!this.#formatting.lineBreak) { this.#formatting.lineBreak = char + ahead; } lineBreakLength = 1; } else { if (!this.#formatting.lineBreak) { this.#formatting.lineBreak = char; } lineBreakLength = 0; } } i += lineBreakLength; } else { line += char; } }
yield line; }
#cleanFormatting(options?: FormattingOptions): FormattingOptions { return Object.fromEntries( Object.entries(options ?? {}).filter(([key]) => FormattingKeys.includes(key as keyof FormattingOptions) ), ); }
toObject(): Record<string, unknown | Record<string, unknown>> { const obj: Record<string, unknown | Record<string, unknown>> = {};
for (const { key, val } of this.#global.values()) { Object.defineProperty(obj, key, { value: val, writable: true, enumerable: true, configurable: true, }); } for (const { sec, map } of this.#sections.values()) { const section: Record<string, unknown> = {}; Object.defineProperty(obj, sec, { value: section, writable: true, enumerable: true, configurable: true, }); for (const { key, val } of map.values()) { Object.defineProperty(section, key, { value: val, writable: true, enumerable: true, configurable: true, }); } }
return obj; }
toJSON(): Record<string, unknown | Record<string, unknown>> { return this.toObject(); }
toString(replacer?: ReplacerFunction): string { const replacerFunc: ReplacerFunction = typeof replacer === "function" ? replacer : (_key, value, _section) => `${value}`; const pretty = this.#formatting?.pretty ?? false; const assignmentMark = (this.#formatting?.assignment ?? "=")[0]; const assignment = pretty ? ` ${assignmentMark} ` : assignmentMark; const lines = this.#formatting.deduplicate ? this.#lines.filter((lineA, index, self) => { if (lineA.type === "value") { const lastIndex = self.findLastIndex((lineB) => { return lineA.sec === (lineB as LineValue).sec && lineA.key === (lineB as LineValue).key; }); return index === lastIndex; } return true; }) : this.#lines;
return lines.map((line) => { switch (line.type) { case "comment": return line.val; case "section": return `[${line.sec}]`; case "value": return line.key + assignment + replacerFunc(line.key, line.val, line.sec); } }).join(this.#formatting?.lineBreak ?? "\n"); }
parse(text: string, reviver?: ReviverFunction): this { if (typeof text !== "string") { throw new SyntaxError(`Unexpected token ${text} in INI at line 0`); } const reviverFunc: ReviverFunction = typeof reviver === "function" ? reviver : (_key, value, _section) => value; const assignment = (this.#formatting.assignment ?? "=").substring(0, 1); let lineNumber = 1; let currentSection: LineSection | undefined;
for (const line of this.#readTextLines(text)) { const trimmed = line.trim(); if (isComment(trimmed)) { if (!this.#formatting.commentChar) { const mark = trimmed[0]; if (mark) { this.#formatting.commentChar = mark === "/" ? "//" : mark; } } this.#lines.push({ type: "comment", num: lineNumber, val: trimmed, }); } else if (isSection(trimmed, lineNumber)) { const sec = trimmed.substring(1, trimmed.length - 1);
if (sec.trim() === "") { throw new SyntaxError( `Unexpected empty section name at line ${lineNumber}`, ); }
currentSection = { type: "section", num: lineNumber, sec, map: new Map<string, LineValue>(), end: lineNumber, }; this.#lines.push(currentSection); this.#sections.set(currentSection.sec, currentSection); } else { const assignmentPos = trimmed.indexOf(assignment);
if (assignmentPos === -1) { throw new SyntaxError( `Unexpected token ${trimmed[0]} in INI at line ${lineNumber}`, ); } if (assignmentPos === 0) { throw new SyntaxError( `Unexpected empty key name at line ${lineNumber}`, ); }
const leftHand = trimmed.substring(0, assignmentPos); const rightHand = trimmed.substring(assignmentPos + 1);
if (this.#formatting.pretty === undefined) { this.#formatting.pretty = leftHand.endsWith(" ") && rightHand.startsWith(" "); }
const key = leftHand.trim(); const value = rightHand.trim();
if (currentSection) { const lineValue: LineValue = { type: "value", num: lineNumber, sec: currentSection.sec, key, val: reviverFunc(key, value, currentSection.sec), }; currentSection.map.set(key, lineValue); this.#lines.push(lineValue); currentSection.end = lineNumber; } else { const lineValue: LineValue = { type: "value", num: lineNumber, key, val: reviverFunc(key, value), }; this.#global.set(key, lineValue); this.#lines.push(lineValue); } }
lineNumber += 1; }
return this; }
static from( input: string, options?: ParseOptions & FormattingOptions, ): IniMap; static from( input: Record<string, any>, formatting?: FormattingOptions, ): IniMap; static from( input: Record<string, any> | string, formatting?: ParseOptions & FormattingOptions, ): IniMap { const ini = new IniMap(formatting); if (typeof input === "object" && input !== null) { const isRecord = (val: any): val is Record<string, any> => typeof val === "object" && val !== null; const sort = ([_a, valA]: [string, any], [_b, valB]: [string, any]) => { if (isRecord(valA)) return 1; if (isRecord(valB)) return -1; return 0; };
for (const [key, val] of Object.entries(input).sort(sort)) { if (isRecord(val)) { for (const [sectionKey, sectionValue] of Object.entries(val)) { ini.set(key, sectionKey, sectionValue); } } else { ini.set(key, val); } } } else { ini.parse(input, formatting?.reviver); } return ini; }}
export interface Comments { clear(): void; deleteAtLine(line: number): boolean; deleteAtKey(key: string): boolean; deleteAtKey(section: string, key: string): boolean; deleteAtSection(section: string): boolean; getAtLine(line: number): string | undefined; getAtKey(key: string): string | undefined; getAtKey(section: string, key: string): string | undefined; getAtSection(section: string): string | undefined; setAtLine(line: number, text: string): Comments; setAtKey(key: string, text: string): Comments; setAtKey(section: string, key: string, text: string): Comments; setAtSection(section: string, text: string): Comments;}
function isComment(input: string): boolean { return input === "" || input.startsWith("#") || input.startsWith(";") || input.startsWith("//");}
function isSection(input: string, lineNumber: number): boolean { if (input.startsWith("[")) { if (input.endsWith("]")) { return true; } throw new SyntaxError( `Unexpected end of INI section at line ${lineNumber}`, ); } return false;}
type LineOp = typeof LineOp[keyof typeof LineOp];const LineOp = { Del: -1, Add: 1,} as const;const DummyFormatting: Required<FormattingOptions> = { assignment: "", lineBreak: "\n", pretty: false, commentChar: "#", deduplicate: false,};const FormattingKeys = Object.keys( DummyFormatting,) as (keyof FormattingOptions)[];
interface LineComment { type: "comment"; num: number; val: string;}
interface LineSection { type: "section"; num: number; sec: string; map: Map<string, LineValue>; end: number;}
interface LineValue { type: "value"; num: number; sec?: string; key: string; val: any;}
type Line = LineComment | LineSection | LineValue;