Renderers.java
package com.maybeitssquid.sensitive;
import java.util.function.IntPredicate;
/**
* Factory methods for creating {@link Renderer} instances.
*
* <p>These factory methods are designed to be used in subclasses of {@link Sensitive} that override
* {@link Sensitive#getRenderer()} to provide custom rendering behavior. The returned renderer
* instances can be stored as static constants for efficient sharing across instances.
*
* <h2>Usage Examples</h2>
*
* <pre>{@code
* // Simple masking subclass - mask leading characters, show last 4
* public class AccountNumber extends Sensitive<String> {
* private static final Renderer<String> RENDERER = Renderers.mask();
*
* public AccountNumber(String value) { super(value); }
*
* @Override
* protected Renderer<String> getRenderer() { return RENDERER; }
* }
*
* AccountNumber acct = new AccountNumber("1234567890");
* System.out.printf("%.4s", acct); // prints "######7890"
*
* // Selective masking - preserve delimiters, mask only digits
* public class SSN extends Sensitive<String> {
* private static final Renderer<String> RENDERER =
* Renderers.mask(Character::isDigit);
*
* public SSN(String value) { super(value); }
*
* @Override
* protected Renderer<String> getRenderer() { return RENDERER; }
* }
*
* SSN ssn = new SSN("123-45-6789");
* System.out.printf("%.4s", ssn); // prints "###-##-6789"
* }</pre>
*
* @see Sensitive#getRenderer()
* @see Sensitive#getAltRenderer()
*/
public class Renderers {
/** Default delimiter character used between segments. */
public static final char DEFAULT_DELIMITER = '-';
/** Default replacement character for masking. */
public static final char DEFAULT_MASK = '#';
private Renderers() {
// EMPTY
}
/**
* Returns a renderer that shows the value completely unredacted.
*
* @param <T> the type of CharSequence to render
* @return a renderer that returns the input unchanged
*/
public static <T extends CharSequence> Renderer<T> unredacted() {
return (cs, p) -> cs == null ? "" : cs;
}
/**
* Returns a renderer that truncates the beginning of the value, showing only the trailing
* characters based on precision.
*
* <p>If precision is negative, shows the last half of the characters (rounded down). Otherwise,
* shows at most {@code precision} trailing characters.
*
* @param <T> the type of CharSequence to render
* @return a renderer that truncates leading characters
*/
public static <T extends CharSequence> Renderer<T> truncate() {
return (cs, p) ->
cs == null ? "" : cs.subSequence(Renderers.redactions(p, cs.length()), cs.length());
}
/**
* Returns a renderer that masks leading characters with the specified mask character.
*
* <p>If precision is negative, masks the first half of the characters (rounded up). Otherwise,
* masks all but the last {@code precision} characters.
*
* @param <T> the type of CharSequence to render
* @param maskCodePoint the code point of the character to use for masking
* @return a renderer that masks leading characters
*/
public static <T extends CharSequence> Renderer<T> mask(final int maskCodePoint) {
return (cs, p) -> {
if (cs == null) return "";
final int redactions = Renderers.redactions(p, cs.length());
final StringBuilder sb = new StringBuilder(cs.length());
for (int i = 0; i < redactions; i++) {
sb.appendCodePoint(maskCodePoint);
}
sb.append(cs, redactions, cs.length());
return sb.toString();
};
}
/**
* Convenience function equivalent to {@code mask((int) mask)}.
*
* @param <T> the type of CharSequence to render
* @param mask the character to use for masking. The character must be on the Basic Multilingual
* Plane.
* @return a renderer that masks leading characters
* @throws IllegalArgumentException if the mask is not on the Basic Multilingual Plane.
* @see #mask(int)
*/
public static <T extends CharSequence> Renderer<T> mask(final char mask) {
if (Character.isSurrogate(mask))
throw new IllegalArgumentException(
"Use code point to specify a mask value outside the Basic Multilingual Plane");
return mask((int) mask);
}
/**
* Convenience function equivalent to {@code mask(DEFAULT_MASK)}.
*
* @param <T> the type of CharSequence to render
* @return a renderer that masks leading characters with '#'
* @see #mask(int)
*/
public static <T extends CharSequence> Renderer<T> mask() {
return mask((int) DEFAULT_MASK);
}
/**
* Returns a renderer that masks only characters matching the predicate, using the specified mask
* character.
*
* <p>Characters not matching the predicate (such as delimiters) are preserved in place. The
* precision applies only to matching characters. For example, masking an SSN "123-45-6789" with a
* digit predicate and precision 4 would produce "###-##-6789".
*
* @param <T> the type of CharSequence to render
* @param redactable predicate that returns true for characters that are candidates for redaction
* @param maskCodePoint the code point of the character to use for masking
* @return a renderer that selectively masks characters
*/
public static <T extends CharSequence> Renderer<T> mask(
IntPredicate redactable, final int maskCodePoint) {
final IntPredicate predicate = redactable == null ? c -> true : redactable;
return (cs, p) -> {
if (cs == null) return "";
final int len = cs.length();
int significant = 0;
for (int i = 0; i < len; ) {
int cp = Character.codePointAt(cs, i);
if (predicate.test(cp)) significant++;
i += Character.charCount(cp);
}
final int redactions = Renderers.redactions(p, significant);
final StringBuilder builder = new StringBuilder(len);
int redacted = 0;
for (int i = 0; i < len; ) {
int codePoint = Character.codePointAt(cs, i);
if (redacted < redactions && predicate.test(codePoint)) {
builder.appendCodePoint(maskCodePoint);
redacted++;
} else {
builder.appendCodePoint(codePoint);
}
i += Character.charCount(codePoint);
}
return builder.toString();
};
}
/**
* Convenience function equivalent to {@code mask(redactable, (int) mask)}.
*
* @param <T> the type of CharSequence to render
* @param redactable predicate that returns true for characters that are candidates for redaction
* @param mask the character to use for masking
* @return a renderer that selectively masks characters
* @throws IllegalArgumentException if the mask is a surrogate character
*/
public static <T extends CharSequence> Renderer<T> mask(
IntPredicate redactable, final char mask) {
if (Character.isSurrogate(mask))
throw new IllegalArgumentException(
"Use code point to specify a mask value outside the Basic Multilingual Plane");
return mask(redactable, (int) mask);
}
/**
* Convenience function equivalent to {@code mask(redactable, DEFAULT_MASK}.
*
* @param <T> the type of CharSequence to render
* @param maskable predicate that returns true for characters that should be masked
* @return a renderer that selectively masks characters
* @see #mask(IntPredicate, int)
*/
public static <T extends CharSequence> Renderer<T> mask(IntPredicate maskable) {
return mask(maskable, (int) DEFAULT_MASK);
}
/**
* Returns a renderer that joins an array of CharSequences with a delimiter and applies the given
* renderer.
*
* @param <T> the element type of the CharSequence array
* @param delimiterCodePoint the character to insert between array elements
* @param renderer the renderer to apply to the joined string
* @return a renderer for arrays of CharSequences
*/
public static <T extends CharSequence> Renderer<T[]> delimit(
final int delimiterCodePoint, final Renderer<CharSequence> renderer) {
if (renderer == null) throw new NullPointerException("Nested renderer is required");
final String delimiter = Character.toString(delimiterCodePoint);
return (cs, p) -> cs == null ? "" : renderer.apply(String.join(delimiter, cs), p);
}
/**
* Returns a renderer that joins an array of CharSequences with a delimiter and applies a masking
* renderer.
*
* @param <T> the element type of the CharSequence array
* @param delimiterCodePoint the character to insert between array elements
* @param maskCodePoint the renderer to apply to the joined string
* @return a renderer for arrays of CharSequences
*/
public static <T extends CharSequence> Renderer<T[]> delimit(
final int delimiterCodePoint, final int maskCodePoint) {
return delimit(delimiterCodePoint, mask(c -> c != delimiterCodePoint, maskCodePoint));
}
/**
* Returns a renderer that joins an array of CharSequences with a delimiter and applies a masking
* renderer.
*
* @param <T> the element type of the CharSequence array
* @param delimiter the character to insert between array elements
* @param mask the renderer to apply to the joined string
* @return a renderer for arrays of CharSequences
*/
public static <T extends CharSequence> Renderer<T[]> delimit(
final char delimiter, final char mask) {
if (Character.isSurrogate(mask) || Character.isSurrogate(delimiter))
throw new IllegalArgumentException(
"Use code point to specify a mask or delimiter value outside the Basic Multilingual Plane");
return delimit((int) delimiter, (int) mask);
}
/**
* Returns a renderer that joins an array of CharSequences with a delimiter and applies a masking
* renderer using the {@link #DEFAULT_MASK}.
*
* @param <T> the element type of the CharSequence array
* @param delimiterCodePoint the character to insert between array elements
* @return a renderer for arrays of CharSequences
*/
public static <T extends CharSequence> Renderer<T[]> delimit(final int delimiterCodePoint) {
return delimit(delimiterCodePoint, DEFAULT_MASK);
}
/**
* Returns a renderer that joins an array of CharSequences with a delimiter and applies a masking
* renderer using the {@link #DEFAULT_MASK}.
*
* @param <T> the element type of the CharSequence array
* @param delimiter the character to insert between array elements
* @return a renderer for arrays of CharSequences
* @throws IllegalArgumentException if the delimiter is a surrogate character
*/
public static <T extends CharSequence> Renderer<T[]> delimit(final char delimiter) {
if (Character.isSurrogate(delimiter))
throw new IllegalArgumentException(
"Use code point to specify a delimiter value outside the Basic Multilingual Plane");
return delimit((int) delimiter);
}
/**
* Returns a renderer that joins an array of CharSequences with the {@link #DEFAULT_DELIMITER }and
* applies a masking renderer using the {@link #DEFAULT_MASK}.
*
* @param <T> the element type of the CharSequence array
* @return a renderer for arrays of CharSequences
*/
public static <T extends CharSequence> Renderer<T[]> delimit() {
return delimit(DEFAULT_DELIMITER);
}
/**
* Returns a renderer that joins an array of CharSequences with a delimiter and applies the given
* renderer.
*
* @param <T> the element type of the CharSequence array
* @param renderer the renderer to apply to the joined string
* @param delimiter the character to insert between array elements
* @return a renderer for arrays of CharSequences
* @throws IllegalArgumentException if the delimiter is a surrogate character
* @see #delimit(int, Renderer)
*/
public static <T extends CharSequence> Renderer<T[]> delimit(
final Renderer<CharSequence> renderer, final char delimiter) {
if (Character.isSurrogate(delimiter))
throw new IllegalArgumentException(
"Use code point to specify a delimiter value outside the Basic Multilingual Plane");
return delimit((int) delimiter, renderer);
}
/**
* Returns a renderer that joins an array of CharSequences with the {@link #DEFAULT_DELIMITER} and
* applies the given renderer.
*
* @param <T> the element type of the CharSequence array
* @param renderer the renderer to apply to the joined string
* @return a renderer for arrays of CharSequences
* @see #delimit(int, Renderer)
*/
public static <T extends CharSequence> Renderer<T[]> delimit(
final Renderer<CharSequence> renderer) {
return delimit(DEFAULT_DELIMITER, renderer);
}
/**
* Returns a renderer that concatenates an array of CharSequences and applies the given renderer.
*
* <p>The array elements are concatenated without a delimiter before rendering.
*
* @param <T> the element type of the CharSequence array
* @param renderer the renderer to apply to the joined string
* @return a renderer for arrays of CharSequences
*/
public static <T extends CharSequence> Renderer<T[]> concatenate(
final Renderer<CharSequence> renderer) {
if (renderer == null) throw new NullPointerException("Nested renderer is required");
return (cs, p) -> cs == null ? "" : renderer.apply(String.join("", cs), p);
}
/**
* Computes the number of symbols to redact. If precision < 0, returns half the length (rounded
* up), otherwise returns length - precision.
*
* @param precision the number of unredacted symbols requested, or -1 if default is desired.
* @param length the number of symbols in the unredacted field.
* @return the number of unredacted symbols to show.
*/
public static int redactions(final int precision, final int length) {
if (length < 0) {
throw new IllegalArgumentException("Length must be >= 0, got " + length);
} else if (precision < 0) {
return (length + 1) / 2;
} else {
return Math.max(0, length - precision);
}
}
}