UNPKG

keep-a-changelog

Version:

Node package to parse and generate changelogs following the [keepachangelog](https://keepachangelog.com/) format.

839 lines (838 loc) 25.9 kB
"use strict"; // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. Object.defineProperty(exports, "__esModule", { value: true }); exports.IniMap = void 0; /** * Class implementation for fine control of INI data structures. * * @example Usage * ```ts * import { IniMap } from "@std/ini"; * import { assertEquals } from "@std/assert"; * * const ini = new IniMap(); * ini.set("section1", "keyA", 100) * assertEquals(ini.toString(), `[section1] * keyA=100`) * * ini.set('keyA', 25) * assertEquals(ini.toObject(), { * keyA: 25, * section1: { * keyA: 100, * }, * }); * ``` */ class IniMap { #global = new Map(); #sections = new Map(); #lines = []; #comments = { clear: () => { 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) => { const comment = this.#getComment(line); if (comment) { this.#appendOrDeleteLine(comment, LineOp.Del); return true; } return false; }, deleteAtKey: (keyOrSection, noneOrKey) => { const lineValue = this.#getValue(keyOrSection, noneOrKey); if (lineValue) { return this.comments.deleteAtLine(lineValue.num - 1); } return false; }, deleteAtSection: (sectionName) => { const section = this.#sections.get(sectionName); if (section) { return this.comments.deleteAtLine(section.num - 1); } return false; }, getAtLine: (line) => { return this.#getComment(line)?.val; }, getAtKey: (keyOrSection, noneOrKey) => { const lineValue = this.#getValue(keyOrSection, noneOrKey); if (lineValue) { return this.comments.getAtLine(lineValue.num - 1); } }, getAtSection: (sectionName) => { const section = this.#sections.get(sectionName); if (section) { return this.comments.getAtLine(section.num - 1); } }, setAtLine: (line, text) => { 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, textOrKey, noneOrText) => { 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, text) => { 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; /** Constructs a new `IniMap`. * * @example Usage * ```ts * import { IniMap } from "@std/ini"; * import { assertEquals } from "@std/assert"; * * const ini = new IniMap(); * ini.set("section1", "keyA", 100) * assertEquals(ini.toString(), `[section1] * keyA=100`) * * ini.set('keyA', 25) * assertEquals(ini.toObject(), { * keyA: 25, * section1: { * keyA: 100, * }, * }); * ``` * * @param formatting Optional formatting options when printing an INI file. */ constructor(formatting) { this.#formatting = this.#cleanFormatting(formatting); } /** * Gets the count of key/value pairs. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * key0 = value0 * key1 = value1 * * [section 1] * foo = Spam * bar = Ham * baz = Egg * * [section 2] * name = John`); * * assertEquals(iniMap.size, 6); // It has 6 keys in total * ``` * * @returns The number of key/value pairs. */ get size() { let size = this.#global.size; for (const { map } of this.#sections.values()) { size += map.size; } return size; } /** * Gets the formatting options. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * key0 = value0 * key1 = value1`); * * assertEquals(iniMap.formatting.pretty, true); * ``` * * @returns The formatting options */ get formatting() { return this.#formatting; } /** Returns the comments in the INI. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * // Hey * key0 = value0 * key1 = value1 * // Hello * [section 1] * foo = Spam * bar = Ham * baz = Egg`); * * assertEquals(iniMap.comments.getAtLine(2), "// Hey") * assertEquals(iniMap.comments.getAtLine(5), "// Hello") * assertEquals(iniMap.comments.getAtSection("section 1"), "// Hello") * ``` * * @returns The comments */ get comments() { return this.#comments; } /** * Clears a single section or the entire INI. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * key0 = value0 * key1 = value1 * * [section 1] * foo = Spam * bar = Ham * baz = Egg * * [section 2] * name = John`); * * iniMap.clear("section 1"); * * assertEquals(iniMap.toObject(), { * key0: "value0", * key1: "value1", * "section 2": { * name: "John", * }, * }); * * iniMap.clear(); // Clears all * * assertEquals(iniMap.toObject(), {}); * ``` * * @param sectionName The section name to clear */ clear(sectionName) { 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(keyOrSection, noneOrKey) { 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(keyOrSection, noneOrKey) { return this.#getValue(keyOrSection, noneOrKey)?.val; } has(keyOrSection, noneOrKey) { return this.#getValue(keyOrSection, noneOrKey) !== undefined; } // deno-lint-ignore no-explicit-any set(keyOrSection, valueOrKey, value) { 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 = { type: "value", num: section.end, sec: section.sec, key: valueOrKey, val: value, }; this.#appendValue(lineValue); section.map.set(valueOrKey, lineValue); } } else { const 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. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * key0 = value0 * key1 = value1 * * [section 1] * foo = Spam * bar = Ham * baz = Egg`); * * assertEquals([...iniMap.entries()], [ * ["key0", "value0"], * ["key1", "value1"], * ["foo", "Spam", "section 1"], * ["bar", "Ham", "section 1"], * ["baz", "Egg", "section 1"] * ]); * ``` * * @returns The iterator of entries */ *entries() { 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) { const existing = this.#sections.get(section); if (existing) { return existing; } const lineSection = { type: "section", num: this.#lines.length + 1, sec: section, map: new Map(), end: this.#lines.length + 1, }; this.#lines.push(lineSection); this.#sections.set(section, lineSection); return lineSection; } #getValue(keyOrSection, noneOrKey) { if (noneOrKey) { const section = this.#sections.get(keyOrSection); return section?.map.get(noneOrKey); } return this.#global.get(keyOrSection); } #getComment(line) { const comment = this.#lines[line - 1]; if (comment?.type === "comment") { return comment; } } #appendValue(lineValue) { 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, op) { 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) { const { length } = text; let line = ""; for (let i = 0; i < length; i += 1) { const char = text[i]; if (char === "\n" || char === "\r") { yield line; line = ""; if (char === "\r" && text[i + 1] === "\n") { i++; if (!this.#formatting.lineBreak) { this.#formatting.lineBreak = "\r\n"; } } else if (!this.#formatting.lineBreak) { this.#formatting.lineBreak = char; } } else { line += char; } } yield line; } #cleanFormatting(options) { return Object.fromEntries(Object.entries(options ?? {}).filter(([key]) => FormattingKeys.includes(key))); } /** * Convert this `IniMap` to a plain object. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * key0 = value0 * key1 = value1 * * [section 1] * foo = Spam * bar = Ham * baz = Egg`); * * assertEquals(iniMap.toObject(), { * key0: "value0", * key1: "value1", * "section 1": { * foo: "Spam", * bar: "Ham", * baz: "Egg", * }, * }); * ``` * * @returns The object equivalent to this {@code IniMap} */ toObject() { const obj = {}; 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 = {}; 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`. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * key0 = value0 * key1 = value1 * * [section 1] * foo = Spam * bar = Ham * baz = Egg`); * * assertEquals(iniMap.toJSON(), { * key0: "value0", * key1: "value1", * "section 1": { * foo: "Spam", * bar: "Ham", * baz: "Egg", * }, * }); * ``` * * @returns The object equivalent to this {@code IniMap} */ toJSON() { return this.toObject(); } /** * Convert this `IniMap` to an INI string. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = IniMap.from(` * // Hey * key0 = value0 * key1 = value1 * // Hello * [section 1] * foo = Spam * bar = Ham * baz = Egg`); * * iniMap.set("section 1", "foo", "Bacon"); * * assertEquals(iniMap.toString(), ` * // Hey * key0 = value0 * key1 = value1 * // Hello * [section 1] * foo = Bacon * bar = Ham * baz = Egg`) * ``` * @param replacer The replacer * @returns Ini string */ toString(replacer) { const replacerFunc = 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.sec && lineA.key === lineB.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`. * * @example Usage * ```ts * import { IniMap } from "@std/ini/ini-map"; * import { assertEquals } from "@std/assert"; * * const iniMap = new IniMap(); * * iniMap.parse(` * key0 = value0 * key1 = value1 * * [section 1] * foo = Spam * bar = Ham * baz = Egg`); * * assertEquals(iniMap.toObject(), { * key0: "value0", * key1: "value1", * "section 1": { * foo: "Spam", * bar: "Ham", * baz: "Egg", * }, * }); * ``` * * @param text The text to parse * @param reviver The reviver function * @returns This {@code IniMap} object */ parse(text, reviver) { if (typeof text !== "string") { throw new SyntaxError(`Unexpected token ${text} in INI at line 0`); } const reviverFunc = typeof reviver === "function" ? reviver : (_key, value, _section) => value; const assignment = (this.#formatting.assignment ?? "=").substring(0, 1); let lineNumber = 1; let currentSection; 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(), 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 = { 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 = { type: "value", num: lineNumber, key, val: reviverFunc(key, value), }; this.#global.set(key, lineValue); this.#lines.push(lineValue); } } lineNumber += 1; } return this; } static from( // deno-lint-ignore no-explicit-any input, formatting) { const ini = new IniMap(formatting); if (typeof input === "object" && input !== null) { // deno-lint-ignore no-explicit-any const isRecord = (val) => typeof val === "object" && val !== null; // deno-lint-ignore no-explicit-any const sort = ([_a, valA], [_b, valB]) => { 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; } } exports.IniMap = IniMap; /** Detect supported comment styles. */ function isComment(input) { return input === "" || input.startsWith("#") || input.startsWith(";") || input.startsWith("//"); } /** Detect a section start. */ function isSection(input, lineNumber) { if (input.startsWith("[")) { if (input.endsWith("]")) { return true; } throw new SyntaxError(`Unexpected end of INI section at line ${lineNumber}`); } return false; } const LineOp = { Del: -1, Add: 1, }; const DummyFormatting = { assignment: "", lineBreak: "\n", pretty: false, commentChar: "#", deduplicate: false, }; const FormattingKeys = Object.keys(DummyFormatting);