cronli5 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lang/zh.js CHANGED
@@ -3,6 +3,21 @@ function isNonNegativeInteger(value) {
3
3
  const digits = /^\d+$/;
4
4
  return digits.test(value);
5
5
  }
6
+ function arithmeticStep(values) {
7
+ if (values.length < 5) {
8
+ return null;
9
+ }
10
+ const interval = values[1] - values[0];
11
+ if (interval < 2) {
12
+ return null;
13
+ }
14
+ for (let i = 2; i < values.length; i += 1) {
15
+ if (values[i] - values[i - 1] !== interval) {
16
+ return null;
17
+ }
18
+ }
19
+ return { start: values[0], interval, last: values[values.length - 1] };
20
+ }
6
21
  function toFieldNumber(token, numberMap) {
7
22
  return isNonNegativeInteger(token) ? +token : numberMap[token.toUpperCase()];
8
23
  }
@@ -31,6 +46,7 @@ var monthNumbers = {
31
46
  NOV: 11,
32
47
  DEC: 12
33
48
  };
49
+ var maxClockTimes = 6;
34
50
 
35
51
  // src/lang/zh/dialects.ts
36
52
  var zh = { variant: "Hans" };
@@ -61,6 +77,46 @@ function stepSegment(ir, field) {
61
77
  function cadence(interval, unit) {
62
78
  return interval === 1 ? "\u6BCF" + unit : "\u6BCF" + interval + unit;
63
79
  }
80
+ function renderStride(stride) {
81
+ const { interval, start, last, cycle, unit, mark, anchor } = stride;
82
+ const tiles = cycle % interval === 0;
83
+ if (start === 0 && tiles) {
84
+ return cadence(interval, unit);
85
+ }
86
+ const lead = anchor + "\u4ECE" + start + mark + "\u8D77" + cadence(interval, unit);
87
+ return start < interval && tiles ? lead : lead + "\uFF0C\u81F3" + last + mark;
88
+ }
89
+ function singleValues(segments) {
90
+ const values = [];
91
+ for (const segment of segments) {
92
+ if (segment.kind !== "single") {
93
+ return null;
94
+ }
95
+ values.push(+segment.value);
96
+ }
97
+ return values;
98
+ }
99
+ function strideFromSegments(segments, unit, mark, anchor) {
100
+ const values = singleValues(segments);
101
+ const step = values && arithmeticStep(values);
102
+ return step ? renderStride({ ...step, cycle: 60, unit, mark, anchor }) : null;
103
+ }
104
+ function stepClause(segment, unit, mark, anchor) {
105
+ const start = segment.startToken === "*" ? 0 : +segment.startToken;
106
+ const short = start !== 0 && segment.fires.length <= 3;
107
+ if (segment.startToken.indexOf("-") !== -1 || short) {
108
+ return anchor + segment.fires.join("\u3001") + mark;
109
+ }
110
+ return renderStride({
111
+ interval: segment.interval,
112
+ start,
113
+ last: segment.fires[segment.fires.length - 1],
114
+ cycle: 60,
115
+ unit,
116
+ mark,
117
+ anchor
118
+ });
119
+ }
64
120
  function dayPeriod(hour) {
65
121
  if (hour < 6) {
66
122
  return "\u51CC\u6668";
@@ -157,8 +213,15 @@ function renderEveryMinute() {
157
213
  function renderEveryHour() {
158
214
  return "\u6BCF\u5C0F\u65F6";
159
215
  }
216
+ function minuteHourClause(ir) {
217
+ const segments = fieldSegments(ir, "minute");
218
+ if (ir.shapes.minute === "step") {
219
+ return stepClause(stepSegment(ir, "minute"), "\u5206\u949F", "\u5206", "\u6BCF\u5C0F\u65F6");
220
+ }
221
+ return strideFromSegments(segments, "\u5206\u949F", "\u5206", "\u6BCF\u5C0F\u65F6") ?? "\u6BCF\u5C0F\u65F6" + valueList(segments, "\u5206");
222
+ }
160
223
  function renderMinutePast(ir) {
161
- return "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
224
+ return minuteHourClause(ir);
162
225
  }
163
226
  function hourList(ir) {
164
227
  return joinAnd(hourFires(ir).map(hourWord));
@@ -172,10 +235,11 @@ function hourFrame(ir) {
172
235
  }
173
236
  function renderMinuteFrequency(ir, plan) {
174
237
  const minuteStep = stepSegment(ir, "minute");
175
- const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
238
+ const base = stepClause(minuteStep, UNITS.minute, "\u5206", "\u6BCF\u5C0F\u65F6");
176
239
  const { hours } = plan;
177
240
  if (hours.kind === "step") {
178
- return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + base;
241
+ const hourStep = stepSegment(ir, "hour");
242
+ return hourStep.startToken === "*" ? cadence(hourStep.interval, UNITS.hour) + base : "\u5728" + hourList(ir) + "\uFF0C" + base;
179
243
  }
180
244
  if (hours.kind === "single" || hours.kind === "window" && hours.from === hours.to) {
181
245
  return "\u5728" + hourWord(hours.from) + "\u81F3" + hours.from + "\u70B9" + hours.last + "\u5206\u4E4B\u95F4\uFF0C" + base;
@@ -190,6 +254,9 @@ function renderMinuteFrequency(ir, plan) {
190
254
  }
191
255
  function renderMinuteSpanInHour(ir, plan) {
192
256
  const span = plan;
257
+ if (ir.pattern.minute === "*") {
258
+ return hourWord(span.hour) + "\u7684\u6BCF\u4E00\u5206\u949F";
259
+ }
193
260
  return "\u5728" + hourWord(span.hour) + "\u81F3" + span.hour + "\u70B9" + span.span[1] + "\u5206\u4E4B\u95F4\uFF0C\u6BCF\u5206\u949F";
194
261
  }
195
262
  function renderMinutesAcrossHours(ir, plan) {
@@ -197,26 +264,46 @@ function renderMinutesAcrossHours(ir, plan) {
197
264
  if (form === "wildcard") {
198
265
  return "\u5728" + hourList(ir) + "\uFF0C\u6BCF\u5206\u949F";
199
266
  }
200
- return hourList(ir) + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
267
+ return hourList(ir) + "\uFF0C" + minuteHourClause(ir) + "\uFF0C\u6BCF\u5206\u949F";
201
268
  }
202
269
  function renderMinuteSpanAcrossHourStep(ir, plan) {
203
- const cad = cadence(stepSegment(ir, "hour").interval, UNITS.hour);
270
+ const hourStep = stepSegment(ir, "hour");
204
271
  const { form } = plan;
205
- if (form === "wildcard") {
206
- return cad + "\u5185\uFF0C\u6BCF\u5206\u949F";
272
+ if (form === "list") {
273
+ return renderMinutePast(ir) + "\uFF0C\u5728" + hourList(ir);
274
+ }
275
+ const minuteTail = form === "wildcard" ? "\u6BCF\u5206\u949F" : minuteHourClause(ir) + "\uFF0C\u6BCF\u5206\u949F";
276
+ if (hourStep.startToken !== "*") {
277
+ return form === "wildcard" ? "\u5728" + hourList(ir) + "\uFF0C" + minuteTail : hourList(ir) + "\uFF0C" + minuteTail;
278
+ }
279
+ if (hourStep.interval === 2 && form === "wildcard") {
280
+ return "\u5728\u5076\u6570\u5C0F\u65F6\uFF0C" + minuteTail;
207
281
  }
208
- return cad + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
282
+ const cad = cadence(hourStep.interval, UNITS.hour);
283
+ return form === "wildcard" ? cad + "\u5185\uFF0C" + minuteTail : cad + "\uFF0C" + minuteTail;
209
284
  }
210
285
  function renderClockTimes(ir, plan, opts) {
286
+ if (ir.shapes.minute === "single") {
287
+ const cad = hourCadence(ir);
288
+ if (cad !== null) {
289
+ return cad;
290
+ }
291
+ }
211
292
  const { times } = plan;
212
293
  return joinAnd(times.map((t) => clockTime(t.hour, t.minute, t.second, opts)));
213
294
  }
214
295
  function renderCompactClockTimes(ir, plan) {
296
+ if (ir.shapes.minute === "single") {
297
+ const cad = hourCadence(ir);
298
+ if (cad !== null) {
299
+ return cad;
300
+ }
301
+ }
215
302
  const { minute } = plan;
216
303
  const secs = fieldSegments(ir, "second");
217
304
  const tail = secs.length && ir.pattern.second !== "0" ? "\uFF0C\u7B2C" + valueText(secs) + "\u79D2" : "";
218
305
  if (minute > 0) {
219
- return "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u5728" + hourList(ir) + tail;
306
+ return minuteHourClause(ir) + "\uFF0C\u5728" + hourList(ir) + tail;
220
307
  }
221
308
  return hourList(ir) + tail;
222
309
  }
@@ -224,7 +311,7 @@ function renderHourRange(ir, plan) {
224
311
  const range = plan;
225
312
  if (range.minuteForm === "lead") {
226
313
  const minuteSegs = fieldSegments(ir, "minute");
227
- const past = minuteSegs.length && ir.pattern.minute !== "0" ? "\u6BCF\u5C0F\u65F6" + valueList(minuteSegs, "\u5206") : "\u6BCF\u5C0F\u65F6";
314
+ const past = minuteSegs.length && ir.pattern.minute !== "0" ? minuteHourClause(ir) : "\u6BCF\u5C0F\u65F6";
228
315
  return "\u5728" + hourWord(range.from) + "\u81F3" + hourWord(range.to) + "\u4E4B\u95F4\uFF0C" + past;
229
316
  }
230
317
  if (range.minuteForm === "range") {
@@ -242,8 +329,52 @@ function renderHourStep(ir) {
242
329
  }
243
330
  return cadence(segment.interval, UNITS.hour);
244
331
  }
332
+ function hourStride(ir) {
333
+ const segments = fieldSegments(ir, "hour");
334
+ if (segments.length === 1 && segments[0].kind === "step") {
335
+ const segment = segments[0];
336
+ const start = segment.startToken === "*" ? 0 : +segment.startToken;
337
+ if (start !== 0 || 24 % segment.interval !== 0) {
338
+ return null;
339
+ }
340
+ return {
341
+ interval: segment.interval,
342
+ last: segment.fires[segment.fires.length - 1]
343
+ };
344
+ }
345
+ const values = singleValues(segments);
346
+ const step = values && arithmeticStep(values);
347
+ if (!step || step.start !== 0 || 24 % step.interval !== 0) {
348
+ return null;
349
+ }
350
+ return { interval: step.interval, last: step.last };
351
+ }
352
+ function hourCadence(ir) {
353
+ const stride = hourStride(ir);
354
+ if (!stride) {
355
+ return null;
356
+ }
357
+ const fires = stride.last / stride.interval + 1;
358
+ if (ir.pattern.second === "0" && fires <= maxClockTimes) {
359
+ return null;
360
+ }
361
+ const prefix = cadence(stride.interval, UNITS.hour);
362
+ const minute = +ir.pattern.minute;
363
+ const subMinute = ir.pattern.second === "*" || ir.shapes.second === "step";
364
+ if (minute === 0 && subMinute) {
365
+ return stride.interval === 2 ? "\u5728\u5076\u6570\u5C0F\u65F60\u5206" + secondTail(ir) : null;
366
+ }
367
+ if (minute === 0) {
368
+ return prefix + "0\u5206" + secondTail(ir);
369
+ }
370
+ return ir.pattern.second === "0" ? prefix + minute + "\u5206" : prefix + minute + "\u5206" + secondTail(ir);
371
+ }
372
+ function secondTail(ir) {
373
+ const sec = secondClause(ir);
374
+ return sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
375
+ }
245
376
  function renderRangeOfMinutes(ir) {
246
- return "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
377
+ return minuteHourClause(ir) + "\uFF0C\u6BCF\u5206\u949F";
247
378
  }
248
379
  function renderStandaloneSeconds(ir) {
249
380
  const segs = fieldSegments(ir, "second");
@@ -251,7 +382,7 @@ function renderStandaloneSeconds(ir) {
251
382
  if (segs.length === 1 && first.kind === "step" && first.startToken === "*") {
252
383
  return cadence(first.interval, UNITS.second);
253
384
  }
254
- return "\u6BCF\u5206\u949F\u7B2C" + valueText(segs) + "\u79D2";
385
+ return strideFromSegments(segs, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F") ?? "\u6BCF\u5206\u949F\u7B2C" + valueText(segs) + "\u79D2";
255
386
  }
256
387
  function renderSecondPastMinute(ir) {
257
388
  return "\u6BCF\u5206\u949F\u7B2C" + valueText(fieldSegments(ir, "second")) + "\u79D2";
@@ -274,7 +405,7 @@ function secondClause(ir) {
274
405
  if (segs.length === 1 && first.kind === "step" && first.startToken === "*") {
275
406
  return cadence(first.interval, UNITS.second);
276
407
  }
277
- return "\u7B2C" + valueText(segs) + "\u79D2";
408
+ return strideFromSegments(segs, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F") ?? "\u7B2C" + valueText(segs) + "\u79D2";
278
409
  }
279
410
  function minuteClause(ir) {
280
411
  if (ir.pattern.minute === "*") {
@@ -291,15 +422,36 @@ function isHourCadence(ir) {
291
422
  function composeSecondsOnHour(ir, plan, opts) {
292
423
  const sec = secondClause(ir);
293
424
  const { rest } = plan;
425
+ const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
426
+ if (composedClock) {
427
+ const hourCad = hourCadence(ir);
428
+ if (hourCad !== null) {
429
+ return hourCad;
430
+ }
431
+ }
432
+ if (composedClock && ir.pattern.minute === "0") {
433
+ return composeMinuteZeroClocks(ir, sec);
434
+ }
294
435
  const restText = render(ir, rest, opts);
295
- if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && isDaily(ir)) {
296
- return "\u6BCF\u5929" + restText + sec;
436
+ if (rest.kind === "clockTimes" || rest.kind === "compactClockTimes") {
437
+ if (isDaily(ir)) {
438
+ return "\u6BCF\u5929" + restText + sec;
439
+ }
297
440
  }
298
441
  if (rest.kind === "singleMinute") {
299
442
  return restText + "\uFF0C" + sec;
300
443
  }
301
444
  return restText + sec;
302
445
  }
446
+ function composeMinuteZeroClocks(ir, sec) {
447
+ const clocks = hourFires(ir).map(function clock(hour) {
448
+ return hour === 12 ? "\u6B63\u5348" : hourWord(hour) + "0\u5206";
449
+ });
450
+ const nested = strideFromSegments(fieldSegments(ir, "second"), "\u79D2", "\u79D2", "");
451
+ const tail = sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + (nested ?? sec);
452
+ const core = joinAnd(clocks) + tail;
453
+ return isDaily(ir) ? "\u6BCF\u5929" + core : core;
454
+ }
303
455
  function composeSecondsCadence(ir) {
304
456
  const sec = secondClause(ir);
305
457
  const tail = minuteClause(ir) + sec;
@@ -310,13 +462,24 @@ function composeSecondsCadence(ir) {
310
462
  return hourWord(hourFires(ir)[0]) + "\u7684" + tail;
311
463
  }
312
464
  if (ir.shapes.hour === "wildcard") {
465
+ if (ir.shapes.minute === "step" && sec === "\u6BCF\u79D2") {
466
+ const minuteStep = stepSegment(ir, "minute");
467
+ if (minuteStep.startToken === "*" && minuteStep.interval === 2) {
468
+ return "\u6BCF\u5076\u6570\u5206\u949F\u7684\u6BCF\u4E00\u79D2";
469
+ }
470
+ }
313
471
  return sec + "\uFF0C" + minuteClause(ir);
314
472
  }
315
473
  return hourFrame(ir) + tail;
316
474
  }
317
475
  function composeSecondsListed(ir) {
318
476
  const sec = secondClause(ir);
319
- const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
477
+ const minutes = minuteHourClause(ir);
478
+ if (ir.shapes.hour === "single" && sec === "\u6BCF\u79D2") {
479
+ const minuteSegs = fieldSegments(ir, "minute");
480
+ const minuteCad = strideFromSegments(minuteSegs, "\u5206\u949F", "\u5206", "") ?? valueList(minuteSegs, "\u5206");
481
+ return hourWord(hourFires(ir)[0]) + minuteCad + "\u7684\u6BCF\u4E00\u79D2";
482
+ }
320
483
  if (ir.shapes.hour === "wildcard") {
321
484
  return minutes + "\uFF0C" + sec;
322
485
  }
@@ -326,10 +489,13 @@ function composeSecondsListed(ir) {
326
489
  return hourFrame(ir) + minutes + "\uFF0C" + sec;
327
490
  }
328
491
  function renderComposeSeconds(ir, plan, opts) {
329
- if (ir.pattern.minute === "0") {
492
+ const { rest } = plan;
493
+ const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
494
+ if (ir.pattern.minute === "0" || composedClock && ir.shapes.minute === "single") {
330
495
  return composeSecondsOnHour(ir, plan, opts);
331
496
  }
332
- if (ir.pattern.minute === "*" || ir.shapes.minute === "step") {
497
+ const minuteCadence = ir.pattern.minute === "*" || ir.shapes.minute === "step" && stepSegment(ir, "minute").startToken === "*";
498
+ if (minuteCadence) {
333
499
  return composeSecondsCadence(ir);
334
500
  }
335
501
  return composeSecondsListed(ir);
@@ -529,11 +695,16 @@ function composeCadence(ir, core) {
529
695
  function composeWindow(ir, core) {
530
696
  return qualifier(ir) + core;
531
697
  }
698
+ function hourCadenceApplies(ir) {
699
+ return ir.shapes.minute === "single" && hourCadence(ir) !== null;
700
+ }
532
701
  function describe(ir, opts) {
533
702
  const { kind } = ir.plan;
534
703
  const core = render(ir, ir.plan, opts);
535
704
  let composed = core;
536
- if (kind === "clockTimes" || kind === "compactClockTimes" && ir.pattern.minute === "0") {
705
+ if (hourCadenceApplies(ir)) {
706
+ composed = composeCadence(ir, core);
707
+ } else if (kind === "clockTimes" || kind === "compactClockTimes" && ir.pattern.minute === "0") {
537
708
  composed = composePoint(ir, core);
538
709
  } else if (kind === "hourStep" || kind === "minuteFrequency" || kind === "minuteSpanAcrossHourStep" || kind === "compactClockTimes") {
539
710
  composed = composeCadence(ir, core);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,8 +60,10 @@
60
60
  "scripts": {
61
61
  "build": "node scripts/build.mjs && npm run types",
62
62
  "docs": "node --import tsx scripts/docs.mjs",
63
+ "conciseness": "node --import tsx tooling/scripts/conciseness.mjs",
63
64
  "fuzz": "node --import tsx scripts/fuzz-lang.mjs",
64
65
  "lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
66
+ "metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
65
67
  "lint:fix": "eslint src test cli.js eslint.config.js scripts tooling/scripts --fix",
66
68
  "test": "mocha",
67
69
  "types": "tsc -p tsconfig.types.json",
@@ -376,6 +376,13 @@ function planMinuteUnderHourStep(
376
376
  return {form: 'range', kind: 'minuteSpanAcrossHourStep'};
377
377
  }
378
378
 
379
+ // A minute list under a clean stride keeps the cadence too, so the hour
380
+ // reads the same whatever the minute's shape. An unclean stride falls
381
+ // through to compactClockTimes and enumerates its hours.
382
+ if (shapes.minute === 'list' && cleanHourStride(pattern.hour)) {
383
+ return {form: 'list', kind: 'minuteSpanAcrossHourStep'};
384
+ }
385
+
379
386
  return null;
380
387
  }
381
388
 
package/src/core/ir.ts CHANGED
@@ -71,7 +71,7 @@ export type PlanNode =
71
71
  form: 'wildcard' | 'range' | 'list';
72
72
  times: HourTimesPlan;
73
73
  }
74
- | {kind: 'minuteSpanAcrossHourStep'; form: 'wildcard' | 'range'}
74
+ | {kind: 'minuteSpanAcrossHourStep'; form: 'wildcard' | 'range' | 'list'}
75
75
  | {kind: 'everyHour'}
76
76
  | {
77
77
  kind: 'hourRange';
@@ -67,10 +67,13 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
67
67
 
68
68
  const cycle = timeFieldCycle[field];
69
69
  const segments = stringValue.split(',').map(function canonical(segment) {
70
- return enumerateNonUniformStep(
71
- collapseDegenerateRange(
72
- collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
73
- spec, cycle);
70
+ return canonicalizeTokens(collapseFullSpanRange(
71
+ enumerateNonUniformStep(
72
+ collapseFullSpanStep(
73
+ collapseDegenerateRange(
74
+ collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
75
+ spec),
76
+ spec, cycle), spec), spec);
74
77
  }).join(',').split(',');
75
78
 
76
79
  // A full-cycle segment covers the whole field.
@@ -83,6 +86,40 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
83
86
  }).join(',');
84
87
  }
85
88
 
89
+ // Rewrite a segment's value tokens to their canonical numbers: a name
90
+ // (`MON`, `JAN`) becomes its index, and a weekday `7` (Sunday again, above
91
+ // `top`) folds to the field minimum (`0`). Applied to every token position —
92
+ // a single, both range bounds, and a step's start — so a normalized field
93
+ // never carries a surface name or the 7-for-Sunday alias. `*` and Quartz
94
+ // tokens (resolved earlier) are left untouched. Output is unchanged: a
95
+ // renderer maps the number back to its own name.
96
+ function canonicalizeTokens(segment: string, spec: FieldSpec): string {
97
+ // Only the named fields (month, weekday) carry name tokens or the
98
+ // weekday `7`-for-Sunday alias; every other field is already numeric.
99
+ if (!spec.numbers) {
100
+ return segment;
101
+ }
102
+
103
+ const parts = segment.split('/');
104
+ const start = parts[0].split('-').map(function fold(token) {
105
+ return canonicalizeToken(token, spec);
106
+ }).join('-');
107
+
108
+ return parts.length === 2 ? start + '/' + parts[1] : start;
109
+ }
110
+
111
+ // A single token to its canonical number string (`*` passes through).
112
+ function canonicalizeToken(token: string, spec: FieldSpec): string {
113
+ if (token === '*') {
114
+ return token;
115
+ }
116
+
117
+ const number = toFieldNumber(token, spec.numbers);
118
+
119
+ // A value above `top` (weekday 7) is the field minimum again.
120
+ return '' + (number > (spec.top as number) ? spec.min : number);
121
+ }
122
+
86
123
  // An interval-one step enumerates every value from its start, so it reads
87
124
  // as the equivalent range: `1/1` is `1-59` and `5-30/1` is `5-30`. A start
88
125
  // at the bottom of the cycle covers the whole field (`0/1` is `*`).
@@ -163,6 +200,59 @@ function enumerateNonUniformStep(
163
200
  return fires.join(',');
164
201
  }
165
202
 
203
+ // A step whose range part covers the whole field (`0-59/2`, `0-23/2`) strides
204
+ // across the entire cycle, so the bound adds nothing — it reads as the
205
+ // unbounded `*/N` step (which the cycle test then renders as "every N" or its
206
+ // fires). Partial-range steps (`9-17/2`) keep their window.
207
+ function collapseFullSpanStep(segment: string, spec: FieldSpec): string {
208
+ const parts = segment.split('/');
209
+
210
+ if (parts.length !== 2 || !includes(parts[0], '-')) {
211
+ return segment;
212
+ }
213
+
214
+ return collapseFullSpanRange(parts[0], spec) === '*' ?
215
+ '*/' + parts[1] :
216
+ segment;
217
+ }
218
+
219
+ // A plain range whose enumerated values cover the whole field imposes no
220
+ // restriction, so it reads identically to `*` (`0-59` minute, `0-23` hour,
221
+ // `1-31` date, `1-12` month, and every seven-day weekday range — `0-6`,
222
+ // `1-7`, `0-7`, `SUN-SAT` — since cron's 7 is Sunday again, folding to the
223
+ // field minimum). Only bare ranges qualify: a step (`0-59/2`) keeps its
224
+ // cadence, so segments carrying a `/` are left untouched.
225
+ function collapseFullSpanRange(segment: string, spec: FieldSpec): string {
226
+ if (typeof spec.top !== 'number' || includes(segment, '/') ||
227
+ !includes(segment, '-')) {
228
+ return segment;
229
+ }
230
+
231
+ const bounds = segment.split('-');
232
+ const low = toFieldNumber(bounds[0], spec.numbers);
233
+ const high = toFieldNumber(bounds[1], spec.numbers);
234
+
235
+ if (low > high) {
236
+ return segment;
237
+ }
238
+
239
+ // The full field is min..top; a value above top (weekday 7) folds to min.
240
+ const top = spec.top as number;
241
+ const fired: Record<number, boolean> = {};
242
+
243
+ for (let value = low; value <= high; value += 1) {
244
+ fired[value > top ? spec.min : value] = true;
245
+ }
246
+
247
+ for (let value = spec.min; value <= top; value += 1) {
248
+ if (!fired[value]) {
249
+ return segment;
250
+ }
251
+ }
252
+
253
+ return '*';
254
+ }
255
+
166
256
  // A degenerate range (`9-9`) fires once, so it reads as its single value.
167
257
  // A stepped degenerate range (`9-9/5`) likewise fires only at its start.
168
258
  function collapseDegenerateRange(segment: string, spec: FieldSpec): string {
package/src/core/util.ts CHANGED
@@ -16,6 +16,34 @@ function isNonNegativeInteger(value: string): boolean {
16
16
  return digits.test(value);
17
17
  }
18
18
 
19
+ // Recognize an arithmetic progression in a sorted, distinct numeric set: a
20
+ // run of length >= 5 whose consecutive gaps are all equal and >= 2. Returns
21
+ // its {start, interval, last}; null for anything shorter, with a gap of one
22
+ // (a plain run, which reads as a range), or irregular. Output-neutral and
23
+ // language-agnostic: renderers use it to speak a bounded/offset step cadence
24
+ // ("every N from M [through K]") instead of enumerating the fires. The set is
25
+ // the field's full value list, which the core has already sorted and deduped.
26
+ function arithmeticStep(values: number[]):
27
+ {start: number; interval: number; last: number} | null {
28
+ if (values.length < 5) {
29
+ return null;
30
+ }
31
+
32
+ const interval = values[1] - values[0];
33
+
34
+ if (interval < 2) {
35
+ return null;
36
+ }
37
+
38
+ for (let i = 2; i < values.length; i += 1) {
39
+ if (values[i] - values[i - 1] !== interval) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return {start: values[0], interval, last: values[values.length - 1]};
45
+ }
46
+
19
47
  // Resolve a numeric or named field token (e.g. '5' or 'FRI') to its number.
20
48
  function toFieldNumber(
21
49
  token: string,
@@ -25,4 +53,6 @@ function toFieldNumber(
25
53
  // weekday) reach here. They always have an associated `numberMap`.
26
54
  return isNonNegativeInteger(token) ? +token : numberMap![token.toUpperCase()];
27
55
  }
28
- export {includes, isNonNegativeInteger, toFieldNumber, unique};
56
+ export {
57
+ arithmeticStep, includes, isNonNegativeInteger, toFieldNumber, unique
58
+ };