001/*
002 * PermissionsEx
003 * Copyright (C) zml and PermissionsEx contributors
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *    http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package ca.stellardrift.permissionsex.impl.context;
018
019import org.checkerframework.checker.nullness.qual.Nullable;
020
021import java.time.Instant;
022import java.time.ZoneId;
023import java.time.ZonedDateTime;
024import java.time.format.DateTimeFormatter;
025import java.time.format.DateTimeParseException;
026import java.time.temporal.ChronoUnit;
027import java.util.regex.Matcher;
028import java.util.regex.Pattern;
029
030/**
031 * Parsers to resolve a time from user input.
032 */
033public interface TimeContextParser {
034
035    /**
036     * Get a list of parse
037     * @param zone the time zone to parse in
038     * @return parser candidates
039     */
040    static TimeContextParser[] parsersForZone(final ZoneId zone) {
041        return new TimeContextParser[] {
042                new ByDateTimeFormatter(DateTimeFormatter.ISO_DATE_TIME.withZone(zone)),
043                new ByDateTimeFormatter(DateTimeFormatter.ISO_TIME.withZone(zone)),
044                new ByDateTimeFormatter(DateTimeFormatter.ISO_DATE.withZone(zone)),
045                new ByDateTimeFormatter(DateTimeFormatter.RFC_1123_DATE_TIME.withZone(zone)),
046                new Relative(zone),
047                new ByEpochTime(zone)
048        };
049    }
050
051    @Nullable ZonedDateTime parse(final String input);
052
053    /**
054     * Attemp to parse using an existing {@link DateTimeFormatter}.
055     */
056    final class ByDateTimeFormatter implements TimeContextParser {
057        private final DateTimeFormatter formatter;
058
059        public ByDateTimeFormatter(final DateTimeFormatter formatter) {
060            this.formatter = formatter;
061        }
062
063        @Override
064        public @Nullable ZonedDateTime parse(final String input) {
065            try {
066                return ZonedDateTime.parse(input, this.formatter);
067            } catch (final DateTimeParseException ex) {
068                return null;
069            }
070        }
071    }
072
073    /**
074     * Given a second since the epoch, create a time in the local time zone.
075     */
076    final class ByEpochTime implements TimeContextParser {
077        private final ZoneId zone;
078
079        public ByEpochTime(final ZoneId zone) {
080            this.zone = zone;
081        }
082
083        @Override
084        public @Nullable ZonedDateTime parse(final String input) {
085            try {
086                return ZonedDateTime.ofInstant(Instant.ofEpochSecond(Long.parseLong(input)), this.zone);
087            } catch (final NumberFormatException ex) {
088                return null;
089            }
090        }
091    }
092
093    /**
094     * Parse a time using relative time syntax.
095     */
096    final class Relative implements TimeContextParser {
097        private static final Pattern RELATIVE_TIME_PART = Pattern.compile("^(((?:\\+|-)?)[0-9\\.]*[0-9])(seconds?|minutes?|hours?|days?|weeks?|months?|years?|s|m|h|d|w|y)");
098        private final ZoneId zone;
099
100        public Relative(final ZoneId zone) {
101            this.zone = zone;
102        }
103
104        private static ChronoUnit unit(final String spec) {
105            switch (spec) {
106                case "second":
107                case "seconds":
108                case "s":
109                    return ChronoUnit.SECONDS;
110                case "minute":
111                case "minutes":
112                case "m":
113                    return ChronoUnit.MINUTES;
114                case "hour":
115                case "hours":
116                case "h":
117                    return ChronoUnit.HOURS;
118                case "day":
119                case "days":
120                case "d":
121                    return ChronoUnit.DAYS;
122                case "week":
123                case "weeks":
124                case "w":
125                    return ChronoUnit.WEEKS;
126                case "month":
127                case "months":
128                    return ChronoUnit.MONTHS;
129                case "year":
130                case "years":
131                case "y":
132                    return ChronoUnit.YEARS;
133                // This shouldn't be reached -- it can't be matched in the regex
134                default: throw new IllegalStateException("Hit datetime opcode which was unspecified");
135            }
136        }
137
138        @Override
139        public @Nullable ZonedDateTime parse(final String input) {
140            if (input.length() < 3) {
141                // This will never result in a successful parse
142                return null;
143            }
144
145            // validate the expression begins with either + or -
146            final char initial = input.charAt(0);
147            if (initial != '+' && initial != '-') {
148                // not a relative time
149                return null;
150            }
151
152            ZonedDateTime working = ZonedDateTime.now(zone);
153            final Matcher match = RELATIVE_TIME_PART.matcher(input);
154            int index = 0;
155            boolean positive = true;
156            while (match.find(index)) {
157                positive = !"-".equals(match.group(1)); // two options are plus or minus, no value is interpreted as a positive
158
159                long quantity;
160                try {
161                    quantity = Long.parseLong(match.group(2));
162                } catch (final NumberFormatException ex) {
163                    // its possible the user can enter an extremely large number not representable by a long - do _not_
164                    // change this into an exception!
165                    return null;
166                }
167
168                if (!positive) {
169                    quantity = -quantity;
170                }
171
172                final ChronoUnit unit = unit(match.group(3));
173                working = working.plus(quantity, unit);
174                index += match.group().length();
175            }
176
177            if (index == input.length()) { // if we matched the whole string
178                return working;
179            }
180            return null;
181        }
182    }
183}