• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ben-manes / caffeine / #5015

19 Aug 2025 05:05AM UTC coverage: 99.974% (-0.03%) from 100.0%
#5015

push

github

ben-manes
Support underscore in CaffeineSpec numeric literals (fixes #1890)

3812 of 3816 branches covered (99.9%)

10 of 10 new or added lines in 2 files covered. (100.0%)

2 existing lines in 1 file now uncovered.

7817 of 7819 relevant lines covered (99.97%)

1.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.68
/caffeine/src/main/java/com/github/benmanes/caffeine/cache/CaffeineSpec.java
1
/*
2
 * Copyright 2016 Ben Manes. All Rights Reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package com.github.benmanes.caffeine.cache;
17

18
import static com.github.benmanes.caffeine.cache.Caffeine.UNSET_INT;
19
import static com.github.benmanes.caffeine.cache.Caffeine.requireArgument;
20
import static com.github.benmanes.caffeine.cache.Caffeine.requireState;
21
import static java.util.Locale.US;
22
import static java.util.Objects.requireNonNull;
23

24
import java.time.Duration;
25
import java.time.format.DateTimeParseException;
26
import java.util.Objects;
27
import java.util.concurrent.TimeUnit;
28

29
import org.jspecify.annotations.NullMarked;
30
import org.jspecify.annotations.Nullable;
31

32
import com.github.benmanes.caffeine.cache.Caffeine.Strength;
33

34
/**
35
 * A specification of a {@link Caffeine} builder configuration.
36
 * <p>
37
 * {@code CaffeineSpec} supports parsing configuration from a string, which makes it especially
38
 * useful for command-line configuration of a {@code Caffeine} builder.
39
 * <p>
40
 * The string syntax is a series of comma-separated keys or key-value pairs, each corresponding to a
41
 * {@code Caffeine} builder method.
42
 * <ul>
43
 *   <li>{@code initialCapacity=[integer]}: sets {@link Caffeine#initialCapacity}.
44
 *   <li>{@code maximumSize=[long]}: sets {@link Caffeine#maximumSize}.
45
 *   <li>{@code maximumWeight=[long]}: sets {@link Caffeine#maximumWeight}.
46
 *   <li>{@code expireAfterAccess=[duration]}: sets {@link Caffeine#expireAfterAccess}.
47
 *   <li>{@code expireAfterWrite=[duration]}: sets {@link Caffeine#expireAfterWrite}.
48
 *   <li>{@code refreshAfterWrite=[duration]}: sets {@link Caffeine#refreshAfterWrite}.
49
 *   <li>{@code weakKeys}: sets {@link Caffeine#weakKeys}.
50
 *   <li>{@code weakValues}: sets {@link Caffeine#weakValues}.
51
 *   <li>{@code softValues}: sets {@link Caffeine#softValues}.
52
 *   <li>{@code recordStats}: sets {@link Caffeine#recordStats}.
53
 * </ul>
54
 * <p>
55
 * Durations are represented as either an ISO-8601 string using {@link Duration#parse(CharSequence)}
56
 * or by an integer followed by one of "d", "h", "m", or "s", representing days, hours, minutes, or
57
 * seconds, respectively. There is currently no short syntax to request durations in milliseconds,
58
 * microseconds, or nanoseconds.
59
 * <p>
60
 * Whitespace before and after commas and equal signs is ignored. Keys may not be repeated; it is
61
 * also illegal to use the following pairs of keys in a single value:
62
 * <ul>
63
 *   <li>{@code maximumSize} and {@code maximumWeight}
64
 *   <li>{@code weakValues} and {@code softValues}
65
 * </ul>
66
 * <p>
67
 * {@code CaffeineSpec} does not support configuring {@code Caffeine} methods with non-value
68
 * parameters. These must be configured in code.
69
 * <p>
70
 * A new {@code Caffeine} builder can be instantiated from a {@code CaffeineSpec} using
71
 * {@link Caffeine#from(CaffeineSpec)} or {@link Caffeine#from(String)}.
72
 *
73
 * @author ben.manes@gmail.com (Ben Manes)
74
 */
75
@NullMarked
76
public final class CaffeineSpec {
77
  static final String SPLIT_OPTIONS = ",";
78
  static final String SPLIT_KEY_VALUE = "=";
79

80
  final String specification;
81

82
  int initialCapacity = UNSET_INT;
1✔
83
  long maximumWeight = UNSET_INT;
1✔
84
  long maximumSize = UNSET_INT;
1✔
85
  boolean recordStats;
86

87
  @Nullable Strength keyStrength;
88
  @Nullable Strength valueStrength;
89
  @Nullable Duration expireAfterWrite;
90
  @Nullable Duration expireAfterAccess;
91
  @Nullable Duration refreshAfterWrite;
92

93
  private CaffeineSpec(String specification) {
1✔
94
    this.specification = requireNonNull(specification);
1✔
95

96
    @SuppressWarnings("StringSplitter")
97
    var options = specification.split(SPLIT_OPTIONS);
1✔
98
    for (String option : options) {
1✔
99
      parseOption(option.strip());
1✔
100
    }
101
  }
1✔
102

103
  /**
104
   * Returns a {@link Caffeine} builder configured according to this specification.
105
   *
106
   * @return a builder configured to the specification
107
   */
108
  Caffeine<Object, Object> toBuilder() {
109
    var builder = Caffeine.newBuilder();
1✔
110
    if (initialCapacity != UNSET_INT) {
1✔
111
      builder.initialCapacity(initialCapacity);
1✔
112
    }
113
    if (maximumSize != UNSET_INT) {
1✔
114
      builder.maximumSize(maximumSize);
1✔
115
    }
116
    if (maximumWeight != UNSET_INT) {
1✔
117
      builder.maximumWeight(maximumWeight);
1✔
118
    }
119
    if (keyStrength != null) {
1✔
120
      requireState(keyStrength == Strength.WEAK);
1✔
121
      builder.weakKeys();
1✔
122
    }
123
    if (valueStrength != null) {
1✔
124
      if (valueStrength == Strength.WEAK) {
1✔
125
        builder.weakValues();
1✔
126
      } else {
127
        builder.softValues();
1✔
128
      }
129
    }
130
    if (expireAfterWrite != null) {
1✔
131
      builder.expireAfterWrite(expireAfterWrite);
1✔
132
    }
133
    if (expireAfterAccess != null) {
1✔
134
      builder.expireAfterAccess(expireAfterAccess);
1✔
135
    }
136
    if (refreshAfterWrite != null) {
1✔
137
      builder.refreshAfterWrite(refreshAfterWrite);
1✔
138
    }
139
    if (recordStats) {
1✔
140
      builder.recordStats();
1✔
141
    }
142
    return builder;
1✔
143
  }
144

145
  /**
146
   * Creates a CaffeineSpec from a string.
147
   *
148
   * @param specification the string form
149
   * @return the parsed specification
150
   */
151
  public static CaffeineSpec parse(String specification) {
152
    return new CaffeineSpec(specification);
1✔
153
  }
154

155
  /** Parses and applies the configuration option. */
156
  void parseOption(String option) {
157
    if (option.isEmpty()) {
1✔
158
      return;
1✔
159
    }
160

161
    @SuppressWarnings("StringSplitter")
162
    String[] keyAndValue = option.split(SPLIT_KEY_VALUE, 3);
1✔
163
    requireArgument(keyAndValue.length <= 2,
1✔
164
        "key-value pair %s with more than one equals sign", option);
165

166
    String key = keyAndValue[0].strip();
1✔
167
    String value = (keyAndValue.length == 1) ? null : keyAndValue[1].strip();
1✔
168

169
    configure(option, key, value);
1✔
170
  }
1✔
171

172
  /** Configures the setting. */
173
  void configure(String option, String key, @Nullable String value) {
174
    switch (key) {
1✔
175
      case "initialCapacity":
176
        initialCapacity(key, value);
1✔
177
        return;
1✔
178
      case "maximumSize":
179
        maximumSize(key, value);
1✔
180
        return;
1✔
181
      case "maximumWeight":
182
        maximumWeight(key, value);
1✔
183
        return;
1✔
184
      case "weakKeys":
185
        weakKeys(value);
1✔
186
        return;
1✔
187
      case "weakValues":
188
        valueStrength(key, value, Strength.WEAK);
1✔
189
        return;
1✔
190
      case "softValues":
191
        valueStrength(key, value, Strength.SOFT);
1✔
192
        return;
1✔
193
      case "expireAfterAccess":
194
        expireAfterAccess(key, value);
1✔
195
        return;
1✔
196
      case "expireAfterWrite":
197
        expireAfterWrite(key, value);
1✔
198
        return;
1✔
199
      case "refreshAfterWrite":
200
        refreshAfterWrite(key, value);
1✔
201
        return;
1✔
202
      case "recordStats":
203
        recordStats(value);
1✔
204
        return;
1✔
205
      default:
206
        throw new IllegalArgumentException("Invalid option " + option);
1✔
207
    }
208
  }
209

210
  /** Configures the initial capacity. */
211
  void initialCapacity(String key, @Nullable String value) {
212
    requireArgument(initialCapacity == UNSET_INT,
1✔
213
        "initial capacity was already set to %,d", initialCapacity);
1✔
214
    initialCapacity = parseInt(key, value);
1✔
215
  }
1✔
216

217
  /** Configures the maximum size. */
218
  void maximumSize(String key, @Nullable String value) {
219
    requireArgument(maximumSize == UNSET_INT,
1✔
220
        "maximum size was already set to %,d", maximumSize);
1✔
221
    requireArgument(maximumWeight == UNSET_INT,
1✔
222
        "maximum weight was already set to %,d", maximumWeight);
1✔
223
    maximumSize = parseLong(key, value);
1✔
224
  }
1✔
225

226
  /** Configures the maximum size. */
227
  void maximumWeight(String key, @Nullable String value) {
228
    requireArgument(maximumWeight == UNSET_INT,
1✔
229
        "maximum weight was already set to %,d", maximumWeight);
1✔
230
    requireArgument(maximumSize == UNSET_INT,
1✔
231
        "maximum size was already set to %,d", maximumSize);
1✔
232
    maximumWeight = parseLong(key, value);
1✔
233
  }
1✔
234

235
  /** Configures the keys as weak references. */
236
  void weakKeys(@Nullable String value) {
237
    requireArgument(value == null, "weak keys does not take a value");
1✔
238
    requireArgument(keyStrength == null, "weak keys was already set");
1✔
239
    keyStrength = Strength.WEAK;
1✔
240
  }
1✔
241

242
  /** Configures the value as weak or soft references. */
243
  void valueStrength(String key, @Nullable String value, Strength strength) {
244
    requireArgument(value == null, "%s does not take a value", key);
1✔
245
    requireArgument(valueStrength == null, "%s was already set to %s", key, valueStrength);
1✔
246
    valueStrength = strength;
1✔
247
  }
1✔
248

249
  /** Configures expire after access. */
250
  void expireAfterAccess(String key, @Nullable String value) {
251
    requireArgument(expireAfterAccess == null, "expireAfterAccess was already set");
1✔
252
    expireAfterAccess = parseDuration(key, value);
1✔
253
  }
1✔
254

255
  /** Configures expire after write. */
256
  void expireAfterWrite(String key, @Nullable String value) {
257
    requireArgument(expireAfterWrite == null, "expireAfterWrite was already set");
1✔
258
    expireAfterWrite = parseDuration(key, value);
1✔
259
  }
1✔
260

261
  /** Configures refresh after write. */
262
  void refreshAfterWrite(String key, @Nullable String value) {
263
    requireArgument(refreshAfterWrite == null, "refreshAfterWrite was already set");
1✔
264
    refreshAfterWrite = parseDuration(key, value);
1✔
265
  }
1✔
266

267
  /** Configures the value as weak or soft references. */
268
  void recordStats(@Nullable String value) {
269
    requireArgument(value == null, "record stats does not take a value");
1✔
270
    requireArgument(!recordStats, "record stats was already set");
1✔
271
    recordStats = true;
1✔
272
  }
1✔
273

274
  /** Returns a parsed int value. */
275
  static int parseInt(String key, @Nullable String value) {
276
    requireArgument((value != null) && !value.isEmpty(), "value of key %s was omitted", key);
1✔
277
    requireNonNull(value);
1✔
278
    try {
279
      if (!value.startsWith("-_") && !value.startsWith("+_")
1✔
280
          && !value.startsWith("_") && !value.endsWith("_")) {
1✔
281
        return Integer.parseInt(value.replace("_", ""));
1✔
282
      }
UNCOV
283
      return Integer.parseInt(value);
×
284
    } catch (NumberFormatException e) {
1✔
285
      throw new IllegalArgumentException(String.format(US,
1✔
286
          "key %s value was set to %s, must be an integer", key, value), e);
287
    }
288
  }
289

290
  /** Returns a parsed long value. */
291
  static long parseLong(String key, @Nullable String value) {
292
    requireArgument((value != null) && !value.isEmpty(), "value of key %s was omitted", key);
1✔
293
    requireNonNull(value);
1✔
294
    try {
295
      if (!value.startsWith("+_") && !value.startsWith("-_")
1✔
296
          && !value.startsWith("_") && !value.endsWith("_")) {
1✔
297
        return Long.parseLong(value.replace("_", ""));
1✔
298
      }
UNCOV
299
      return Long.parseLong(value);
×
300
    } catch (NumberFormatException e) {
1✔
301
      throw new IllegalArgumentException(String.format(US,
1✔
302
          "key %s value was set to %s, must be a long", key, value), e);
303
    }
304
  }
305

306
  /** Returns a parsed duration value. */
307
  static Duration parseDuration(String key, @Nullable String value) {
308
    requireArgument((value != null) && !value.isEmpty(), "value of key %s omitted", key);
1✔
309
    requireNonNull(value);
1✔
310

311
    boolean isIsoFormat = value.contains("p") || value.contains("P");
1✔
312
    Duration duration = isIsoFormat
1✔
313
        ? parseIsoDuration(key, value)
1✔
314
        : parseSimpleDuration(key, value);
1✔
315
    requireArgument(!duration.isNegative(),
1✔
316
        "key %s invalid format; was %s, but the duration cannot be negative", key, value);
317
    return duration;
1✔
318

319
  }
320

321
  /** Returns a parsed duration using the ISO-8601 format. */
322
  static Duration parseIsoDuration(String key, String value) {
323
    try {
324
      return Duration.parse(value);
1✔
325
    } catch (DateTimeParseException e) {
1✔
326
      throw new IllegalArgumentException(String.format(US,
1✔
327
          "key %s invalid format; was %s, but the duration cannot be parsed", key, value), e);
328
    }
329
  }
330

331
  /** Returns a parsed duration using the simple time unit format. */
332
  static Duration parseSimpleDuration(String key, String value) {
333
    long duration = parseLong(key, value.substring(0, value.length() - 1));
1✔
334
    TimeUnit unit = parseTimeUnit(key, value);
1✔
335
    return Duration.ofNanos(unit.toNanos(duration));
1✔
336
  }
337

338
  /** Returns a parsed {@link TimeUnit} value. */
339
  @SuppressWarnings("StatementSwitchToExpressionSwitch")
340
  static TimeUnit parseTimeUnit(String key, String value) {
341
    requireArgument((value != null) && !value.isEmpty(), "value of key %s omitted", key);
1✔
342
    char lastChar = Character.toLowerCase(value.charAt(value.length() - 1));
1✔
343
    switch (lastChar) {
1✔
344
      case 'd':
345
        return TimeUnit.DAYS;
1✔
346
      case 'h':
347
        return TimeUnit.HOURS;
1✔
348
      case 'm':
349
        return TimeUnit.MINUTES;
1✔
350
      case 's':
351
        return TimeUnit.SECONDS;
1✔
352
      default:
353
        throw new IllegalArgumentException(String.format(US,
1✔
354
            "key %s invalid format; was %s, must end with one of [dDhHmMsS]", key, value));
355
    }
356
  }
357

358
  @Override
359
  public boolean equals(@Nullable Object o) {
360
    if (this == o) {
1✔
361
      return true;
1✔
362
    } else if (!(o instanceof CaffeineSpec)) {
1✔
363
      return false;
1✔
364
    }
365
    var spec = (CaffeineSpec) o;
1✔
366
    return Objects.equals(refreshAfterWrite, spec.refreshAfterWrite)
1✔
367
        && Objects.equals(expireAfterAccess, spec.expireAfterAccess)
1✔
368
        && Objects.equals(expireAfterWrite, spec.expireAfterWrite)
1✔
369
        && (initialCapacity == spec.initialCapacity)
370
        && (maximumWeight == spec.maximumWeight)
371
        && (valueStrength == spec.valueStrength)
372
        && (keyStrength == spec.keyStrength)
373
        && (maximumSize == spec.maximumSize)
374
        && (recordStats == spec.recordStats);
375
  }
376

377
  @Override
378
  public int hashCode() {
379
    return Objects.hash(
1✔
380
        initialCapacity, maximumSize, maximumWeight, keyStrength, valueStrength,
1✔
381
        recordStats, expireAfterWrite, expireAfterAccess, refreshAfterWrite);
1✔
382
  }
383

384
  /**
385
   * Returns a string representation that can be used to parse an equivalent {@code CaffeineSpec}.
386
   * The order and form of this representation is not guaranteed, except that parsing its output
387
   * will produce a {@code CaffeineSpec} equal to this instance.
388
   *
389
   * @return a string representation of this specification that can be parsed into a
390
   *         {@code CaffeineSpec}
391
   */
392
  public String toParsableString() {
393
    return specification;
1✔
394
  }
395

396
  /**
397
   * Returns a string representation for this {@code CaffeineSpec} instance. The form of this
398
   * representation is not guaranteed.
399
   */
400
  @Override
401
  public String toString() {
402
    return getClass().getSimpleName() + '{' + toParsableString() + '}';
1✔
403
  }
404
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc