UNPKG

fracturedjsonjs

Version:

JSON formatter that produces highly readable but fairly compact output

360 lines (359 loc) 19.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TableTemplate = void 0; /** * Collects spacing information about the columns of a potential table. Each TableTemplate corresponds to * a part of a row, and they're nested recursively to match the JSON structure. (Also used in complex multiline * arrays to try to fit them all nicely together.) * * Say you have an object/array where each item would make a nice row all by itself. We want to try to line up * everything about it - comments, prop names, values. If the row items are themselves objects/arrays, ideally * we'd like to line up all of their children, too, recursively. This only works as long as the structure/types * are consistent. */ const JsonItemType_1 = require("./JsonItemType"); const BracketPaddingType_1 = require("./BracketPaddingType"); const NumberListAlignment_1 = require("./NumberListAlignment"); const TableColumnType_1 = require("./TableColumnType"); class TableTemplate { constructor(pads, numberListAlignment) { /** * The property name in the table that this segment matches up with. */ this.LocationInParent = undefined; /** * Type of the column, for table formatting purposes. Numbers have special options. Arrays or objects can * have recursive sub-columns. If they're other simple types or if there's a mix of types, we basically * treat them as strings (no recursion). */ this.Type = TableColumnType_1.TableColumnType.Unknown; this.RowCount = 0; /** * Length of the longest property name. */ this.NameLength = 0; /** * Length of the shortest property name. */ this.NameMinimum = Number.MAX_SAFE_INTEGER; /** * Largest length for the value parts of the column, not counting any table formatting padding. */ this.MaxValueLength = 0; /** * Length of the largest value that can't be split apart; i.e., values other than arrays and objects. */ this.MaxAtomicValueLength = 0; this.PrefixCommentLength = 0; this.MiddleCommentLength = 0; this.AnyMiddleCommentHasNewline = false; this.PostfixCommentLength = 0; this.IsAnyPostCommentLineStyle = false; this.PadType = BracketPaddingType_1.BracketPaddingType.Simple; this.RequiresMultipleLines = false; /** * Length of the value for this template when things are complicated. For arrays and objects, it's the sum of * all the child templates' lengths, plus brackets and commas and such. For number lists, it's the space * required to align them as appropriate. */ this.CompositeValueLength = 0; /** * Length of the entire template, including space for the value, property name, and all comments. */ this.TotalLength = 0; /** * If the row contains non-empty array or objects whose value is shorter than the literal null, an extra adjustment * is needed. */ this.ShorterThanNullAdjustment = 0; /** * True if at least one row in the column this represents has a null value. */ this.ContainsNull = false; /** * If this TableTemplate corresponds to an object or array, Children contains sub-templates * for the array/object's children. */ this.Children = []; this._maxDigBeforeDec = 0; this._maxDigAfterDec = 0; this._pads = pads; this._numberListAlignment = numberListAlignment; } /** * Analyzes an object/array for formatting as a table, formatting as a compact multiline array, or * formatting as an expanded object with aligned properties. In the first two cases, we measure recursively so * that values nested inside arrays and objects can be aligned too. * * The item given is presumed to require multiple lines. For table formatting, each of its children is * expected to be one row. For compact multiline arrays, there will be multiple children per line, but they * will be given the same amount of space and lined up neatly when spanning multiple lines. For expanded * object properties, the values may or may not span multiple lines, but the property names and the start of * their values will be on separate lines, lined up. */ MeasureTableRoot(tableRoot, recursive) { // For each row of the potential table, measure it and its children, making room for everything. // (Or, if there are incompatible types at any level, set CanBeUsedInTable to false.) for (const child of tableRoot.Children) this.MeasureRowSegment(child, recursive); // Get rid of incomplete junk and determine our final size. this.PruneAndRecompute(Number.MAX_VALUE); } /** * Check if the template's width fits in the given size. Repeatedly drop inner formatting and * recompute to make it fit, if needed. * * Fully expanded, an array might look like this when table-formatted. * * [ * { "a": 3.4, "b": 8, "c": {"x": 2, "y": 16 } }, * { "a": 2, "b": 301, "c": { "y": -4, "z": 0} } * ] * * If that's too wide, the template will give up trying to align the x,y,z properties but keep the rest. * * [ * { "a": 3.4, "b": 8, "c": {"x": 2, "y": 16} }, * { "a": 2, "b": 301, "c": {"y": -4, "z": 0} } * ] */ TryToFit(maximumLength) { let complexity = this.GetTemplateComplexity(); while (true) { if (this.TotalLength <= maximumLength) return true; if (complexity <= 0) return false; complexity -= 1; this.PruneAndRecompute(complexity); } } /** * Added the number, properly aligned and possibly reformatted, according to our measurements. * This assumes that the segment is a number list, and therefore that the item is a number or null. */ FormatNumber(buffer, item, commaBeforePadType) { // The easy cases. Use the value exactly as it was in the source doc. switch (this._numberListAlignment) { case NumberListAlignment_1.NumberListAlignment.Left: buffer.Add(item.Value, commaBeforePadType).Spaces(this.MaxValueLength - item.ValueLength); return; case NumberListAlignment_1.NumberListAlignment.Right: buffer.Spaces(this.MaxValueLength - item.ValueLength).Add(item.Value, commaBeforePadType); return; } if (item.Type === JsonItemType_1.JsonItemType.Null) { buffer.Spaces(this._maxDigBeforeDec - item.ValueLength) .Add(item.Value, commaBeforePadType).Spaces(this.CompositeValueLength - this._maxDigBeforeDec); return; } // Normalize case - rewrite the number with the appropriate precision. if (this._numberListAlignment === NumberListAlignment_1.NumberListAlignment.Normalize) { const parsedVal = Number(item.Value); const reformattedStr = parsedVal.toFixed(this._maxDigAfterDec); buffer.Spaces(this.CompositeValueLength - reformattedStr.length).Add(reformattedStr, commaBeforePadType); return; } // Decimal case - line up the decimals (or E's) but leave the value exactly as it was in the source. let leftPad; let rightPad; const indexOfDot = item.Value.search(TableTemplate._dotOrE); if (indexOfDot > 0) { leftPad = this._maxDigBeforeDec - indexOfDot; rightPad = this.CompositeValueLength - leftPad - item.ValueLength; } else { leftPad = this._maxDigBeforeDec - item.ValueLength; rightPad = this.CompositeValueLength - this._maxDigBeforeDec; } buffer.Spaces(leftPad).Add(item.Value, commaBeforePadType).Spaces(rightPad); } /** * Length of the largest item - including property name, comments, and padding - that can't be split across * multiple lines. */ AtomicItemSize() { return this.NameLength + this._pads.ColonLen + this.MiddleCommentLength + ((this.MiddleCommentLength > 0) ? this._pads.CommentLen : 0) + this.MaxAtomicValueLength + this.PostfixCommentLength + ((this.PostfixCommentLength > 0) ? this._pads.CommentLen : 0) + this._pads.CommaLen; } /** * Adjusts this TableTemplate (and its children) to make room for the given rowSegment (and its children). */ MeasureRowSegment(rowSegment, recursive) { // Standalone comments and blank lines don't figure into template measurements if (rowSegment.Type === JsonItemType_1.JsonItemType.BlankLine || rowSegment.Type === JsonItemType_1.JsonItemType.BlockComment || rowSegment.Type === JsonItemType_1.JsonItemType.LineComment) return; let rowTableType; switch (rowSegment.Type) { case JsonItemType_1.JsonItemType.Null: rowTableType = TableColumnType_1.TableColumnType.Unknown; break; case JsonItemType_1.JsonItemType.Number: rowTableType = TableColumnType_1.TableColumnType.Number; break; case JsonItemType_1.JsonItemType.Array: rowTableType = TableColumnType_1.TableColumnType.Array; break; case JsonItemType_1.JsonItemType.Object: rowTableType = TableColumnType_1.TableColumnType.Object; break; default: rowTableType = TableColumnType_1.TableColumnType.Simple; break; } if (this.Type === TableColumnType_1.TableColumnType.Unknown) { this.Type = rowTableType; } else if (rowTableType !== TableColumnType_1.TableColumnType.Unknown && this.Type !== rowTableType) { this.Type = TableColumnType_1.TableColumnType.Mixed; } if (rowSegment.Type === JsonItemType_1.JsonItemType.Null) { this._maxDigBeforeDec = Math.max(this._maxDigBeforeDec, this._pads.LiteralNullLen); this.ContainsNull = true; } if (rowSegment.RequiresMultipleLines) { this.RequiresMultipleLines = true; this.Type = TableColumnType_1.TableColumnType.Mixed; } // Update the numbers this.RowCount += 1; this.NameLength = Math.max(this.NameLength, rowSegment.NameLength); this.NameMinimum = Math.min(this.NameMinimum, rowSegment.NameLength); this.MaxValueLength = Math.max(this.MaxValueLength, rowSegment.ValueLength); this.MiddleCommentLength = Math.max(this.MiddleCommentLength, rowSegment.MiddleCommentLength); this.PrefixCommentLength = Math.max(this.PrefixCommentLength, rowSegment.PrefixCommentLength); this.PostfixCommentLength = Math.max(this.PostfixCommentLength, rowSegment.PostfixCommentLength); this.IsAnyPostCommentLineStyle || (this.IsAnyPostCommentLineStyle = rowSegment.IsPostCommentLineStyle); this.AnyMiddleCommentHasNewline || (this.AnyMiddleCommentHasNewline = rowSegment.MiddleCommentHasNewLine); if (rowSegment.Type !== JsonItemType_1.JsonItemType.Array && rowSegment.Type !== JsonItemType_1.JsonItemType.Object) this.MaxAtomicValueLength = Math.max(this.MaxAtomicValueLength, rowSegment.ValueLength); if (rowSegment.Complexity >= 2) this.PadType = BracketPaddingType_1.BracketPaddingType.Complex; if (this.RequiresMultipleLines || rowSegment.Type === JsonItemType_1.JsonItemType.Null) return; if (this.Type === TableColumnType_1.TableColumnType.Array && recursive) { // For each row in this rowSegment, find or create this TableTemplate's child template for // the that array index, and then measure recursively. for (let i = 0; i < rowSegment.Children.length; ++i) { if (this.Children.length <= i) this.Children.push(new TableTemplate(this._pads, this._numberListAlignment)); this.Children[i].MeasureRowSegment(rowSegment.Children[i], true); } } else if (this.Type === TableColumnType_1.TableColumnType.Object && recursive) { // If this object has multiple children with the same property name, which is allowed by the JSON standard, // although it's hard to imagine anyone would deliberately do it, we can't format it as part of a table. if (this.ContainsDuplicateKeys(rowSegment.Children)) { this.Type = TableColumnType_1.TableColumnType.Simple; return; } // For each property in rowSegment, check whether there's sub-template with the same name. If not // found, create one. Then measure recursively. for (const rowSegChild of rowSegment.Children) { let subTemplate = this.Children.find(tt => tt.LocationInParent === rowSegChild.Name); if (!subTemplate) { subTemplate = new TableTemplate(this._pads, this._numberListAlignment); subTemplate.LocationInParent = rowSegChild.Name; this.Children.push(subTemplate); } subTemplate.MeasureRowSegment(rowSegChild, true); } } // The rest is only relevant to number columns were we plan to align the decimal points. const skipDecimalStuff = this.Type !== TableColumnType_1.TableColumnType.Number || this._numberListAlignment === NumberListAlignment_1.NumberListAlignment.Left || this._numberListAlignment === NumberListAlignment_1.NumberListAlignment.Right; if (skipDecimalStuff) return; // For Decimal, we use the string exactly as it is from the input document. For Normalize, we need to rewrite // it before we count digits. let normalizedStr = rowSegment.Value; if (this._numberListAlignment === NumberListAlignment_1.NumberListAlignment.Normalize) { const parsedVal = Number(normalizedStr); normalizedStr = parsedVal.toString(); // Normalize only works for numbers that can be faithfully represented without too many digits and without // scientific notation. The JSON standard allows numbers of any length/precision. If we detect any case // where we'd lose precision, fall back to left alignment for this column. const canNormalize = !Number.isNaN(parsedVal) && parsedVal !== Number.POSITIVE_INFINITY && parsedVal !== Number.NEGATIVE_INFINITY && normalizedStr.length <= TableTemplate._maxCharsForNormalize && normalizedStr.indexOf('e') < 0 && (parsedVal !== 0.0 || TableTemplate._trulyZeroValString.test(rowSegment.Value)); if (!canNormalize) { this._numberListAlignment = NumberListAlignment_1.NumberListAlignment.Left; return; } } const indexOfDot = normalizedStr.search(TableTemplate._dotOrE); this._maxDigBeforeDec = Math.max(this._maxDigBeforeDec, (indexOfDot >= 0) ? indexOfDot : normalizedStr.length); this._maxDigAfterDec = Math.max(this._maxDigAfterDec, (indexOfDot >= 0) ? normalizedStr.length - indexOfDot - 1 : 0); } PruneAndRecompute(maxAllowedComplexity) { const clearChildren = maxAllowedComplexity <= 0 || (this.Type !== TableColumnType_1.TableColumnType.Array && this.Type !== TableColumnType_1.TableColumnType.Object) || this.RowCount < 2; if (clearChildren) this.Children = []; for (const subTemplate of this.Children) subTemplate.PruneAndRecompute(maxAllowedComplexity - 1); if (this.Type === TableColumnType_1.TableColumnType.Number) { this.CompositeValueLength = this.GetNumberFieldWidth(); } else if (this.Children.length > 0) { const totalChildLen = this.Children.map(ch => ch.TotalLength).reduce((prev, cur) => prev + cur); this.CompositeValueLength = totalChildLen + Math.max(0, this._pads.CommaLen * (this.Children.length - 1)) + this._pads.ArrStartLen(this.PadType) + this._pads.ArrEndLen(this.PadType); if (this.ContainsNull && this.CompositeValueLength < this._pads.LiteralNullLen) { this.ShorterThanNullAdjustment = this._pads.LiteralNullLen - this.CompositeValueLength; this.CompositeValueLength = this._pads.LiteralNullLen; } } else { this.CompositeValueLength = this.MaxValueLength; } this.TotalLength = ((this.PrefixCommentLength > 0) ? this.PrefixCommentLength + this._pads.CommentLen : 0) + ((this.NameLength > 0) ? this.NameLength + this._pads.ColonLen : 0) + ((this.MiddleCommentLength > 0) ? this.MiddleCommentLength + this._pads.CommentLen : 0) + this.CompositeValueLength + ((this.PostfixCommentLength > 0) ? this.PostfixCommentLength + this._pads.CommentLen : 0); } GetTemplateComplexity() { if (this.Children.length === 0) return 0; const childComplexities = this.Children.map(ch => ch.GetTemplateComplexity()); return 1 + Math.max(...childComplexities); } ContainsDuplicateKeys(list) { const seenNames = new Set(); for (const item of list) { if (seenNames.has(item.Name)) return true; seenNames.add(item.Name); } return false; } GetNumberFieldWidth() { if (this._numberListAlignment === NumberListAlignment_1.NumberListAlignment.Normalize || this._numberListAlignment === NumberListAlignment_1.NumberListAlignment.Decimal) { const rawDecLen = (this._maxDigAfterDec > 0) ? 1 : 0; return this._maxDigBeforeDec + rawDecLen + this._maxDigAfterDec; } return this.MaxValueLength; } } exports.TableTemplate = TableTemplate; // Regex to help us distinguish between numbers that truly have a zero value - which can take many forms like // 0, 0.000, and 0.0e75 - and numbers too small for a 64bit float, such as 1e-500. TableTemplate._trulyZeroValString = new RegExp("^-?[0.]+([eE].*)?$"); TableTemplate._dotOrE = new RegExp("[.eE]"); TableTemplate._maxCharsForNormalize = 16;