diff --git a/monticore-grammar/src/main/grammars/de/monticore/temporal/TimeTriggerConditions.mc4 b/monticore-grammar/src/main/grammars/de/monticore/temporal/TimeTriggerConditions.mc4 new file mode 100644 index 0000000000..59259e4d70 --- /dev/null +++ b/monticore-grammar/src/main/grammars/de/monticore/temporal/TimeTriggerConditions.mc4 @@ -0,0 +1,53 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal; + +/** + * This grammar defines time trigger conditions (such as 'after', 'on', 'every', 'cron') + * using SI unit literals and ISO 8601 temporals. + */ +component grammar TimeTriggerConditions extends de.monticore.siunit.SIUnitLiterals, + de.monticore.temporal.ISOTemporals { + + interface TimeTriggerCondition; + + AfterCondition implements TimeTriggerCondition = + "after" (duration:SIUnitLiteral | period:ISOPeriod); + + OnCondition implements TimeTriggerCondition = + "on" (isoDateTime:ISODateTime | dateOnly:CalendarDate | timeOnly:ISOTime); + + EveryTimeCondition implements TimeTriggerCondition = + "every" period:SIUnitLiteral (times:NatLiteral "times")? + | "from" start:ISODateTime "every" period:SIUnitLiteral + (times:NatLiteral "times")?; + + CronCondition implements TimeTriggerCondition = + "cron" "[" expression:CronExpression "]"; + + CronExpression = + minute:CronField + hour:CronField + dayOfMonth:CronField + month:CronField + dayOfWeek:CronField; + + CronField = + elements:(CronElement || ",")+; + + interface CronElement; + + CronValue implements CronElement = + value:CronAtom ("/" step:NatLiteral)?; + + CronRange implements CronElement = + start:CronAtom "-" end:CronAtom ("/" step:NatLiteral)?; + + interface CronAtom; + + CronWildcard implements CronAtom = "*"; + + CronNumber implements CronAtom = value:NatLiteral; + + CronName implements CronAtom = value:Name; + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java index 59e8ce0353..02b0bf1f62 100644 --- a/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java +++ b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java @@ -119,8 +119,12 @@ public long getLong(TemporalField field) { case NANO_OF_SECOND: return getNanoOfSecond(); case OFFSET_SECONDS: - // Time shift is in hours but result has to be in seconds - return 3600L * getTimeShiftHour() + 60L * getTimeShiftMinute(); + long offsetSeconds = 3600L * Math.abs(getTimeShiftHour()) + 60L * getTimeShiftMinute(); + if (isPresentTimeShiftSource() && getTimeShiftSource().isPresentSign() + && getTimeShiftSource().getSign() == ASTSign.MINUS) { + return -offsetSeconds; + } + return offsetSeconds; } } throw new UnsupportedTemporalTypeException(field.toString()); diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/util/ISOTemporalsConversions.java b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/util/ISOTemporalsConversions.java new file mode 100644 index 0000000000..908bfdd1bd --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/util/ISOTemporalsConversions.java @@ -0,0 +1,79 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.isotemporals.util; + +import de.monticore.temporal.isotemporals._ast.ASTCalendarDate; +import de.monticore.temporal.isotemporals._ast.ASTISODateTime; +import de.monticore.temporal.isotemporals._ast.ASTISOTime; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +/** + * Java-time conversion helpers for {@link de.monticore.temporal.isotemporals._ast} + * AST nodes. + * + *

All methods require an explicit UTC offset in the source value. + * Named time-zone identifiers are not supported; only {@code Z} and numeric + * offsets such as {@code +02:00} or {@code -05:30} are accepted.

+ */ +public final class ISOTemporalsConversions { + + private ISOTemporalsConversions() { + } + + /** + * Converts an ISO 8601 date-time node to an {@link OffsetDateTime}. + * + *

Hour and minute components of the UTC offset are preserved + * separately, so offsets such as {@code +05:45} and {@code -00:30} + * are represented correctly.

+ * + * @param value the parsed ISO date-time node (must not be {@code null}) + * @return the corresponding {@link OffsetDateTime} + * @throws java.time.format.DateTimeParseException if the value cannot be parsed + */ + public static OffsetDateTime toOffsetDateTime(ASTISODateTime value) { + Objects.requireNonNull(value, "value"); + return OffsetDateTime.parse(value.toRawString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + + /** + * Converts an ISO 8601 calendar-date node to a {@link LocalDate}. + * + *

Date-only values carry no time-zone information. Callers that need + * an instant must supply a time zone and time-of-day themselves.

+ * + * @param value the parsed ISO calendar-date node (must not be {@code null}) + * @return the corresponding {@link LocalDate} + * @throws java.time.format.DateTimeParseException if the value cannot be parsed + */ + public static LocalDate toLocalDate(ASTCalendarDate value) { + Objects.requireNonNull(value, "value"); + return LocalDate.parse(value.toRawString(), DateTimeFormatter.ISO_LOCAL_DATE); + } + + /** + * Converts an ISO 8601 time node to an {@link OffsetTime}. + * + *

An explicit UTC offset is required. The optional leading {@code T} + * designator is stripped before parsing so that both {@code T12:00:00Z} + * and {@code 12:00:00Z} are accepted.

+ * + * @param value the parsed ISO time node (must not be {@code null}) + * @return the corresponding {@link OffsetTime} + * @throws java.time.format.DateTimeParseException if the value cannot be parsed + * or lacks a UTC offset + */ + public static OffsetTime toOffsetTime(ASTISOTime value) { + Objects.requireNonNull(value, "value"); + String source = value.toRawString(); + if (source.startsWith("T")) { + source = source.substring(1); + } + return OffsetTime.parse(source, DateTimeFormatter.ISO_OFFSET_TIME); + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidDuration.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidDuration.java new file mode 100644 index 0000000000..9d48da693f --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidDuration.java @@ -0,0 +1,59 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions._cocos; + +import de.monticore.siunit.siunitliterals._ast.ASTSIUnitLiteral; +import de.monticore.temporal.timetriggerconditions._ast.ASTAfterCondition; +import de.monticore.types.check.SymTypeExpression; +import de.monticore.types.check.SymTypeExpressionFactory; +import de.monticore.types.check.SymTypeOfSIUnit; +import de.monticore.types3.TypeCheck3; +import de.monticore.types3.util.SIUnitTypeRelations; +import de.se_rwth.commons.logging.Log; + +import java.util.List; + +/** + * Checks that an after timer uses a time unit. + *

+ * Requires an initialized {@link TypeCheck3}. + */ +public class AfterConditionHasValidDuration implements + TimeTriggerConditionsASTAfterConditionCoCo { + + /** Error code for a unit that is not of dimension time. */ + public static final String UNSUPPORTED_UNIT = "0xF0002"; + + @Override + public void check(ASTAfterCondition node) { + if (!node.isPresentDuration()) { + return; + } + ASTSIUnitLiteral duration = node.getDuration(); + SymTypeExpression type = TypeCheck3.typeOf(duration); + if (!type.isObscureType() && !isTimeDuration(type)) { + Log.error(UNSUPPORTED_UNIT + " After timer unit must be a unit of time, e.g., ms, s, min, h," + + " or d, but got " + type.printFullName() + ".", node.get_SourcePositionStart()); + } + } + + /** + * @return whether the type is an SI unit (with or without a numeric) + * of dimension time, e.g., {@code [ms]}, {@code [h]}, or {@code [s]} + */ + protected boolean isTimeDuration(SymTypeExpression type) { + SymTypeOfSIUnit siUnit; + if (type.isNumericWithSIUnitType()) { + siUnit = type.asNumericWithSIUnitType().getSIUnitType(); + } + else if (type.isSIUnitType()) { + siUnit = type.asSIUnitType(); + } + else { + return false; + } + SymTypeOfSIUnit second = SymTypeExpressionFactory.createSIUnit( + List.of(SymTypeExpressionFactory.createSIUnitBasic("s")), List.of()); + return SIUnitTypeRelations.isOfSameDimension(siUnit, second); + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidPeriod.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidPeriod.java new file mode 100644 index 0000000000..78a7381502 --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidPeriod.java @@ -0,0 +1,56 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions._cocos; + +import de.monticore.temporal.isotemporals._ast.ASTFullPeriod; +import de.monticore.temporal.isotemporals._ast.ASTISOPeriod; +import de.monticore.temporal.isotemporals._ast.ASTWeekPeriod; +import de.monticore.temporal.timetriggerconditions._ast.ASTAfterCondition; +import de.se_rwth.commons.logging.Log; + +/** + * Checks that an ISO 8601 after timer represents a positive period. + */ +public class AfterConditionHasValidPeriod implements + TimeTriggerConditionsASTAfterConditionCoCo { + + /** Error code for an ISO period that does not denote a positive duration. */ + public static final String NON_POSITIVE_PERIOD = "0xF0008"; + + /** Creates the stateless ISO-period CoCo. */ + public AfterConditionHasValidPeriod() { + } + + @Override + public void check(ASTAfterCondition node) { + if (!node.isPresentPeriod()) { + return; + } + if (!isPositive(node.getPeriod())) { + Log.error(NON_POSITIVE_PERIOD + " ISO after timer period must be greater than 0.", node + .get_SourcePositionStart()); + } + } + + /** Returns whether at least one period component is positive. */ + protected boolean isPositive(ASTISOPeriod period) { + if (period instanceof ASTWeekPeriod weekPeriod) { + return weekPeriod.getWeeks() > 0; + } + if (period instanceof ASTFullPeriod fullPeriod) { + return (fullPeriod.isPresentYears() && fullPeriod.getYears() > 0) || (fullPeriod + .isPresentMonths() && fullPeriod.getMonths() > 0) || (fullPeriod.isPresentDays() + && fullPeriod.getDays() > 0) || (fullPeriod.isPresentHours() && fullPeriod.getHours() + > 0) || (fullPeriod.isPresentMinutes() && fullPeriod.getMinutes() > 0) + || (fullPeriod.isPresentSeconds() && fullPeriod.getSeconds() > 0) || hasPositiveFraction( + fullPeriod); + } + return false; + } + + /** Returns whether a parsed fractional component contains a non-zero digit. */ + protected boolean hasPositiveFraction(ASTFullPeriod period) { + return period.isPresentDecimalDigits() && period.getDecimalDigits().chars().anyMatch( + character -> character != '0'); + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/CronExpressionIsValid.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/CronExpressionIsValid.java new file mode 100644 index 0000000000..e12c9d247b --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/CronExpressionIsValid.java @@ -0,0 +1,237 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions._cocos; + +import de.se_rwth.commons.logging.Log; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronAtom; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronCondition; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronElement; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronExpression; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronField; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronName; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronNumber; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronRange; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronValue; +import de.monticore.temporal.timetriggerconditions._ast.ASTCronWildcard; + +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalInt; +import java.util.Set; + +/** + * Checks that a Cron condition uses the supported Crontab.guru five-field syntax. + */ +public class CronExpressionIsValid implements TimeTriggerConditionsASTCronConditionCoCo { + + /** Error code for invalid field values, names, or ranges. */ + public static final String INVALID_FIELD_VALUE = "0xF0012"; + /** Error code for a zero or unrepresentable step. */ + public static final String INVALID_STEP = "0xF0013"; + /** Warning code for month/day combinations without a Gregorian occurrence. */ + public static final String IMPOSSIBLE_DATE = "0xF0014"; + + /** Maximum possible Gregorian day for each month, including leap-day February. */ + private static final int[] MAXIMUM_DAY_BY_MONTH = { 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, + 31 }; + + /** Case-insensitive Cron month names mapped to their numeric values. */ + protected static final Map MONTH_NAMES = Map.ofEntries(Map.entry("JAN", 1), Map + .entry("FEB", 2), Map.entry("MAR", 3), Map.entry("APR", 4), Map.entry("MAY", 5), Map.entry( + "JUN", 6), Map.entry("JUL", 7), Map.entry("AUG", 8), Map.entry("SEP", 9), Map.entry("OCT", + 10), Map.entry("NOV", 11), Map.entry("DEC", 12)); + + /** Case-insensitive Cron weekday names mapped to Sunday-based numeric values. */ + protected static final Map WEEKDAY_NAMES = Map.of("SUN", 0, "MON", 1, "TUE", 2, + "WED", 3, "THU", 4, "FRI", 5, "SAT", 6); + + /** Creates the stateless Cron validation CoCo. */ + public CronExpressionIsValid() { + } + + @Override + public void check(ASTCronCondition node) { + ASTCronExpression expression = node.getExpression(); + + boolean valid = checkField(expression.getMinute(), 0, 59, "minute", Map.of()); + valid &= checkField(expression.getHour(), 0, 23, "hour", Map.of()); + valid &= checkField(expression.getDayOfMonth(), 1, 31, "day-of-month", Map.of()); + valid &= checkField(expression.getMonth(), 1, 12, "month", MONTH_NAMES); + valid &= checkField(expression.getDayOfWeek(), 0, 7, "day-of-week", WEEKDAY_NAMES); + + if (valid) { + warnIfCalendarImpossible(expression); + } + } + + /** Validates every value, range, name, and step in one Cron field. */ + protected boolean checkField(ASTCronField field, int minimum, int maximum, String fieldName, + Map names) { + for (ASTCronElement element : field.getElementsList()) { + if (!hasValidValue(element, minimum, maximum, names)) { + Log.error(INVALID_FIELD_VALUE + " Cron " + fieldName + " must use values from " + minimum + + " to " + maximum + ", valid names, and ascending ranges.", element + .get_SourcePositionStart()); + return false; + } + if (!hasValidStep(element)) { + Log.error(INVALID_STEP + " Cron " + fieldName + + " step must be a positive representable integer.", element.get_SourcePositionStart()); + return false; + } + } + return true; + } + + /** + * Reports schedules whose selected months cannot contain any selected day-of-month. + * February is evaluated with 29 days because the dialect has no year field and leap + * years therefore provide valid occurrences. A restricted day-of-week can rescue a + * restricted day-of-month through the POSIX OR rule; in that case no warning is issued. + */ + protected void warnIfCalendarImpossible(ASTCronExpression expression) { + if (!startsWithWildcard(expression.getDayOfMonth()) && !startsWithWildcard(expression + .getDayOfWeek())) { + return; + } + + Set days = expand(expression.getDayOfMonth(), 1, 31, Map.of()); + Set months = expand(expression.getMonth(), 1, 12, MONTH_NAMES); + boolean possible = months.stream().anyMatch(month -> days.stream().anyMatch(day -> day + <= MAXIMUM_DAY_BY_MONTH[month])); + + if (!possible) { + Log.warn(IMPOSSIBLE_DATE + + " Cron day-of-month and month fields cannot match a Gregorian date.", expression + .getDayOfMonth().get_SourcePositionStart()); + } + } + + /** + * Returns whether a field begins with a wildcard value. Vixie Cron decides between the + * intersection and the union of the two day fields by looking at the first element only, + * so {@code *,MON} forms an intersection while {@code MON,*} forms a union. This test is + * textual on purpose and must not be widened to the whole field. + */ + protected boolean startsWithWildcard(ASTCronField field) { + if (field.getElementsList().isEmpty()) { + return false; + } + ASTCronElement first = field.getElements(0); + return first instanceof ASTCronValue value && value.getValue() instanceof ASTCronWildcard; + } + + /** Expands a previously validated field to its selected numeric set. */ + protected Set expand(ASTCronField field, int minimum, int maximum, + Map names) { + Set values = new LinkedHashSet<>(); + for (ASTCronElement element : field.getElementsList()) { + int start; + int end; + int step = 1; + if (element instanceof ASTCronValue value) { + if (value.getValue() instanceof ASTCronWildcard) { + start = minimum; + end = maximum; + } + else { + start = resolve(value.getValue(), names).orElseThrow(); + end = value.isPresentStep() ? maximum : start; + } + if (value.isPresentStep()) { + step = value.getStep().getValue(); + } + } + else if (element instanceof ASTCronRange range) { + start = resolve(range.getStart(), names).orElseThrow(); + end = resolve(range.getEnd(), names).orElseThrow(); + if (range.isPresentStep()) { + step = range.getStep().getValue(); + } + } + else { + throw new IllegalArgumentException("Unsupported Cron element AST type: " + element + .getClass().getName()); + } + + for (int value = start; value <= end; value += step) { + values.add(value); + if (value > end - step) { + break; + } + } + } + return values; + } + + /** Validates the atom or inclusive range represented by one Cron element. */ + protected boolean hasValidValue(ASTCronElement element, int minimum, int maximum, + Map names) { + if (element instanceof ASTCronValue) { + ASTCronAtom atom = ((ASTCronValue) element).getValue(); + if (atom instanceof ASTCronWildcard) { + return true; + } + OptionalInt value = resolve(atom, names); + return value.isPresent() && isInRange(value.getAsInt(), minimum, maximum); + } + if (element instanceof ASTCronRange) { + ASTCronRange range = (ASTCronRange) element; + if (range.getStart() instanceof ASTCronWildcard || range + .getEnd() instanceof ASTCronWildcard) { + return false; + } + OptionalInt start = resolve(range.getStart(), names); + OptionalInt end = resolve(range.getEnd(), names); + return start.isPresent() && end.isPresent() && isInRange(start.getAsInt(), minimum, maximum) + && isInRange(end.getAsInt(), minimum, maximum) && start.getAsInt() <= end.getAsInt(); + } + return false; + } + + /** Validates an optional step attached to a value or range. */ + protected boolean hasValidStep(ASTCronElement element) { + if (element instanceof ASTCronValue && ((ASTCronValue) element).isPresentStep()) { + return isPositive(((ASTCronValue) element).getStep()); + } + if (element instanceof ASTCronRange && ((ASTCronRange) element).isPresentStep()) { + return isPositive(((ASTCronRange) element).getStep()); + } + return true; + } + + /** Resolves a numeric or named atom to its field-specific integer value. */ + protected OptionalInt resolve(ASTCronAtom atom, Map names) { + if (atom instanceof ASTCronNumber) { + return getValue(((ASTCronNumber) atom).getValue()); + } + if (atom instanceof ASTCronName) { + Integer value = names.get(((ASTCronName) atom).getValue().toUpperCase(Locale.ROOT)); + return value == null ? OptionalInt.empty() : OptionalInt.of(value); + } + return OptionalInt.empty(); + } + + /** Returns whether a step literal is representable and strictly positive. */ + protected boolean isPositive(de.monticore.literals.mccommonliterals._ast.ASTNatLiteral literal) { + OptionalInt value = getValue(literal); + return value.isPresent() && value.getAsInt() > 0; + } + + /** Safely reads a natural-number literal without propagating overflow errors. */ + protected OptionalInt getValue( + de.monticore.literals.mccommonliterals._ast.ASTNatLiteral literal) { + try { + return OptionalInt.of(literal.getValue()); + } + catch (NumberFormatException exception) { + return OptionalInt.empty(); + } + } + + /** Returns whether a value lies inside an inclusive field range. */ + protected boolean isInRange(int value, int minimum, int maximum) { + return value >= minimum && value <= maximum; + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/EveryTimeConditionHasValidRepetitions.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/EveryTimeConditionHasValidRepetitions.java new file mode 100644 index 0000000000..09944703e0 --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/EveryTimeConditionHasValidRepetitions.java @@ -0,0 +1,89 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions._cocos; + +import de.monticore.literals.mccommonliterals._ast.ASTNatLiteral; +import de.monticore.siunit.siunitliterals._ast.ASTSIUnitLiteral; +import de.monticore.temporal.isotemporals._ast.ASTISODateTime; +import de.monticore.temporal.isotemporals.util.ISOTemporalsConversions; +import de.monticore.temporal.timetriggerconditions._ast.ASTEveryTimeCondition; +import de.monticore.types.check.SymTypeExpression; +import de.monticore.types.check.SymTypeExpressionFactory; +import de.monticore.types.check.SymTypeOfSIUnit; +import de.monticore.types3.TypeCheck3; +import de.monticore.types3.util.SIUnitTypeRelations; +import de.se_rwth.commons.logging.Log; + +import java.util.List; + +/** + * Ensures that every-time intervals and repetition counts are valid. + *

+ * Requires an initialized {@link TypeCheck3}. + */ +public class EveryTimeConditionHasValidRepetitions implements + TimeTriggerConditionsASTEveryTimeConditionCoCo { + + public static final String INVALID_INTERVAL = "0xF0009"; + public static final String INVALID_START = "0xF000A"; + public static final String NON_POSITIVE_REPETITIONS = "0xF000B"; + + @Override + public void check(ASTEveryTimeCondition node) { + validateInterval(node.getPeriod(), node); + if (node.isPresentStart()) { + validateStart(node.getStart(), node); + } + if (node.isPresentTimes()) { + validateRepetitions(node.getTimes(), node); + } + } + + protected void validateInterval(ASTSIUnitLiteral duration, ASTEveryTimeCondition node) { + SymTypeExpression type = TypeCheck3.typeOf(duration); + if (!type.isObscureType() && !isTimeDuration(type)) { + report(node, INVALID_INTERVAL, "Every timer interval unit must be a unit of time," + + " e.g., ms, s, min, h, or d, but got " + type.printFullName()); + } + } + + protected void validateStart(ASTISODateTime start, ASTEveryTimeCondition node) { + try { + ISOTemporalsConversions.toOffsetDateTime(start); + } + catch (java.time.format.DateTimeParseException exception) { + report(node, INVALID_START, + "Every timer start must be a valid ISO date-time with an explicit UTC offset"); + } + } + + protected void validateRepetitions(ASTNatLiteral literal, ASTEveryTimeCondition node) { + if (literal.getDigits().chars().allMatch(c -> c == '0')) { + report(node, NON_POSITIVE_REPETITIONS, "Every timer repetition count must be greater than 0"); + } + } + + /** + * @return whether the type is an SI unit (with or without a numeric) + * of dimension time, e.g., {@code [ms]}, {@code [h]}, or {@code [s]} + */ + protected boolean isTimeDuration(SymTypeExpression type) { + SymTypeOfSIUnit siUnit; + if (type.isNumericWithSIUnitType()) { + siUnit = type.asNumericWithSIUnitType().getSIUnitType(); + } + else if (type.isSIUnitType()) { + siUnit = type.asSIUnitType(); + } + else { + return false; + } + SymTypeOfSIUnit second = SymTypeExpressionFactory.createSIUnit( + List.of(SymTypeExpressionFactory.createSIUnitBasic("s")), List.of()); + return SIUnitTypeRelations.isOfSameDimension(siUnit, second); + } + + protected void report(ASTEveryTimeCondition node, String code, String requirement) { + Log.error(code + " " + requirement + ".", node.get_SourcePositionStart()); + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/OnConditionHasValidValue.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/OnConditionHasValidValue.java new file mode 100644 index 0000000000..4e14ec8a88 --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/OnConditionHasValidValue.java @@ -0,0 +1,81 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions._cocos; + +import de.monticore.temporal.isotemporals._ast.ASTCalendarDate; +import de.monticore.temporal.isotemporals._ast.ASTISODateTime; +import de.monticore.temporal.isotemporals._ast.ASTISOTime; +import de.se_rwth.commons.logging.Log; + +import java.time.format.DateTimeParseException; + +import de.monticore.temporal.timetriggerconditions._ast.ASTOnCondition; +import de.monticore.temporal.isotemporals.util.ISOTemporalsConversions; + +/** + * Checks that an on condition denotes a valid date-time, date, or time. + * Date-times and times require an explicit UTC offset; dates denote midnight UTC. + */ +public class OnConditionHasValidValue implements TimeTriggerConditionsASTOnConditionCoCo { + + public static final String INVALID_DATE_TIME = "0xF0004"; + public static final String INVALID_DATE = "0xF0006"; + public static final String INVALID_TIME = "0xF0007"; + + @Override + public void check(ASTOnCondition node) { + if (node.isPresentIsoDateTime()) { + checkDateTime(node.getIsoDateTime()); + return; + } + if (node.isPresentDateOnly()) { + checkDate(node.getDateOnly()); + return; + } + checkTime(node.getTimeOnly()); + } + + private void checkDateTime(ASTISODateTime node) { + String dateTime = node.toRawString(); + + try { + ISOTemporalsConversions.toOffsetDateTime(node); + } + catch (DateTimeParseException exception) { + Log.error(INVALID_DATE_TIME + " ISO 8601 date-time '" + dateTime + + "' is not a valid UTC instant: " + exception.getMessage() + ".", node + .get_SourcePositionStart()); + } + } + + private void checkDate(ASTCalendarDate node) { + String date = node.toRawString(); + + try { + ISOTemporalsConversions.toLocalDate(node); + } + catch (DateTimeParseException exception) { + Log.error(INVALID_DATE + " ISO 8601 date '" + date + "' is not a valid date: " + exception + .getMessage() + ".", node.get_SourcePositionStart()); + } + } + + private void checkTime(ASTISOTime node) { + String time = node.toRawString(); + + if (!node.isPresentTimeShift()) { + Log.error(INVALID_TIME + " ISO 8601 time '" + time + + "' requires an explicit UTC offset ('Z' or a numeric offset).", node + .get_SourcePositionStart()); + return; + } + + try { + ISOTemporalsConversions.toOffsetTime(node); + } + catch (DateTimeParseException exception) { + Log.error(INVALID_TIME + " ISO 8601 time '" + time + "' is not a valid UTC time: " + exception + .getMessage() + ".", node.get_SourcePositionStart()); + } + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/TimeTriggerConditionsCoCos.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/TimeTriggerConditionsCoCos.java new file mode 100644 index 0000000000..a931feb17b --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/TimeTriggerConditionsCoCos.java @@ -0,0 +1,25 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions._cocos; + +/** Creates fully configured CoCo checkers for the TimeTriggerConditions grammar. */ +public final class TimeTriggerConditionsCoCos { + + private TimeTriggerConditionsCoCos() { + } + + /** + * Creates a new checker containing every TimeTrigger condition validation. + * + * @return a newly configured checker + */ + public static TimeTriggerConditionsCoCoChecker createChecker() { + TimeTriggerConditionsCoCoChecker checker = new TimeTriggerConditionsCoCoChecker(); + checker.addCoCo(new AfterConditionHasValidDuration()); + checker.addCoCo(new AfterConditionHasValidPeriod()); + checker.addCoCo(new OnConditionHasValidValue()); + checker.addCoCo(new EveryTimeConditionHasValidRepetitions()); + checker.addCoCo(new CronExpressionIsValid()); + return checker; + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/semantics/AfterPeriodSemantics.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/semantics/AfterPeriodSemantics.java new file mode 100644 index 0000000000..1ab4954d96 --- /dev/null +++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/semantics/AfterPeriodSemantics.java @@ -0,0 +1,59 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.temporal.timetriggerconditions.semantics; + +import de.monticore.temporal.isotemporals._ast.ASTFullPeriod; +import de.monticore.temporal.isotemporals._ast.ASTISOPeriod; +import de.monticore.temporal.isotemporals._ast.ASTWeekPeriod; + +import java.util.Objects; + +/** Static semantic classification for ISO periods used by {@code after}. */ +public final class AfterPeriodSemantics { + + /** The two duration classes exposed to TimeTrigger consumers. */ + public enum DurationKind { + /** A duration consisting only of weeks, days, hours, minutes, and seconds. */ + FIXED, + /** A duration containing a year or month component. */ + CALENDAR_RELATIVE + } + + private AfterPeriodSemantics() { + } + + /** + * Classifies a structured ISO period without choosing a runtime calendar policy. + * + * @param period the parsed ISO period + * @return {@link DurationKind#CALENDAR_RELATIVE} if the period contains a year or + * month component, otherwise {@link DurationKind#FIXED} + * @throws NullPointerException if {@code period} is {@code null} + * @throws IllegalArgumentException if an unknown period implementation is supplied + */ + public static DurationKind classify(ASTISOPeriod period) { + Objects.requireNonNull(period, "period"); + if (period instanceof ASTWeekPeriod) { + return DurationKind.FIXED; + } + if (period instanceof ASTFullPeriod fullPeriod) { + if ((fullPeriod.isPresentYears() && fullPeriod.getYears() > 0) || (fullPeriod + .isPresentMonths() && fullPeriod.getMonths() > 0)) { + return DurationKind.CALENDAR_RELATIVE; + } + return DurationKind.FIXED; + } + throw new IllegalArgumentException("Unsupported ISO period AST type: " + period.getClass() + .getName()); + } + + /** + * Determines whether a period requires a calendar, timezone, and end-of-month policy. + * + * @param period the parsed ISO period + * @return whether the period is calendar-relative + */ + public static boolean isCalendarRelative(ASTISOPeriod period) { + return classify(period) == DurationKind.CALENDAR_RELATIVE; + } + +} diff --git a/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java b/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java index df504d6bc6..2e0769d20b 100644 --- a/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java +++ b/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java @@ -190,6 +190,19 @@ protected boolean _isOfDimensionOne(SymTypeOfSIUnit siUnit) { return siUnitNormalized.getNumerator().isEmpty() && siUnitNormalized.getDenominator().isEmpty(); } + /** + * whether both SIUnits are of the same dimension, + * i.e., they only differ in prefixes and factors, + * e.g.: ms and h, km and m, J and eV + */ + public static boolean isOfSameDimension(SymTypeOfSIUnit siUnit1, SymTypeOfSIUnit siUnit2) { + return getDelegate()._isOfSameDimension(siUnit1, siUnit2); + } + + protected boolean _isOfSameDimension(SymTypeOfSIUnit siUnit1, SymTypeOfSIUnit siUnit2) { + return isOfDimensionOne(multiply(siUnit1, invert(siUnit2))); + } + /** * returns a SymTypeOfSIUnit only consisting of the seven SI base units * (s, m, kg, A, K, mol, cd)