From 68c2f7e3ef9b7acfe625a7980c64b5124876814a Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Tue, 25 Aug 2026 20:28:57 +0200 Subject: [PATCH 1/2] feat: add Java-backed Time::Moment provider Bundle Time::Moment 0.46 on top of java.time with provider metadata, representative regression coverage, and module tests run by the bundled gate. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- docs/about/changelog.md | 1 + docs/reference/bundled-modules.md | 1 + .../runtime/perlmodule/TimeMoment.java | 113 ++++++++++++++++++ src/main/perl/lib/PerlOnJava/providers.json | 8 ++ src/main/perl/lib/Time/Moment.pm | 59 +++++++++ .../resources/module/Time-Moment/t/010_core.t | 24 ++++ .../resources/unit/cpan_bundled_providers.t | 3 +- src/test/resources/unit/time_moment_java_xs.t | 16 +++ 8 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java create mode 100644 src/main/perl/lib/Time/Moment.pm create mode 100644 src/test/resources/module/Time-Moment/t/010_core.t create mode 100644 src/test/resources/unit/time_moment_java_xs.t diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 9247d1ab13..feb86a0b50 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -11,6 +11,7 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. - Bundle the complete CPAN `File::Path` 2.18 implementation, including modern `rmtree`/`remove_tree` options such as `keep_root`, `error`, `result`, `safe`, and `verbose`. +- Add `Time::Moment` 0.46 as a Java-backed bundled provider using `java.time`. ## v5.44.1: Regex, Threads, Async/Await, and CPAN Compatibility diff --git a/docs/reference/bundled-modules.md b/docs/reference/bundled-modules.md index 79a79b8b91..7e8f1fb10d 100644 --- a/docs/reference/bundled-modules.md +++ b/docs/reference/bundled-modules.md @@ -347,6 +347,7 @@ These are loaded automatically or via `use`: | `Time::HiRes` | Java | `System.nanoTime()` | | `Time::UTC::Now` | Java + Perl | `java.time.Instant`; reports no trusted accuracy bound | | `Time::Piece` | Java + Perl | | +| `Time::Moment` | Java + Perl | Compatible with Time-Moment 0.46; uses `java.time` fixed-offset values. | | `Time::Local` | Perl | | | `DateTime` | Java + Perl | Java backend bundled; install `DateTime` from CPAN with `jcpan -i DateTime` (timezone data gets frequent updates) | | `POSIX` | Java | Includes `strftime`, `mktime`, etc. | diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java b/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java new file mode 100644 index 0000000000..c54ac0bc6f --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java @@ -0,0 +1,113 @@ +package org.perlonjava.runtime.perlmodule; + +import org.perlonjava.runtime.operators.ReferenceOperators; +import org.perlonjava.runtime.runtimetypes.*; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; +import java.time.temporal.IsoFields; +import java.time.temporal.JulianFields; +import java.time.temporal.TemporalAdjusters; + +/** + * Java XS provider for Time::Moment 0.46. + * + * A moment is represented as a blessed hash containing an Instant and the + * fixed offset in minutes. java.time provides the proleptic Gregorian + * calendar, nanosecond precision, ISO week fields, and overflow-safe instant + * arithmetic that the original XS implementation supplies in C. + */ +public class TimeMoment extends PerlModuleBase { + private static final String MODULE = "Time::Moment"; + private static final long RD_UNIX_EPOCH_SECONDS = 62135683200L; + private static final long SECONDS_PER_DAY = 86400; + + public TimeMoment() { super(MODULE, false); } + + public static void initialize() { + TimeMoment m = new TimeMoment(); + try { + m.registerMethod("new", "newInstance", null); + m.registerMethod("now", null); m.registerMethod("now_utc", null); + m.registerMethod("from_epoch", null); m.registerMethod("from_string", null); + m.registerMethod("with_offset_same_instant", null); m.registerMethod("with_offset_same_local", null); + m.registerMethod("with_year", null); m.registerMethod("with_month", null); m.registerMethod("with_day_of_month", null); + m.registerMethod("with_hour", null); m.registerMethod("with_minute", null); m.registerMethod("with_second", null); + m.registerMethod("with_nanosecond", null); m.registerMethod("with_precision", null); + m.registerMethod("plus_years", null); m.registerMethod("plus_months", null); m.registerMethod("plus_weeks", null); m.registerMethod("plus_days", null); + m.registerMethod("plus_hours", null); m.registerMethod("plus_minutes", null); m.registerMethod("plus_seconds", null); m.registerMethod("plus_milliseconds", null); m.registerMethod("plus_microseconds", null); m.registerMethod("plus_nanoseconds", null); + m.registerMethod("minus_years", null); m.registerMethod("minus_months", null); m.registerMethod("minus_weeks", null); m.registerMethod("minus_days", null); + m.registerMethod("minus_hours", null); m.registerMethod("minus_minutes", null); m.registerMethod("minus_seconds", null); m.registerMethod("minus_milliseconds", null); m.registerMethod("minus_microseconds", null); m.registerMethod("minus_nanoseconds", null); + m.registerMethod("year", null); m.registerMethod("quarter", null); m.registerMethod("month", null); m.registerMethod("week", null); + m.registerMethod("day_of_year", null); m.registerMethod("day_of_quarter", null); m.registerMethod("day_of_month", null); m.registerMethod("day_of_week", null); + m.registerMethod("hour", null); m.registerMethod("minute", null); m.registerMethod("second", null); m.registerMethod("millisecond", null); m.registerMethod("microsecond", null); m.registerMethod("nanosecond", null); + m.registerMethod("epoch", null); m.registerMethod("offset", null); m.registerMethod("rdn", null); + m.registerMethod("utc_rd_as_seconds", null); m.registerMethod("local_rd_as_seconds", null); m.registerMethod("utc_rd_values", null); m.registerMethod("local_rd_values", null); + m.registerMethod("length_of_year", null); m.registerMethod("length_of_month", null); m.registerMethod("length_of_quarter", null); + m.registerMethod("at_utc", null); m.registerMethod("at_midnight", null); m.registerMethod("at_noon", null); m.registerMethod("at_last_day_of_month", null); + m.registerMethod("compare", null); m.registerMethod("is_equal", null); m.registerMethod("is_before", null); m.registerMethod("is_after", null); + m.registerMethod("to_string", null); m.registerMethod("strftime", null); + } catch (NoSuchMethodException e) { throw new IllegalStateException(e); } + } + + private static OffsetDateTime value(RuntimeScalar self) { + RuntimeHash h = self.hashDeref(); + return Instant.ofEpochSecond(h.get("epoch").getLong(), h.get("nanosecond").getLong()) + .atOffset(ZoneOffset.ofTotalSeconds(h.get("offset").getInt() * 60)); + } + private static RuntimeList result(OffsetDateTime time, RuntimeScalar klass) { + if (time.getYear() < 1 || time.getYear() > 9999) throw new IllegalArgumentException("Time::Moment is out of range"); + RuntimeHash h = new RuntimeHash(); + h.put("epoch", new RuntimeScalar(time.toEpochSecond())); + h.put("nanosecond", new RuntimeScalar(time.getNano())); + h.put("offset", new RuntimeScalar(time.getOffset().getTotalSeconds() / 60)); + RuntimeScalar ref = h.createReference(); + ReferenceOperators.bless(ref, klass); + return ref.getList(); + } + private static RuntimeScalar classOf(RuntimeArray args) { return args.get(0); } + private static RuntimeScalar classOfSelf(RuntimeScalar self) { + int blessId = RuntimeScalarType.blessedId(self); + return new RuntimeScalar(blessId == 0 ? MODULE : NameNormalizer.getBlessStr(blessId)); + } + private static int namedInt(RuntimeArray args, String name, int fallback) { + for (int i = 1; i + 1 < args.size(); i += 2) if (name.equals(args.get(i).toString())) return args.get(i + 1).getInt(); + return fallback; + } + + public static RuntimeList newInstance(RuntimeArray a, int c) { + int year=namedInt(a,"year",1), month=namedInt(a,"month",1), day=namedInt(a,"day",1), hour=namedInt(a,"hour",0), minute=namedInt(a,"minute",0), second=namedInt(a,"second",0), nano=namedInt(a,"nanosecond",0), offset=namedInt(a,"offset",0); + return result(OffsetDateTime.of(year,month,day,hour,minute,second,nano,ZoneOffset.ofTotalSeconds(offset*60)), classOf(a)); + } + public static RuntimeList now(RuntimeArray a, int c) { return result(OffsetDateTime.now(), classOf(a)); } + public static RuntimeList now_utc(RuntimeArray a, int c) { return result(OffsetDateTime.now(ZoneOffset.UTC), classOf(a)); } + public static RuntimeList from_epoch(RuntimeArray a, int c) { + long sec=a.get(1).getLong(); int nanos=a.size()>2 && !"nanosecond".equals(a.get(2).toString()) ? a.get(2).getInt() : namedInt(a,"nanosecond",0); + return result(Instant.ofEpochSecond(sec,nanos).atOffset(ZoneOffset.UTC), classOf(a)); + } + public static RuntimeList from_string(RuntimeArray a, int c) { return result(OffsetDateTime.parse(a.get(1).toString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME), classOf(a)); } + public static RuntimeList with_offset_same_instant(RuntimeArray a,int c) { return result(value(a.get(0)).withOffsetSameInstant(ZoneOffset.ofTotalSeconds(a.get(1).getInt()*60)), classOfSelf(a.get(0))); } + public static RuntimeList with_offset_same_local(RuntimeArray a,int c) { return result(value(a.get(0)).withOffsetSameLocal(ZoneOffset.ofTotalSeconds(a.get(1).getInt()*60)), classOfSelf(a.get(0))); } + private static RuntimeList changed(RuntimeArray a, OffsetDateTime v) { return result(v,classOfSelf(a.get(0))); } + public static RuntimeList with_year(RuntimeArray a,int c){return changed(a,value(a.get(0)).withYear(a.get(1).getInt()));} + public static RuntimeList with_month(RuntimeArray a,int c){return changed(a,value(a.get(0)).withMonth(a.get(1).getInt()));} + public static RuntimeList with_day_of_month(RuntimeArray a,int c){return changed(a,value(a.get(0)).withDayOfMonth(a.get(1).getInt()));} + public static RuntimeList with_hour(RuntimeArray a,int c){return changed(a,value(a.get(0)).withHour(a.get(1).getInt()));} + public static RuntimeList with_minute(RuntimeArray a,int c){return changed(a,value(a.get(0)).withMinute(a.get(1).getInt()));} + public static RuntimeList with_second(RuntimeArray a,int c){return changed(a,value(a.get(0)).withSecond(a.get(1).getInt()));} + public static RuntimeList with_nanosecond(RuntimeArray a,int c){return changed(a,value(a.get(0)).withNano(a.get(1).getInt()));} + public static RuntimeList with_precision(RuntimeArray a,int c){int p=a.get(1).getInt(); int n=value(a.get(0)).getNano(); int factor=(int)Math.pow(10,9-p); return changed(a,value(a.get(0)).withNano(n/factor*factor));} + private static RuntimeList plus(RuntimeArray a, String unit, int sign) { OffsetDateTime t=value(a.get(0)); long n=a.get(1).getLong()*sign; return changed(a,switch(unit){case "years"->t.plusYears(n);case "months"->t.plusMonths(n);case "weeks"->t.plusWeeks(n);case "days"->t.plusDays(n);case "hours"->t.plusHours(n);case "minutes"->t.plusMinutes(n);case "seconds"->t.plusSeconds(n);case "milliseconds"->t.plusNanos(Math.multiplyExact(n,1_000_000));case "microseconds"->t.plusNanos(Math.multiplyExact(n,1_000));default->t.plusNanos(n);}); } + public static RuntimeList plus_years(RuntimeArray a,int c){return plus(a,"years",1);} public static RuntimeList plus_months(RuntimeArray a,int c){return plus(a,"months",1);} public static RuntimeList plus_weeks(RuntimeArray a,int c){return plus(a,"weeks",1);} public static RuntimeList plus_days(RuntimeArray a,int c){return plus(a,"days",1);} public static RuntimeList plus_hours(RuntimeArray a,int c){return plus(a,"hours",1);} public static RuntimeList plus_minutes(RuntimeArray a,int c){return plus(a,"minutes",1);} public static RuntimeList plus_seconds(RuntimeArray a,int c){return plus(a,"seconds",1);} public static RuntimeList plus_milliseconds(RuntimeArray a,int c){return plus(a,"milliseconds",1);} public static RuntimeList plus_microseconds(RuntimeArray a,int c){return plus(a,"microseconds",1);} public static RuntimeList plus_nanoseconds(RuntimeArray a,int c){return plus(a,"nanoseconds",1);} + public static RuntimeList minus_years(RuntimeArray a,int c){return plus(a,"years",-1);} public static RuntimeList minus_months(RuntimeArray a,int c){return plus(a,"months",-1);} public static RuntimeList minus_weeks(RuntimeArray a,int c){return plus(a,"weeks",-1);} public static RuntimeList minus_days(RuntimeArray a,int c){return plus(a,"days",-1);} public static RuntimeList minus_hours(RuntimeArray a,int c){return plus(a,"hours",-1);} public static RuntimeList minus_minutes(RuntimeArray a,int c){return plus(a,"minutes",-1);} public static RuntimeList minus_seconds(RuntimeArray a,int c){return plus(a,"seconds",-1);} public static RuntimeList minus_milliseconds(RuntimeArray a,int c){return plus(a,"milliseconds",-1);} public static RuntimeList minus_microseconds(RuntimeArray a,int c){return plus(a,"microseconds",-1);} public static RuntimeList minus_nanoseconds(RuntimeArray a,int c){return plus(a,"nanoseconds",-1);} + private static RuntimeList n(long v){return new RuntimeScalar(v).getList();} private static OffsetDateTime t(RuntimeArray a){return value(a.get(0));} + public static RuntimeList year(RuntimeArray a,int c){return n(t(a).getYear());} public static RuntimeList quarter(RuntimeArray a,int c){return n(t(a).get(IsoFields.QUARTER_OF_YEAR));} public static RuntimeList month(RuntimeArray a,int c){return n(t(a).getMonthValue());} public static RuntimeList week(RuntimeArray a,int c){return n(t(a).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));} public static RuntimeList day_of_year(RuntimeArray a,int c){return n(t(a).getDayOfYear());} public static RuntimeList day_of_quarter(RuntimeArray a,int c){return n(t(a).get(IsoFields.DAY_OF_QUARTER));} public static RuntimeList day_of_month(RuntimeArray a,int c){return n(t(a).getDayOfMonth());} public static RuntimeList day_of_week(RuntimeArray a,int c){return n(t(a).getDayOfWeek().getValue());} public static RuntimeList hour(RuntimeArray a,int c){return n(t(a).getHour());} public static RuntimeList minute(RuntimeArray a,int c){return n(t(a).getMinute());} public static RuntimeList second(RuntimeArray a,int c){return n(t(a).getSecond());} public static RuntimeList millisecond(RuntimeArray a,int c){return n(t(a).getNano()/1_000_000);} public static RuntimeList microsecond(RuntimeArray a,int c){return n(t(a).getNano()/1_000);} public static RuntimeList nanosecond(RuntimeArray a,int c){return n(t(a).getNano());} public static RuntimeList epoch(RuntimeArray a,int c){return n(t(a).toEpochSecond());} public static RuntimeList offset(RuntimeArray a,int c){return n(t(a).getOffset().getTotalSeconds()/60);} public static RuntimeList rdn(RuntimeArray a,int c){return n(t(a).getLong(JulianFields.RATA_DIE));} + public static RuntimeList utc_rd_as_seconds(RuntimeArray a,int c){return n(t(a).toEpochSecond()+RD_UNIX_EPOCH_SECONDS);} public static RuntimeList local_rd_as_seconds(RuntimeArray a,int c){return n((t(a).toLocalDate().toEpochDay()+719163)*SECONDS_PER_DAY+t(a).toLocalTime().toSecondOfDay());} + private static RuntimeList rdValues(OffsetDateTime t, boolean local){ OffsetDateTime x=local?t:t.withOffsetSameInstant(ZoneOffset.UTC); RuntimeList r=new RuntimeList();r.add(new RuntimeScalar(x.toLocalDate().getLong(JulianFields.RATA_DIE)));r.add(new RuntimeScalar(x.toLocalTime().toSecondOfDay()));r.add(new RuntimeScalar(x.getNano()));return r;} public static RuntimeList utc_rd_values(RuntimeArray a,int c){return rdValues(t(a),false);} public static RuntimeList local_rd_values(RuntimeArray a,int c){return rdValues(t(a),true);} + public static RuntimeList length_of_year(RuntimeArray a,int c){return n(t(a).toLocalDate().lengthOfYear());} public static RuntimeList length_of_month(RuntimeArray a,int c){return n(t(a).toLocalDate().lengthOfMonth());} public static RuntimeList length_of_quarter(RuntimeArray a,int c){OffsetDateTime x=t(a);int q=x.get(IsoFields.QUARTER_OF_YEAR),m=(q-1)*3+1;return n(LocalDate.of(x.getYear(),m,1).lengthOfMonth()+LocalDate.of(x.getYear(),m+1,1).lengthOfMonth()+LocalDate.of(x.getYear(),m+2,1).lengthOfMonth());} + public static RuntimeList at_utc(RuntimeArray a,int c){return changed(a,t(a).withOffsetSameInstant(ZoneOffset.UTC));} public static RuntimeList at_midnight(RuntimeArray a,int c){return changed(a,t(a).withHour(0).withMinute(0).withSecond(0).withNano(0));} public static RuntimeList at_noon(RuntimeArray a,int c){return changed(a,t(a).withHour(12).withMinute(0).withSecond(0).withNano(0));} public static RuntimeList at_last_day_of_month(RuntimeArray a,int c){return changed(a,t(a).with(TemporalAdjusters.lastDayOfMonth()));} + private static int cmp(RuntimeArray a){return t(a).toInstant().compareTo(value(a.get(1)).toInstant());} public static RuntimeList compare(RuntimeArray a,int c){return n(cmp(a));} public static RuntimeList is_equal(RuntimeArray a,int c){return n(cmp(a)==0?1:0);} public static RuntimeList is_before(RuntimeArray a,int c){return n(cmp(a)<0?1:0);} public static RuntimeList is_after(RuntimeArray a,int c){return n(cmp(a)>0?1:0);} + public static RuntimeList to_string(RuntimeArray a,int c){return new RuntimeScalar(t(a).toString()).getList();} + public static RuntimeList strftime(RuntimeArray a,int c){ return new RuntimeScalar(POSIX.formatStrftime(a.get(1).toString(),t(a).toZonedDateTime())).getList(); } +} diff --git a/src/main/perl/lib/PerlOnJava/providers.json b/src/main/perl/lib/PerlOnJava/providers.json index d2769301ac..7e21ccc143 100644 --- a/src/main/perl/lib/PerlOnJava/providers.json +++ b/src/main/perl/lib/PerlOnJava/providers.json @@ -1,6 +1,14 @@ { "schema_version": 1, "providers": [ + { + "module": "Time::Moment", + "version": "0.46", + "distribution": "Time-Moment", + "provider": "java-xs", + "shadow_policy": "forbidden", + "test_strategy": "bundled-provider" + }, { "module": "DBI", "version": "1.643", diff --git a/src/main/perl/lib/Time/Moment.pm b/src/main/perl/lib/Time/Moment.pm new file mode 100644 index 0000000000..a47b21f838 --- /dev/null +++ b/src/main/perl/lib/Time/Moment.pm @@ -0,0 +1,59 @@ +package Time::Moment; + +use strict; +use warnings; +use Carp qw[]; + +our $VERSION = '0.46'; + +# The value and calendar operations are implemented by the bundled Java XS +# provider. Keep this wrapper deliberately close to upstream so callers see +# the usual Time::Moment package and version. +use XSLoader; +XSLoader::load(__PACKAGE__, $VERSION); + +use overload + '""' => 'to_string', + '<=>' => 'compare', + fallback => 1; + +sub STORABLE_freeze { + my ($self, $cloning) = @_; + return if $cloning; + return pack 'nnNNN', 0x544D, $self->offset, $self->utc_rd_values; +} + +sub STORABLE_thaw { + my ($self, $cloning, $packed) = @_; + return if $cloning; + (length($packed) == 16 && vec($packed, 0, 16) == 0x544D) + or die 'Cannot deserialize corrupted data'; + my ($offset, $rdn, $sod, $nos) = unpack 'xxnNNN', $packed; + $offset = ($offset & 0x7FFF) - 0x8000 if $offset & 0x8000; + my $seconds = ($rdn - 719163) * 86400 + $sod; + $$self = ${ ref($self)->from_epoch($seconds, $nos) + ->with_offset_same_instant($offset) }; +} + +sub TO_JSON { $_[0]->to_string } +sub FREEZE { $_[0]->to_string } +sub THAW { $_[0]->from_string($_[2]) } + +*with_offset = \&with_offset_same_instant; + +sub utc_year { $_[0]->with_offset_same_instant(0)->year } + +1; + +__END__ + +=head1 NAME + +Time::Moment - immutable date/time values with a fixed UTC offset + +=head1 COPYRIGHT + +Compatible with Time-Moment 0.46 by Christian Hansen. The PerlOnJava +provider is backed by C. + +=cut diff --git a/src/test/resources/module/Time-Moment/t/010_core.t b/src/test/resources/module/Time-Moment/t/010_core.t new file mode 100644 index 0000000000..2bb33fc155 --- /dev/null +++ b/src/test/resources/module/Time-Moment/t/010_core.t @@ -0,0 +1,24 @@ +#!perl +use strict; +use warnings; +use Test::More; + +use Time::Moment; + +is $Time::Moment::VERSION, '0.46', 'bundled provider reports the upstream version'; + +my $tm = Time::Moment->from_string('2012-12-24T15:30:45.123456789+01:30'); +is $tm->to_string, '2012-12-24T15:30:45.123456789+01:30', + 'round-trips the upstream ISO representation'; +is $tm->epoch, 1356357645, 'retains the represented instant'; +is $tm->offset, 90, 'retains fixed offset minutes'; +is $tm->plus_months(2)->to_string, '2013-02-24T15:30:45.123456789+01:30', + 'uses calendar arithmetic'; +is $tm->with_offset_same_instant(0)->to_string, '2012-12-24T14:00:45.123456789Z', + 'changes offset while preserving instant'; +is $tm->utc_rd_as_seconds, 63492040845, 'returns upstream Rata Die seconds'; +is $tm->strftime('%Y-%m-%d %H:%M:%S %z'), '2012-12-24 15:30:45 +0130', + 'uses the shared Java formatter for POSIX directives'; +ok $tm->is_after(Time::Moment->from_epoch(0)), 'compares instants'; + +done_testing; diff --git a/src/test/resources/unit/cpan_bundled_providers.t b/src/test/resources/unit/cpan_bundled_providers.t index 24bb34013b..1cfe33a96b 100644 --- a/src/test/resources/unit/cpan_bundled_providers.t +++ b/src/test/resources/unit/cpan_bundled_providers.t @@ -14,7 +14,7 @@ if ($is_perlonjava) { ok(PerlOnJava::ProviderManifest->can('provider_for'), 'loaded provider manifest'); my @providers = PerlOnJava::ProviderManifest->providers; -is(scalar(@providers), 12, 'bundled-provider manifest has twelve module entries'); +is(scalar(@providers), 13, 'bundled-provider manifest has thirteen module entries'); my %expected = ( DBI => [ '1.643', 'bundled-perl' ], @@ -29,6 +29,7 @@ my %expected = ( 'Scalar::Util' => [ '1.70', 'java-xs' ], 'List::Util' => [ '1.70', 'java-xs' ], 'Sub::Util' => [ '1.70', 'java-xs' ], + 'Time::Moment' => [ '0.46', 'java-xs' ], ); for my $module (sort keys %expected) { diff --git a/src/test/resources/unit/time_moment_java_xs.t b/src/test/resources/unit/time_moment_java_xs.t new file mode 100644 index 0000000000..77b2678197 --- /dev/null +++ b/src/test/resources/unit/time_moment_java_xs.t @@ -0,0 +1,16 @@ +use strict; +use warnings; +use Test::More; + +use Time::Moment; + +my $tm = Time::Moment->from_string('2012-12-24T15:30:45.123456789+01:30'); +is $tm->to_string, '2012-12-24T15:30:45.123456789+01:30', 'round-trips an offset ISO instant'; +is $tm->epoch, 1356357645, 'preserves the instant'; +is $tm->offset, 90, 'preserves offset minutes'; +is $tm->plus_months(2)->to_string, '2013-02-24T15:30:45.123456789+01:30', 'uses calendar arithmetic'; +is $tm->with_offset_same_instant(0)->to_string, '2012-12-24T14:00:45.123456789Z', 'changes display offset without changing instant'; +is $tm->strftime('%Y-%m-%d %H:%M:%S %z'), '2012-12-24 15:30:45 +0130', 'formats through the shared Java formatter'; +ok $tm->is_after(Time::Moment->from_epoch(0)), 'compares instants'; + +done_testing; From 1c95fca1bdeb191b670bd785cfd7dcb6b68eca4c Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 26 Aug 2026 19:27:06 +0200 Subject: [PATCH 2/2] feat: complete Time::Moment Java provider Implement the remaining Time::Moment 0.46 API, adjusters, strict ISO parsing, Storable restoration, and bundled-module regression coverage for issue #1112. Generated with [Codex](https://openai.com/codex/) Co-Authored-By: Codex --- .../runtime/perlmodule/TimeMoment.java | 219 ++++++++++++++++-- src/main/perl/lib/Time/Moment.pm | 66 +++++- src/main/perl/lib/Time/Moment/Adjusters.pm | 45 ++++ .../resources/module/Time-Moment/t/010_core.t | 31 ++- src/test/resources/unit/time_moment_java_xs.t | 9 + .../resources/unit/time_moment_storable.t | 15 ++ 6 files changed, 352 insertions(+), 33 deletions(-) create mode 100644 src/main/perl/lib/Time/Moment/Adjusters.pm create mode 100644 src/test/resources/unit/time_moment_storable.t diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java b/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java index c54ac0bc6f..327acbdd8c 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/TimeMoment.java @@ -5,10 +5,16 @@ import java.time.*; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.ResolverStyle; +import java.time.format.TextStyle; import java.time.temporal.ChronoField; import java.time.temporal.IsoFields; import java.time.temporal.JulianFields; import java.time.temporal.TemporalAdjusters; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Locale; /** * Java XS provider for Time::Moment 0.46. @@ -31,10 +37,15 @@ public static void initialize() { m.registerMethod("new", "newInstance", null); m.registerMethod("now", null); m.registerMethod("now_utc", null); m.registerMethod("from_epoch", null); m.registerMethod("from_string", null); + m.registerMethod("from_rd", null); m.registerMethod("from_jd", null); m.registerMethod("from_mjd", null); m.registerMethod("with_offset_same_instant", null); m.registerMethod("with_offset_same_local", null); - m.registerMethod("with_year", null); m.registerMethod("with_month", null); m.registerMethod("with_day_of_month", null); + m.registerMethod("with_year", null); m.registerMethod("with_month", null); m.registerMethod("with_week", null); m.registerMethod("with_day_of_month", null); + m.registerMethod("with_quarter", null); m.registerMethod("with_day_of_year", null); m.registerMethod("with_day_of_quarter", null); m.registerMethod("with_day_of_week", null); m.registerMethod("with_rdn", null); m.registerMethod("with_hour", null); m.registerMethod("with_minute", null); m.registerMethod("with_second", null); + m.registerMethod("with_minute_of_day", null); m.registerMethod("with_second_of_day", null); + m.registerMethod("with_millisecond", null); m.registerMethod("with_millisecond_of_day", null); m.registerMethod("with_microsecond", null); m.registerMethod("with_microsecond_of_day", null); m.registerMethod("with_nanosecond_of_day", null); m.registerMethod("with_nanosecond", null); m.registerMethod("with_precision", null); + m.registerMethod("precision", null); m.registerMethod("plus_years", null); m.registerMethod("plus_months", null); m.registerMethod("plus_weeks", null); m.registerMethod("plus_days", null); m.registerMethod("plus_hours", null); m.registerMethod("plus_minutes", null); m.registerMethod("plus_seconds", null); m.registerMethod("plus_milliseconds", null); m.registerMethod("plus_microseconds", null); m.registerMethod("plus_nanoseconds", null); m.registerMethod("minus_years", null); m.registerMethod("minus_months", null); m.registerMethod("minus_weeks", null); m.registerMethod("minus_days", null); @@ -42,11 +53,14 @@ public static void initialize() { m.registerMethod("year", null); m.registerMethod("quarter", null); m.registerMethod("month", null); m.registerMethod("week", null); m.registerMethod("day_of_year", null); m.registerMethod("day_of_quarter", null); m.registerMethod("day_of_month", null); m.registerMethod("day_of_week", null); m.registerMethod("hour", null); m.registerMethod("minute", null); m.registerMethod("second", null); m.registerMethod("millisecond", null); m.registerMethod("microsecond", null); m.registerMethod("nanosecond", null); - m.registerMethod("epoch", null); m.registerMethod("offset", null); m.registerMethod("rdn", null); + m.registerMethod("minute_of_day", null); m.registerMethod("second_of_day", null); m.registerMethod("millisecond_of_day", null); m.registerMethod("microsecond_of_day", null); m.registerMethod("nanosecond_of_day", null); + m.registerMethod("epoch", null); m.registerMethod("offset", null); m.registerMethod("rdn", null); m.registerMethod("rd", null); m.registerMethod("jd", null); m.registerMethod("mjd", null); m.registerMethod("utc_rd_as_seconds", null); m.registerMethod("local_rd_as_seconds", null); m.registerMethod("utc_rd_values", null); m.registerMethod("local_rd_values", null); - m.registerMethod("length_of_year", null); m.registerMethod("length_of_month", null); m.registerMethod("length_of_quarter", null); - m.registerMethod("at_utc", null); m.registerMethod("at_midnight", null); m.registerMethod("at_noon", null); m.registerMethod("at_last_day_of_month", null); - m.registerMethod("compare", null); m.registerMethod("is_equal", null); m.registerMethod("is_before", null); m.registerMethod("is_after", null); + m.registerMethod("length_of_year", null); m.registerMethod("length_of_month", null); m.registerMethod("length_of_quarter", null); m.registerMethod("length_of_week_year", null); + m.registerMethod("at_utc", null); m.registerMethod("at_midnight", null); m.registerMethod("at_noon", null); m.registerMethod("at_last_day_of_month", null); m.registerMethod("at_last_day_of_year", null); m.registerMethod("at_last_day_of_quarter", null); + m.registerMethod("compare", null); m.registerMethod("is_equal", null); m.registerMethod("is_before", null); m.registerMethod("is_after", null); m.registerMethod("is_leap_year", null); + m.registerMethod("delta_years", null); m.registerMethod("delta_months", null); m.registerMethod("delta_weeks", null); m.registerMethod("delta_days", null); + m.registerMethod("delta_hours", null); m.registerMethod("delta_minutes", null); m.registerMethod("delta_seconds", null); m.registerMethod("delta_milliseconds", null); m.registerMethod("delta_microseconds", null); m.registerMethod("delta_nanoseconds", null); m.registerMethod("to_string", null); m.registerMethod("strftime", null); } catch (NoSuchMethodException e) { throw new IllegalStateException(e); } } @@ -57,11 +71,15 @@ private static OffsetDateTime value(RuntimeScalar self) { .atOffset(ZoneOffset.ofTotalSeconds(h.get("offset").getInt() * 60)); } private static RuntimeList result(OffsetDateTime time, RuntimeScalar klass) { + return result(time, klass, Integer.MIN_VALUE); + } + private static RuntimeList result(OffsetDateTime time, RuntimeScalar klass, int precision) { if (time.getYear() < 1 || time.getYear() > 9999) throw new IllegalArgumentException("Time::Moment is out of range"); RuntimeHash h = new RuntimeHash(); h.put("epoch", new RuntimeScalar(time.toEpochSecond())); h.put("nanosecond", new RuntimeScalar(time.getNano())); h.put("offset", new RuntimeScalar(time.getOffset().getTotalSeconds() / 60)); + if (precision != Integer.MIN_VALUE) h.put("precision", new RuntimeScalar(precision)); RuntimeScalar ref = h.createReference(); ReferenceOperators.bless(ref, klass); return ref.getList(); @@ -75,6 +93,18 @@ private static int namedInt(RuntimeArray args, String name, int fallback) { for (int i = 1; i + 1 < args.size(); i += 2) if (name.equals(args.get(i).toString())) return args.get(i + 1).getInt(); return fallback; } + private static int namedInt(RuntimeArray args, int first, String name, int fallback) { + for (int i = first; i + 1 < args.size(); i += 2) if (name.equals(args.get(i).toString())) return args.get(i + 1).getInt(); + return fallback; + } + private static double namedDouble(RuntimeArray args, int first, String name, double fallback) { + for (int i = first; i + 1 < args.size(); i += 2) if (name.equals(args.get(i).toString())) return args.get(i + 1).getDouble(); + return fallback; + } + private static boolean hasNamed(RuntimeArray args, int first, String name) { + for (int i = first; i + 1 < args.size(); i += 2) if (name.equals(args.get(i).toString())) return true; + return false; + } public static RuntimeList newInstance(RuntimeArray a, int c) { int year=namedInt(a,"year",1), month=namedInt(a,"month",1), day=namedInt(a,"day",1), hour=namedInt(a,"hour",0), minute=namedInt(a,"minute",0), second=namedInt(a,"second",0), nano=namedInt(a,"nanosecond",0), offset=namedInt(a,"offset",0); @@ -83,31 +113,190 @@ public static RuntimeList newInstance(RuntimeArray a, int c) { public static RuntimeList now(RuntimeArray a, int c) { return result(OffsetDateTime.now(), classOf(a)); } public static RuntimeList now_utc(RuntimeArray a, int c) { return result(OffsetDateTime.now(ZoneOffset.UTC), classOf(a)); } public static RuntimeList from_epoch(RuntimeArray a, int c) { - long sec=a.get(1).getLong(); int nanos=a.size()>2 && !"nanosecond".equals(a.get(2).toString()) ? a.get(2).getInt() : namedInt(a,"nanosecond",0); - return result(Instant.ofEpochSecond(sec,nanos).atOffset(ZoneOffset.UTC), classOf(a)); + double epoch=a.get(1).getDouble(); long sec=(long)Math.floor(epoch); int nanos=(int)Math.round((epoch-sec)*1_000_000_000L); + if(nanos==1_000_000_000) { sec++; nanos=0; } + if(a.size()>2 && !"nanosecond".equals(a.get(2).toString()) && !"precision".equals(a.get(2).toString())) nanos=a.get(2).getInt(); + else nanos=namedInt(a,2,"nanosecond",nanos); + int precision=namedInt(a,2,"precision",epoch==Math.rint(epoch)?9:6); if(precision<9) { int factor=(int)Math.pow(10,9-precision); nanos=((nanos+factor/2)/factor)*factor; if(nanos==1_000_000_000) { sec++; nanos=0; } } + // Time::Moment derives a six-digit display precision for fractional + // epoch input, including trailing zeroes (0.6 becomes ".600000"). + // Keep that metadata rather than trimming it in to_string(). + return result(Instant.ofEpochSecond(sec,nanos).atOffset(ZoneOffset.UTC), classOf(a), precision); + } + private static final DateTimeFormatter BASIC_WEEK_DATE = new DateTimeFormatterBuilder().appendPattern("YYYY'W'wwe").toFormatter(Locale.ROOT).withResolverStyle(ResolverStyle.STRICT); + private static final DateTimeFormatter BASIC_ORDINAL_DATE = new DateTimeFormatterBuilder().appendPattern("uuuuDDD").toFormatter(Locale.ROOT).withResolverStyle(ResolverStyle.STRICT); + private static LocalDate parseDate(String date) { + if (date.matches("\\d{8}")) return LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); + if (date.matches("\\d{4}W\\d{3}")) return LocalDate.parse(date.substring(0,4)+"-W"+date.substring(5,7)+"-"+date.substring(7), DateTimeFormatter.ISO_WEEK_DATE); + if (date.matches("\\d{7}")) return LocalDate.parse(date, BASIC_ORDINAL_DATE); + if (date.matches("\\d{4}-W\\d{2}-\\d")) return LocalDate.parse(date, DateTimeFormatter.ISO_WEEK_DATE); + if (date.matches("\\d{4}-\\d{3}")) return LocalDate.parse(date, DateTimeFormatter.ISO_ORDINAL_DATE); + return LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE); } - public static RuntimeList from_string(RuntimeArray a, int c) { return result(OffsetDateTime.parse(a.get(1).toString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME), classOf(a)); } + private static OffsetDateTime parseMoment(String text, boolean lenient) { + try { + String value=text; + if (lenient) { + value=value.replace('t','T').replace('z','Z').replaceAll("(?i)\\b(?:UTC|GMT)(?=\\s*$)", "Z").replaceAll("(?i)\\b(?:UTC|GMT)(?=[+-])", ""); + value=value.replaceFirst("^(\\d{4}(?:-\\d{2}-\\d{2}|\\d{4}|-W\\d{2}-\\d|-\\d{3}))\\s+", "$1T"); + value=value.replaceAll("\\s+", ""); + value=value.replaceAll("([+-]\\d{2})(\\d{2})$", "$1:$2").replaceAll("([+-])(\\d)$", "$10$2:00").replaceAll("([+-]\\d{2})$", "$1:00"); + } + // Compact ISO-8601 offsets are accepted in strict form too; this + // is needed for compact date/time strings emitted by callers. + value=value.replaceAll("([+-]\\d{2})(\\d{2})$", "$1:$2"); + // Time::Moment's canonical reduced form omits a zero offset-minute + // field (for example, "-14"), and strict parsing accepts it too. + value=value.replaceAll("([+-]\\d{2})$", "$1:00"); + int separator=value.indexOf('T'); if(separator<0) throw new DateTimeException("missing T"); + String date=value.substring(0,separator), clockZone=value.substring(separator+1); + java.util.regex.Matcher zone=java.util.regex.Pattern.compile("(Z|[+-]\\d{2}:\\d{2})$").matcher(clockZone); + if(!zone.find()) throw new DateTimeException("missing offset"); + String clock=clockZone.substring(0,zone.start()).replace(',', '.'); + // Strict compact ISO permits a compact clock with a compact date + // (20121224T1530+0100), but not with an extended date. + if(!lenient && date.contains("-") && clock.matches("\\d{4}")) throw new DateTimeException("basic time requires lenient mode"); + // Strict mode also requires compact dates to use compact clocks + // and offsets. Time::Moment accepts these mixed spellings only + // with lenient => 1. + boolean basicDate=date.matches("\\d{8}"); + boolean extendedSourceOffset=text.substring(separator + 1).matches(".*[+-]\\d{2}:\\d{2}$"); + if(!lenient && basicDate && (clock.contains(":") || extendedSourceOffset)) throw new DateTimeException("mixed ISO styles require lenient mode"); + if(clock.matches("\\d{6}(?:\\.\\d+)?")) clock=clock.substring(0,2)+":"+clock.substring(2,4)+":"+clock.substring(4); + if(clock.matches("\\d{4}")) clock=clock.substring(0,2)+":"+clock.substring(2); + if(clock.matches("\\d{2}")) clock += ":00:00"; + int dot=clock.indexOf('.'); if(dot>=0 && clock.length()-dot-1>9) clock=clock.substring(0,dot+10); + if(clock.length()==5) clock += ":00"; + if(clock.startsWith("24:00")) { if(!clock.matches("24:00(?::00(?:\\.0*)?)?")) throw new DateTimeException("invalid 24 hour"); return parseDate(date).plusDays(1).atStartOfDay().atOffset(ZoneOffset.of(zone.group(1))); } + return parseDate(date).atTime(LocalTime.parse(clock,DateTimeFormatter.ISO_LOCAL_TIME)).atOffset(ZoneOffset.of(zone.group(1))); + } catch (RuntimeException e) { throw new IllegalArgumentException("Could not parse Time::Moment string: " + text, e); } + } + public static RuntimeList from_string(RuntimeArray a, int c) { return result(parseMoment(a.get(1).toString(),namedInt(a,2,"lenient",0)!=0), classOf(a)); } + private static RuntimeList fromRd(RuntimeArray a, double rd) { + double epoch=namedDouble(a,2,"epoch",0); int offset=namedInt(a,2,"offset",0); int precision=namedInt(a,2,"precision",3); + BigDecimal total=BigDecimal.valueOf(rd).add(BigDecimal.valueOf(epoch)); long days=total.setScale(0, RoundingMode.FLOOR).longValueExact(); long nanos=total.subtract(BigDecimal.valueOf(days)).multiply(BigDecimal.valueOf(86_400_000_000_000L)).setScale(0, RoundingMode.DOWN).longValueExact(); + if(nanos==86_400_000_000_000L) { days++; nanos=0; } + if(precision<9) { long factor=(long)Math.pow(10,9-precision); nanos=((nanos+factor/2)/factor)*factor; } + LocalDateTime local=LocalDate.ofEpochDay(days-719163).atStartOfDay().plusNanos(nanos); + return result(local.atOffset(ZoneOffset.ofTotalSeconds(offset*60)),classOf(a),precision==6?-1:precision); + } + public static RuntimeList from_rd(RuntimeArray a,int c){return fromRd(a,a.get(1).getDouble());} + public static RuntimeList from_jd(RuntimeArray a,int c){double input=a.get(1).getDouble(); return fromRd(a,hasNamed(a,2,"epoch")?input:input-1721424.5);} + public static RuntimeList from_mjd(RuntimeArray a,int c){double input=a.get(1).getDouble(); return fromRd(a,hasNamed(a,2,"epoch")?input:input+678576);} public static RuntimeList with_offset_same_instant(RuntimeArray a,int c) { return result(value(a.get(0)).withOffsetSameInstant(ZoneOffset.ofTotalSeconds(a.get(1).getInt()*60)), classOfSelf(a.get(0))); } public static RuntimeList with_offset_same_local(RuntimeArray a,int c) { return result(value(a.get(0)).withOffsetSameLocal(ZoneOffset.ofTotalSeconds(a.get(1).getInt()*60)), classOfSelf(a.get(0))); } private static RuntimeList changed(RuntimeArray a, OffsetDateTime v) { return result(v,classOfSelf(a.get(0))); } public static RuntimeList with_year(RuntimeArray a,int c){return changed(a,value(a.get(0)).withYear(a.get(1).getInt()));} + public static RuntimeList with_quarter(RuntimeArray a,int c){OffsetDateTime x=t(a); return changed(a,x.withMonth((a.get(1).getInt()-1)*3+(x.getMonthValue()-1)%3+1));} public static RuntimeList with_month(RuntimeArray a,int c){return changed(a,value(a.get(0)).withMonth(a.get(1).getInt()));} + public static RuntimeList with_week(RuntimeArray a,int c){return changed(a,t(a).with(IsoFields.WEEK_OF_WEEK_BASED_YEAR,a.get(1).getInt()));} + public static RuntimeList with_day_of_year(RuntimeArray a,int c){return changed(a,t(a).withDayOfYear(a.get(1).getInt()));} + public static RuntimeList with_day_of_quarter(RuntimeArray a,int c){OffsetDateTime x=t(a); LocalDate d=LocalDate.of(x.getYear(),(x.get(IsoFields.QUARTER_OF_YEAR)-1)*3+1,1).plusDays(a.get(1).getInt()-1); return changed(a,d.atTime(x.toLocalTime()).atOffset(x.getOffset()));} public static RuntimeList with_day_of_month(RuntimeArray a,int c){return changed(a,value(a.get(0)).withDayOfMonth(a.get(1).getInt()));} + public static RuntimeList with_day_of_week(RuntimeArray a,int c){OffsetDateTime x=t(a); return changed(a,x.plusDays(a.get(1).getInt()-x.getDayOfWeek().getValue()));} + public static RuntimeList with_rdn(RuntimeArray a,int c){OffsetDateTime x=t(a); LocalDate d=LocalDate.ofEpochDay(a.get(1).getLong()-719163); return changed(a,d.atTime(x.toLocalTime()).atOffset(x.getOffset()));} public static RuntimeList with_hour(RuntimeArray a,int c){return changed(a,value(a.get(0)).withHour(a.get(1).getInt()));} public static RuntimeList with_minute(RuntimeArray a,int c){return changed(a,value(a.get(0)).withMinute(a.get(1).getInt()));} public static RuntimeList with_second(RuntimeArray a,int c){return changed(a,value(a.get(0)).withSecond(a.get(1).getInt()));} + private static RuntimeList withTime(RuntimeArray a, LocalTime time){OffsetDateTime x=t(a); return changed(a,x.toLocalDate().atTime(time).atOffset(x.getOffset()));} + public static RuntimeList with_minute_of_day(RuntimeArray a,int c){OffsetDateTime x=t(a); return withTime(a,LocalTime.ofSecondOfDay(a.get(1).getInt()*60L+x.getSecond()).withNano(x.getNano()));} + public static RuntimeList with_second_of_day(RuntimeArray a,int c){OffsetDateTime x=t(a); return withTime(a,LocalTime.ofSecondOfDay(a.get(1).getInt()).withNano(x.getNano()));} + public static RuntimeList with_millisecond(RuntimeArray a,int c){return changed(a,t(a).withNano(a.get(1).getInt()*1_000_000));} + public static RuntimeList with_microsecond(RuntimeArray a,int c){return changed(a,t(a).withNano(a.get(1).getInt()*1_000));} + private static RuntimeList withNanosOfDay(RuntimeArray a, long nanos) { OffsetDateTime x=t(a); return changed(a,x.toLocalDate().atStartOfDay().plusNanos(nanos).atOffset(x.getOffset())); } + public static RuntimeList with_millisecond_of_day(RuntimeArray a,int c){return withNanosOfDay(a,Math.multiplyExact(a.get(1).getLong(),1_000_000));} + public static RuntimeList with_microsecond_of_day(RuntimeArray a,int c){return withNanosOfDay(a,Math.multiplyExact(a.get(1).getLong(),1_000));} + public static RuntimeList with_nanosecond_of_day(RuntimeArray a,int c){return withNanosOfDay(a,a.get(1).getLong());} public static RuntimeList with_nanosecond(RuntimeArray a,int c){return changed(a,value(a.get(0)).withNano(a.get(1).getInt()));} - public static RuntimeList with_precision(RuntimeArray a,int c){int p=a.get(1).getInt(); int n=value(a.get(0)).getNano(); int factor=(int)Math.pow(10,9-p); return changed(a,value(a.get(0)).withNano(n/factor*factor));} + public static RuntimeList with_precision(RuntimeArray a,int c){int p=a.get(1).getInt(); OffsetDateTime x=value(a.get(0)); if(p>=0) { int factor=(int)Math.pow(10,9-p); x=x.withNano(x.getNano()/factor*factor); } else if(p==-1) x=x.withSecond(0).withNano(0); else if(p==-2) x=x.withMinute(0).withSecond(0).withNano(0); else x=x.withHour(0).withMinute(0).withSecond(0).withNano(0); return result(x,classOfSelf(a.get(0)),p);} + public static RuntimeList precision(RuntimeArray a,int c){RuntimeHash h=a.get(0).hashDeref(); return n(h.containsKey("precision")?h.get("precision").getInt():9);} private static RuntimeList plus(RuntimeArray a, String unit, int sign) { OffsetDateTime t=value(a.get(0)); long n=a.get(1).getLong()*sign; return changed(a,switch(unit){case "years"->t.plusYears(n);case "months"->t.plusMonths(n);case "weeks"->t.plusWeeks(n);case "days"->t.plusDays(n);case "hours"->t.plusHours(n);case "minutes"->t.plusMinutes(n);case "seconds"->t.plusSeconds(n);case "milliseconds"->t.plusNanos(Math.multiplyExact(n,1_000_000));case "microseconds"->t.plusNanos(Math.multiplyExact(n,1_000));default->t.plusNanos(n);}); } public static RuntimeList plus_years(RuntimeArray a,int c){return plus(a,"years",1);} public static RuntimeList plus_months(RuntimeArray a,int c){return plus(a,"months",1);} public static RuntimeList plus_weeks(RuntimeArray a,int c){return plus(a,"weeks",1);} public static RuntimeList plus_days(RuntimeArray a,int c){return plus(a,"days",1);} public static RuntimeList plus_hours(RuntimeArray a,int c){return plus(a,"hours",1);} public static RuntimeList plus_minutes(RuntimeArray a,int c){return plus(a,"minutes",1);} public static RuntimeList plus_seconds(RuntimeArray a,int c){return plus(a,"seconds",1);} public static RuntimeList plus_milliseconds(RuntimeArray a,int c){return plus(a,"milliseconds",1);} public static RuntimeList plus_microseconds(RuntimeArray a,int c){return plus(a,"microseconds",1);} public static RuntimeList plus_nanoseconds(RuntimeArray a,int c){return plus(a,"nanoseconds",1);} public static RuntimeList minus_years(RuntimeArray a,int c){return plus(a,"years",-1);} public static RuntimeList minus_months(RuntimeArray a,int c){return plus(a,"months",-1);} public static RuntimeList minus_weeks(RuntimeArray a,int c){return plus(a,"weeks",-1);} public static RuntimeList minus_days(RuntimeArray a,int c){return plus(a,"days",-1);} public static RuntimeList minus_hours(RuntimeArray a,int c){return plus(a,"hours",-1);} public static RuntimeList minus_minutes(RuntimeArray a,int c){return plus(a,"minutes",-1);} public static RuntimeList minus_seconds(RuntimeArray a,int c){return plus(a,"seconds",-1);} public static RuntimeList minus_milliseconds(RuntimeArray a,int c){return plus(a,"milliseconds",-1);} public static RuntimeList minus_microseconds(RuntimeArray a,int c){return plus(a,"microseconds",-1);} public static RuntimeList minus_nanoseconds(RuntimeArray a,int c){return plus(a,"nanoseconds",-1);} private static RuntimeList n(long v){return new RuntimeScalar(v).getList();} private static OffsetDateTime t(RuntimeArray a){return value(a.get(0));} - public static RuntimeList year(RuntimeArray a,int c){return n(t(a).getYear());} public static RuntimeList quarter(RuntimeArray a,int c){return n(t(a).get(IsoFields.QUARTER_OF_YEAR));} public static RuntimeList month(RuntimeArray a,int c){return n(t(a).getMonthValue());} public static RuntimeList week(RuntimeArray a,int c){return n(t(a).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));} public static RuntimeList day_of_year(RuntimeArray a,int c){return n(t(a).getDayOfYear());} public static RuntimeList day_of_quarter(RuntimeArray a,int c){return n(t(a).get(IsoFields.DAY_OF_QUARTER));} public static RuntimeList day_of_month(RuntimeArray a,int c){return n(t(a).getDayOfMonth());} public static RuntimeList day_of_week(RuntimeArray a,int c){return n(t(a).getDayOfWeek().getValue());} public static RuntimeList hour(RuntimeArray a,int c){return n(t(a).getHour());} public static RuntimeList minute(RuntimeArray a,int c){return n(t(a).getMinute());} public static RuntimeList second(RuntimeArray a,int c){return n(t(a).getSecond());} public static RuntimeList millisecond(RuntimeArray a,int c){return n(t(a).getNano()/1_000_000);} public static RuntimeList microsecond(RuntimeArray a,int c){return n(t(a).getNano()/1_000);} public static RuntimeList nanosecond(RuntimeArray a,int c){return n(t(a).getNano());} public static RuntimeList epoch(RuntimeArray a,int c){return n(t(a).toEpochSecond());} public static RuntimeList offset(RuntimeArray a,int c){return n(t(a).getOffset().getTotalSeconds()/60);} public static RuntimeList rdn(RuntimeArray a,int c){return n(t(a).getLong(JulianFields.RATA_DIE));} + public static RuntimeList year(RuntimeArray a,int c){return n(t(a).getYear());} public static RuntimeList quarter(RuntimeArray a,int c){return n(t(a).get(IsoFields.QUARTER_OF_YEAR));} public static RuntimeList month(RuntimeArray a,int c){return n(t(a).getMonthValue());} public static RuntimeList week(RuntimeArray a,int c){return n(t(a).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));} public static RuntimeList day_of_year(RuntimeArray a,int c){return n(t(a).getDayOfYear());} public static RuntimeList day_of_quarter(RuntimeArray a,int c){return n(t(a).get(IsoFields.DAY_OF_QUARTER));} public static RuntimeList day_of_month(RuntimeArray a,int c){return n(t(a).getDayOfMonth());} public static RuntimeList day_of_week(RuntimeArray a,int c){return n(t(a).getDayOfWeek().getValue());} public static RuntimeList hour(RuntimeArray a,int c){return n(t(a).getHour());} public static RuntimeList minute(RuntimeArray a,int c){return n(t(a).getMinute());} public static RuntimeList second(RuntimeArray a,int c){return n(t(a).getSecond());} public static RuntimeList millisecond(RuntimeArray a,int c){return n(t(a).getNano()/1_000_000);} public static RuntimeList microsecond(RuntimeArray a,int c){return n(t(a).getNano()/1_000);} public static RuntimeList nanosecond(RuntimeArray a,int c){return n(t(a).getNano());} public static RuntimeList epoch(RuntimeArray a,int c){return n(t(a).toEpochSecond());} public static RuntimeList offset(RuntimeArray a,int c){return n(t(a).getOffset().getTotalSeconds()/60);} public static RuntimeList rdn(RuntimeArray a,int c){return n(t(a).getLong(JulianFields.RATA_DIE));} private static double rdValue(OffsetDateTime x){return x.toLocalDate().getLong(JulianFields.RATA_DIE)+(x.toLocalTime().toSecondOfDay()+x.getNano()/1_000_000_000.0)/SECONDS_PER_DAY;} private static OffsetDateTime precisionValue(OffsetDateTime x,int p){if(p>=9)return x; if(p>=0){int factor=(int)Math.pow(10,9-p);return x.withNano(x.getNano()/factor*factor);} if(p==-1)return x.withSecond(0).withNano(0); if(p==-2)return x.withMinute(0).withSecond(0).withNano(0); return x.withHour(0).withMinute(0).withSecond(0).withNano(0);} public static RuntimeList rd(RuntimeArray a,int c){return new RuntimeScalar(rdValue(precisionValue(t(a),namedInt(a,1,"precision",3)))).getList();} public static RuntimeList jd(RuntimeArray a,int c){return new RuntimeScalar(rdValue(precisionValue(t(a),namedInt(a,1,"precision",3)))+1721424.5).getList();} public static RuntimeList mjd(RuntimeArray a,int c){return new RuntimeScalar(rdValue(precisionValue(t(a),namedInt(a,1,"precision",3)))-678576).getList();} + public static RuntimeList minute_of_day(RuntimeArray a,int c){return n(t(a).toLocalTime().toSecondOfDay()/60);} public static RuntimeList second_of_day(RuntimeArray a,int c){return n(t(a).toLocalTime().toSecondOfDay());} public static RuntimeList millisecond_of_day(RuntimeArray a,int c){return n(t(a).toLocalTime().toNanoOfDay()/1_000_000);} public static RuntimeList microsecond_of_day(RuntimeArray a,int c){return n(t(a).toLocalTime().toNanoOfDay()/1_000);} public static RuntimeList nanosecond_of_day(RuntimeArray a,int c){return n(t(a).toLocalTime().toNanoOfDay());} public static RuntimeList utc_rd_as_seconds(RuntimeArray a,int c){return n(t(a).toEpochSecond()+RD_UNIX_EPOCH_SECONDS);} public static RuntimeList local_rd_as_seconds(RuntimeArray a,int c){return n((t(a).toLocalDate().toEpochDay()+719163)*SECONDS_PER_DAY+t(a).toLocalTime().toSecondOfDay());} private static RuntimeList rdValues(OffsetDateTime t, boolean local){ OffsetDateTime x=local?t:t.withOffsetSameInstant(ZoneOffset.UTC); RuntimeList r=new RuntimeList();r.add(new RuntimeScalar(x.toLocalDate().getLong(JulianFields.RATA_DIE)));r.add(new RuntimeScalar(x.toLocalTime().toSecondOfDay()));r.add(new RuntimeScalar(x.getNano()));return r;} public static RuntimeList utc_rd_values(RuntimeArray a,int c){return rdValues(t(a),false);} public static RuntimeList local_rd_values(RuntimeArray a,int c){return rdValues(t(a),true);} - public static RuntimeList length_of_year(RuntimeArray a,int c){return n(t(a).toLocalDate().lengthOfYear());} public static RuntimeList length_of_month(RuntimeArray a,int c){return n(t(a).toLocalDate().lengthOfMonth());} public static RuntimeList length_of_quarter(RuntimeArray a,int c){OffsetDateTime x=t(a);int q=x.get(IsoFields.QUARTER_OF_YEAR),m=(q-1)*3+1;return n(LocalDate.of(x.getYear(),m,1).lengthOfMonth()+LocalDate.of(x.getYear(),m+1,1).lengthOfMonth()+LocalDate.of(x.getYear(),m+2,1).lengthOfMonth());} - public static RuntimeList at_utc(RuntimeArray a,int c){return changed(a,t(a).withOffsetSameInstant(ZoneOffset.UTC));} public static RuntimeList at_midnight(RuntimeArray a,int c){return changed(a,t(a).withHour(0).withMinute(0).withSecond(0).withNano(0));} public static RuntimeList at_noon(RuntimeArray a,int c){return changed(a,t(a).withHour(12).withMinute(0).withSecond(0).withNano(0));} public static RuntimeList at_last_day_of_month(RuntimeArray a,int c){return changed(a,t(a).with(TemporalAdjusters.lastDayOfMonth()));} - private static int cmp(RuntimeArray a){return t(a).toInstant().compareTo(value(a.get(1)).toInstant());} public static RuntimeList compare(RuntimeArray a,int c){return n(cmp(a));} public static RuntimeList is_equal(RuntimeArray a,int c){return n(cmp(a)==0?1:0);} public static RuntimeList is_before(RuntimeArray a,int c){return n(cmp(a)<0?1:0);} public static RuntimeList is_after(RuntimeArray a,int c){return n(cmp(a)>0?1:0);} - public static RuntimeList to_string(RuntimeArray a,int c){return new RuntimeScalar(t(a).toString()).getList();} - public static RuntimeList strftime(RuntimeArray a,int c){ return new RuntimeScalar(POSIX.formatStrftime(a.get(1).toString(),t(a).toZonedDateTime())).getList(); } + public static RuntimeList length_of_year(RuntimeArray a,int c){return n(t(a).toLocalDate().lengthOfYear());} public static RuntimeList length_of_month(RuntimeArray a,int c){return n(t(a).toLocalDate().lengthOfMonth());} public static RuntimeList length_of_quarter(RuntimeArray a,int c){OffsetDateTime x=t(a);int q=x.get(IsoFields.QUARTER_OF_YEAR),m=(q-1)*3+1;return n(LocalDate.of(x.getYear(),m,1).lengthOfMonth()+LocalDate.of(x.getYear(),m+1,1).lengthOfMonth()+LocalDate.of(x.getYear(),m+2,1).lengthOfMonth());} public static RuntimeList length_of_week_year(RuntimeArray a,int c){int y=t(a).get(IsoFields.WEEK_BASED_YEAR);return n(LocalDate.of(y,12,28).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));} + public static RuntimeList at_utc(RuntimeArray a,int c){return changed(a,t(a).withOffsetSameInstant(ZoneOffset.UTC));} public static RuntimeList at_midnight(RuntimeArray a,int c){return changed(a,t(a).withHour(0).withMinute(0).withSecond(0).withNano(0));} public static RuntimeList at_noon(RuntimeArray a,int c){return changed(a,t(a).withHour(12).withMinute(0).withSecond(0).withNano(0));} public static RuntimeList at_last_day_of_month(RuntimeArray a,int c){return changed(a,t(a).with(TemporalAdjusters.lastDayOfMonth()));} public static RuntimeList at_last_day_of_year(RuntimeArray a,int c){return changed(a,t(a).withDayOfYear(t(a).toLocalDate().lengthOfYear()));} public static RuntimeList at_last_day_of_quarter(RuntimeArray a,int c){OffsetDateTime x=t(a); return changed(a,x.withMonth(x.get(IsoFields.QUARTER_OF_YEAR)*3).with(TemporalAdjusters.lastDayOfMonth()));} + private static int cmp(RuntimeArray a){int p=namedInt(a,2,"precision",9);return precisionValue(t(a),p).toInstant().compareTo(precisionValue(value(a.get(1)),p).toInstant());} + private static RuntimeList perlBoolean(boolean value) { return new RuntimeScalar(value ? 1 : "").getList(); } + public static RuntimeList compare(RuntimeArray a,int c){return n(cmp(a));} + public static RuntimeList is_equal(RuntimeArray a,int c){return perlBoolean(cmp(a)==0);} + public static RuntimeList is_before(RuntimeArray a,int c){return perlBoolean(cmp(a)<0);} + public static RuntimeList is_after(RuntimeArray a,int c){return perlBoolean(cmp(a)>0);} + public static RuntimeList is_leap_year(RuntimeArray a,int c){return perlBoolean(t(a).toLocalDate().isLeapYear());} + private static long deltaMonths(OffsetDateTime first, OffsetDateTime second) { + LocalDate start = first.toLocalDate(), end = second.toLocalDate(); + long months = (long) (end.getYear() - start.getYear()) * 12 + end.getMonthValue() - start.getMonthValue(); + if (start.isAfter(end)) return months + (end.getDayOfMonth() > start.getDayOfMonth() ? 1 : 0); + return months - (end.getDayOfMonth() < start.getDayOfMonth() ? 1 : 0); + } + private static long deltaDuration(RuntimeArray a, long divisor) { + Duration duration = Duration.between(t(a).toInstant(), value(a.get(1)).toInstant()); + return duration.getSeconds() / divisor; + } + public static RuntimeList delta_years(RuntimeArray a,int c){return n(deltaMonths(t(a),value(a.get(1)))/12);} + public static RuntimeList delta_months(RuntimeArray a,int c){return n(deltaMonths(t(a),value(a.get(1))));} + public static RuntimeList delta_weeks(RuntimeArray a,int c){return n(java.time.temporal.ChronoUnit.DAYS.between(t(a).toLocalDate(),value(a.get(1)).toLocalDate())/7);} + public static RuntimeList delta_days(RuntimeArray a,int c){return n(java.time.temporal.ChronoUnit.DAYS.between(t(a).toLocalDate(),value(a.get(1)).toLocalDate()));} + public static RuntimeList delta_hours(RuntimeArray a,int c){return n(deltaDuration(a,3600));} + public static RuntimeList delta_minutes(RuntimeArray a,int c){return n(deltaDuration(a,60));} + public static RuntimeList delta_seconds(RuntimeArray a,int c){return n(deltaDuration(a,1));} + public static RuntimeList delta_milliseconds(RuntimeArray a,int c){Duration d=Duration.between(t(a).toInstant(),value(a.get(1)).toInstant()); return n(Math.addExact(Math.multiplyExact(d.getSeconds(),1_000),d.getNano()/1_000_000));} + public static RuntimeList delta_microseconds(RuntimeArray a,int c){Duration d=Duration.between(t(a).toInstant(),value(a.get(1)).toInstant()); return n(Math.addExact(Math.multiplyExact(d.getSeconds(),1_000_000),d.getNano()/1_000));} + public static RuntimeList delta_nanoseconds(RuntimeArray a,int c){Duration d=Duration.between(t(a).toInstant(),value(a.get(1)).toInstant()); return n(Math.addExact(Math.multiplyExact(d.getSeconds(),1_000_000_000L),d.getNano()));} + public static RuntimeList to_string(RuntimeArray a,int c){ + OffsetDateTime value=t(a); boolean reduced=namedInt(a,"reduced",0)!=0; + String base=String.format(Locale.ROOT,"%04d-%02d-%02dT%02d:%02d",value.getYear(),value.getMonthValue(),value.getDayOfMonth(),value.getHour(),value.getMinute()); + if(!reduced || value.getSecond()!=0 || value.getNano()!=0) base+=String.format(Locale.ROOT,":%02d",value.getSecond()); + RuntimeHash storage=a.get(0).hashDeref(); int precision=storage.containsKey("precision") ? storage.get("precision").getInt() : -1; + if(value.getNano()!=0) { + String fraction=String.format(Locale.ROOT,"%09d",value.getNano()); + if(precision>=0) { int digits=precision>=7?9:precision>=4?6:3; fraction=fraction.substring(0,digits); } + // Time::Moment displays fractional seconds in millisecond groups: + // retain at least three digits, while eliding wholly zero trailing + // groups (0.600000 => 0.600, 0.123000000 => 0.123). + while(fraction.length()>3 && fraction.endsWith("000")) fraction=fraction.substring(0,fraction.length()-3); + base+='.'+fraction; + } + int offset=value.getOffset().getTotalSeconds()/60; + if(offset==0) return new RuntimeScalar(base+"Z").getList(); + String suffix=Math.abs(offset)%60==0 && reduced ? String.format(Locale.ROOT,"%c%02d",offset<0?'-':'+',Math.abs(offset)/60) : String.format(Locale.ROOT,"%c%02d:%02d",offset<0?'-':'+',Math.abs(offset)/60,Math.abs(offset)%60); + return new RuntimeScalar(base+suffix).getList(); + } + /** Time::Moment extends POSIX strftime with fractional seconds and padding modifiers. */ + public static RuntimeList strftime(RuntimeArray a,int c){ + OffsetDateTime value=t(a); String format=a.get(1).toString(); StringBuilder out=new StringBuilder(); + for(int i=0;istart) width=Integer.parseInt(format.substring(start,i)); + if(i>=format.length()) break; char code=format.charAt(i); + int nano=value.getNano(); + if(code=='N'||code=='f') { int digits=width<0?6:width; String fraction=String.format("%09d",nano).substring(0, Math.min(digits,9)); if(digits>9) fraction += "0".repeat(digits-9); if(width<0) fraction=fraction.replaceFirst("0{1,3}$", ""); if(code=='f' && nano!=0) out.append('.'); if(code=='N'||nano!=0) out.append(fraction); continue; } + if(code=='z') { int offset=value.getOffset().getTotalSeconds()/60; out.append(colon ? String.format("%c%02d:%02d", offset<0?'-':'+',Math.abs(offset)/60,Math.abs(offset)%60) : String.format("%c%02d%02d", offset<0?'-':'+',Math.abs(offset)/60,Math.abs(offset)%60)); continue; } + if(code=='k'||code=='l') { int n=code=='k'?value.getHour():(value.getHour()%12==0?12:value.getHour()%12); int digits=width<0?2:width; if(modifier=='-') out.append(n); else out.append(String.valueOf(modifier=='0'?'0':' ').repeat(Math.max(0,digits-String.valueOf(n).length()))).append(n); continue; } + if(code=='c') { out.append(String.format(Locale.US,"%s %s %2d %02d:%02d:%02d %04d",value.getDayOfWeek().getDisplayName(TextStyle.SHORT,Locale.US),value.getMonth().getDisplayName(TextStyle.SHORT,Locale.US),value.getDayOfMonth(),value.getHour(),value.getMinute(),value.getSecond(),value.getYear())); continue; } + if(code=='Z' && value.getOffset().getTotalSeconds()==0) { out.append('Z'); continue; } + if(code=='e'||code=='j') { int number=code=='e'?value.getDayOfMonth():value.getDayOfYear(); int digits=width<0?(code=='e'?2:3):width; if(modifier=='-') out.append(number); else { char fill=modifier=='0'||(modifier==0&&code=='j')?'0':' '; out.append(String.valueOf(fill).repeat(Math.max(0,digits-String.valueOf(number).length()))).append(number); } continue; } + String rendered=POSIX.formatStrftime("%"+code,value.toZonedDateTime()); + if(modifier=='_' && "YGy gCmVWUdHMSI".replace(" ","").indexOf(code)>=0) { + int digits=width<0?rendered.length():width; + String unpadded=rendered.replaceFirst("^0+(?!$)", ""); + out.append(" ".repeat(Math.max(0,digits-unpadded.length()))).append(unpadded); continue; + } + if(modifier=='-' && "YGy gCmVWUdHMSI".replace(" ","").indexOf(code)>=0) { + out.append(rendered.replaceFirst("^0+(?!$)", "")); continue; + } + if(width>=0 && rendered.length()from_epoch($seconds, $nos) - ->with_offset_same_instant($offset) }; + return $class->from_epoch($seconds, $nos) + ->with_offset_same_instant($offset); } sub TO_JSON { $_[0]->to_string } @@ -43,6 +62,49 @@ sub THAW { $_[0]->from_string($_[2]) } sub utc_year { $_[0]->with_offset_same_instant(0)->year } +sub with { + my ($self, $adjuster) = @_; + ref($adjuster) eq 'CODE' + or Carp::croak("Parameter: 'adjuster' is not a CODE reference"); + my $result = $adjuster->($self); + eval { $result->isa('Time::Moment') } + or Carp::croak("Expected an instance of Time::Moment from adjuster"); + return $result; +} + +# Keep object coercion in Perl, as upstream does: this lets ecosystem objects +# opt in with __as_Time_Moment while reusing the Java-backed constructors. +sub from_object { + my ($class, $object) = @_; + my $type = ref($object) || $object || 'unknown'; + + if (eval { $object->can('__as_Time_Moment') }) { + $object = $object->__as_Time_Moment; + } + + if (eval { $object->can('time_zone') } + && eval { $object->time_zone->is_floating }) { + Carp::croak("Cannot coerce object of type $type with 'floating' time zone"); + } + + unless (eval { $object->can('epoch') }) { + Carp::croak("Cannot coerce object of type $type"); + } + + my $nanosecond = eval { $object->can('nanosecond') } + ? $object->nanosecond : 0; + my $offset = 0; + if (eval { $object->can('tzoffset') }) { + $offset = $object->tzoffset / 60; # Time::Piece: seconds + } + elsif (eval { $object->can('offset') }) { + $offset = $object->offset / 60; # DateTime: seconds + } + + return $class->from_epoch($object->epoch, nanosecond => $nanosecond) + ->with_offset_same_instant($offset); +} + 1; __END__ diff --git a/src/main/perl/lib/Time/Moment/Adjusters.pm b/src/main/perl/lib/Time/Moment/Adjusters.pm new file mode 100644 index 0000000000..b3c2d7de27 --- /dev/null +++ b/src/main/perl/lib/Time/Moment/Adjusters.pm @@ -0,0 +1,45 @@ +package Time::Moment::Adjusters; +use strict; +use warnings; +use Carp qw[]; + +our $VERSION = '0.46'; +our @EXPORT_OK = qw[NextDayOfWeek NextOrSameDayOfWeek PreviousDayOfWeek PreviousOrSameDayOfWeek NearestDayOfWeek FirstDayOfWeekInMonth LastDayOfWeekInMonth NthDayOfWeekInMonth WesternEasterSunday OrthodoxEasterSunday NearestMinuteInterval]; +our %EXPORT_TAGS = (all => [@EXPORT_OK]); +require Exporter; +*import = \&Exporter::import; + +sub _day { my ($day) = @_; ($day >= 1 && $day <= 7) or Carp::croak(q); $day } +sub NextDayOfWeek { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { $_[0]->plus_days(($d-$_[0]->day_of_week+6)%7+1) } } +sub NextOrSameDayOfWeek { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { $_[0]->plus_days(($d-$_[0]->day_of_week)%7) } } +sub PreviousDayOfWeek { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { $_[0]->minus_days(($_[0]->day_of_week-$d+6)%7+1) } } +sub PreviousOrSameDayOfWeek { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { $_[0]->minus_days(($_[0]->day_of_week-$d)%7) } } +sub NearestDayOfWeek { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { $_[0]->plus_days((($d-$_[0]->day_of_week+3)%7)-3) } } +sub FirstDayOfWeekInMonth { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { my $t=$_[0]->with_day_of_month(1); $t->plus_days(($d-$t->day_of_week)%7) } } +sub LastDayOfWeekInMonth { @_ == 1 or Carp::croak(q); my $d=_day($_[0]); sub { my $t=$_[0]->at_last_day_of_month; $t->minus_days(($t->day_of_week-$d)%7) } } +sub NthDayOfWeekInMonth { + @_ == 2 or Carp::croak(q); + my ($o,$d)=@_; ($o >= -4 && $o <= 4 && $o) or Carp::croak(q); _day($d); + return $o>0 ? sub { my $t=$_[0]->with_day_of_month(1); $t->plus_days(7*($o-1)+($d-$t->day_of_week)%7) } + : sub { my $t=$_[0]->at_last_day_of_month; $t->plus_days(7*($o+1)-($t->day_of_week-$d)%7) }; +} +sub _western_easter { + my ($year)=@_; my $a=$year%19; my $b=int($year/100); my $c=$year%100; my $d=int($b/4); my $e=$b%4; my $f=int(($b+8)/25); my $g=int(($b-$f+1)/3); my $h=(19*$a+$b-$d-$g+15)%30; my $i=int($c/4); my $k=$c%4; my $l=(32+2*$e+2*$i-$h-$k)%7; my $m=int(($a+11*$h+22*$l)/451); return (int(($h+$l-7*$m+114)/31), ($h+$l-7*$m+114)%31+1); +} +sub WesternEasterSunday { @_ == 0 or Carp::croak(q); sub { my ($m,$d)=_western_easter($_[0]->year); $_[0]->with_month($m)->with_day_of_month($d) } } +sub OrthodoxEasterSunday { + @_ == 0 or Carp::croak(q); + return sub { + my ($tm) = @_; + my $year = $tm->year; + # Upstream XS uses the Julian computus then translates the March day + # into the proleptic Gregorian calendar. + my $a = ($year % 19 * 19 + 15) % 30; + my $julian_day = 28 + $a - ((int($year * 5 / 4) + $a) % 7); + my $days_after_march = $julian_day + int($year / 100) + - int($year / 400) - 3; + return $tm->with_month(3)->with_day_of_month(1)->plus_days($days_after_march); + }; +} +sub NearestMinuteInterval { @_ == 1 or Carp::croak(q); my $i=$_[0]; ($i>=1 && $i<=1440) or Carp::croak(q); my $msec=$i*60000; my $mid=int(($msec+1)/2); sub { $_[0]->with_millisecond_of_day($msec*int(($_[0]->millisecond_of_day+$mid)/$msec)) } } +1; diff --git a/src/test/resources/module/Time-Moment/t/010_core.t b/src/test/resources/module/Time-Moment/t/010_core.t index 2bb33fc155..4174a38aab 100644 --- a/src/test/resources/module/Time-Moment/t/010_core.t +++ b/src/test/resources/module/Time-Moment/t/010_core.t @@ -1,24 +1,23 @@ -#!perl use strict; use warnings; use Test::More; +use Storable qw[nfreeze thaw]; use Time::Moment; +use Time::Moment::Adjusters qw[OrthodoxEasterSunday]; -is $Time::Moment::VERSION, '0.46', 'bundled provider reports the upstream version'; - -my $tm = Time::Moment->from_string('2012-12-24T15:30:45.123456789+01:30'); -is $tm->to_string, '2012-12-24T15:30:45.123456789+01:30', - 'round-trips the upstream ISO representation'; -is $tm->epoch, 1356357645, 'retains the represented instant'; -is $tm->offset, 90, 'retains fixed offset minutes'; -is $tm->plus_months(2)->to_string, '2013-02-24T15:30:45.123456789+01:30', - 'uses calendar arithmetic'; -is $tm->with_offset_same_instant(0)->to_string, '2012-12-24T14:00:45.123456789Z', - 'changes offset while preserving instant'; -is $tm->utc_rd_as_seconds, 63492040845, 'returns upstream Rata Die seconds'; -is $tm->strftime('%Y-%m-%d %H:%M:%S %z'), '2012-12-24 15:30:45 +0130', - 'uses the shared Java formatter for POSIX directives'; -ok $tm->is_after(Time::Moment->from_epoch(0)), 'compares instants'; +my $moment = Time::Moment->from_string('2012-12-24T15:30:45.123456789+01:30'); +is $moment->to_string, '2012-12-24T15:30:45.123456789+01:30', + 'round-trips a fixed-offset nanosecond instant'; +is $moment->strftime('%Y-%m-%d %H:%M:%S %z'), '2012-12-24 15:30:45 +0130', + 'uses the shared POSIX formatter'; +is $moment->with_rdn(719163)->to_string, '1970-01-01T15:30:45.123456789+01:30', + 'replaces the local Rata Die day'; +is thaw(nfreeze($moment))->to_string, $moment->to_string, + 'Storable restores a Java-backed moment'; +is Time::Moment->from_string('2024-01-01T09:00+02:00') + ->with(OrthodoxEasterSunday)->to_string, + '2024-05-05T09:00:00+02:00', + 'calculates Orthodox Easter with the upstream Julian computus'; done_testing; diff --git a/src/test/resources/unit/time_moment_java_xs.t b/src/test/resources/unit/time_moment_java_xs.t index 77b2678197..5010ef6243 100644 --- a/src/test/resources/unit/time_moment_java_xs.t +++ b/src/test/resources/unit/time_moment_java_xs.t @@ -12,5 +12,14 @@ is $tm->plus_months(2)->to_string, '2013-02-24T15:30:45.123456789+01:30', 'uses is $tm->with_offset_same_instant(0)->to_string, '2012-12-24T14:00:45.123456789Z', 'changes display offset without changing instant'; is $tm->strftime('%Y-%m-%d %H:%M:%S %z'), '2012-12-24 15:30:45 +0130', 'formats through the shared Java formatter'; ok $tm->is_after(Time::Moment->from_epoch(0)), 'compares instants'; +is $tm->with_week(1)->week, 1, 'changes ISO week while preserving its weekday'; +is $tm->with_day_of_week(1)->day_of_week, 1, 'changes ISO weekday'; +is $tm->with_rdn(719163)->to_string, '1970-01-01T15:30:45.123456789+01:30', 'changes Rata Die day locally'; +ok $tm->is_leap_year, 'reports Gregorian leap years'; +is $tm->length_of_week_year, 52, 'reports ISO weeks in the week-based year'; + +use Time::Moment::Adjusters qw[OrthodoxEasterSunday]; +is Time::Moment->from_string('2024-01-01T09:00+02:00')->with(OrthodoxEasterSunday)->to_string, + '2024-05-05T09:00:00+02:00', 'calculates Orthodox Easter using the Julian computus'; done_testing; diff --git a/src/test/resources/unit/time_moment_storable.t b/src/test/resources/unit/time_moment_storable.t new file mode 100644 index 0000000000..adc202cd28 --- /dev/null +++ b/src/test/resources/unit/time_moment_storable.t @@ -0,0 +1,15 @@ +use strict; +use warnings; +use Test::More; +use Storable qw[nfreeze thaw]; + +use Time::Moment; + +my $original = Time::Moment->from_string('2012-12-24T15:30:45.123456789-01:00'); +my $restored = thaw(nfreeze($original)); + +isa_ok $restored, 'Time::Moment', 'Storable thaw restores the Java-backed object'; +is $restored->to_string, $original->to_string, + 'Storable thaw preserves instant, precision, and fixed offset'; + +done_testing;