-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathJSONPrettifier.java
More file actions
412 lines (390 loc) · 15.2 KB
/
Copy pathJSONPrettifier.java
File metadata and controls
412 lines (390 loc) · 15.2 KB
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
/*
* MIT License
*
* Copyright (c) 2017 Donato Rimenti
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package co.aurasphere.scripts;
import java.util.concurrent.atomic.AtomicInteger;
/**
*
* Utility class used to pretty print JSON. <br>
* <br>
* This class is ThreadSafe but due to its slowness [O(n)], it's highly
* discouraged to use it on production environments of performance-critical
* applications where other libraries like Google's GSON are better choices.<br>
* <br>
* It may be useful for scripts and long-running tasks though since the
* implementation is self-contained.
*
* @author Donato Rimenti
*
*/
public class JSONPrettifier {
/**
* String used to represent one indentation level.
*/
private static final String INDENTATION_CHAR = " ";
/**
* Formats the JSON into different readable rows. The applied algorithm adds
* one or more escape characters to the JSON String following this rules:
*
* <pre>
* - "{" || "[": adds \n, adds a level of indentation
* - "}" || "]": adds \n, removes a level of indentation
* - ",": adds \n
* - " ": trimmed if outside quotes
* - ":": adds " " after and before if outside quotes
* - any other character: adds that character
* </pre>
*
* This method supports parsing multiple JSON's lines at once and also can
* parse truncated JSON as far as it ends with the "...\n" String. Please
* notice that the truncation may happen both inside or outside a JSON
* field. This means that if you have a field that contains "...\n" you will
* have to escape the newline character in order to avoid to truncate the
* JSON. <br>
* <br>
* The algorithm applied is slow for large inputs since the String is parsed
* character by character and thus has a complexity of O(n)(linear).
*
* @param data
* the JSON string to prettify.
* @return the JSON string prettified.
*/
public static String prettify(String data) {
if (data == null || data == "") {
return "";
}
StringBuilder builder = new StringBuilder();
// Whether I'm currently parsing a String or not. Used in order to avoid
// indentation if I'm inside double quotes.
boolean outsideQuotes = true;
// Handles the current indentation within the method instead of using
// an instance field in order to make the class ThreadSafe at the cost
// of some code cleanliness.
AtomicInteger currentIndentationLevel = new AtomicInteger();
char currentChar;
// Cycles all characters in the JSON String.
for (int i = 0; i < data.length(); i++) {
currentChar = data.charAt(i);
switch (currentChar) {
// If the char is a "{" or a "[" then adds a newline and a level of
// indentation. If i == 0, then we are at the first character of the
// stream, no need to add a newline at the beginning.
case '[':
case '{':
conditionalIndent(builder, currentIndentationLevel, i == 0, outsideQuotes);
builder.append(currentChar);
conditionalIndent(builder, currentIndentationLevel, false, outsideQuotes);
break;
// If the char is a "}" or a "]" then adds a newline and removes a
// level of
// indentation.
case ']':
case '}':
conditionalDeindent(builder, currentIndentationLevel, false, outsideQuotes);
builder.append(currentChar);
// If the char after this is another "}" or a "," it doesn't had
// another newline.
conditionalDeindent(builder, currentIndentationLevel, charAfter(data, i, '}'), outsideQuotes,
!charAfter(data, i, ','));
// If this JSON object has been closed, adds an extra newline in
// order to improve readability.
conditionalAppend(builder, "\n\n", currentIndentationLevel.get() == 0);
break;
// If the char is a "," then removes a level of indentation if the
// character before was a "}" (because they are sibling objects).
case ',':
builder.append(currentChar);
// Since the two statements in this case checks both on the char
// before this only one of them is executed.
boolean wasTokenBeforeAnObject = charBefore(data, i, '}');
boolean isTokenAfterAnObject = charAfter(data, i, '{');
// If the char before this was a "}" then before this comma
// there was an object that has ended and thus removes a level
// of indentation. It doesn't add a new line if the next
// character is "{" since it will be added on the next round of
// parsing.
conditionalDeindent(builder, currentIndentationLevel, isTokenAfterAnObject, outsideQuotes,
wasTokenBeforeAnObject);
// If the char before this one was not a "}" then we didn't
// parse an object before but just a String or an array. In that
// case, I don't remove any level of indentation and I append
// the current indentation level plus a newline if the next
// character is not a "{" for the same reason as above.
conditionalAppend(builder, currentIndentationToString(isTokenAfterAnObject, currentIndentationLevel),
outsideQuotes, !wasTokenBeforeAnObject);
break;
// If the char is a "\"" (quote) and it's not escaped, switches the
// outsideQuotes flag in order to prevent parsing commas and
// brackets inside quotes as if they were JSON.
case '"':
if (!charBefore(data, i, '\\')) {
outsideQuotes = !outsideQuotes;
}
builder.append(currentChar);
break;
// If the char is a "\n" (newline) checks if the 3 character before
// where dots. If that's the case, the JSON has been truncated.
// Resets the indentation level and other flags and adds a newline
// to improve readability. Notice that the truncation may happen
// inside one of the JSON fields as well, so make sure to escape any
// newline character inside the JSON before parsing it.
case '\n':
if (charBefore(data, i, '.') && charBefore(data, i - 1, '.') && charBefore(data, i - 2, '.')) {
currentIndentationLevel.set(0);
outsideQuotes = true;
builder.append("\n\n");
}
break;
// Trims whitespaces in JSON if outside quotes.
case ' ':
if (!outsideQuotes) {
builder.append(currentChar);
}
break;
// Adds spaces between and after colon.
case ':':
if (outsideQuotes) {
builder.append(" ").append(currentChar).append(" ");
} else {
builder.append(currentChar);
}
break;
// If the char is anything else, just appends it.
default:
builder.append(currentChar);
}
}
return builder.toString();
}
/**
* Returns the current indentation level as a String. The String will be
* made of n {@link JSONPrettifier#INDENTATION_CHAR} with n being the
* currentIndentation argument passed.
*
* @param noNewLine
* whether to omit a new line character ("\n") at the beginning
* of the returned String or not.
* @param currentIndentationLevel
* the current indentation level or the number of
* {@link #INDENTATION_CHAR} to append to the returned String.
* @return a String representing the current indentation level.
*/
private static String currentIndentationToString(boolean noNewLine, AtomicInteger currentIndentationLevel) {
StringBuilder builder = new StringBuilder();
int indentation = currentIndentationLevel.get();
// If the current indentation is 0, doesn't add a newline. This is to
// prevent the case where there are multiple commas outside the JSON
// String so that they don't always go on a newline.
if (indentation > 0 && !noNewLine) {
builder.append("\n");
}
for (int i = 0; i < indentation; i++) {
builder.append(INDENTATION_CHAR);
}
return builder.toString();
}
/**
* Adds a level of indentation and returns the current indentation level.
*
* @param noNewLine
* whether to omit a new line character ("\n") at the beginning
* of the returned String or not.
* @param currentIndentationLevel
* the current indentation level or the number of
* {@link #INDENTATION_CHAR} to append to the returned String.
* @return a String representing the current indentation level.
*/
private static String indent(boolean noNewLine, AtomicInteger currentIndentationLevel) {
currentIndentationLevel.incrementAndGet();
return currentIndentationToString(noNewLine, currentIndentationLevel);
}
/**
* Removes a level of indentation and returns the current indentation level.
*
* @param noNewLine
* whether to omit a new line character ("\n") at the beginning
* of the returned String or not.
* @param currentIndentationLevel
* the current indentation level or the number of
* {@link #INDENTATION_CHAR} to append to the returned String.
* @return a String representing the current indentation level.
*/
private static String deindent(boolean noNewLine, AtomicInteger currentIndentationLevel) {
currentIndentationLevel.decrementAndGet();
return currentIndentationToString(noNewLine, currentIndentationLevel);
}
/**
* Checks whether the character before the one at the index passed (ignoring
* whitespace characters) is equal to the character passed.
*
* @param data
* the String where to perform the check.
* @param index
* the index of the character whose preceding character has to be
* checked.
* @param character
* the character to match.
* @return true if data.charAt(index - 1) == character (ignoring whitespace
* characters), false otherwise
*/
private static boolean charBefore(String data, int index, char character) {
if (index - 1 < 0 || data.length() <= index - 1) {
return false;
}
// Trims whitespace characters.
do {
index--;
} while (index - 1 > 0 && data.charAt(index) == ' ');
return character == data.charAt(index);
}
/**
* Checks whether the character after the one at the index passed (ignoring
* whitespace characters) is equal to the character passed.
*
* @param data
* the String where to perform the check.
* @param index
* the index of the character whose subsequent character has to
* be checked.
* @param character
* the character to match.
* @return true if data.charAt(index + 1) == character (ignoring whitespace
* characters), false otherwise
*/
private static boolean charAfter(String data, int index, char character) {
if (index + 1 < 0 || data.length() <= index + 1) {
return false;
}
// Trims whitespace characters.
do {
index++;
} while (data.length() > index + 1 && data.charAt(index) == ' ');
return character == data.charAt(index);
}
/**
* Appends a String to a StringBuilder only if ALL the conditions passed are
* met. If no conditions are passed, appends the String.
*
* @param builder
* the StringBuilder where to append the String.
* @param string
* the String to append.
* @param conditions
* the conditions to check before appending the String. If no
* conditions are passed, the String is appended by default.
* @return the StringBuilder passed as argument.
*/
private static StringBuilder conditionalAppend(StringBuilder builder, String string, boolean... conditions) {
boolean append = true;
// Checks all the conditions.
for (boolean b : conditions) {
append = append && b;
}
if (append) {
builder.append(string);
}
return builder;
}
/**
* Adds a level of indentation to a StringBuilder only if ALL the conditions
* passed are met. If no conditions are passed, adds an indentation level.
*
* @param builder
* the StringBuilder where to append the indentation.
* @param noNewLine
* whether to omit a new line character ("\n") at the beginning
* of the appended indentation.
* @param currentIndentationLevel
* the current indentation level or the number of
* {@link #INDENTATION_CHAR} to append when indenting.
* @param conditions
* the conditions to check before adding the indentation. If no
* conditions are passed, the indentation is added by default.
* @return the StringBuilder passed as argument.
*/
private static StringBuilder conditionalIndent(StringBuilder builder, AtomicInteger currentIndentationLevel,
boolean noNewLine, boolean... conditions) {
boolean append = true;
// Checks all the conditions.
for (boolean b : conditions) {
append = append && b;
}
if (append) {
builder.append(indent(noNewLine, currentIndentationLevel));
}
return builder;
}
/**
* Removes a level of indentation to a StringBuilder only if ALL the
* conditions passed are met. If no conditions are passed, removes an
* indentation level.
*
* Please notice that this method removes a level of indentation by actually
* appending a new indentation that is one level below the one passed as
* argument.
*
* @param builder
* the StringBuilder where to remove the indentation.
* @param noNewLine
* whether to omit a new line character ("\n") at the beginning
* of the appended indentation.
* @param currentIndentationLevel
* the current indentation level or the number of
* {@link #INDENTATION_CHAR} to append when indenting.
* @param conditions
* the conditions to check before removing. If no conditions are
* passed, the indentation is removed by default.
* @return the StringBuilder passed as argument.
*/
private static StringBuilder conditionalDeindent(StringBuilder builder, AtomicInteger currentIndentationLevel,
boolean noNewLine, boolean... conditions) {
boolean append = true;
// Checks all the conditions.
for (boolean b : conditions) {
append = append && b;
}
if (append) {
builder.append(deindent(noNewLine, currentIndentationLevel));
}
return builder;
}
/**
* Checks that only one argument, a JSON string, is passed to this
* application and then prettifies it.
*
* @param args
* contains the JSON string to prettify
*/
public static void main(String[] args) {
if (args == null || args.length != 1) {
// Error if zero or more than one arguments.
System.out.println(
"Wrong arguments number. You must pass exactly one argument, the JSON string to prettify.");
System.exit(1);
} else {
// Prints the prettified JSON string and exits.
System.out.println(prettify(args[0]));
System.exit(0);
}
}
}