001package io.jstach.jstachio.context; 002 003import java.lang.reflect.Array; 004import java.util.ArrayList; 005import java.util.Arrays; 006import java.util.Collection; 007import java.util.Collections; 008import java.util.Iterator; 009import java.util.List; 010import java.util.Map; 011import java.util.concurrent.atomic.AtomicInteger; 012import java.util.stream.Stream; 013import java.util.stream.StreamSupport; 014 015import org.eclipse.jdt.annotation.Nullable; 016 017/** 018 * This interface serves two puproses: 019 * 020 * <ol> 021 * <li>A way to represent the current context stack (see {@link #parent()}) 022 * <li>Allow you to simulate JSON/Javscript object node like trees without being coupled 023 * to a particularly JSON lib. 024 * </ol> 025 * The interface simply wraps {@link Map} and {@link Iterable} (and arrays) lazily through 026 * composition but generally cannot wrap other context nodes. If an object is wrapped that 027 * is not a Map or Iterable it becomes a leaf node similar to JSON. 028 * <p> 029 * It is not recommended you use this interface as it avoids much of the type checking 030 * safty of this library as well as increase coupling however it does provide a slightly 031 * better bridge to legacy {@code Map<String,?>} models over using the maps directly. 032 * 033 * @apiNote The parents do not know anything about their children as it is the child that 034 * has reference to the parent. 035 * @author agentgt 036 * 037 */ 038public interface ContextNode extends Iterable<ContextNode> { 039 040 /** 041 * Creates the root node which has no name. 042 * @apiNote Unlike the other methods in this class if the passed in object is a 043 * context node it is simply returned if it is a root node otherwise it is rewrapped. 044 * @param o the object to be wrapped. Maybe <code>null</code>. 045 * @return <code>null</code> if the root object is null otherwise a new root node. 046 */ 047 public static @Nullable ContextNode ofRoot(@Nullable Object o) { 048 if (o == null) { 049 return null; 050 } 051 if (o instanceof ContextNode n) { 052 if (n.parent() != null) { 053 return ofRoot(n.object()); 054 } 055 return n; 056 } 057 return new RootContextNode(o); 058 059 } 060 061 /** 062 * Creates a named child node off of this node where the return child nodes parent 063 * will be this node. 064 * @param name the context name. 065 * @param o the object to be wrapped. 066 * @return <code>null</code> if the child object is null otherwise a new child node. 067 * @throws IllegalArgumentException if the input object is a {@link ContextNode} 068 */ 069 default @Nullable ContextNode ofChild(String name, @Nullable Object o) throws IllegalArgumentException { 070 if (o == null) { 071 return null; 072 } 073 if (o instanceof ContextNode) { 074 throw new IllegalArgumentException("Cannot wrap ContextNode around another ContextNode"); 075 } 076 return new NamedContextNode(this, o, name); 077 } 078 079 /** 080 * Creates an indexed child node off of this node where the return child nodes parent 081 * will be this node. 082 * @apiNote there is no checking to see if the same index is reused as the parent 083 * knows nothing of the child. 084 * @param index a numeric index 085 * @param o the object to be wrapped. Maybe <code>null</code>. 086 * @return <code>null</code> if the child object is null otherwise a new child node. 087 * @throws IllegalArgumentException if the input object is a {@link ContextNode} 088 */ 089 default @Nullable ContextNode ofChild(int index, @Nullable Object o) { 090 if (o == null) { 091 return null; 092 } 093 if (o instanceof ContextNode) { 094 throw new IllegalArgumentException("Cannot wrap ContextNode around another ContextNode"); 095 } 096 return new IndexedContextNode(this, o, index); 097 } 098 099 /** 100 * Gets a field from a {@link Map} if ContextNode is wrapping one. This is direct 101 * access (end of a dotted path) and does not check the parents. 102 * 103 * Just like {@link Map} <code>null</code> will be returned if no field is found. 104 * @param field the name of the field 105 * @return a new child node. Maybe <code>null</code>. 106 */ 107 default @Nullable ContextNode get(String field) { 108 Object o = object(); 109 ContextNode child = null; 110 if (o instanceof Map<?, ?> m) { 111 child = ofChild(field, m.get(field)); 112 } 113 return child; 114 } 115 116 /** 117 * Will search up the tree for a field starting at this nodes children first. 118 * @param field context name (e.g. section name) 119 * @return <code>null</code> if not found otherwise creates a new node from the map or 120 * object containing the field. 121 */ 122 default @Nullable ContextNode find(String field) { 123 /* 124 * In theory we could make a special RenderingContext for ContextNode to go up the 125 * stack (generated code) but it would probably look similar to the following. 126 */ 127 ContextNode child = get(field); 128 if (child != null) { 129 return child; 130 } 131 var parent = parent(); 132 if (parent != null && parent != this) { 133 child = parent.find(field); 134 if (child != null) { 135 child = ofChild(field, child.object()); 136 } 137 } 138 return child; 139 } 140 141 /** 142 * The object being wrapped. 143 * @return the Map, Iterable or object that was wrapped. Never <code>null</code>. 144 */ 145 public Object object(); 146 147 /** 148 * Convenience method for calling <code>toString</code> on the wrapped object. 149 * @return a toString on the wrapped object. 150 */ 151 default String renderString() { 152 return String.valueOf(object()); 153 } 154 155 /** 156 * The parent node. 157 * @return the parent node or <code>null</code> if this is the root. 158 */ 159 default @Nullable ContextNode parent() { 160 return null; 161 } 162 163 /** 164 * If the node is a Map or a non iterable/array a singleton iterator will be returned. 165 * Otherwise if it is an interable/array new child context nodes will be created 166 * lazily. 167 * @return lazy iterator of context nodes. 168 */ 169 @Override 170 default Iterator<ContextNode> iterator() { 171 Object o = object(); 172 if (o instanceof Iterable<?> it) { 173 AtomicInteger index = new AtomicInteger(); 174 return StreamSupport.stream(it.spliterator(), false).map(i -> this.ofChild(index.getAndIncrement(), i)) 175 .iterator(); 176 } 177 else if (o == null || Boolean.FALSE.equals(o)) { 178 return Collections.emptyIterator(); 179 } 180 else if (o.getClass().isArray()) { 181 /* 182 * There is probably an easier way to do this 183 */ 184 Stream<? extends Object> s; 185 if (o instanceof int[] a) { 186 s = Arrays.stream(a).boxed(); 187 } 188 else if (o instanceof long[] a) { 189 s = Arrays.stream(a).boxed(); 190 } 191 else if (o instanceof double[] a) { 192 s = Arrays.stream(a).boxed(); 193 } 194 else if (o instanceof boolean[] a) { 195 List<Boolean> b = new ArrayList<>(); 196 for (var _a : a) { 197 b.add(_a); 198 } 199 s = b.stream(); 200 } 201 else if (o instanceof char[] a) { 202 List<Character> b = new ArrayList<>(); 203 for (var _a : a) { 204 b.add(_a); 205 } 206 s = b.stream(); 207 } 208 else if (o instanceof byte[] a) { 209 List<Byte> b = new ArrayList<>(); 210 for (var _a : a) { 211 b.add(_a); 212 } 213 s = b.stream(); 214 } 215 else if (o instanceof float[] a) { 216 List<Float> b = new ArrayList<>(); 217 for (var _a : a) { 218 b.add(_a); 219 } 220 s = b.stream(); 221 } 222 else if (o instanceof short[] a) { 223 List<Short> b = new ArrayList<>(); 224 for (var _a : a) { 225 b.add(_a); 226 } 227 s = b.stream(); 228 } 229 else if (o instanceof Object[] a) { 230 s = Arrays.asList(a).stream(); 231 } 232 else { 233 throw new IllegalArgumentException("array type not supported: " + o.getClass()); 234 } 235 AtomicInteger index = new AtomicInteger(); 236 return s.map(i -> this.ofChild(index.getAndIncrement(), i)).iterator(); 237 } 238 239 return Collections.singletonList(this).iterator(); 240 } 241 242 /** 243 * Determines if an object is falsey based on mustache spec semantics where: 244 * <code>null</code>, empty iterables, empty arrays and boolean <code>false</code> are 245 * falsey. 246 * @param context a context object. ContextNode are allowed as input as well as 247 * <code>null</code>. 248 * @return true if the object is falsey. 249 */ 250 static boolean isFalsey(@Nullable Object context) { 251 if (context == null) { 252 return true; 253 } 254 if (Boolean.FALSE.equals(context)) { 255 return true; 256 } 257 if (context instanceof Collection<?> c) { 258 return c.isEmpty(); 259 } 260 if (context instanceof Iterable<?> it) { 261 return !it.iterator().hasNext(); 262 } 263 if (context.getClass().isArray() && Array.getLength(context) == 0) { 264 return false; 265 } 266 return false; 267 } 268 269} 270 271record RootContextNode(Object object) implements ContextNode { 272 @Override 273 public String toString() { 274 return renderString(); 275 } 276} 277 278record NamedContextNode(ContextNode parent, Object object, String name) implements ContextNode { 279 @Override 280 public String toString() { 281 return renderString(); 282 } 283} 284 285record IndexedContextNode(ContextNode parent, Object object, int index) implements ContextNode { 286 @Override 287 public String toString() { 288 return renderString(); 289 } 290}