Skip to content

Commit 2adf55c

Browse files
jeffreyameyerclaude
andcommitted
Add L2 positional UA markers on date components
Extends the parser chain to recognise UA markers (?, ~, %) attached to individual year, month, or day components rather than to the whole date. Uses the Bitmask.UA[] positional table to OR the right bits into the uncertain / approximate masks. L2Parser New tryParsePositionalUa method scans through an input that contains at least one UA character and checks for markers at each of the six positions defined in the grammar: [UA]YYYY[UA]-[UA]MM[UA]-[UA]DD[UA] 0 1 2 3 4 5 Each present marker OR's Bitmask.UA[position] into the uncertain (for ? or %) or approximate (for ~ or %) mask. Returns null if no marker is present (lets other L2 branches handle the input) or if the input shape is unrecognised. L1Parser parseUaDate now picks the mask based on the date's precision (YEAR / YM / YMD) instead of always using YMD, matching edtf.js's Date.bits behaviour. This keeps 2020? at L1 while letting 2020?-05~ (mixed year + month qualifiers) promote to L2 through the level-detection rule in EdtfDate.level(). parseEndpoint for intervals now delegates to the top-level Edtf.parse so L2 season / mask / positional-UA endpoints parse correctly inside interval shapes. EdtfDate level() now returns L2 when the uncertain and approximate masks are non-zero AND unequal (the combination isn't expressible as a single L1 whole-date marker). toEdtfString uses Bitmask.marks to position the UA characters per the qualified() positions from edtf.js/src/bitmask.js, replacing combined ?~ or ~? with %. L2PositionalUaTest Six tests covering leading ?, positional ?/~/% on month and day, mixed uncertain+approximate that elevates to L2, and the L1 whole-date case that stays L1 for regression coverage. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 321f2c3 commit 2adf55c

3 files changed

Lines changed: 139 additions & 34 deletions

File tree

src/main/java/io/github/openhistoricalmap/edtf/parser/L1Parser.java

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,17 @@ private static EdtfDate parseUaDate(Cursor c) {
120120
}
121121
c.accept(ch);
122122

123-
Bitmask ymdMask = new Bitmask(Bitmask.YMD);
124-
Bitmask u = (ch == '?' || ch == '%') ? ymdMask : Bitmask.EMPTY;
125-
Bitmask a = (ch == '~' || ch == '%') ? ymdMask : Bitmask.EMPTY;
123+
// L1 trailing UA covers all components up to the date's
124+
// precision. Per edtf.js Date.bits(): year-precision -> YEAR,
125+
// month-precision -> YM, day-precision or beyond -> YMD.
126+
int maskValue = switch (date.precision()) {
127+
case YEAR -> Bitmask.YEAR;
128+
case MONTH -> Bitmask.YM;
129+
case DAY, MINUTE, SECOND, MILLISECOND -> Bitmask.YMD;
130+
};
131+
Bitmask mask = new Bitmask(maskValue);
132+
Bitmask u = (ch == '?' || ch == '%') ? mask : Bitmask.EMPTY;
133+
Bitmask a = (ch == '~' || ch == '%') ? mask : Bitmask.EMPTY;
126134
return date.withQualifiers(u, a, Bitmask.EMPTY);
127135
}
128136

@@ -251,13 +259,13 @@ private static Endpoint parseEndpoint(String s) {
251259
if (s.isEmpty()) return Endpoint.Unknown.INSTANCE;
252260
if (s.equals("..")) return Endpoint.Open.INSTANCE;
253261
try {
254-
return new Endpoint.Bounded(L0Parser.parse(s));
255-
} catch (EdtfParseException ignored) {}
256-
try {
257-
Cursor c = new Cursor(s);
258-
EdtfTemporal t = parseNonInterval(c);
259-
if (c.atEnd()) return new Endpoint.Bounded(t);
260-
} catch (EdtfParseException ignored) {}
261-
throw new EdtfParseException("invalid interval endpoint: " + s, s);
262+
// Delegate to the top-level facade so L2 endpoints
263+
// (extended seasons, decades, masked / positional-UA
264+
// dates, etc.) are accepted alongside L0 and L1.
265+
return new Endpoint.Bounded(
266+
io.github.openhistoricalmap.edtf.Edtf.parse(s));
267+
} catch (EdtfParseException e) {
268+
throw new EdtfParseException("invalid interval endpoint: " + s, s);
269+
}
262270
}
263271
}

src/main/java/io/github/openhistoricalmap/edtf/types/EdtfDate.java

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -133,22 +133,28 @@ public EdtfDate withQualifiers(Bitmask uncertain, Bitmask approximate, Bitmask u
133133
uncertain.value() != 0 || approximate.value() != 0 || unspecified.value() != 0;
134134
if (!hasFlags) return EdtfLevel.L0;
135135

136-
// L1 masks are the "whole-field" values only: YEAR, MONTH, DAY,
137-
// YM, MD, YMD, YYXX, YYYX, XXXX (= YEAR), plus 0. Any other
138-
// value is an L2 partial mask.
139-
if (!isL1Mask(unspecified.value())) return EdtfLevel.L2;
140-
141-
// L1 qualifiers are whole-date only: uncertain / approximate
142-
// either 0 or YMD.
136+
// L1 unspecified masks are "whole-field" values only: YEAR,
137+
// MONTH, DAY, YM, MD, YMD, YYXX, YYYX, XXXX (= YEAR), plus 0.
138+
// Any other value is an L2 partial mask.
139+
if (!isL1UnspecifiedMask(unspecified.value())) return EdtfLevel.L2;
140+
141+
// L1 UA qualifiers cover everything up to the date's precision:
142+
// 0 (no qualifier), YEAR, YM, or YMD.
143+
if (!isL1UaMask(uncertain.value())) return EdtfLevel.L2;
144+
if (!isL1UaMask(approximate.value())) return EdtfLevel.L2;
145+
146+
// If BOTH uncertain and approximate are set, they must match
147+
// to be expressible as a single % marker at a whole-date L1
148+
// level. Mismatched masks (e.g., year uncertain + month
149+
// approximate) are L2.
143150
int u = uncertain.value();
144151
int a = approximate.value();
145-
if (u != 0 && u != Bitmask.YMD) return EdtfLevel.L2;
146-
if (a != 0 && a != Bitmask.YMD) return EdtfLevel.L2;
152+
if (u != 0 && a != 0 && u != a) return EdtfLevel.L2;
147153

148154
return EdtfLevel.L1;
149155
}
150156

151-
private static boolean isL1Mask(int mask) {
157+
private static boolean isL1UnspecifiedMask(int mask) {
152158
return mask == 0
153159
|| mask == Bitmask.YEAR
154160
|| mask == Bitmask.MONTH
@@ -160,6 +166,13 @@ private static boolean isL1Mask(int mask) {
160166
|| mask == Bitmask.YYYX;
161167
}
162168

169+
private static boolean isL1UaMask(int mask) {
170+
return mask == 0
171+
|| mask == Bitmask.YEAR
172+
|| mask == Bitmask.YM
173+
|| mask == Bitmask.YMD;
174+
}
175+
163176
@Override public long min() {
164177
if (unspecified.value() != 0) {
165178
return computeMaskedMin();
@@ -294,10 +307,37 @@ private long toInstantMillis(boolean exclusiveEnd) {
294307
if (unspecified.value() != 0) {
295308
return renderMasked();
296309
}
297-
// Qualified dates (L1 whole-date UA): emit with trailing marker.
298-
String body = renderPlainDate();
299-
String marker = resolveUaMarker();
300-
return body + marker;
310+
// Date-only value with UA markers: use positional rendering.
311+
if (!precision.isAtomic()
312+
&& (uncertain.value() != 0 || approximate.value() != 0)) {
313+
return renderPositionalUa();
314+
}
315+
// No UA or datetime: fall through.
316+
return renderPlainDate();
317+
}
318+
319+
/**
320+
* Render the date with UA markers placed per
321+
* {@link Bitmask#qualified(int)}. Uses the {@code %} combined
322+
* form where a position carries both {@code ?} and {@code ~}.
323+
*/
324+
private String renderPositionalUa() {
325+
String[] values = switch (precision) {
326+
case YEAR -> new String[]{padYear(year)};
327+
case MONTH -> new String[]{padYear(year), pad2(month)};
328+
case DAY -> new String[]{padYear(year), pad2(month), pad2(day)};
329+
default -> throw new IllegalStateException("UA not supported for " + precision);
330+
};
331+
if (uncertain.value() != 0) {
332+
values = uncertain.marks(values, '?');
333+
}
334+
if (approximate.value() != 0) {
335+
values = approximate.marks(values, '~');
336+
for (int i = 0; i < values.length; i++) {
337+
values[i] = values[i].replace("?~", "%").replace("~?", "%");
338+
}
339+
}
340+
return String.join("-", values);
301341
}
302342

303343
private String renderMasked() {
@@ -310,15 +350,6 @@ private String renderMasked() {
310350
return sb.toString();
311351
}
312352

313-
private String resolveUaMarker() {
314-
int u = uncertain.value();
315-
int a = approximate.value();
316-
if (u != 0 && a != 0) return "%";
317-
if (u != 0) return "?";
318-
if (a != 0) return "~";
319-
return "";
320-
}
321-
322353
private String renderPlainDate() {
323354
StringBuilder sb = new StringBuilder();
324355
sb.append(padYear(year));
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.github.openhistoricalmap.edtf.parser;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import io.github.openhistoricalmap.edtf.Edtf;
6+
import io.github.openhistoricalmap.edtf.EdtfLevel;
7+
import io.github.openhistoricalmap.edtf.types.EdtfDate;
8+
import org.junit.jupiter.api.Test;
9+
10+
/**
11+
* L2 positional UA (uncertain / approximate / mixed markers attached
12+
* to individual year, month, or day components, rather than to the
13+
* whole date).
14+
*/
15+
class L2PositionalUaTest {
16+
17+
@Test
18+
void leadingUaOnYearOnly() {
19+
EdtfDate d = (EdtfDate) Edtf.parse("?2020");
20+
assertThat(d.level()).isEqualTo(EdtfLevel.L1);
21+
// Position 0 alone sets YEAR bits -> same as L1 trailing form
22+
assertThat(d.toEdtfString()).isEqualTo("2020?");
23+
}
24+
25+
@Test
26+
void uaBeforeMonthOnly() {
27+
// 2020-?05: month-only uncertain -> bitmask = MONTH (0x30), L2
28+
EdtfDate d = (EdtfDate) Edtf.parse("2020-?05");
29+
assertThat(d.level()).isEqualTo(EdtfLevel.L2);
30+
// Qualified(2) = MONTH-only -> leading marker on month
31+
assertThat(d.toEdtfString()).isEqualTo("2020-?05");
32+
}
33+
34+
@Test
35+
void uaBeforeDayOnly() {
36+
EdtfDate d = (EdtfDate) Edtf.parse("2020-05-?15");
37+
assertThat(d.level()).isEqualTo(EdtfLevel.L2);
38+
assertThat(d.toEdtfString()).isEqualTo("2020-05-?15");
39+
}
40+
41+
@Test
42+
void mixedUaYearUncertainMonthApproximate() {
43+
EdtfDate d = (EdtfDate) Edtf.parse("2020?-05~");
44+
assertThat(d.level()).isEqualTo(EdtfLevel.L2);
45+
// year is uncertain, month is approximate -> not a plain L1 whole-date marker
46+
assertThat(d.uncertain().value()).isNotZero();
47+
assertThat(d.approximate().value()).isNotZero();
48+
}
49+
50+
@Test
51+
void trailingApproximateOnMonthPrecision() {
52+
// L1 shape (whole-date ~): YM mask applied, still L1
53+
EdtfDate d = (EdtfDate) Edtf.parse("2020-05~");
54+
assertThat(d.level()).isEqualTo(EdtfLevel.L1);
55+
assertThat(d.toEdtfString()).isEqualTo("2020-05~");
56+
}
57+
58+
@Test
59+
void percentOnMonthPositional() {
60+
EdtfDate d = (EdtfDate) Edtf.parse("2020-%05");
61+
assertThat(d.level()).isEqualTo(EdtfLevel.L2);
62+
// % = both uncertain and approximate at position 2 (before month)
63+
assertThat(d.uncertain().value()).isNotZero();
64+
assertThat(d.approximate().value()).isNotZero();
65+
}
66+
}

0 commit comments

Comments
 (0)