1/**
2 * Parses INI configuration files.
3 *
4 * This module contains the decoding surface used by Effect's CLI without
5 * pulling in the complete `ini` package.
6 *
7 * @stability unstable
8 * @since 4.0.0
9 */
10
11/*
12 * The parser is adapted from `ini` 7.0.0.
13 *
14 * Copyright (c) Isaac Z. Schlueter and Contributors
15 *
16 * Permission to use, copy, modify, and/or distribute this software for any
17 * purpose with or without fee is hereby granted, provided that the above
18 * copyright notice and this permission notice appear in all copies.
19 *
20 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
21 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
22 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
23 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
24 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
25 * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
26 * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
27 */
28
29const hasOwn = Object.prototype.hasOwnProperty
30
31const splitSections = (value: string): Array<string> => {
32 const sections: Array<string> = []
33 let start = 0
34 let index = 0
35 while ((index = value.indexOf(".", index)) !== -1) {
36 if (index === 0 || value[index - 1] !== "\\") {
37 sections.push(value.slice(start, index))
38 start = index + 1
39 }
40 index++
41 }
42 sections.push(value.slice(start))
43 return sections
44}
45
46const isQuoted = (value: string): boolean =>
47 value.length >= 2 &&
48 ((value.startsWith("\"") && value.endsWith("\"")) ||
49 (value.startsWith("'") && value.endsWith("'")))
50
51const unquote = (input: string | undefined): string => {
52 let value = (input ?? "").trim()
53 if (isQuoted(value)) {
54 if (value[0] === "'") {
55 value = value.slice(1, -1)
56 }
57 try {
58 return JSON.parse(value)
59 } catch {
60 return value
61 }
62 }
63
64 let escaped = false
65 let output = ""
66 for (const character of value) {
67 if (escaped) {
68 output += character === ";" || character === "#" || character === "\\"
69 ? character
70 : `\\${character}`
71 escaped = false
72 } else if (character === ";" || character === "#") {
73 break
74 } else if (character === "\\") {
75 escaped = true
76 } else {
77 output += character
78 }
79 }
80 return output.trim()
81}
82
83/**
84 * Parses an INI document into a null-prototype record.
85 *
86 * **Details**
87 *
88 * Section names separated by dots become nested records, repeated keys ending
89 * in `[]` become arrays, and the scalar values `true`, `false`, and `null` are
90 * decoded. Other scalar values remain strings.
91 *
92 * @stability unstable
93 * @category decoding
94 * @since 4.0.0
95 */
96export const parse = (input: string): Record<string, unknown> => {
97 const output: Record<string, any> = Object.create(null)
98 let current = output
99 const sectionPattern = /^\[([^\]]*)\]\s*$/
100 const propertyPattern = /^([^=]+)(=(.*))?$/
101
102 for (const line of input.split(/[\r\n]+/g)) {
103 if (line.length === 0 || /^\s*(?:[;#]|$)/.test(line)) {
104 continue
105 }
106
107 const sectionMatch = sectionPattern.exec(line)
108 if (sectionMatch !== null) {
109 const section = unquote(sectionMatch[1])
110 if (section === "__proto__") {
111 current = Object.create(null)
112 } else {
113 current = output[section] ??= Object.create(null)
114 }
115 continue
116 }
117
118 const propertyMatch = propertyPattern.exec(line)
119 if (propertyMatch === null) {
120 continue
121 }
122
123 const rawKey = unquote(propertyMatch[1])
124 const array = rawKey.length > 2 && rawKey.endsWith("[]")
125 const key = array ? rawKey.slice(0, -2) : rawKey
126 if (key === "__proto__") {
127 continue
128 }
129
130 const rawValue = propertyMatch[2] === undefined ? true : unquote(propertyMatch[3])
131 const value = rawValue === "true" || rawValue === "false" || rawValue === "null"
132 ? JSON.parse(rawValue)
133 : rawValue
134
135 if (array) {
136 if (!hasOwn.call(current, key)) {
137 current[key] = []
138 } else if (!Array.isArray(current[key])) {
139 current[key] = [current[key]]
140 }
141 }
142 if (Array.isArray(current[key])) {
143 current[key].push(value)
144 } else {
145 current[key] = value
146 }
147 }
148
149 const remove: Array<string> = []
150 for (const section of Object.keys(output)) {
151 if (typeof output[section] !== "object" || output[section] === null || Array.isArray(output[section])) {
152 continue
153 }
154
155 const parts = splitSections(section)
156 const last = parts.pop()!
157 const key = last.replace(/\\\./g, ".")
158 current = output
159 for (const part of parts) {
160 if (part === "__proto__") {
161 continue
162 }
163 if (!hasOwn.call(current, part) || typeof current[part] !== "object" || current[part] === null) {
164 current[part] = Object.create(null)
165 }
166 current = current[part]
167 }
168 if (current === output && key === last) {
169 continue
170 }
171 current[key] = output[section]
172 remove.push(section)
173 }
174 for (const section of remove) {
175 delete output[section]
176 }
177 return output
178}