json_util.ts 8.12 KB
Newer Older
liuzhe-lz's avatar
liuzhe-lz committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const batchThreshold = 0.5;

/**
 *  Format objects in a "batch", which means they should either be all collapsed or all expanded.
 *
 *  Note that in this file the word "object" means any serializable value,
 *  while JavaScript `Object` is called "dict" instead.
 *
 *  Caller should use `detectBatch` to ensure that all non-null `objects` are "batchable".
 *  A single object is always a valid batch, and `null` can be batched with any value.
 *
 *  If the objects are values of dict, their keys can be passed with `keyOrKeys`,
 *  which will add `"key": ` before each stringified object.
 *
 *  @param objects  Objects to be stringify.
 *  @param curIndent  Spaces that should be prepended to each line.
 *  @param indent  Extra spaces to add for each level of indentation.
 *  @param width  Expected width of text block. This is only a hint, not hard limit.
 *  @param keyOrKeys  Array of keys for each object,
 *      or a single string as the same key of all objects,
 *      or `undefined` if they are not dict value.
 *
 *  @returns  Formatted string for each object, without trailing comma.
 **/
function batchFormat(
    objects: any[],
    curIndent: string,
    indent: string,
    width: number,
    keyOrKeys?: string | string[]
): string[] {
32
    let keys: string[]; // dict key as prefix string
liuzhe-lz's avatar
liuzhe-lz committed
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
    if (keyOrKeys === undefined) {
        keys = objects.map(() => '');
    } else if (typeof keyOrKeys === 'string') {
        keys = objects.map(() => `"${keyOrKeys}": `);
    } else {
        keys = keyOrKeys.map(k => `"${k}": `);
    }

    // try to collapse all
    const lines = objects.map((obj, i) => keys[i] + stringifySingleLine(obj));

    // null values don't affect hierarchy detection
    const nonNull = objects.filter(obj => obj !== null);
    if (nonNull.length === 0) {
        return lines;
    }

    const hasNested = nonNull.some(obj => detectNested(obj));

52
    if (!hasNested && lines.every(line => line.length + curIndent.length < width)) {
liuzhe-lz's avatar
liuzhe-lz committed
53
54
55
56
57
58
59
60
61
62
63
        return lines;
    }

    if (Array.isArray(nonNull[0])) {
        // objects are arrays, format all items in one batch
        const elements = batchFormat(concat(nonNull), curIndent + indent, indent, width);
        const iter = elements[Symbol.iterator]();
        return objects.map((obj, i) => {
            if (obj === null) {
                return keys[i] + 'null';
            } else {
64
65
66
67
68
69
70
71
72
                return (
                    keys[i] +
                    createBlock(
                        curIndent,
                        indent,
                        '[]',
                        obj.map(() => iter.next().value)
                    )
                );
liuzhe-lz's avatar
liuzhe-lz committed
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
            }
        });
    }

    if (typeof nonNull[0] === 'object') {
        // objects are dicts, format values in one batch if they have similar keys
        const values = concat(nonNull.map(obj => Object.values(obj)));
        const childrenKeys = concat(nonNull.map(obj => Object.keys(obj)));
        if (detectBatch(values)) {
            // these objects look like TypeScript style `Map` or `Record`, where the values have same "type"
            const elements = batchFormat(values, curIndent + indent, indent, width, childrenKeys);
            const iter = elements[Symbol.iterator]();
            return objects.map((obj, i) => {
                if (obj === null) {
                    return keys[i] + 'null';
                } else {
89
90
91
92
93
94
95
96
97
                    return (
                        keys[i] +
                        createBlock(
                            curIndent,
                            indent,
                            '{}',
                            Object.keys(obj).map(() => iter.next().value)
                        )
                    );
liuzhe-lz's avatar
liuzhe-lz committed
98
99
100
101
102
103
104
105
106
                }
            });
        } else {
            // these objects look like class instances, so we will try to group their fields
            const uniqueKeys = new Set(childrenKeys);
            const iters = new Map();
            for (const key of uniqueKeys) {
                const fields = nonNull.map(obj => obj[key]).filter(v => v !== undefined);
                let elements;
107
108
                if (detectBatch(fields)) {
                    // look like same field of class instances
liuzhe-lz's avatar
liuzhe-lz committed
109
                    elements = batchFormat(fields, curIndent + indent, indent, width, key);
110
111
                } else {
                    // no idea what these are, fallback to format them independently
liuzhe-lz's avatar
liuzhe-lz committed
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
                    elements = fields.map(field => batchFormat([field], curIndent + indent, indent, width, key));
                }
                iters.set(key, elements[Symbol.iterator]());
            }
            return objects.map((obj, i) => {
                if (obj === null) {
                    return keys[i] + 'null';
                } else {
                    const elements = Object.keys(obj).map(key => iters.get(key).next().value);
                    return keys[i] + createBlock(curIndent, indent, '{}', elements);
                }
            });
        }
    }

    // objects are primitive, impossible to break lines although they are too long
    return lines;
}

/**
 *  Detect whether objects should be formated as a batch or formatted on their own.
 *
 *  Objects should be batched if and only if one of following conditions holds:
 *    * They are all primitive values.
 *    * They are all arrays or null.
 *    * They are all dicts or null, and the dicts have similar keys.
 *
 *  For dicts, we assume the perfect situation is that each dict has all keys.
 *  Then we measure their similarity by how many fields are "missing" in order to become perfect match.
 *  The similarity value is calculated as:
 *      number of missing fields / total fields of all dicts if they are perfectly matched
 *  The threshold of similarity is defined by `batchThreshold`, which is 0.5 by default.
 *  Dicts are considered batchable iff their similarity value is greater than the threshold.
 *
 *  @param objects  The objects to be analyzed.
 *
 *  @returns  `true` if objects should be batched; `false` otherwise.
 **/
function detectBatch(objects: any[]): boolean {
    const nonNull = objects.filter(obj => obj !== null);

    if (nonNull.every(obj => Array.isArray(obj))) {
        return sameType(concat(nonNull));
    }

157
    if (nonNull.every(obj => typeof obj === 'object' && !Array.isArray(obj))) {
liuzhe-lz's avatar
liuzhe-lz committed
158
        const totalKeys = new Set(concat(nonNull.map(obj => Object.keys(obj)))).size;
159
        const missKeys = nonNull.map(obj => totalKeys - Object.keys(obj).length);
liuzhe-lz's avatar
liuzhe-lz committed
160
        const missSum = missKeys.reduce((a, b) => a + b, 0);
161
        return missSum < totalKeys * nonNull.length * batchThreshold;
liuzhe-lz's avatar
liuzhe-lz committed
162
163
164
165
166
167
    }

    return sameType(nonNull);
}

function detectNested(obj: any): boolean {
168
    return typeof obj == 'object' && Object.values(obj).some(child => typeof child == 'object');
liuzhe-lz's avatar
liuzhe-lz committed
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
}

function concat(arrays: any[][]): any[] {
    return ([] as any[]).concat(...arrays);
}

function createBlock(curIndent: string, indent: string, brackets: string, elements: string[]): string {
    if (elements.length === 0) {
        return brackets;
    }
    const head = brackets[0] + '\n' + curIndent + indent;
    const lineSeparator = ',\n' + curIndent + indent;
    const tail = '\n' + curIndent + brackets[1];
    return head + elements.join(lineSeparator) + tail;
}

function sameType(objects: any[]): boolean {
    const nonNull = objects.filter(obj => obj !== undefined);
187
    return nonNull.length > 0 ? nonNull.every(obj => typeof obj === typeof nonNull[0]) : true;
liuzhe-lz's avatar
liuzhe-lz committed
188
189
190
191
192
193
194
195
196
197
}

function stringifySingleLine(obj: any): string {
    if (obj === null) {
        return 'null';
    } else if (typeof obj === 'number' || typeof obj === 'boolean') {
        return obj.toString();
    } else if (typeof obj === 'string') {
        return `"${obj}"`;
    } else if (Array.isArray(obj)) {
198
        return '[' + obj.map(x => stringifySingleLine(x)).join(', ') + ']';
liuzhe-lz's avatar
liuzhe-lz committed
199
    } else {
200
201
202
203
204
205
206
        return (
            '{' +
            Object.keys(obj)
                .map(key => `"${key}": ${stringifySingleLine(obj[key])}`)
                .join(', ') +
            '}'
        );
liuzhe-lz's avatar
liuzhe-lz committed
207
208
209
210
211
212
213
214
    }
}

function prettyStringify(obj: any, width: number, indent: number): string {
    return batchFormat([obj], '', ' '.repeat(indent), width)[0];
}

export { prettyStringify };