[m-rev.] for review: fix a bug in duration parsing
Julien Fischer
jfischer at opturion.com
Mon Jul 6 16:53:50 AEST 2026
For review by anyone.
Fix a bug in duration parsing.
duration_from_string/2 incorrectly accepts strings containing duration
component designators that are not preceded by digits (e.g. "PY", "PTH") for
all designators except for S (which is handled by a different code path).
Such components are currently treated as being zero, instead of being
rejected.
Fix this by requiring that if a given component designator is present in
a duration, then it must be preceded by at least one digit.
library/calendar.m:
Make the predicates that parse duration components semidet and have them
fail if we encounter a component designator with an associated digit.
Move the predicate that responsible for parsing duration components
to their own section. Document them.
Delete some predicates that are no longer required.
tests/hard_coded/calendar_duration_conv.{m,exp}:
Extend this test case to cover the above.
Julien.
diff --git a/library/calendar.m b/library/calendar.m
index fa70c13e0..a85ea4ded 100644
--- a/library/calendar.m
+++ b/library/calendar.m
@@ -1477,6 +1477,30 @@ foldl3_days(Pred, !.Curr, End, !Acc1, !Acc2, !Acc3) :-
% Parsing predicates.
%
+:- pred read_char(char::out, list(char)::in, list(char)::out) is semidet.
+
+read_char(Char, [Char | Rest], Rest).
+
+:- pred read_int_and_num_chars(int::out, int::out,
+ list(char)::in, list(char)::out) is det.
+
+read_int_and_num_chars(Val, N, !Chars) :-
+ read_int_and_num_chars_2(0, Val, 0, N, !Chars).
+
+:- pred read_int_and_num_chars_2(int::in, int::out, int::in, int::out,
+ list(char)::in, list(char)::out) is det.
+
+read_int_and_num_chars_2(!Val, !N, !Chars) :-
+ ( if
+ !.Chars = [Char | Rest],
+ decimal_digit_to_int(Char, Digit)
+ then
+ !:Val = !.Val * 10 + Digit,
+ read_int_and_num_chars_2(!Val, !.N + 1, !:N, Rest, !:Chars)
+ else
+ true
+ ).
+
:- pred read_microseconds(microseconds::out, list(char)::in, list(char)::out)
is det.
@@ -1492,26 +1516,14 @@ read_microseconds(MicroSeconds, !Chars) :-
MicroSeconds = 0
).
-:- pred read_int_and_num_chars(int::out, int::out,
- list(char)::in, list(char)::out) is det.
-
-read_int_and_num_chars(Val, N, !Chars) :-
- read_int_and_num_chars_2(0, Val, 0, N, !Chars).
-
-:- pred read_int_and_num_chars_2(int::in, int::out, int::in, int::out,
- list(char)::in, list(char)::out) is det.
-
-read_int_and_num_chars_2(!Val, !N, !Chars) :-
- ( if
- !.Chars = [Char | Rest],
- decimal_digit_to_int(Char, Digit)
- then
- !:Val = !.Val * 10 + Digit,
- read_int_and_num_chars_2(!Val, !.N + 1, !:N, Rest, !:Chars)
- else
- true
- ).
-
+%---------------------------------------------------------------------------%
+%
+% Duration parsing predicates.
+%
+
+ % Read an optional leading minus sign. Sign is -1 if one is present (and is
+ % consumed), and 1 otherwise (consuming nothing).
+ %
:- pred read_sign(int::out, list(char)::in, list(char)::out) is det.
read_sign(Sign, !Chars) :-
@@ -1522,35 +1534,73 @@ read_sign(Sign, !Chars) :-
Sign = 1
).
-:- pred read_char(char::out, list(char)::in, list(char)::out) is semidet.
-
-read_char(Char, [Char | Rest], Rest).
-
-:- pred read_years(int::out, list(char)::in, list(char)::out) is det.
+ % read_years, read_months, read_days, read_hours and read_minutes each read
+ % one optional duration component using read_int_and_char_or_zero, with the
+ % designator character 'Y', 'M', 'D', 'H' and 'M' respectively.
+ % Months and minutes share the designator 'M', and are distinguished only
+ % by position: months are read before the 'T' separator and minutes after
+ % it.
+ %
+:- pred read_years(int::out, list(char)::in, list(char)::out) is semidet.
read_years(Years, !Chars) :-
read_int_and_char_or_zero(Years, 'Y', !Chars).
-:- pred read_months(int::out, list(char)::in, list(char)::out) is det.
+:- pred read_months(int::out, list(char)::in, list(char)::out) is semidet.
read_months(Months, !Chars) :-
read_int_and_char_or_zero(Months, 'M', !Chars).
-:- pred read_days(int::out, list(char)::in, list(char)::out) is det.
+:- pred read_days(int::out, list(char)::in, list(char)::out) is semidet.
read_days(Days, !Chars) :-
read_int_and_char_or_zero(Days, 'D', !Chars).
-:- pred read_hours(int::out, list(char)::in, list(char)::out) is det.
+:- pred read_hours(int::out, list(char)::in, list(char)::out) is semidet.
read_hours(Hours, !Chars) :-
read_int_and_char_or_zero(Hours, 'H', !Chars).
-:- pred read_minutes(int::out, list(char)::in, list(char)::out) is det.
+:- pred read_minutes(int::out, list(char)::in, list(char)::out) is semidet.
read_minutes(Minutes, !Chars) :-
read_int_and_char_or_zero(Minutes, 'M', !Chars).
+ % read_int_and_char_or_zero(Int, Char, !Chars):
+ %
+ % Read one optional "<integer><Char>" duration component from the front of
+ % !.Chars, where <integer> is a run of decimal digits.
+ % If !.Chars begins with Char preceded by at least one digit, then set Int
+ % to the value of those digits and !:Chars to the characters after Char.
+ % If !.Chars begins with Char with no digits before it, then fail.
+ % Otherwise, set Int to zero and leave !:Chars unchanged.
+ %
+:- pred read_int_and_char_or_zero(int::out, char::in,
+ list(char)::in, list(char)::out) is semidet.
+
+read_int_and_char_or_zero(Int, Char, !Chars) :-
+ ( if
+ read_int_and_num_chars(Int0, NumDigits, !.Chars, Chars1),
+ Chars1 = [Char | Rest]
+ then
+ NumDigits > 0,
+ !:Chars = Rest,
+ Int = Int0
+ else
+ Int = 0
+ ).
+
+ % read_seconds_and_microseconds(Seconds, MicroSeconds, !Chars):
+ %
+ % Read the optional seconds component from the front of !.Chars: a run of
+ % at least one decimal digit, an optional fractional part, and the
+ % designator 'S' (for example "9S" or "9.0003S").
+ % If !.Chars begins with such a component, then set Seconds to the whole
+ % part, MicroSeconds to the fractional part expressed in microseconds, and
+ % !:Chars to the characters after the 'S'.
+ % Otherwise, set both Seconds and MicroSeconds to zero and leave !:Chars
+ % unchanged.
+ %
:- pred read_seconds_and_microseconds(seconds::out, microseconds::out,
list(char)::in, list(char)::out) is det.
@@ -1569,38 +1619,6 @@ read_seconds_and_microseconds(Seconds,
MicroSeconds, !Chars) :-
MicroSeconds = 0
).
-:- pred read_int_and_char_or_zero(int::out, char::in,
- list(char)::in, list(char)::out) is det.
-
-read_int_and_char_or_zero(Int, Char, !Chars) :-
- ( if
- read_int(Int0, !.Chars, Chars1),
- Chars1 = [Char | Rest]
- then
- !:Chars = Rest,
- Int = Int0
- else
- Int = 0
- ).
-
-:- pred read_int(int::out, list(char)::in, list(char)::out) is det.
-
-read_int(Val, !Chars) :-
- read_int_2(0, Val, !Chars).
-
-:- pred read_int_2(int::in, int::out, list(char)::in, list(char)::out) is det.
-
-read_int_2(!Val, !Chars) :-
- ( if
- !.Chars = [Char | Rest],
- decimal_digit_to_int(Char, Digit)
- then
- !:Val = !.Val * 10 + Digit,
- read_int_2(!Val, Rest, !:Chars)
- else
- true
- ).
-
%---------------------------------------------------------------------------%
:- end_module calendar.
%---------------------------------------------------------------------------%
diff --git a/tests/hard_coded/calendar_duration_conv.exp
b/tests/hard_coded/calendar_duration_conv.exp
index a2f31333d..86ada02b6 100644
--- a/tests/hard_coded/calendar_duration_conv.exp
+++ b/tests/hard_coded/calendar_duration_conv.exp
@@ -270,3 +270,21 @@ PASS: ERROR DETECTED
"P1DT": T with no time components
PASS: ERROR DETECTED
+
+"PY": Y with no year digits
+PASS: ERROR DETECTED
+
+"P5YM": M with no month digits
+PASS: ERROR DETECTED
+
+"P5YD": D with no day digits
+PASS: ERROR DETECTED
+
+"PTH": H with no hour digits
+PASS: ERROR DETECTED
+
+"PT5HM": M with no minute digits
+PASS: ERROR DETECTED
+
+"PTS": S with no second digits
+PASS: ERROR DETECTED
diff --git a/tests/hard_coded/calendar_duration_conv.m
b/tests/hard_coded/calendar_duration_conv.m
index e79f9a35f..68ac43837 100644
--- a/tests/hard_coded/calendar_duration_conv.m
+++ b/tests/hard_coded/calendar_duration_conv.m
@@ -270,7 +270,15 @@ invalid_durations = [
duration_test("letter in time component", "PT1HxM"),
% T present but no time components follow.
- duration_test("T with no time components", "P1DT")
+ duration_test("T with no time components", "P1DT"),
+
+ % Component designators with missing digits.
+ duration_test("Y with no year digits", "PY"),
+ duration_test("M with no month digits", "P5YM"),
+ duration_test("D with no day digits", "P5YD"),
+ duration_test("H with no hour digits", "PTH"),
+ duration_test("M with no minute digits", "PT5HM"),
+ duration_test("S with no second digits", "PTS")
].
%---------------------------------------------------------------------------%
More information about the reviews
mailing list