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
JavaScript
"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);