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