Fix String value parsing/serialization
[yangtools.git] / data / yang-data-util / src / main / java / org / opendaylight / yangtools / yang / data / util / XpathStringParsingPathArgumentBuilder.java
1 /*
2  * Copyright (c) 2015 Cisco Systems, Inc. and others.  All rights reserved.
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6  * and is available at http://www.eclipse.org/legal/epl-v10.html
7  */
8 package org.opendaylight.yangtools.yang.data.util;
9
10 import static java.util.Objects.requireNonNull;
11
12 import com.google.common.base.CharMatcher;
13 import com.google.common.collect.ImmutableList;
14 import com.google.common.collect.ImmutableMap;
15 import java.util.ArrayList;
16 import java.util.List;
17 import org.eclipse.jdt.annotation.NonNull;
18 import org.opendaylight.yangtools.concepts.Mutable;
19 import org.opendaylight.yangtools.yang.common.QName;
20 import org.opendaylight.yangtools.yang.common.QNameModule;
21 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
22 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeWithValue;
23 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
24 import org.opendaylight.yangtools.yang.model.api.TypeDefinition;
25 import org.opendaylight.yangtools.yang.model.api.type.LeafrefTypeDefinition;
26 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack;
27
28 /**
29  * Iterator which lazily parses {@link PathArgument} from string representation.
30  *
31  * <p>
32  * Note that invocation of {@link #hasNext()} or {@link #next()} may result in
33  * throwing of {@link IllegalArgumentException} if underlying string representation
34  * is not correctly serialized or does not represent instance identifier valid
35  * for associated schema context.
36  */
37 final class XpathStringParsingPathArgumentBuilder implements Mutable {
38     /**
39      * Matcher matching WSP YANG ABNF token.
40      */
41     private static final CharMatcher WSP = CharMatcher.anyOf(" \t");
42
43     /**
44      * Matcher matching IDENTIFIER first char token.
45      */
46     private static final CharMatcher IDENTIFIER_FIRST_CHAR = CharMatcher.inRange('a', 'z')
47             .or(CharMatcher.inRange('A', 'Z')).or(CharMatcher.is('_')).precomputed();
48
49     /**
50      * Matcher matching IDENTIFIER token.
51      */
52     private static final CharMatcher IDENTIFIER = IDENTIFIER_FIRST_CHAR.or(CharMatcher.inRange('0', '9'))
53             .or(CharMatcher.anyOf(".-")).precomputed();
54
55     private static final char SLASH = '/';
56     private static final char BACKSLASH = '\\';
57     private static final char COLON = ':';
58     private static final char DOT = '.';
59     private static final char EQUALS = '=';
60     private static final char PRECONDITION_START = '[';
61     private static final char PRECONDITION_END = ']';
62     private static final char SQUOT = '\'';
63     private static final char DQUOT = '"';
64
65     private final List<PathArgument> product = new ArrayList<>();
66     private final AbstractStringInstanceIdentifierCodec codec;
67     private final SchemaInferenceStack stack;
68     private final String data;
69
70     private DataSchemaContextNode<?> current;
71     private QNameModule lastModule;
72     private int offset;
73
74     XpathStringParsingPathArgumentBuilder(final AbstractStringInstanceIdentifierCodec codec, final String data) {
75         this.codec = requireNonNull(codec);
76         this.data = requireNonNull(data);
77         offset = 0;
78
79         final DataSchemaContextTree tree = codec.getDataContextTree();
80         stack = SchemaInferenceStack.of(tree.getEffectiveModelContext());
81         current = tree.getRoot();
82     }
83
84     /**
85      * Parse input string and return the corresponding list of {@link PathArgument}s.
86      *
87      * @return List of PathArguments
88      * @throws IllegalArgumentException if the input string is not valid
89      */
90     @NonNull List<PathArgument> build() {
91         while (!allCharactersConsumed()) {
92             product.add(computeNextArgument());
93         }
94         return ImmutableList.copyOf(product);
95     }
96
97     private PathArgument computeNextArgument() {
98         checkValid(SLASH == currentChar(), "Identifier must start with '/'.");
99         skipCurrentChar();
100         checkValid(!allCharactersConsumed(), "Identifier cannot end with '/'.");
101         final QName name = nextQName();
102         // Memoize module
103         lastModule = name.getModule();
104         if (allCharactersConsumed() || SLASH == currentChar()) {
105             return computeIdentifier(name);
106         }
107
108         checkValid(PRECONDITION_START == currentChar(), "Last element must be identifier, predicate or '/'");
109         return computeIdentifierWithPredicate(name);
110     }
111
112     private DataSchemaContextNode<?> nextContextNode(final QName name) {
113         current = current.getChild(name);
114         checkValid(current != null, "%s is not correct schema node identifier.", name);
115         while (current.isMixin()) {
116             product.add(current.getIdentifier());
117             current = current.getChild(name);
118         }
119         stack.enterDataTree(name);
120         return current;
121     }
122
123     /**
124      * Creates path argument with predicates and sets offset
125      * to end of path argument.
126      *
127      * {@code
128      *     predicate = "[" *WSP (predicate-expr / pos) *WSP "]"
129      *     predicate-expr = (node-identifier / ".") *WSP "=" *WSP
130      *          ((DQUOTE string DQUOTE) /
131      *           (SQUOTE string SQUOTE))
132      *     pos = non-negative-integer-value
133      * }
134      *
135      * @param name QName of node, for which predicates are computed.
136      * @return PathArgument representing node selection with predictes
137      */
138     private PathArgument computeIdentifierWithPredicate(final QName name) {
139         final DataSchemaContextNode<?> currentNode = nextContextNode(name);
140         checkValid(currentNode.isKeyedEntry(), "Entry %s does not allow specifying predicates.", name);
141
142         ImmutableMap.Builder<QName, Object> keyValues = ImmutableMap.builder();
143         while (!allCharactersConsumed() && PRECONDITION_START == currentChar()) {
144             skipCurrentChar();
145             skipWhitespaces();
146             final QName key;
147             if (DOT == currentChar()) {
148                 key = null;
149                 skipCurrentChar();
150             } else {
151                 key = nextQName();
152             }
153             skipWhitespaces();
154             checkCurrentAndSkip(EQUALS, "Precondition must contain '='");
155             skipWhitespaces();
156             final String keyValue = nextQuotedValue();
157             skipWhitespaces();
158             checkCurrentAndSkip(PRECONDITION_END, "Precondition must ends with ']'");
159
160             // Break-out from method for leaf-list case
161             if (key == null && currentNode.isLeaf()) {
162                 checkValid(offset == data.length(), "Leaf argument must be last argument of instance identifier.");
163                 return new NodeWithValue<>(name, keyValue);
164             }
165             final DataSchemaContextNode<?> keyNode = currentNode.getChild(key);
166             checkValid(keyNode != null, "%s is not correct schema node identifier.", key);
167             final Object value = codec.deserializeKeyValue(keyNode.getDataSchemaNode(),
168                 type -> resolveLeafref(key, type), keyValue);
169             keyValues.put(key, value);
170         }
171         return NodeIdentifierWithPredicates.of(name, keyValues.build());
172     }
173
174     private @NonNull TypeDefinition<?> resolveLeafref(final QName qname, final LeafrefTypeDefinition type) {
175         final SchemaInferenceStack tmp = stack.copy();
176         tmp.enterDataTree(qname);
177         return tmp.resolveLeafref(type);
178     }
179
180     private PathArgument computeIdentifier(final QName name) {
181         DataSchemaContextNode<?> currentNode = nextContextNode(name);
182         checkValid(!currentNode.isKeyedEntry(), "Entry %s requires key or value predicate to be present", name);
183         return currentNode.getIdentifier();
184     }
185
186     /**
187      * Returns following QName and sets offset to end of QName.
188      *
189      * @return following QName.
190      */
191     private QName nextQName() {
192         // Consume prefix or identifier
193         final String maybePrefix = nextIdentifier();
194         if (!allCharactersConsumed() && COLON == currentChar()) {
195             // previous token is prefix
196             skipCurrentChar();
197             return codec.createQName(maybePrefix, nextIdentifier());
198         }
199
200         return codec.createQName(lastModule, maybePrefix);
201     }
202
203     /**
204      * Returns true if all characters from input string were consumed.
205      *
206      * @return true if all characters from input string were consumed.
207      */
208     private boolean allCharactersConsumed() {
209         return offset == data.length();
210     }
211
212     /**
213      * Skips current char if it equals expected otherwise fails parsing.
214      *
215      * @param expected Expected character
216      * @param errorMsg Error message if {@link #currentChar()} does not match expected.
217      */
218     private void checkCurrentAndSkip(final char expected, final String errorMsg) {
219         checkValid(expected == currentChar(), errorMsg);
220         offset++;
221     }
222
223     /**
224      * Fails parsing if a condition is not met.
225      *
226      * <p>
227      * In case of error provides pointer to failed instance identifier,
228      * offset on which failure occurred with explanation.
229      *
230      * @param condition Fails parsing if {@code condition} is false
231      * @param errorMsg Error message which will be provided to user.
232      */
233     private void checkValid(final boolean condition, final String errorMsg, final Object... attributes) {
234         if (!condition) {
235             throw iae(errorMsg, attributes);
236         }
237     }
238
239     private @NonNull IllegalArgumentException iae(final String errorMsg, final Object... attributes) {
240         return new IllegalArgumentException("Could not parse Instance Identifier '%s'. Offset: %s : Reason: %s"
241             .formatted(data, offset, errorMsg.formatted(attributes)));
242     }
243
244     /**
245      * Returns following value of quoted literal (without quotes) and sets offset after literal.
246      *
247      * @return String literal
248      */
249     private String nextQuotedValue() {
250         return switch (currentChar()) {
251             case SQUOT -> nextSingleQuotedValue();
252             case DQUOT -> nextDoubleQuotedValue();
253             default -> throw iae("Value must be quote escaped with ''' or '\"'.");
254         };
255     }
256
257     // Simple: just look for the matching single quote and return substring
258     private String nextSingleQuotedValue() {
259         skipCurrentChar();
260         final int start = offset;
261         final int end = data.indexOf(SQUOT, start);
262         checkValid(end != -1, "Closing single quote not found");
263         offset = end;
264         skipCurrentChar();
265         return data.substring(start, end);
266     }
267
268     // Complicated: we need to potentially un-escape
269     private String nextDoubleQuotedValue() {
270         skipCurrentChar();
271
272         final int maxIndex = data.length() - 1;
273         final var sb = new StringBuilder();
274         while (true) {
275             final int nextStart = offset;
276
277             // Find next double quotes
278             final int nextEnd = data.indexOf(DQUOT, nextStart);
279             checkValid(nextEnd != -1, "Closing double quote not found");
280             offset = nextEnd;
281
282             // Find next backslash
283             final int nextBackslash = data.indexOf(BACKSLASH, nextStart);
284             if (nextBackslash == -1 || nextBackslash > nextEnd) {
285                 // No backslash between nextStart and nextEnd -- just copy characters and terminate
286                 offset = nextEnd;
287                 skipCurrentChar();
288                 return sb.append(data, nextStart, nextEnd).toString();
289             }
290
291             // Validate escape completeness and append buffer
292             checkValid(nextBackslash != maxIndex, "Incomplete escape");
293             sb.append(data, nextStart, nextBackslash);
294
295             // Adjust offset before potentially referencing it and
296             offset = nextBackslash;
297             sb.append(unescape(data.charAt(nextBackslash + 1)));
298
299             // Rinse and repeat
300             offset = nextBackslash + 2;
301         }
302     }
303
304     // As per https://www.rfc-editor.org/rfc/rfc7950#section-6.1.3
305     private char unescape(final char escape) {
306         return switch (escape) {
307             case 'n' -> '\n';
308             case 't' -> '\t';
309             case DQUOT -> DQUOT;
310             case BACKSLASH -> BACKSLASH;
311             default -> throw iae("Unrecognized escape");
312         };
313     }
314
315     /**
316      * Returns character at current offset.
317      *
318      * @return character at current offset.
319      */
320     private char currentChar() {
321         return data.charAt(offset);
322     }
323
324     /**
325      * Increases processing offset by 1.
326      */
327     private void skipCurrentChar() {
328         offset++;
329     }
330
331     /**
332      * Skip whitespace characters, sets offset to first following non-whitespace character.
333      */
334     private void skipWhitespaces() {
335         nextSequenceEnd(WSP);
336     }
337
338     /**
339      * Returns string which matches IDENTIFIER YANG ABNF token
340      * and sets processing offset after end of identifier.
341      *
342      * @return string which matches IDENTIFIER YANG ABNF token
343      */
344     private String nextIdentifier() {
345         checkValid(IDENTIFIER_FIRST_CHAR.matches(currentChar()),
346             "Identifier must start with character from set 'a-zA-Z_'");
347         final int start = offset;
348         nextSequenceEnd(IDENTIFIER);
349         return data.substring(start, offset);
350     }
351
352     private void nextSequenceEnd(final CharMatcher matcher) {
353         while (!allCharactersConsumed() && matcher.matches(data.charAt(offset))) {
354             offset++;
355         }
356     }
357 }