001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2024-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.common.context; 019 020import static java.util.Objects.requireNonNull; 021 022import org.jspecify.annotations.Nullable; 023import org.slf4j.Logger; 024import org.slf4j.LoggerFactory; 025 026import java.io.IOException; 027import java.util.Optional; 028 029/** 030 * Contexts 🧿 put things into perspective! 031 * 032 * <p>Contexts are "hierarchical", and child contexts "mask" keys in their parent. 033 * 034 * <p>This class is NOT thread safe. Might you want to use {@link TLC} instead? 035 * 036 * @author <a href="http://www.vorburger.ch">Michael Vorburger.ch</a> 037 */ 038public class Context implements AutoCloseable { 039 040 // TODO Change this to not permit pushing to a key that's already set 041 042 // TODO Change this to have a separate Context.Builder 043 044 public interface Key<T> {} 045 046 private static final Logger LOG = LoggerFactory.getLogger(Context.class); 047 048 private final @Nullable Context parent; 049 050 @Nullable Entry last = null; 051 private boolean closed = false; 052 053 public Context(Context parent) { 054 this.parent = requireNonNull(parent); 055 } 056 057 public Context() { 058 this.parent = null; 059 } 060 061 /** 062 * Push, but not too hard… 063 * 064 * @param key Key. 065 * @param value Value to associate with the key. 066 * @return this, for chaining. 067 * @throws IllegalStateException if this Context already has another value for the key. 068 */ 069 public <K extends Enum<K> & Key<T>, T> Context push(K key, T value) { 070 return _push(key, value); 071 } 072 073 public <T> void push(Key<T> key, T instance) { 074 _push(key, instance); 075 } 076 077 public <T> Context push(Class<T> key, T value) { 078 _push(key, value); // NOT .getName() 079 return this; 080 } 081 082 private Context _push(Object key, Object value) { 083 check(); 084 if (_getJustThisLevel(key) != null) 085 throw new IllegalStateException( 086 "Use nesting; this Context already has another value for: #" + key); 087 last = new Entry(key, value, last); 088 return this; 089 } 090 091 private Object _get(Object key) { 092 if (isEmpty()) throw new IllegalStateException("Context is empty, no: " + key); 093 var object = _getRecursive(key); 094 if (object == null) 095 throw new IllegalStateException("Context has no " + key + "; only:\n" + toString(" ")); 096 return object; 097 } 098 099 /** 100 * Get the value for the given key, from this or its parent context. 101 * 102 * <p>Never null, but may throw IllegalStateException if not available. 103 * 104 * <p>Use {@link #optional(Enum)} to check if key is available in Context. 105 */ 106 public <K extends Enum<K> & Key<T>, T> T get(K key) { 107 // TODO Better error message instead of ClassCastException ? See below... 108 return (T) _get(key); 109 } 110 111 /** 112 * Gets the instance of Class, from this or its parent context. 113 * 114 * <p>Never null, but may throw IllegalStateException if not available. 115 * 116 * <p>Use {@link #optional(Class)} to check if key is available in Context. 117 */ 118 @SuppressWarnings("unchecked") 119 public <T> T get(Class<T> key) { 120 var object = _get(key); 121 if (key.isInstance(object)) return (T) object; 122 throw new IllegalStateException("Context's " + key + " is a " + object.getClass()); 123 } 124 125 /** 126 * Gets the instance of Class, from this or its parent context, if available. 127 * 128 * <p>Use {@link #get(Class)} if key must be available in Context. 129 */ 130 public <T> Optional<T> optional(Class<T> key) { 131 return Optional.ofNullable((T) _getRecursive(key)); 132 } 133 134 /** 135 * Get the value for the given key, from this or its parent context, if available. 136 * 137 * <p>Use {@link #get(Enum)} if key must be available in Context. 138 */ 139 public <T, K extends Enum<K> & Key<T>> Optional<T> optional(K key) { 140 return Optional.ofNullable((T) _getRecursive(key)); 141 } 142 143 private @Nullable Object _getJustThisLevel(Object key) { 144 var current = last; 145 while (current != null) { 146 if (current.key.equals(key)) { 147 return current.value; 148 } 149 current = current.previous; 150 } 151 return null; 152 } 153 154 private @Nullable Object _getRecursive(Object key) { 155 check(); 156 var current = last; 157 while (current != null) { 158 if (current.key.equals(key)) { 159 return current.value; 160 } 161 current = current.previous; 162 } 163 if (parent != null) return parent._getRecursive(key); 164 else return null; 165 } 166 167 private boolean isEmpty() { 168 return last == null && parent == null; 169 } 170 171 // Nota bene: This (kind of) Stack-like data structure (intentionally) 172 // does not have (need) any pop() ("goes the weasel”) kind of method! 173 174 void append(Appendable a, String indent) { 175 try { 176 var current = last; 177 while (current != null) { 178 a.append(indent); 179 a.append(current.key.toString()); 180 a.append(" => "); 181 a.append(current.value.toString()); 182 a.append('\n'); 183 current = current.previous; 184 } 185 if (parent != null) parent.append(a, indent + ContextualizedException.INDENT); 186 } catch (IOException e) { 187 LOG.error("append() hit an IOException", e); 188 } 189 } 190 191 private String toString(String indent) { 192 var sb = new StringBuilder(); 193 append(sb, indent); 194 return sb.toString(); 195 } 196 197 public String toString() { 198 return toString(""); 199 } 200 201 /** Close this context. Don't use it anymore! */ 202 @Override 203 public void close() { 204 closed = true; 205 TLC.reset(parent); 206 207 // NB: It's tempting to do "last = null" here, intending to free up memory; 208 // but doing so breaks e.g. the ContextsTest#exceptionWithContext(). We do 209 // NOT have to do it to free memory, because Context (should) will be GC. 210 } 211 212 private void check() { 213 if (closed) { 214 throw new IllegalStateException("Context already closed"); 215 } 216 } 217 218 private record Entry(Object key, Object value, @Nullable Entry previous) { 219 private Entry(Object key, Object value, @Nullable Entry previous) { 220 this.key = key; 221 this.value = value; 222 this.previous = previous; 223 } 224 } 225}