codekingpro/portable-devtools
115k
1const util = require('node:util')
2const _delete = Symbol('delete')
3const _append = Symbol('append')
4
5const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/)
6
7// replaces any occurrence of an empty-brackets (e.g: []) with a special Symbol(append) to represent it
8// this is going to be useful for the setter method that will push values to the end of the array when finding these
9const replaceAppendSymbols = str => {
10 const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/)
11
12 if (matchEmptyBracket) {
13 const [, pre, post] = matchEmptyBracket
14 return [...replaceAppendSymbols(pre), _append, post].filter(Boolean)
15 }
16
17 return [str]
18}
19
20const parseKeys = key => {
21 const sqBracketItems = new Set()
22 sqBracketItems.add(_append)
23 const parseSqBrackets = str => {
24 const index = sqBracketsMatcher(str)
25
26 // once we find square brackets, we recursively parse all these
27 if (index) {
28 const preSqBracketPortion = index[1]
29
30 // we want to have a `new String` wrapper here in order to differentiate between multiple occurrences of the same string,
31 // e.g: foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } }
32 /* eslint-disable-next-line no-new-wrappers */
33 const foundKey = new String(index[2])
34 const postSqBracketPortion = index[3]
35
36 // we keep track of items found during this step to make sure we don't try to split-separate keys that were defined within square brackets, since the key name itself might contain dots
37 sqBracketItems.add(foundKey)
38
39 // returns an array that contains either dot-separate items (that will be split apart during the next step OR the fully parsed keys read from square brackets
40 // e.g: foo.bar[1.0.0].a.b -> ['foo.bar', '1.0.0', 'a.b']
41 return [
42 ...parseSqBrackets(preSqBracketPortion),
43 foundKey,
44 ...(postSqBracketPortion ? parseSqBrackets(postSqBracketPortion) : []),
45 ]
46 }
47
48 // at the end of parsing, any usage of the special empty-bracket syntax (e.g: foo.array[]) has not yet been parsed
49 // here we'll take care of parsing it and adding a special symbol to represent it in the resulting list of keys
50 return replaceAppendSymbols(str)
51 }
52
53 const res = []
54 // starts by parsing items defined as square brackets
55 // those might be representing properties that have a dot in the name or just array indexes
56 // e.g: foo[1.0.0] or list[0]
57 const sqBracketKeys = parseSqBrackets(key.trim())
58
59 for (const k of sqBracketKeys) {
60 // keys parsed from square brackets should just be added to list of resulting keys as they might have dots as part of the key
61 if (sqBracketItems.has(k)) {
62 res.push(k)
63 } else {
64 // splits the dot-sep property names and add them to the list of keys
65 /* eslint-disable-next-line no-new-wrappers */
66 for (const splitKey of k.split('.')) {
67 res.push(String(splitKey))
68 }
69 }
70 }
71
72 // returns an ordered list of strings in which each entry represents a key in an object defined by the previous entry
73 return res
74}
75
76const getter = ({ data, key }, { unwrapSingleItemArrays = true } = {}) => {
77 // keys are a list in which each entry represents the name of a property that should be walked through the object in order to return the final found value
78 const keys = parseKeys(key)
79 let _data = data
80 let label = ''
81
82 for (const k of keys) {
83 // empty-bracket-shortcut-syntax is not supported on getter
84 if (k === _append) {
85 throw Object.assign(new Error('Empty brackets are not valid syntax for retrieving values.'), {
86 code: 'EINVALIDSYNTAX',
87 })
88 }
89
90 // extra logic to take into account printing array, along with its special syntax in which using a dot-sep property name after an array will expand it's results
91 // e.g: arr.name -> arr[0].name=value, arr[1].name=value, ...
92 const maybeIndex = Number(k)
93 if (Array.isArray(_data) && !Number.isInteger(maybeIndex)) {
94 _data = _data.reduce((acc, i, index) => {
95 acc[`${label}[${index}].${k}`] = i[k]
96 return acc
97 }, {})
98 return _data
99 } else {
100 if (!Object.hasOwn(_data, k)) {
101 return undefined
102 }
103 _data = _data[k]
104 }
105
106 label += k
107 }
108
109 // these are some legacy expectations from the old API consumed by lib/view.js
110 if (unwrapSingleItemArrays && Array.isArray(_data) && _data.length <= 1) {
111 _data = _data[0]
112 }
113
114 return {
115 [key]: _data,
116 }
117}
118
119const setter = ({ data, key, value, force }) => {
120 // setter goes to recursively transform the provided data obj
121 // setting properties from the list of parsed keys
122 // e.g: ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } }
123 const keys = parseKeys(key)
124 const setKeys = (_data, _key) => {
125 // handles array indexes, converting valid integers to numbers
126 // note that occurrences of Symbol(append) will throw so we just ignore these for now
127 let maybeIndex = Number.NaN
128 try {
129 maybeIndex = Number(_key)
130 } catch {
131 // leave it NaN
132 }
133 if (!Number.isNaN(maybeIndex)) {
134 _key = maybeIndex
135 }
136
137 // creates new array in case key is an index and the array obj is not yet defined
138 const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
139 const dataHasNoItems = !Object.keys(_data).length
140 if (keyIsAnArrayIndex && dataHasNoItems && !Array.isArray(_data)) {
141 _data = []
142 }
143
144 // converting from array to an object is also possible, in case the user is using force mode
145 // we should also convert existing arrays to an empty object if the current _data is an array
146 if (force && Array.isArray(_data) && !keyIsAnArrayIndex) {
147 _data = { ..._data }
148 }
149
150 // the _append key is a special key that is used to represent the empty-bracket notation
151 // e.g: arr[] -> arr[arr.length]
152 if (_key === _append) {
153 if (!Array.isArray(_data)) {
154 throw Object.assign(new Error(`Can't use append syntax in non-Array element`), {
155 code: 'ENOAPPEND',
156 })
157 }
158 _key = _data.length
159 }
160
161 // retrieves the next data object to recursively iterate on
162 // throws if trying to override a literal value or add props to an array
163 const next = () => {
164 const haveContents = !force && _data[_key] != null && value !== _delete
165 const shouldNotOverrideLiteralValue = !(typeof _data[_key] === 'object')
166 // if the next obj to recurse is an array and the next key to be appended to the resulting obj is not an array index, then it should throw since we can't append arbitrary props to arrays
167 const shouldNotAddPropsToArrays =
168 typeof keys[0] !== 'symbol' && Array.isArray(_data[_key]) && Number.isNaN(Number(keys[0]))
169
170 const overrideError = haveContents && shouldNotOverrideLiteralValue
171 if (overrideError) {
172 throw Object.assign(
173 new Error(`Property ${_key} already exists and is not an Array or Object.`),
174 { code: 'EOVERRIDEVALUE' }
175 )
176 }
177
178 const addPropsToArrayError = haveContents && shouldNotAddPropsToArrays
179 if (addPropsToArrayError) {
180 throw Object.assign(new Error(`Can't add property ${key} to an Array.`), {
181 code: 'ENOADDPROP',
182 })
183 }
184
185 return typeof _data[_key] === 'object' ? _data[_key] || {} : {}
186 }
187
188 // sets items from the parsed array of keys as objects, recurses to setKeys in case there are still items to be handled
189 // otherwise, it just sets the original value set by the user
190 if (keys.length) {
191 _data[_key] = setKeys(next(), keys.shift())
192 } else {
193 // handles special deletion cases for obj props / array items
194 if (value === _delete) {
195 if (Array.isArray(_data)) {
196 _data.splice(_key, 1)
197 } else {
198 delete _data[_key]
199 }
200 } else {
201 // finally, sets the value in its right place
202 _data[_key] = value
203 }
204 }
205
206 return _data
207 }
208
209 setKeys(data, keys.shift())
210}
211
212class Queryable {
213 static ALL = ''
214
215 #data = null
216
217 constructor (obj) {
218 if (!obj || typeof obj !== 'object') {
219 throw Object.assign(new Error('Queryable needs an object to query properties from.'), {
220 code: 'ENOQUERYABLEOBJ',
221 })
222 }
223
224 this.#data = obj
225 }
226
227 query (queries, opts) {
228 // this ugly interface here is meant to be a compatibility layer with the legacy API lib/view.js is consuming
229 // if at some point we refactor that command then we can revisit making this nicer
230 if (queries === Queryable.ALL) {
231 return { [Queryable.ALL]: this.#data }
232 }
233
234 const q = query =>
235 getter({
236 data: this.#data,
237 key: query,
238 }, opts)
239
240 if (Array.isArray(queries)) {
241 let res = {}
242 for (const query of queries) {
243 res = { ...res, ...q(query) }
244 }
245 return res
246 } else {
247 return q(queries)
248 }
249 }
250
251 // return the value for a single query if found; otherwise, returns undefined
252 get (query) {
253 const obj = this.query(query)
254 if (obj) {
255 return obj[query]
256 }
257 }
258
259 // creates objects along the way for the provided `query` parameter and assigns `value` to the last property of the query chain
260 set (query, value, { force } = {}) {
261 setter({
262 data: this.#data,
263 key: query,
264 value,
265 force,
266 })
267 }
268
269 // deletes the value of the property found at `query`
270 delete (query) {
271 setter({
272 data: this.#data,
273 key: query,
274 value: _delete,
275 })
276 }
277
278 toJSON () {
279 return this.#data
280 }
281
282 [util.inspect.custom] () {
283 return this.toJSON()
284 }
285}
286
287module.exports = Queryable
288 