diff --git a/packages/lib/factorio/export.js b/packages/lib/factorio/export.js index 2e71dd08..173f5cb3 100644 --- a/packages/lib/factorio/export.js +++ b/packages/lib/factorio/export.js @@ -2,10 +2,10 @@ "use strict"; const fs = require("fs-extra"); const path = require("path"); -const ini = require("ini"); const Jimp = require("jimp"); const JSZip = require("jszip"); +const libIni = require("../ini"); const libBuildMod = require("../build_mod"); /** @@ -312,7 +312,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) { } let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg"); - mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8"))); + mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8"))); for (let mod of modOrder) { if (["core", "base", "export"].includes(mod)) { @@ -330,7 +330,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) { } for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) { let content = await file.async("nodebuffer"); - mergeLocale(ini.parse(content.toString("utf8"))); + mergeLocale(libIni.parse(content.toString("utf8"))); } } diff --git a/packages/lib/factorio/server.js b/packages/lib/factorio/server.js index 5b08447a..ec2d4766 100644 --- a/packages/lib/factorio/server.js +++ b/packages/lib/factorio/server.js @@ -7,7 +7,7 @@ const events = require("events"); const util = require("util"); const crypto = require("crypto"); -const ini = require("ini"); +const libIni = require("../ini"); const rconClient = require("rcon-client"); const libErrors = require("../errors"); const { logger } = require("../logging"); @@ -1167,7 +1167,7 @@ class FactorioServer extends events.EventEmitter { } async _writeConfigIni() { - let content = ini.encode({ + let content = libIni.stringify({ path: { "read-data": this.dataPath(), "write-data": this.writePath(), diff --git a/packages/lib/ini.js b/packages/lib/ini.js new file mode 100644 index 00000000..413aeb92 --- /dev/null +++ b/packages/lib/ini.js @@ -0,0 +1,94 @@ +/** + * Factorio Compatible INI parser + * + * @module lib/ini + * @author Hornwitser + */ + +"use strict"; + +/** + * Parse INI file into object of sections + * @param {string} input - content of INI file to parse. + * @returns {object>} + * sections parsed from the ini file. + */ +function parse(input) { + const sections = {}; + let currentSection = sections; + const lines = input.split(/\r\n|\n/); + for (let number = 1; number <= lines.length; number++) { + let line = lines[number - 1].replace(/^[\t ]+/, ""); + if (!line || line[0] === "#" || line[0] === ";") { + continue; + } + + // Read section header + if (line[0] === "[") { + line = line.replace(/[\t ]+$/, ""); + if (line.slice(-1) !== "]") { + throw new Error(`Unterminated section header on line ${number}`); + } + const name = line.slice(1, -1); + if (Object.prototype.hasOwnProperty.call(sections, name)) { + throw new Error(`Duplicated section [${name}] on line ${number}`); + } + currentSection = {}; + sections[name] = currentSection; + continue; + } + + // Read key value + const equalIndex = line.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Missing value for key ${line} on line ${number}`); + } + const key = line.slice(0, equalIndex); + if (Object.prototype.hasOwnProperty.call(currentSection, key)) { + throw new Error(`Duplicated key ${key} on line ${number}`); + } + const value = line.slice(equalIndex + 1); + currentSection[key] = value; + } + + return sections; +} + +/** + * Serialize an object of section definitions into an INI file + * @param {object>} sections - + * Sections to encode into ini format + * @returns {string} sections serialized into INI format + */ +function stringify(sections) { + let serialized = []; + let first = true; + + for (let [key, value] of Object.entries(sections)) { + if (typeof value === "string") { + serialized.push(`${key}=${value}`); + first = false; + } + } + + for (let [section, content] of Object.entries(sections)) { + if (typeof content === "object") { + if (!first) { + serialized.push(""); + } + first = false; + serialized.push(`[${section}]`); + for (let [key, value] of Object.entries(content)) { + serialized.push(`${key}=${value}`); + } + } + } + + if (serialized.length) { serialized.push(""); } + return serialized.join("\n"); +} + +module.exports = { + parse, + stringify, +}; diff --git a/packages/lib/package.json b/packages/lib/package.json index 63e9d6a3..f7b78901 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -15,7 +15,6 @@ "ajv": "^6.12.6", "chalk": "^4.1.0", "fs-extra": "^8.1.0", - "ini": "^1.3.5", "isomorphic-ws": "^4.0.1", "jimp": "^0.12.1", "jsonwebtoken": "^8.2.1", diff --git a/test/lib/ini.js b/test/lib/ini.js new file mode 100644 index 00000000..846e614c --- /dev/null +++ b/test/lib/ini.js @@ -0,0 +1,93 @@ +"use strict"; +const assert = require("assert").strict; +const libIni = require("@clusterio/lib/ini"); + +describe("lib/ini", function() { + describe("parse()", function() { + it("should ignore comments", function() { + assert.deepEqual( + libIni.parse("# A comment\n; Another comment\nfoo=bar\n"), + { foo: "bar" }, + ); + }); + it("should ignore certain whitespace", function() { + assert.deepEqual( + libIni.parse(" # A comment \n\n [spam] \t \n \tfoo=bar\n"), + { spam: { foo: "bar" }} + ); + }); + it("should preserve certain whitespace", function() { + assert.deepEqual( + libIni.parse("[\t spam\t ]\nfoo =\t bar \n"), + { "\t spam\t ": { "foo ": "\t bar " }} + ); + }); + it("should not treat characters specially", function() { + assert.deepEqual( + libIni.parse("[#;spam]\nfoo#;=#;bar=#;'\""), + { "#;spam": { "foo#;": "#;bar=#;'\"" }} + ); + }); + it("should handle windows line endings", function() { + assert.deepEqual( + libIni.parse("[spam]\r\nfoo=bar\r\n"), + { "spam": { "foo": "bar" }} + ); + }); + it("should throw on incomplete section", function() { + assert.throws( + () => libIni.parse("[spam"), + new Error("Unterminated section header on line 1") + ); + }); + it("should throw on missing value", function() { + assert.throws( + () => libIni.parse("key"), + new Error("Missing value for key key on line 1") + ); + }); + it("should throw on duplicated section", function() { + assert.throws( + () => libIni.parse("[spam]\n[spam]\n"), + new Error("Duplicated section [spam] on line 2") + ); + }); + it("should throw on duplicated keys", function() { + assert.throws( + () => libIni.parse("key=value\nkey=value\n"), + new Error("Duplicated key key on line 2") + ); + }); + }); + describe("stringify()", function() { + it("should serialize to reference output", function() { + let cases = [ + [{}, ""], + [{ foo: "bar" }, "foo=bar\n"], + [{ foo: { spam: "one", bar: "two" }}, "[foo]\nspam=one\nbar=two\n"], + [ + { start: "alpha", foo: { spam: "one", bar: "two" }, end: "beta" }, + "start=alpha\nend=beta\n\n[foo]\nspam=one\nbar=two\n", + ], + [{ "trail ": "\t tabs \t" }, "trail =\t tabs \t\n"], + [{ "#;spam": { "foo#;": "#;bar=#;'\"" }}, "[#;spam]\nfoo#;=#;bar=#;'\"\n"], + ]; + for (let [input, expected] of cases) { + assert.equal(libIni.stringify(input), expected); + } + }); + }); + it("should round trip object", function() { + for (let expected of [ + {}, + { simple: "value" }, + { section: { foo: "false", bar: "text" }}, + { mixed: "root", section: { foo: "1", bar: "2" }, end: "value" }, + { first: { foo: "1", bar: "2" }, second: { spam: "true" }}, + { '"#;[] ': ' "#;[] ', ' #;"[ ': { '"#;[] ': ' "#;[ ' }}, + ]) { + let actual = libIni.parse(libIni.stringify(expected)); + assert.deepEqual(actual, expected); + } + }); +});