Skip to main content
Module

std/ini/ini_map.ts

Deno standard library
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
/** Options for providing formatting marks. */export interface FormattingOptions { /** The character used to assign a value to a key; defaults to '='. */ assignment?: string; /** Character(s) used to break lines in the config file; defaults to '\n'. Ignored on parse. */ lineBreak?: "\n" | "\r\n"; /** Mark to use for setting comments; expects '#', ';', '//', defaults to '#' unless another mark is found. */ commentChar?: "#" | ";" | "//"; /** Use a plain assignment char or pad with spaces; defaults to false. Ignored on parse. */ pretty?: boolean; /** Filter duplicate keys from INI string output; defaults to false to preserve data parity. */ deduplicate?: boolean;}
type Formatting = Omit<FormattingOptions, "lineBreak" | "commentChar"> & { lineBreak?: string; commentChar?: string;};
/** Options for parsing INI strings. */export interface ParseOptions { /** The character used to assign a value to a key; defaults to '='. */ assignment?: FormattingOptions["assignment"]; /** Provide custom parsing of the value in a key/value pair. */ reviver?: ReviverFunction;}
/** Options for constructing INI strings. */export interface StringifyOptions extends FormattingOptions { /** Provide custom string conversion for the value in a key/value pair. */ replacer?: ReplacerFunction;}
/** Function for replacing JavaScript values with INI string values. */export type ReplacerFunction = ( key: string, // deno-lint-ignore no-explicit-any value: any, section?: string,) => string;
/** Function for replacing INI values with JavaScript values. */export type ReviverFunction = ( key: string, // deno-lint-ignore no-explicit-any value: any, section?: string, // deno-lint-ignore no-explicit-any) => any;
/** Class implementation for fine control of INI data structures. */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 the count of key/value pairs. */ 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; }
/** Manage comments in the INI. */ get comments(): Comments { return this.#comments; }
/** Clear a single section or the entire INI. */ 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 a global key in the INI. */ delete(key: string): boolean; /** Delete a section key in the INI. */ 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 a value from a global key in the INI. */ get(key: string): unknown; /** Get a value from a section key in the INI. */ get(section: string, key: string): unknown; get(keyOrSection: string, noneOrKey?: string): unknown { return this.#getValue(keyOrSection, noneOrKey)?.val; }
/** Check if a global key exists in the INI. */ has(key: string): boolean; /** Check if a section key exists in the INI. */ has(section: string, key: string): boolean; has(keyOrSection: string, noneOrKey?: string): boolean { return this.#getValue(keyOrSection, noneOrKey) !== undefined; }
/** Set the value of a global key in the INI. */ // deno-lint-ignore no-explicit-any set(key: string, value: any): this; /** Set the value of a section key in the INI. */ // deno-lint-ignore no-explicit-any set(section: string, key: string, value: any): this; // deno-lint-ignore no-explicit-any 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, // Simply set to zero since we have to find the end ofthe global keys key: keyOrSection, val: valueOrKey, }; this.#appendValue(lineValue); this.#global.set(keyOrSection, lineValue); }
return this; }
/** Iterate over each entry in the INI to retrieve key, value, and section. */ *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) { // For an empty array, just insert the line value lineValue.num = 1; this.#lines.push(lineValue); } else if (lineValue.sec) { // For line values in a section, the end of the section is known this.#appendOrDeleteLine(lineValue, LineOp.Add); } else { // For global values, find the line preceding the first section lineValue.num = this.#lines.length + 1; for (const [i, line] of this.#lines.entries()) { if (line.type === "section") { lineValue.num = i + 1; break; } } // Append the line value at the end of all global values 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); } // If the input is a comment, find the next section if any to update. 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; // If the comment is before the nearest section, don't update the section further. updateSection = false; } if (updateSection) { // if the comment precedes a value in a section, get and update the section end. 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) ), ); }
/** Convert this `IniMap` to a plain object. */ 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; }
/** Convenience method for `JSON.stringify`. */ toJSON(): Record<string, unknown | Record<string, unknown>> { return this.toObject(); }
/** Convert this `IniMap` to an INI string. */ 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 an INI string in this `IniMap`. */ 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 comment formatting mark is not set, discover it. if (!this.#formatting.commentChar) { const mark = trimmed[0]; if (mark) { // if mark is truthy, use the character. 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; }
/** Create an `IniMap` from an INI string. */ static from( input: string, options?: ParseOptions & FormattingOptions, ): IniMap; /** Create an `IniMap` from a plain object. */ static from( // deno-lint-ignore no-explicit-any input: Record<string, any>, formatting?: FormattingOptions, ): IniMap; static from( // deno-lint-ignore no-explicit-any input: Record<string, any> | string, formatting?: ParseOptions & FormattingOptions, ): IniMap { const ini = new IniMap(formatting); if (typeof input === "object" && input !== null) { // deno-lint-ignore no-explicit-any const isRecord = (val: any): val is Record<string, any> => typeof val === "object" && val !== null; // deno-lint-ignore no-explicit-any 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 all comments in the INI. */ clear(): void; /** Delete a comment at a specific line in the INI. */ deleteAtLine(line: number): boolean; /** Delete a comment before a global key in the INI. */ deleteAtKey(key: string): boolean; /** Delete a comment before a section key in the INI. */ deleteAtKey(section: string, key: string): boolean; /** Delete a comment before a section line in the INI. */ deleteAtSection(section: string): boolean; /** Get a comment at a specific line in the INI. */ getAtLine(line: number): string | undefined; /** Get a comment before a global key in the INI. */ getAtKey(key: string): string | undefined; /** Get a comment before a section key in the INI. */ getAtKey(section: string, key: string): string | undefined; /** Get a comment before a section line in the INI. */ getAtSection(section: string): string | undefined; /** Set a comment at a specific line in the INI. */ setAtLine(line: number, text: string): Comments; /** Set a comment before a global key in the INI. */ setAtKey(key: string, text: string): Comments; /** Set a comment before a section key in the INI. */ setAtKey(section: string, key: string, text: string): Comments; /** Set a comment before a section line in the INI. */ setAtSection(section: string, text: string): Comments;}
/** Detect supported comment styles. */function isComment(input: string): boolean { return input === "" || input.startsWith("#") || input.startsWith(";") || input.startsWith("//");}
/** Detect a section start. */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; // deno-lint-ignore no-explicit-any val: any;}
type Line = LineComment | LineSection | LineValue;