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.annotation.Annotation;
29  import java.lang.reflect.Array;
30  import java.lang.reflect.InvocationTargetException;
31  import java.lang.reflect.Method;
32  import java.lang.reflect.Parameter;
33  import java.util.ArrayList;
34  import java.util.Arrays;
35  import java.util.List;
36  import java.util.Map;
37  import java.util.Objects;
38  
39  import io.jawk.ext.annotations.JawkAssocArray;
40  import io.jawk.ext.annotations.JawkFunction;
41  import io.jawk.ext.annotations.JawkOptional;
42  import io.jawk.ext.annotations.JawkRawValue;
43  import io.jawk.ext.annotations.JawkRegexp;
44  import io.jawk.jrt.AssocArray;
45  import io.jawk.jrt.IllegalAwkArgumentException;
46  
47  /**
48   * Metadata describing a single annotated extension function.
49   */
50  public final class ExtensionFunction implements Serializable {
51  
52  	private static final long serialVersionUID = 1L;
53  
54  	/** AWK-visible keyword that dispatches to the underlying Java method. */
55  	private final String keyword;
56  
57  	/** Extension type declaring the Java implementation method. */
58  	private final Class<? extends AbstractExtension> declaringType;
59  
60  	/** Name of the Java method used when rehydrating serialized metadata. */
61  	private final String methodName;
62  
63  	/** Java parameter types of the extension method. */
64  	private final Class<?>[] parameterTypes;
65  	private transient Method method;
66  
67  	/** Flags describing which parameters must receive associative arrays. */
68  	private final boolean[] assocArrayParameters;
69  
70  	/** Whether the underlying Java method accepts varargs. */
71  	private final boolean varArgs;
72  
73  	/** Number of non-vararg parameters that must always be present. */
74  	private final int mandatoryParameterCount;
75  
76  	/** Whether the vararg component type must be an associative array. */
77  	private final boolean varArgAssocArray;
78  
79  	/**
80  	 * Explicit AWK argument positions that need raw, non-coercing evaluation.
81  	 * Transient: annotation-derived metadata is recomputed from the resolved
82  	 * {@link Method} after deserialization so that tuples files written by other
83  	 * Jawk versions stay loadable.
84  	 */
85  	private transient int[] rawValueParameterIndexes;
86  
87  	/** AWK argument positions where regexp literals keep their precompiled pattern. */
88  	private transient int[] regexpParameterIndexes;
89  
90  	ExtensionFunction(String keywordParam, Method methodParam) {
91  		this.keyword = validateKeyword(keywordParam, methodParam);
92  		this.declaringType = resolveDeclaringType(methodParam);
93  		this.methodName = methodParam.getName();
94  		this.parameterTypes = methodParam.getParameterTypes();
95  		this.method = prepareMethod(methodParam);
96  		this.varArgs = methodParam.isVarArgs();
97  		this.assocArrayParameters = inspectParameters(methodParam, methodParam.getParameters());
98  		int optionalCount = scanOptionalParameterCount(methodParam, methodParam.getParameters());
99  		this.mandatoryParameterCount = varArgs ?
100 				assocArrayParameters.length - 1 : assocArrayParameters.length - optionalCount;
101 		this.varArgAssocArray = varArgs && assocArrayParameters[assocArrayParameters.length - 1];
102 		computeAnnotationMetadata(methodParam);
103 	}
104 
105 	/**
106 	 * Derives the argument-position metadata from the method's annotations.
107 	 * Called from the constructor and again after deserialization, because this
108 	 * metadata belongs to the class loaded in the current JVM, not to the
109 	 * serialized stream.
110 	 */
111 	private void computeAnnotationMetadata(Method methodParam) {
112 		this.rawValueParameterIndexes = scanObjectParameterIndexes(
113 				methodParam,
114 				methodParam.getParameters(),
115 				JawkRawValue.class);
116 		this.regexpParameterIndexes = scanObjectParameterIndexes(
117 				methodParam,
118 				methodParam.getParameters(),
119 				JawkRegexp.class);
120 	}
121 
122 	/*
123 	 * Optional parameters let a fixed-signature method accept a shorter AWK
124 	 * argument list; missing trailing arguments are passed as null. They must be
125 	 * trailing and cannot be combined with varargs.
126 	 */
127 	private static int scanOptionalParameterCount(Method methodParam, Parameter[] parameters) {
128 		int optionalCount = 0;
129 		for (Parameter parameter : parameters) {
130 			if (parameter.isAnnotationPresent(JawkOptional.class)) {
131 				if (methodParam.isVarArgs()) {
132 					throw new IllegalStateException(
133 							"@" + JawkOptional.class.getSimpleName()
134 									+ " cannot be combined with varargs: " + methodParam.toGenericString());
135 				}
136 				optionalCount++;
137 			} else if (optionalCount > 0) {
138 				throw new IllegalStateException(
139 						"@" + JawkOptional.class.getSimpleName()
140 								+ " parameters must be trailing: " + methodParam.toGenericString());
141 			}
142 		}
143 		return optionalCount;
144 	}
145 
146 	private static String validateKeyword(String keyword, Method method) {
147 		Objects.requireNonNull(method, "method");
148 		if (keyword == null || keyword.trim().isEmpty()) {
149 			throw new IllegalStateException(
150 					"@" + JawkFunction.class.getSimpleName()
151 							+ " on " + method + " must declare a non-empty name");
152 		}
153 		return keyword;
154 	}
155 
156 	private static Class<? extends AbstractExtension> resolveDeclaringType(Method method) {
157 		Class<?> declaringClass = method.getDeclaringClass();
158 		if (!AbstractExtension.class.isAssignableFrom(declaringClass)) {
159 			throw new IllegalStateException(
160 					"@" + JawkFunction.class.getSimpleName()
161 							+ " must be declared on a subclass of " + AbstractExtension.class.getName()
162 							+ ": " + method);
163 		}
164 		@SuppressWarnings("unchecked")
165 		Class<? extends AbstractExtension> type = (Class<? extends AbstractExtension>) declaringClass;
166 		return type;
167 	}
168 
169 	private static Method prepareMethod(Method method) {
170 		if (java.lang.reflect.Modifier.isStatic(method.getModifiers())) {
171 			throw new IllegalStateException(
172 					"@" + JawkFunction.class.getSimpleName()
173 							+ " does not support static methods: " + method.toGenericString());
174 		}
175 		method.setAccessible(true);
176 		return method;
177 	}
178 
179 	/*
180 	 * Raw-value and regexp markers change how the compiler evaluates the matching
181 	 * AWK argument, so the annotated Java parameter must be able to receive any
182 	 * runtime value (Pattern, Map, untyped placeholders, ...): plain Object.
183 	 */
184 	private static int[] scanObjectParameterIndexes(
185 			Method methodParam,
186 			Parameter[] parameters,
187 			Class<? extends Annotation> annotationType) {
188 		int count = 0;
189 		int[] indexes = new int[parameters.length];
190 		for (int idx = 0; idx < parameters.length; idx++) {
191 			Parameter parameter = parameters[idx];
192 			if (!parameter.isAnnotationPresent(annotationType)) {
193 				continue;
194 			}
195 			if (parameter.isVarArgs() || parameter.getType() != Object.class) {
196 				throw new IllegalStateException(
197 						"Parameter " + idx + " of " + methodParam
198 								+ " annotated with @" + annotationType.getSimpleName()
199 								+ " must be a non-vararg " + Object.class.getName());
200 			}
201 			indexes[count++] = idx;
202 		}
203 		return Arrays.copyOf(indexes, count);
204 	}
205 
206 	/**
207 	 * Inspects the declared Java parameters and records which ones are annotated
208 	 * with {@link JawkAssocArray}.
209 	 * <p>
210 	 * The validation is intentionally stricter than checking
211 	 * {@code Map.class.isAssignableFrom(parameterType)} alone. Jawk passes runtime
212 	 * associative arrays as {@link AssocArray} instances, so the declared parameter
213 	 * type must satisfy two constraints:
214 	 * </p>
215 	 * <ul>
216 	 * <li>it must be a {@link Map} type, because {@code @JawkAssocArray} is a
217 	 * map-shaped contract for extension authors</li>
218 	 * <li>it must also be able to receive an {@link AssocArray} instance at
219 	 * invocation time</li>
220 	 * </ul>
221 	 * <p>
222 	 * That second constraint rejects concrete map implementations such as
223 	 * {@link java.util.HashMap}. A declaration like
224 	 * {@code @JawkAssocArray HashMap<Object, Object>} is map-shaped, but it is not
225 	 * compatible with the {@link AssocArray} values that Jawk actually passes, so
226 	 * letting it register here would only defer the failure until reflective
227 	 * invocation.
228 	 * </p>
229 	 *
230 	 * @param methodParam method whose parameters are being inspected
231 	 * @param parameters declared parameters of {@code methodParam}
232 	 * @return flags indicating which parameter positions require associative arrays
233 	 * @throws IllegalStateException when an annotated parameter cannot receive the
234 	 *         runtime {@link AssocArray} values provided by Jawk
235 	 */
236 	private boolean[] inspectParameters(Method methodParam, Parameter[] parameters) {
237 		boolean[] assoc = new boolean[parameters.length];
238 		for (int idx = 0; idx < parameters.length; idx++) {
239 			Parameter parameter = parameters[idx];
240 			if (parameter.isAnnotationPresent(JawkAssocArray.class)) {
241 				Class<?> parameterType = parameter.getType();
242 				if (parameter.isVarArgs()) {
243 					parameterType = parameterType.getComponentType();
244 				}
245 				if (!Map.class.isAssignableFrom(parameterType)
246 						|| !parameterType.isAssignableFrom(AssocArray.class)) {
247 					throw new IllegalStateException(
248 							"Parameter " + idx + " of " + methodParam
249 									+ " annotated with @" + JawkAssocArray.class.getSimpleName()
250 									+ " must accept " + AssocArray.class.getName()
251 									+ " instances via " + Map.class.getName());
252 				}
253 				assoc[idx] = true;
254 			}
255 		}
256 		return assoc;
257 	}
258 
259 	/**
260 	 * Restores the reflective {@link Method} handle after Java deserialization.
261 	 *
262 	 * @param in Object stream containing the serialized metadata
263 	 * @throws IOException If the stream cannot be read
264 	 * @throws ClassNotFoundException If a serialized dependency cannot be resolved
265 	 */
266 	private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
267 		in.defaultReadObject();
268 		try {
269 			Method resolved = declaringType.getDeclaredMethod(methodName, parameterTypes);
270 			this.method = prepareMethod(resolved);
271 			computeAnnotationMetadata(resolved);
272 		} catch (NoSuchMethodException ex) {
273 			throw new IllegalStateException(
274 					"Unable to rehydrate extension method '" + methodName
275 							+ "' on type " + declaringType.getName(),
276 					ex);
277 		}
278 	}
279 
280 	/**
281 	 * Returns the Awk keyword mapped to this extension function.
282 	 *
283 	 * @return the keyword exposed by the annotated method
284 	 */
285 	public String getKeyword() {
286 		return keyword;
287 	}
288 
289 	/**
290 	 * Returns the extension type that declares the underlying Java method.
291 	 *
292 	 * @return declaring {@link AbstractExtension} subtype
293 	 */
294 	public Class<? extends AbstractExtension> getDeclaringType() {
295 		return declaringType;
296 	}
297 
298 	/**
299 	 * Returns the fully-qualified class name of the declaring extension.
300 	 *
301 	 * @return extension class name
302 	 */
303 	public String getExtensionClassName() {
304 		return declaringType.getName();
305 	}
306 
307 	/**
308 	 * Returns the minimum number of arguments required to invoke the function.
309 	 *
310 	 * @return required argument count before considering varargs
311 	 */
312 	public int getArity() {
313 		return mandatoryParameterCount;
314 	}
315 
316 	/**
317 	 * Indicates whether the parameter at the supplied index must be an associative
318 	 * array.
319 	 *
320 	 * @param index zero-based parameter index
321 	 * @return {@code true} when the parameter must be an associative array
322 	 * @throws IndexOutOfBoundsException when the index exceeds the parameter count
323 	 */
324 	public boolean expectsAssocArray(int index) {
325 		if (index < 0 || index >= assocArrayParameters.length) {
326 			throw new IndexOutOfBoundsException("Parameter index out of range: " + index);
327 		}
328 		return assocArrayParameters[index];
329 	}
330 
331 	/**
332 	 * Collects the indexes of arguments that must be associative arrays for a call
333 	 * with the supplied argument count. Vararg positions are included when the
334 	 * vararg parameter requires associative arrays.
335 	 *
336 	 * @param argCount number of arguments supplied by the caller
337 	 * @return indexes of arguments that must be associative arrays
338 	 */
339 	public int[] collectAssocArrayIndexes(int argCount) {
340 		verifyArgCount(argCount);
341 		List<Integer> indexes = new ArrayList<Integer>();
342 		int upperBound = Math.min(argCount, declaredParameterUpperBound());
343 		for (int idx = 0; idx < upperBound; idx++) {
344 			if (assocArrayParameters[idx]) {
345 				indexes.add(Integer.valueOf(idx));
346 			}
347 		}
348 		if (varArgs && varArgAssocArray) {
349 			for (int idx = mandatoryParameterCount; idx < argCount; idx++) {
350 				indexes.add(Integer.valueOf(idx));
351 			}
352 		}
353 		int[] result = new int[indexes.size()];
354 		for (int idx = 0; idx < indexes.size(); idx++) {
355 			result[idx] = indexes.get(idx).intValue();
356 		}
357 		return result;
358 	}
359 
360 	/** Highest exclusive index of the declared (non-vararg) parameters. */
361 	private int declaredParameterUpperBound() {
362 		return varArgs ? mandatoryParameterCount : parameterTypes.length;
363 	}
364 
365 	/**
366 	 * Collects the indexes of arguments that should be evaluated without
367 	 * autoconverting untyped values to assigned scalar blanks.
368 	 *
369 	 * @param argCount number of arguments supplied by the caller
370 	 * @return indexes requiring raw value evaluation
371 	 */
372 	public int[] collectRawValueIndexes(int argCount) {
373 		verifyArgCount(argCount);
374 		return Arrays.stream(rawValueParameterIndexes).filter(idx -> idx < argCount).toArray();
375 	}
376 
377 	/**
378 	 * Collects the indexes of arguments where a regexp literal keeps its
379 	 * precompiled pattern instead of being evaluated as {@code $0 ~ /re/}.
380 	 *
381 	 * @param argCount number of arguments supplied by the caller
382 	 * @return indexes keeping regexp literals raw
383 	 */
384 	public int[] collectRegexpIndexes(int argCount) {
385 		verifyArgCount(argCount);
386 		return Arrays.stream(regexpParameterIndexes).filter(idx -> idx < argCount).toArray();
387 	}
388 
389 	/**
390 	 * Invokes the underlying Java method on the supplied target instance.
391 	 *
392 	 * @param target extension instance to receive the call
393 	 * @param args arguments evaluated by the interpreter
394 	 * @return result of the Java invocation
395 	 * @throws IllegalAwkArgumentException when the arguments violate the metadata
396 	 * @throws IllegalStateException when reflection cannot invoke the method
397 	 */
398 	public Object invoke(AbstractExtension target, Object[] args) {
399 		Objects.requireNonNull(target, "target");
400 		if (!declaringType.isInstance(target)) {
401 			throw new IllegalArgumentException(
402 					"Extension instance " + target.getClass().getName()
403 							+ " is not compatible with " + declaringType.getName());
404 		}
405 		int argCount = args == null ? 0 : args.length;
406 		verifyArgCount(argCount);
407 		enforceAssocArrayParameters(args);
408 		Object[] invocationArgs = prepareArguments(args);
409 		try {
410 			return method.invoke(target, invocationArgs);
411 		} catch (IllegalAccessException ex) {
412 			throw new IllegalStateException(
413 					"Unable to access extension function method for keyword '" + keyword + "'",
414 					ex);
415 		} catch (InvocationTargetException ex) {
416 			Throwable cause = ex.getCause();
417 			if (cause instanceof RuntimeException) {
418 				throw (RuntimeException) cause;
419 			}
420 			if (cause instanceof Error) {
421 				throw (Error) cause;
422 			}
423 			throw new IllegalStateException(
424 					"Invocation of extension function '" + keyword + "' failed",
425 					cause);
426 		}
427 	}
428 
429 	private Object[] prepareArguments(Object[] args) {
430 		int argCount = args == null ? 0 : args.length;
431 		if (!varArgs) {
432 			// Omitted optional trailing arguments are passed as null
433 			Object[] invocationArgs = new Object[parameterTypes.length];
434 			if (argCount > 0) {
435 				System.arraycopy(args, 0, invocationArgs, 0, argCount);
436 			}
437 			return invocationArgs;
438 		}
439 		if (argCount == 0) {
440 			return new Object[] { Array.newInstance(parameterTypes[parameterTypes.length - 1].getComponentType(), 0) };
441 		}
442 		Object[] invocationArgs = new Object[mandatoryParameterCount + 1];
443 		for (int idx = 0; idx < mandatoryParameterCount; idx++) {
444 			invocationArgs[idx] = args[idx];
445 		}
446 		int varArgCount = argCount - mandatoryParameterCount;
447 		Class<?> componentType = parameterTypes[parameterTypes.length - 1].getComponentType();
448 		Object varArgArray = Array.newInstance(componentType, varArgCount);
449 		for (int idx = 0; idx < varArgCount; idx++) {
450 			Array.set(varArgArray, idx, args[mandatoryParameterCount + idx]);
451 		}
452 		invocationArgs[mandatoryParameterCount] = varArgArray;
453 		return invocationArgs;
454 	}
455 
456 	/**
457 	 * Verifies that the provided argument count satisfies the arity constraints
458 	 * encoded in the metadata.
459 	 *
460 	 * @param argCount number of arguments the caller supplied
461 	 * @throws IllegalAwkArgumentException when the count violates the signature
462 	 */
463 	public void verifyArgCount(int argCount) {
464 		if (!varArgs) {
465 			int maxCount = parameterTypes.length;
466 			if (argCount < mandatoryParameterCount || argCount > maxCount) {
467 				String expected = mandatoryParameterCount == maxCount ?
468 						String.valueOf(maxCount) : mandatoryParameterCount + " to " + maxCount;
469 				throw new IllegalAwkArgumentException(
470 						"Extension function '" + keyword + "' expects " + expected
471 								+ " argument(s), not " + argCount);
472 			}
473 			return;
474 		}
475 		if (argCount < mandatoryParameterCount) {
476 			throw new IllegalAwkArgumentException(
477 					"Extension function '" + keyword + "' expects " + getArity()
478 							+ " argument(s), not " + argCount);
479 		}
480 	}
481 
482 	private void enforceAssocArrayParameters(Object[] args) {
483 		if (args == null) {
484 			return;
485 		}
486 		int argCount = args.length;
487 		int upperBound = Math.min(argCount, declaredParameterUpperBound());
488 		for (int idx = 0; idx < upperBound; idx++) {
489 			if (assocArrayParameters[idx]) {
490 				requireAssocArray(idx, args[idx]);
491 			}
492 		}
493 		if (varArgs && varArgAssocArray) {
494 			for (int idx = mandatoryParameterCount; idx < argCount; idx++) {
495 				requireAssocArray(idx, args[idx]);
496 			}
497 		}
498 	}
499 
500 	private void requireAssocArray(int idx, Object arg) {
501 		if (!(arg instanceof Map)) {
502 			throw new IllegalAwkArgumentException(
503 					"Argument " + idx + " passed to extension function '" + keyword
504 							+ "' must be an associative array");
505 		}
506 	}
507 }