Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 0 additions & 162 deletions ini/_ini_map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,10 @@ export type ReviverFunction = (
section?: string,
) => unknown;

const ASSIGNMENT_MARK = "=";

function isPlainObject(object: unknown): object is object {
return Object.prototype.toString.call(object) === "[object Object]";
}

function trimQuotes(value: string): string {
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1);
}
return value;
}

const NON_WHITESPACE_REGEXP = /\S/;

/**
* Class implementation for fine control of INI data structures.
*/
Expand Down Expand Up @@ -206,32 +195,6 @@ export class IniMap {
}
}

*#readTextLines(text: string): Generator<string> {
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;
}

/**
* Convert this `IniMap` to a plain object.
*
Expand Down Expand Up @@ -269,108 +232,6 @@ export class IniMap {
return obj;
}

/**
* Parse an INI string in this `IniMap`.
*
* @param text The text to parse
* @param reviver The reviver function
* @returns This {@code IniMap} object
*/
parse(text: string, reviver?: ReviverFunction): this {
if (typeof text !== "string") {
throw new SyntaxError(`Unexpected token ${text} in INI at line 0`);
}

reviver ??= (_key, value, _section) => {
if (!isNaN(+value) && !value.includes('"')) return +value;
if (value === "null") return null;
if (value === "true" || value === "false") return value === "true";
return trimQuotes(value);
};

let lineNumber = 1;
let currentSection: LineSection | undefined;

for (const line of this.#readTextLines(text)) {
const trimmed = line.trim();
if (isComment(trimmed)) {
this.#lines.push({
type: "comment",
num: lineNumber,
val: trimmed,
});
} else if (isSection(trimmed, lineNumber)) {
const sec = trimmed.substring(1, trimmed.length - 1);

if (!NON_WHITESPACE_REGEXP.test(sec)) {
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_MARK);

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: reviver(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: reviver(key, value),
};
this.#global.set(key, lineValue);
this.#lines.push(lineValue);
}
}

lineNumber += 1;
}

return this;
}

/**
* Create an `IniMap` from an INI string.
*
Expand Down Expand Up @@ -414,34 +275,11 @@ export class IniMap {
ini.set(key, val);
}
}
} else {
ini.parse(input, formatting?.reviver);
}
return ini;
}
}

/** 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,
Expand Down
129 changes: 126 additions & 3 deletions ini/parse.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,63 @@
// Copyright 2018-2025 the Deno authors. MIT license.
// This module is browser compatible.

import { IniMap, type ReviverFunction } from "./_ini_map.ts";
import type { ReviverFunction } from "./_ini_map.ts";
export type { ReviverFunction };

const SECTION_REGEXP = /^\[(?<name>.*\S.*)]$/;
const KEY_VALUE_REGEXP = /^(?<key>.*?)\s*=\s*(?<value>.*?)$/;

function trimQuotes(value: string): string {
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1);
}
return value;
}

/** Detect supported comment styles. */
function isComment(input: string): boolean {
return (
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;
}

function* readTextLines(text: string): Generator<string> {
let line = "";
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
switch (char) {
case "\n":
yield line;
line = "";
break;
case "\r":
yield line;
line = "";
if (text[i + 1] === "\n") i += 1;
break;
default:
line += char;
break;
}
}
yield line;
}

/** Options for {@linkcode parse}. */
export interface ParseOptions {
/**
Expand All @@ -14,6 +68,13 @@ export interface ParseOptions {
reviver?: ReviverFunction;
}

function defaultReviver(_key: string, value: string, _section?: string) {
if (!isNaN(+value) && !value.includes('"')) return +value;
if (value === "null") return null;
if (value === "true" || value === "false") return value === "true";
return trimQuotes(value);
}

/**
* Parse an INI config string into an object.
*
Expand Down Expand Up @@ -81,7 +142,69 @@ export interface ParseOptions {
*/
export function parse<T extends object>(
text: string,
options?: ParseOptions,
options: ParseOptions = {},
): T {
return IniMap.from(text, options).toObject<T>();
if (typeof text !== "string") {
throw new SyntaxError(`Unexpected token ${text} in INI at line 0`);
}

const { reviver = defaultReviver } = options;

const root = {} as T;
let object: object = root;
let sectionName: string | undefined;

let lineNumber = 0;
for (let line of readTextLines(text)) {
line = line.trim();
lineNumber += 1;

// skip empty lines
if (line === "") continue;

// skip comment
if (isComment(line)) continue;

if (isSection(line, lineNumber)) {
sectionName = SECTION_REGEXP.exec(line)?.groups?.name;
if (!sectionName) {
throw new SyntaxError(
`Unexpected empty section name at line ${lineNumber}`,
);
}

object = {};
Object.defineProperty(root, sectionName, {
value: object,
writable: true,
enumerable: true,
configurable: true,
});

continue;
}

const groups = KEY_VALUE_REGEXP.exec(line)?.groups;

if (!groups) {
throw new SyntaxError(
`Unexpected token ${line[0]} in INI at line ${lineNumber}`,
);
}

const { key, value } = groups as { key: string; value: string };
if (!key.length) {
throw new SyntaxError(`Unexpected empty key name at line ${lineNumber}`);
}

const val = reviver(key, value, sectionName);
Object.defineProperty(object, key, {
value: val,
writable: true,
enumerable: true,
configurable: true,
});
}

return root;
}
36 changes: 36 additions & 0 deletions ini/parse_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ Deno.test({
SyntaxError,
"Unexpected empty section name at line 1",
);
assertInvalidParse(
"[ ]\na=1",
SyntaxError,
"Unexpected empty section name at line 1",
);
assertInvalidParse(
`=100`,
SyntaxError,
Expand Down Expand Up @@ -230,3 +235,34 @@ Deno.test({
assertEquals(parse("value=true\t"), { value: true });
},
});

Deno.test({
name: "parse() parses padded lines",
fn() {
assertEquals(parse(" value=true"), { value: true });
assertEquals(parse("\tvalue=true"), { value: true });
assertEquals(parse("value =true"), { value: true });
assertEquals(parse("value\t=true"), { value: true });
assertEquals(parse("value= true"), { value: true });
assertEquals(parse("value=\ttrue"), { value: true });
assertEquals(parse("value= true "), { value: true });
assertEquals(parse("value=true\t"), { value: true });
assertEquals(parse(" \tvalue \t= \ttrue \t"), { value: true });
assertEquals(parse("[s]"), { s: {} });
assertEquals(parse("[ s ]"), { " s ": {} });
assertEquals(parse("[section]"), { section: {} });
assertEquals(parse("[ section ]"), { " section ": {} });

assertEquals(parse(" [section]"), { section: {} });
assertEquals(parse("\t[section]"), { section: {} });
assertEquals(parse("[section] "), { section: {} });
assertEquals(parse("[section]\t"), { section: {} });
assertEquals(parse(" \t[section] \t"), { section: {} });

assertEquals(parse(" value=true "), { value: true });
assertEquals(parse(' value = "abc" '), { value: "abc" });
assertEquals(parse(" [section] \n value = foo "), {
section: { value: "foo" },
});
},
});
Loading