/** * Class for representing a JSON pointer (see * https://tools.ietf.org/html/rfc6901). * Allows quick and simple access to components of it's path: * Path "/a/b/c" will be stored as a sequence of components "a", "b" and "c", * path "/" will be stored as a singular empty component "" * and empty path "" would mean that there is not components at all. * Copyright 2021-2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * * Acedia is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License, or * (at your option) any later version. * * Acedia is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ class JSONPointer extends AcediaObject; // Component of the pointer (the part, separated by slash character '/'). struct Component { // For arrays, a component is specified by a numeric index; // To avoid parsing `asText` multiple times we record whether we // have already done so. var bool testedForBeingNumeric; // Numeric index, represented by `asText`; // `-1` if it was already tested and found to be equal to not be a number // (valid index values are always `>= 0`). var int asNumber; // `Text` representation of the component. // Can be equal to `none` only if this component was specified via // numeric index (guarantees `testedForBeingNumeric == true`). var MutableText asText; }; // Segments of the path this JSON pointer was initialized with var private array components; var protected const int TSLASH, TJSON_ESCAPE, TJSON_ESCAPED_SLASH; var protected const int TJSON_ESCAPED_ESCAPE; protected function Finalizer() { Empty(); } /** * Checks whether caller `JSONPointer` is empty (points at the root value). * * @return `true` iff caller `JSONPointer` points at the root value. */ public final function bool IsEmpty() { return components.length == 0; } /** * Resets caller `JSONPointer`, erasing all of it's components. * * @return Caller `JSONPointer` to allow for method chaining. */ public final function JSONPointer Empty() { local int i; for (i = 0; i < components.length; i += 1) { _.memory.Free(components[i].asText); } components.length = 0; return self; } /** * Sets caller `JSONPointer` to correspond to a given path in * JSON pointer format (https://tools.ietf.org/html/rfc6901). * * If provided `Text` value is an incorrect pointer, then it will be * treated like an empty pointer. * However, if given pointer can be fixed by prepending "/" - it will be * done automatically. This means that "foo/bar" is treated like "/foo/bar", * "path" like "/path", but empty `Text` "" is treated like itself. * * @param pointerAsText `Text` representation of the JSON pointer. * @return Reference to the caller `JSONPointer` to allow for method chaining. */ public final function JSONPointer Set(BaseText pointerAsText) { local int i; local bool hasEscapedSequences; local Component nextComponent; local MutableText nextPart; local array parts; Empty(); if (pointerAsText == none) { return self; } hasEscapedSequences = (pointerAsText.IndexOf(T(TJSON_ESCAPE)) >= 0); parts = pointerAsText.SplitByCharacter(T(TSLASH).GetCharacter(0),, true); // First element of the array is expected to be empty, so throw it away; // If it is not empty - then `pointerAsText` does not start with "/" and // we will pretend that we have already removed first element, thus // "fixing" path (e.g. effectively turning "foo/bar" into "/foo/bar"). if (parts[0].IsEmpty()) { _.memory.Free(parts[0]); parts.Remove(0, 1); } if (hasEscapedSequences) { // Replace escaped sequences "~0" and "~1". // Order is specific, necessity of which is explained in // JSON Pointer's documentation: // https://tools.ietf.org/html/rfc6901 for (i = 0; i < parts.length; i += 1) { nextPart = MutableText(parts[i]); nextPart.Replace(T(TJSON_ESCAPED_SLASH), T(TSLASH)); nextPart.Replace(T(TJSON_ESCAPED_ESCAPE), T(TJSON_ESCAPE)); } } for (i = 0; i < parts.length; i += 1) { nextComponent.asText = MutableText(parts[i]); components[components.length] = nextComponent; } return self; } /** * Adds new component to the caller `JSONPointer`. * * Adding component "new" to the pointer representing path "/a/b/c" would * result in it representing a path "/a/b/c/new". * * Although this method can be used to add numeric components, `PushNumeric()` * should be used for that if possible. * * @param newComponent Component to add. If passed `none` value - * no changes will be made at all. * @return Reference to the caller `JSONPointer` to allow for method chaining. */ public final function JSONPointer Push(BaseText newComponent) { local Component newComponentRecord; if (newComponent == none) { return self; } newComponentRecord.asText = newComponent.MutableCopy(); components[components.length] = newComponentRecord; return self; } /** * Adds new numeric component to the caller `JSONPointer`. * * Adding component `7` to the pointer representing path "/a/b/c" would * result in it representing a path "/a/b/c/7". * * @param newComponent Numeric component to add. If passed negative value - * no changes will be made at all. * @return Reference to the caller `JSONPointer` to allow for method chaining. */ public final function JSONPointer PushNumeric(int newComponent) { local Component newComponentRecord; if (newComponent < 0) { return self; } newComponentRecord.asNumber = newComponent; // Obviously this component is going to be numeric newComponentRecord.testedForBeingNumeric = true; components[components.length] = newComponentRecord; return self; } /** * Removes and returns last component from the caller `JSONPointer`. * * In `JSONPointer` corresponding to "/ab/c/d" this method would return "d" * and leave caller `JSONPointer` to correspond to "/ab/c". * * @param doNotRemove Set this to `true` if you want to return last component * without changing caller pointer. * @return Last component of the caller `JSONPointer`. * `none` iff caller `JSONPointer` is empty. */ public final function Text Pop(optional bool doNotRemove) { local int lastIndex; local Text result; if (components.length <= 0) { return none; } lastIndex = components.length - 1; // Do not use `GetComponent()` to avoid unnecessary `Text` copying if (components[lastIndex].asText == none) { result = _.text.FromInt(components[lastIndex].asNumber); } else { result = components[lastIndex].asText.Copy(); } if (!doNotRemove) { _.memory.Free(components[lastIndex].asText); components.length = components.length - 1; } return result; } /** * Removes and returns last numeric component from the caller `JSONPointer`. * * In `JSONPointer` corresponding to "/ab/c/7" this method would return `7` * and leave caller `JSONPointer` to correspond to "/ab/c". * * Component is removed regardless of whether it was actually numeric. * * @param doNotRemove Set this to `true` if you want to return last component * without changing caller pointer. * @return Last component of the caller `JSONPointer`. * `-1` iff caller `JSONPointer` is empty or last component is not numeric. */ public final function int PopNumeric(optional bool doNotRemove) { local int lastIndex; local int result; if (components.length <= 0) { return -1; } lastIndex = components.length - 1; result = GetNumericComponent(lastIndex); if (!doNotRemove) { _.memory.Free(components[lastIndex].asText); components.length = components.length - 1; } return result; } /** * Returns a component of the path by it's index, starting from `0`. * * @param index Index of the component to return. Must be inside * `[0; GetLength() - 1]` segment. * @return Path's component as a `Text`. If passed `index` is outside of * `[0; GetLength() - 1]` segment - returns `none`. */ public final function Text GetComponent(int index) { if (index < 0) return none; if (index >= components.length) return none; // `asText` will store `none` only if we have added this component as // numeric one if (components[index].asText == none) { components[index].asText = _.text.FromIntM(components[index].asNumber); } return components[index].asText.Copy(); } /** * Returns a numeric component of the path by it's index, starting from `0`. * * @param index Index of the component to return. Must be inside * `[0; GetLength() - 1]` segment and correspond to numeric comonent. * @return Path's component as a `Text`. If passed `index` is outside of * `[0; GetLength() - 1]` segment or does not correspond to * a numeric component - returns `-1`. */ public final function int GetNumericComponent(int index) { local Parser parser; if (index < 0) return -1; if (index >= components.length) return -1; if (!components[index].testedForBeingNumeric) { components[index].testedForBeingNumeric = true; parser = _.text.Parse(components[index].asText); parser.MUnsignedInteger(components[index].asNumber); if (!parser.Ok() || !parser.HasFinished()) { components[index].asNumber = -1; } parser.FreeSelf(); } return components[index].asNumber; } /** * Checks whether component at given index can be used to index array. * * This method accepts numeric components plus component equal to "-", that can * be used to point at the element after the last on in the `JSONArray`. * * @param index Index of the component to check. * @param `true` if component with given index exists and it either positive * number or "-". */ public final function bool IsComponentArrayApplicable(int index) { local bool isAddElementAlias; local Text component; if (GetNumericComponent(index) >= 0) { return true; } component = GetComponent(index); isAddElementAlias = P("-").IsEqual(component); _.memory.Free(component); return isAddElementAlias; } /** * Converts caller `JSONPointer` into it's `Text` representation. * * For the method, but returning `MutableText` see `ToTextM()`. * * @return `Text` that represents caller `JSONPointer`. */ public final function Text ToText() { local Text result; local MutableText builder; builder = ToTextM(); result = builder.Copy(); builder.FreeSelf(); return result; } /** * Converts caller `JSONPointer` into it's `MutableText` representation. * * For the method, but returning `Text` see `ToTextM()`. * * @return `MutableText` that represents caller `JSONPointer`. */ public final function MutableText ToTextM() { local int i; local Text nextComponent; local MutableText nextMutableComponent; local MutableText result; result = _.text.Empty(); if (GetLength() <= 0) { return result; } for (i = 0; i < GetLength(); i += 1) { nextComponent = GetComponent(i); nextMutableComponent = nextComponent.MutableCopy(); // Replace (order is important) nextMutableComponent.Replace(T(TJSON_ESCAPE), T(TJSON_ESCAPED_ESCAPE)); nextMutableComponent.Replace(T(TSLASH), T(TJSON_ESCAPED_SLASH)); result.Append(T(TSLASH)).Append(nextMutableComponent); // Get rid of temporary values nextMutableComponent.FreeSelf(); nextComponent.FreeSelf(); } return result; } /** * Amount of path components in the caller `JSONPointer`. * * Also see `GetFoldsAmount()` method. * * @return Amount of components in the caller `JSONPointer`. */ public final function int GetLength() { return components.length; } /** * Amount of path components in the caller `JSONPointer` that do not directly * correspond to a pointed value. * * Equal to the `Max(0, GetLength() - 1)`. * * For example, path "/user/Ivan/records/5/count" refers to the value named * "value" that is _folded_ inside `4` objects named "users", "Ivan", * "records" and "5". Therefore it's folds amount if `4`. * * @return Amount of components in the caller `JSONPointer` that do not * directly correspond to a pointed value. */ public final function int GetFoldsAmount() { return Max(0, components.length - 1); } /** * Makes an exact copy of the caller `JSONPointer`. * * Copies components in the range `[startIndex; startIndex + maxLength - 1]` * If provided parameters `startIndex` and `maxLength` define a range that * goes beyond `[0; self.GetLength() - 1]`, then intersection with a valid * range will be used. * * @param startIndex Position of the first component to copy. * By default `0`, corresponding to the very first component. * @param maxLength Max length of the extracted JSON pointer (in amount of * components). By default `0` - that and all negative values mean that * method should extract all components to the right of `startIndex`. * @return Copy of the specified range of the caller `JSONPointer`. */ public final function JSONPointer Copy( optional int startIndex, optional int maxLength) { local int i, endIndex; local JSONPointer newPointer; local array newComponents; if (maxLength <= 0) { maxLength = components.length - startIndex; } endIndex = startIndex + maxLength; if (endIndex <= 0) { return JSONPointer(_.memory.Allocate(class'JSONPointer')); } startIndex = Max(startIndex, 0); endIndex = Min(endIndex, components.length); for (i = startIndex; i < endIndex; i += 1) { newComponents[newComponents.length] = components[i]; if (components[i].asText != none) { newComponents[newComponents.length - 1].asText = components[i].asText.MutableCopy(); } } newPointer = JSONPointer(_.memory.Allocate(class'JSONPointer')); newPointer.components = newComponents; return newPointer; } /** * Appends path, contained in JSON pointer `other` to the caller JSON pointer. * Appending "/A/B/7/C" to "/object/hey/1/there/" produces * "/object/hey/1/there//A/B/7/C". * * @param other Pointer to append. If `none` - caller `JSONPointer` will * not change. * @return Reference to the caller `JSONPointer` to allow for method chaining. */ public final function JSONPointer Append(JSONPointer other) { local int i; local array otherComponents; if (other == none) { return self; } otherComponents = other.components; for (i = 0; i < otherComponents.length; i += 1) { if (otherComponents[i].asText != none) { otherComponents[i].asText = otherComponents[i].asText.MutableCopy(); } components[components.length] = otherComponents[i]; } return self; } /** * Checks if given pointer corresponds with the beginning of the caller one. * * Pointer starts with another one if it includes all of its fields from * the beginning and in order * E.g. "/A/B/C" starts with "/A/B", but not with "/A/B/C/D", "/D/A/B/C" or * "/A/B/CD". * * @param other Candidate into being caller pointer's prefix. * @return `true` if `other` is prefix and `false` otherwise. `none` is * considered to be an empty pointer and, therefore, prefix to any other * pointer. */ public final function bool StartsWith(JSONPointer other) { local int i; local array otherComponents; // `none` is same as empty if (other == none) return true; otherComponents = other.components; // Not enough length if (components.length < otherComponents.length) return false; for (i = 0; i < otherComponents.length; i += 1) { // Compare numeric components if at least one is such if ( components[i].testedForBeingNumeric || otherComponents[i].testedForBeingNumeric) { if (GetNumericComponent(i) != other.GetNumericComponent(i)) { return false; } // End this iteration for numeric component, but continue for // text ones if (GetNumericComponent(i) >= 0) { continue; } } // We can reach here if: // 1. Neither components have `testedForBeingNumeric` set to // `true`, neither `asText` fields are `none` by the invariant; // 2. At least one had `testedForBeingNumeric`, but they tested // negative for being numeric. if (!components[i].asText.Compare(otherComponents[i].asText)) { return false; } } return true; } defaultproperties { TSLASH = 0 stringConstants(0) = "/" TJSON_ESCAPE = 1 stringConstants(1) = "~" TJSON_ESCAPED_SLASH = 2 stringConstants(2) = "~1" TJSON_ESCAPED_ESCAPE = 3 stringConstants(3) = "~0" }