View Javadoc
1   package io.jawk.ext;
2   
3   /*-
4    * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
5    * Jawk
6    * ჻჻჻჻჻჻
7    * Copyright (C) 2006 - 2026 MetricsHub
8    * ჻჻჻჻჻჻
9    * This program is free software: you can redistribute it and/or modify
10   * it under the terms of the GNU Lesser General Public License as
11   * published by the Free Software Foundation, either version 3 of the
12   * License, or (at your option) any later version.
13   *
14   * This program is distributed in the hope that it will be useful,
15   * but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17   * GNU General Lesser Public License for more details.
18   *
19   * You should have received a copy of the GNU General Lesser Public
20   * License along with this program.  If not, see
21   * <http://www.gnu.org/licenses/lgpl-3.0.html>.
22   * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
23   */
24  
25  import java.io.IOException;
26  import java.io.ObjectInputStream;
27  import java.io.Serializable;
28  import java.lang.reflect.Array;
29  import java.lang.reflect.InvocationTargetException;
30  import java.lang.reflect.Method;
31  import java.lang.reflect.Parameter;
32  import java.util.ArrayList;
33  import java.util.List;
34  import java.util.Map;
35  import java.util.Objects;
36  
37  import io.jawk.ext.annotations.JawkAssocArray;
38  import io.jawk.ext.annotations.JawkFunction;
39  import io.jawk.jrt.AssocArray;
40  import io.jawk.jrt.IllegalAwkArgumentException;
41  
42  /**
43   * Metadata describing a single annotated extension function.
44   */
45  public final class ExtensionFunction implements Serializable {
46  
47  	private static final long serialVersionUID = 1L;
48  
49  	/** AWK-visible keyword that dispatches to the underlying Java method. */
50  	private final String keyword;
51  
52  	/** Extension type declaring the Java implementation method. */
53  	private final Class<? extends AbstractExtension> declaringType;
54  
55  	/** Name of the Java method used when rehydrating serialized metadata. */
56  	private final String methodName;
57  
58  	/** Java parameter types of the extension method. */
59  	private final Class<?>[] parameterTypes;
60  	private transient Method method;
61  
62  	/** Flags describing which parameters must receive associative arrays. */
63  	private final boolean[] assocArrayParameters;
64  
65  	/** Whether the underlying Java method accepts varargs. */
66  	private final boolean varArgs;
67  
68  	/** Number of non-vararg parameters that must always be present. */
69  	private final int mandatoryParameterCount;
70  
71  	/** Whether the vararg component type must be an associative array. */
72  	private final boolean varArgAssocArray;
73  
74  	ExtensionFunction(String keywordParam, Method methodParam) {
75  		this.keyword = validateKeyword(keywordParam, methodParam);
76  		this.declaringType = resolveDeclaringType(methodParam);
77  		this.methodName = methodParam.getName();
78  		this.parameterTypes = methodParam.getParameterTypes();
79  		this.method = prepareMethod(methodParam);
80  		this.varArgs = methodParam.isVarArgs();
81  		this.assocArrayParameters = inspectParameters(methodParam, methodParam.getParameters());
82  		this.mandatoryParameterCount = varArgs ? assocArrayParameters.length - 1 : assocArrayParameters.length;
83  		this.varArgAssocArray = varArgs && assocArrayParameters[assocArrayParameters.length - 1];
84  	}
85  
86  	private static String validateKeyword(String keyword, Method method) {
87  		Objects.requireNonNull(method, "method");
88  		if (keyword == null || keyword.trim().isEmpty()) {
89  			throw new IllegalStateException(
90  					"@" + JawkFunction.class.getSimpleName()
91  							+ " on " + method + " must declare a non-empty name");
92  		}
93  		return keyword;
94  	}
95  
96  	private static Class<? extends AbstractExtension> resolveDeclaringType(Method method) {
97  		Class<?> declaringClass = method.getDeclaringClass();
98  		if (!AbstractExtension.class.isAssignableFrom(declaringClass)) {
99  			throw new IllegalStateException(
100 					"@" + JawkFunction.class.getSimpleName()
101 							+ " must be declared on a subclass of " + AbstractExtension.class.getName()
102 							+ ": " + method);
103 		}
104 		@SuppressWarnings("unchecked")
105 		Class<? extends AbstractExtension> type = (Class<? extends AbstractExtension>) declaringClass;
106 		return type;
107 	}
108 
109 	private static Method prepareMethod(Method method) {
110 		if (java.lang.reflect.Modifier.isStatic(method.getModifiers())) {
111 			throw new IllegalStateException(
112 					"@" + JawkFunction.class.getSimpleName()
113 							+ " does not support static methods: " + method.toGenericString());
114 		}
115 		method.setAccessible(true);
116 		return method;
117 	}
118 
119 	/**
120 	 * Inspects the declared Java parameters and records which ones are annotated
121 	 * with {@link JawkAssocArray}.
122 	 * <p>
123 	 * The validation is intentionally stricter than checking
124 	 * {@code Map.class.isAssignableFrom(parameterType)} alone. Jawk passes runtime
125 	 * associative arrays as {@link AssocArray} instances, so the declared parameter
126 	 * type must satisfy two constraints:
127 	 * </p>
128 	 * <ul>
129 	 * <li>it must be a {@link Map} type, because {@code @JawkAssocArray} is a
130 	 * map-shaped contract for extension authors</li>
131 	 * <li>it must also be able to receive an {@link AssocArray} instance at
132 	 * invocation time</li>
133 	 * </ul>
134 	 * <p>
135 	 * That second constraint rejects concrete map implementations such as
136 	 * {@link java.util.HashMap}. A declaration like
137 	 * {@code @JawkAssocArray HashMap<Object, Object>} is map-shaped, but it is not
138 	 * compatible with the {@link AssocArray} values that Jawk actually passes, so
139 	 * letting it register here would only defer the failure until reflective
140 	 * invocation.
141 	 * </p>
142 	 *
143 	 * @param methodParam method whose parameters are being inspected
144 	 * @param parameters declared parameters of {@code methodParam}
145 	 * @return flags indicating which parameter positions require associative arrays
146 	 * @throws IllegalStateException when an annotated parameter cannot receive the
147 	 *         runtime {@link AssocArray} values provided by Jawk
148 	 */
149 	private boolean[] inspectParameters(Method methodParam, Parameter[] parameters) {
150 		boolean[] assoc = new boolean[parameters.length];
151 		for (int idx = 0; idx < parameters.length; idx++) {
152 			Parameter parameter = parameters[idx];
153 			if (parameter.isAnnotationPresent(JawkAssocArray.class)) {
154 				Class<?> parameterType = parameter.getType();
155 				if (parameter.isVarArgs()) {
156 					parameterType = parameterType.getComponentType();
157 				}
158 				if (!Map.class.isAssignableFrom(parameterType)
159 						|| !parameterType.isAssignableFrom(AssocArray.class)) {
160 					throw new IllegalStateException(
161 							"Parameter " + idx + " of " + methodParam
162 									+ " annotated with @" + JawkAssocArray.class.getSimpleName()
163 									+ " must accept " + AssocArray.class.getName()
164 									+ " instances via " + Map.class.getName());
165 				}
166 				assoc[idx] = true;
167 			}
168 		}
169 		return assoc;
170 	}
171 
172 	/**
173 	 * Restores the reflective {@link Method} handle after Java deserialization.
174 	 *
175 	 * @param in Object stream containing the serialized metadata
176 	 * @throws IOException If the stream cannot be read
177 	 * @throws ClassNotFoundException If a serialized dependency cannot be resolved
178 	 */
179 	private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
180 		in.defaultReadObject();
181 		try {
182 			Method resolved = declaringType.getDeclaredMethod(methodName, parameterTypes);
183 			this.method = prepareMethod(resolved);
184 		} catch (NoSuchMethodException ex) {
185 			throw new IllegalStateException(
186 					"Unable to rehydrate extension method '" + methodName
187 							+ "' on type " + declaringType.getName(),
188 					ex);
189 		}
190 	}
191 
192 	/**
193 	 * Returns the Awk keyword mapped to this extension function.
194 	 *
195 	 * @return the keyword exposed by the annotated method
196 	 */
197 	public String getKeyword() {
198 		return keyword;
199 	}
200 
201 	/**
202 	 * Returns the extension type that declares the underlying Java method.
203 	 *
204 	 * @return declaring {@link AbstractExtension} subtype
205 	 */
206 	public Class<? extends AbstractExtension> getDeclaringType() {
207 		return declaringType;
208 	}
209 
210 	/**
211 	 * Returns the fully-qualified class name of the declaring extension.
212 	 *
213 	 * @return extension class name
214 	 */
215 	public String getExtensionClassName() {
216 		return declaringType.getName();
217 	}
218 
219 	/**
220 	 * Returns the minimum number of arguments required to invoke the function.
221 	 *
222 	 * @return required argument count before considering varargs
223 	 */
224 	public int getArity() {
225 		return mandatoryParameterCount;
226 	}
227 
228 	/**
229 	 * Indicates whether the parameter at the supplied index must be an associative
230 	 * array.
231 	 *
232 	 * @param index zero-based parameter index
233 	 * @return {@code true} when the parameter must be an associative array
234 	 * @throws IndexOutOfBoundsException when the index exceeds the parameter count
235 	 */
236 	public boolean expectsAssocArray(int index) {
237 		if (index < 0 || index >= assocArrayParameters.length) {
238 			throw new IndexOutOfBoundsException("Parameter index out of range: " + index);
239 		}
240 		return assocArrayParameters[index];
241 	}
242 
243 	/**
244 	 * Collects the indexes of arguments that must be associative arrays for a call
245 	 * with the supplied argument count. Vararg positions are included when the
246 	 * vararg parameter requires associative arrays.
247 	 *
248 	 * @param argCount number of arguments supplied by the caller
249 	 * @return indexes of arguments that must be associative arrays
250 	 */
251 	public int[] collectAssocArrayIndexes(int argCount) {
252 		verifyArgCount(argCount);
253 		List<Integer> indexes = new ArrayList<Integer>();
254 		int upperBound = Math.min(argCount, mandatoryParameterCount);
255 		for (int idx = 0; idx < upperBound; idx++) {
256 			if (assocArrayParameters[idx]) {
257 				indexes.add(Integer.valueOf(idx));
258 			}
259 		}
260 		if (varArgs && varArgAssocArray) {
261 			for (int idx = mandatoryParameterCount; idx < argCount; idx++) {
262 				indexes.add(Integer.valueOf(idx));
263 			}
264 		}
265 		int[] result = new int[indexes.size()];
266 		for (int idx = 0; idx < indexes.size(); idx++) {
267 			result[idx] = indexes.get(idx).intValue();
268 		}
269 		return result;
270 	}
271 
272 	/**
273 	 * Invokes the underlying Java method on the supplied target instance.
274 	 *
275 	 * @param target extension instance to receive the call
276 	 * @param args arguments evaluated by the interpreter
277 	 * @return result of the Java invocation
278 	 * @throws IllegalAwkArgumentException when the arguments violate the metadata
279 	 * @throws IllegalStateException when reflection cannot invoke the method
280 	 */
281 	public Object invoke(AbstractExtension target, Object[] args) {
282 		Objects.requireNonNull(target, "target");
283 		if (!declaringType.isInstance(target)) {
284 			throw new IllegalArgumentException(
285 					"Extension instance " + target.getClass().getName()
286 							+ " is not compatible with " + declaringType.getName());
287 		}
288 		int argCount = args == null ? 0 : args.length;
289 		verifyArgCount(argCount);
290 		enforceAssocArrayParameters(args);
291 		Object[] invocationArgs = prepareArguments(args);
292 		try {
293 			return method.invoke(target, invocationArgs);
294 		} catch (IllegalAccessException ex) {
295 			throw new IllegalStateException(
296 					"Unable to access extension function method for keyword '" + keyword + "'",
297 					ex);
298 		} catch (InvocationTargetException ex) {
299 			Throwable cause = ex.getCause();
300 			if (cause instanceof RuntimeException) {
301 				throw (RuntimeException) cause;
302 			}
303 			if (cause instanceof Error) {
304 				throw (Error) cause;
305 			}
306 			throw new IllegalStateException(
307 					"Invocation of extension function '" + keyword + "' failed",
308 					cause);
309 		}
310 	}
311 
312 	private Object[] prepareArguments(Object[] args) {
313 		int argCount = args == null ? 0 : args.length;
314 		if (argCount == 0) {
315 			return varArgs ?
316 					new Object[]
317 					{ Array.newInstance(parameterTypes[parameterTypes.length - 1].getComponentType(), 0) } : new Object[0];
318 		}
319 		if (!varArgs) {
320 			Object[] invocationArgs = new Object[argCount];
321 			System.arraycopy(args, 0, invocationArgs, 0, argCount);
322 			return invocationArgs;
323 		}
324 		Object[] invocationArgs = new Object[mandatoryParameterCount + 1];
325 		for (int idx = 0; idx < mandatoryParameterCount; idx++) {
326 			invocationArgs[idx] = args[idx];
327 		}
328 		int varArgCount = argCount - mandatoryParameterCount;
329 		Class<?> componentType = parameterTypes[parameterTypes.length - 1].getComponentType();
330 		Object varArgArray = Array.newInstance(componentType, varArgCount);
331 		for (int idx = 0; idx < varArgCount; idx++) {
332 			Array.set(varArgArray, idx, args[mandatoryParameterCount + idx]);
333 		}
334 		invocationArgs[mandatoryParameterCount] = varArgArray;
335 		return invocationArgs;
336 	}
337 
338 	/**
339 	 * Verifies that the provided argument count satisfies the arity constraints
340 	 * encoded in the metadata.
341 	 *
342 	 * @param argCount number of arguments the caller supplied
343 	 * @throws IllegalAwkArgumentException when the count violates the signature
344 	 */
345 	public void verifyArgCount(int argCount) {
346 		if (!varArgs) {
347 			if (argCount != mandatoryParameterCount) {
348 				throw new IllegalAwkArgumentException(
349 						"Extension function '" + keyword + "' expects " + mandatoryParameterCount
350 								+ " argument(s), not " + argCount);
351 			}
352 			return;
353 		}
354 		if (argCount < mandatoryParameterCount) {
355 			throw new IllegalAwkArgumentException(
356 					"Extension function '" + keyword + "' expects " + getArity()
357 							+ " argument(s), not " + argCount);
358 		}
359 	}
360 
361 	private void enforceAssocArrayParameters(Object[] args) {
362 		if (args == null) {
363 			return;
364 		}
365 		int argCount = args.length;
366 		int upperBound = Math.min(argCount, mandatoryParameterCount);
367 		for (int idx = 0; idx < upperBound; idx++) {
368 			if (!assocArrayParameters[idx]) {
369 				continue;
370 			}
371 			Object argument = args[idx];
372 			if (!(argument instanceof Map)) {
373 				throw new IllegalAwkArgumentException(
374 						"Argument " + idx + " passed to extension function '" + keyword
375 								+ "' must be an associative array");
376 			}
377 		}
378 		if (varArgs && varArgAssocArray) {
379 			for (int idx = mandatoryParameterCount; idx < argCount; idx++) {
380 				Object argument = args[idx];
381 				if (!(argument instanceof Map)) {
382 					throw new IllegalAwkArgumentException(
383 							"Argument " + idx + " passed to extension function '" + keyword
384 									+ "' must be an associative array");
385 				}
386 			}
387 		}
388 	}
389 }