Add mostly standard compliant os.time/os.date

- os.time, when given a table, will act the same as PUC Lua - returning
   the seconds since the epoch. We preserve the previous string/nil
   behaviour though - os.epoch("local") is equivalent to PUC's
   os.time().

 - os.date will now act accept a string and (optional) time, returning
   an appropriate table.

Somewhat resolves the madness which was dan200/ComputerCraft#183, and
hopefully (though probably not) makes @Vexatos happy.
This commit is contained in:
SquidDev 2019-05-24 22:33:56 +01:00
parent d661cfa88b
commit 210f3fa9e2
3 changed files with 444 additions and 6 deletions

View File

@ -0,0 +1,281 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2019. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.LuaException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.TextStyle;
import java.time.temporal.*;
import java.util.HashMap;
import java.util.Map;
import java.util.function.LongUnaryOperator;
final class LuaDateTime
{
private LuaDateTime()
{
}
static void format( DateTimeFormatterBuilder formatter, String format, ZoneOffset offset ) throws LuaException
{
for( int i = 0; i < format.length(); )
{
char c;
switch( c = format.charAt( i++ ) )
{
case '\n':
formatter.appendLiteral( '\n' );
break;
default:
formatter.appendLiteral( c );
break;
case '%':
if( i >= format.length() ) break;
switch( c = format.charAt( i++ ) )
{
default:
throw new LuaException( "bad argument #1: invalid conversion specifier '%" + (char) c + "'" );
case '%':
formatter.appendLiteral( '%' );
break;
case 'a':
formatter.appendText( ChronoField.DAY_OF_WEEK, TextStyle.SHORT );
break;
case 'A':
formatter.appendText( ChronoField.DAY_OF_WEEK, TextStyle.FULL );
break;
case 'b':
case 'h':
formatter.appendText( ChronoField.MONTH_OF_YEAR, TextStyle.SHORT );
break;
case 'B':
formatter.appendText( ChronoField.MONTH_OF_YEAR, TextStyle.FULL );
break;
case 'c':
format( formatter, "%a %b %e %H:%M:%S %Y", offset );
break;
case 'C':
formatter.appendValueReduced( CENTURY, 2, 2, 0 );
break;
case 'd':
formatter.appendValue( ChronoField.DAY_OF_MONTH, 2 );
break;
case 'D':
case 'x':
format( formatter, "%m/%d/%y", offset );
break;
case 'e':
formatter.padNext( 2 ).appendValue( ChronoField.DAY_OF_MONTH );
break;
case 'F':
format( formatter, "%Y-%m-%d", offset );
break;
case 'g':
formatter.appendValueReduced( IsoFields.WEEK_BASED_YEAR, 2, 2, 0 );
break;
case 'G':
formatter.appendValue( IsoFields.WEEK_BASED_YEAR );
break;
case 'H':
formatter.appendValue( ChronoField.HOUR_OF_DAY, 2 );
break;
case 'I':
formatter.appendValue( ChronoField.HOUR_OF_AMPM );
break;
case 'j':
formatter.appendValue( ChronoField.DAY_OF_YEAR, 3 );
break;
case 'm':
formatter.appendValue( ChronoField.MONTH_OF_YEAR, 2 );
break;
case 'M':
formatter.appendValue( ChronoField.MINUTE_OF_HOUR, 2 );
break;
case 'n':
formatter.appendLiteral( '\n' );
break;
case 'p':
formatter.appendText( ChronoField.AMPM_OF_DAY );
break;
case 'r':
format( formatter, "%I:%M:%S %p", offset );
break;
case 'R':
format( formatter, "%H:%M", offset );
break;
case 'S':
formatter.appendValue( ChronoField.SECOND_OF_MINUTE, 2 );
break;
case 't':
formatter.appendLiteral( '\t' );
break;
case 'T':
case 'X':
format( formatter, "%H:%M:%S", offset );
break;
case 'u':
formatter.appendValue( ChronoField.DAY_OF_WEEK );
break;
case 'U':
formatter.appendValue( ChronoField.ALIGNED_WEEK_OF_YEAR, 2 );
break;
case 'V':
formatter.appendValue( IsoFields.WEEK_OF_WEEK_BASED_YEAR, 2 );
break;
case 'w':
formatter.appendValue( ZERO_WEEK );
break;
case 'W':
formatter.appendValue( WeekFields.ISO.weekOfYear(), 2 );
break;
case 'y':
formatter.appendValueReduced( ChronoField.YEAR, 2, 2, 0 );
break;
case 'Y':
formatter.appendValue( ChronoField.YEAR );
break;
case 'z':
formatter.appendOffset( "+HHMM", "+0000" );
break;
case 'Z':
formatter.appendChronologyId();
break;
}
}
}
}
static long fromTable( Map<?, ?> table ) throws LuaException
{
int year = getField( table, "year", -1 );
int month = getField( table, "month", -1 );
int day = getField( table, "day", -1 );
int hour = getField( table, "hour", 12 );
int minute = getField( table, "min", 12 );
int second = getField( table, "sec", 12 );
LocalDateTime time = LocalDateTime.of( year, month, day, hour, minute, second );
Boolean isDst = getBoolField( table, "isdst" );
if( isDst != null )
{
boolean requireDst = isDst;
for( ZoneOffset possibleOffset : ZoneOffset.systemDefault().getRules().getValidOffsets( time ) )
{
Instant instant = time.toInstant( possibleOffset );
if( possibleOffset.getRules().getDaylightSavings( instant ).isZero() == requireDst )
{
return instant.getEpochSecond();
}
}
}
ZoneOffset offset = ZoneOffset.systemDefault().getRules().getOffset( time );
return time.toInstant( offset ).getEpochSecond();
}
static Map<String, ?> toTable( TemporalAccessor date, ZoneId offset, Instant instant )
{
HashMap<String, Object> table = new HashMap<>( 9 );
table.put( "year", date.getLong( ChronoField.YEAR ) );
table.put( "month", date.getLong( ChronoField.MONTH_OF_YEAR ) );
table.put( "day", date.getLong( ChronoField.DAY_OF_MONTH ) );
table.put( "hour", date.getLong( ChronoField.HOUR_OF_DAY ) );
table.put( "min", date.getLong( ChronoField.MINUTE_OF_HOUR ) );
table.put( "sec", date.getLong( ChronoField.SECOND_OF_MINUTE ) );
table.put( "wday", date.getLong( WeekFields.SUNDAY_START.dayOfWeek() ) );
table.put( "yday", date.getLong( ChronoField.DAY_OF_YEAR ) );
table.put( "isdst", offset.getRules().isDaylightSavings( instant ) );
return table;
}
private static int getField( Map<?, ?> table, String field, int def ) throws LuaException
{
Object value = table.get( field );
if( value instanceof Number ) return ((Number) value).intValue();
if( def < 0 ) throw new LuaException( "field \"" + field + "\" missing in date table" );
return def;
}
private static Boolean getBoolField( Map<?, ?> table, String field ) throws LuaException
{
Object value = table.get( field );
if( value instanceof Boolean || value == null ) return (Boolean) value;
throw new LuaException( "field \"" + field + "\" missing in date table" );
}
private static final TemporalField CENTURY = map( ChronoField.YEAR, ValueRange.of( 0, 6 ), x -> (x / 100) % 100 );
private static final TemporalField ZERO_WEEK = map( WeekFields.SUNDAY_START.dayOfWeek(), ValueRange.of( 0, 6 ), x -> x - 1 );
private static TemporalField map( TemporalField field, ValueRange range, LongUnaryOperator convert )
{
return new TemporalField()
{
private final ValueRange range = ValueRange.of( 0, 99 );
@Override
public TemporalUnit getBaseUnit()
{
return field.getBaseUnit();
}
@Override
public TemporalUnit getRangeUnit()
{
return field.getRangeUnit();
}
@Override
public ValueRange range()
{
return range;
}
@Override
public boolean isDateBased()
{
return field.isDateBased();
}
@Override
public boolean isTimeBased()
{
return field.isTimeBased();
}
@Override
public boolean isSupportedBy( TemporalAccessor temporal )
{
return field.isSupportedBy( temporal );
}
@Override
public ValueRange rangeRefinedBy( TemporalAccessor temporal )
{
return range;
}
@Override
public long getFrom( TemporalAccessor temporal )
{
return convert.applyAsLong( temporal.getLong( field ) );
}
@Override
@SuppressWarnings( "unchecked" )
public <R extends Temporal> R adjustInto( R temporal, long newValue )
{
return (R) temporal.with( field, newValue );
}
};
}
}

View File

@ -12,6 +12,11 @@
import dan200.computercraft.shared.util.StringUtil;
import javax.annotation.Nonnull;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatterBuilder;
import java.util.*;
import static dan200.computercraft.core.apis.ArgumentHelper.*;
@ -184,11 +189,12 @@ public String[] getMethodNames()
"day",
"cancelTimer",
"cancelAlarm",
"epoch"
"epoch",
"date",
};
}
private float getTimeForCalendar( Calendar c )
private static float getTimeForCalendar( Calendar c )
{
float time = c.get( Calendar.HOUR_OF_DAY );
time += c.get( Calendar.MINUTE ) / 60.0f;
@ -196,7 +202,7 @@ private float getTimeForCalendar( Calendar c )
return time;
}
private int getDayForCalendar( Calendar c )
private static int getDayForCalendar( Calendar c )
{
GregorianCalendar g = c instanceof GregorianCalendar ? (GregorianCalendar) c : new GregorianCalendar();
int year = c.get( Calendar.YEAR );
@ -209,7 +215,7 @@ private int getDayForCalendar( Calendar c )
return day;
}
private long getEpochForCalendar( Calendar c )
private static long getEpochForCalendar( Calendar c )
{
return c.getTime().getTime();
}
@ -282,6 +288,9 @@ public Object[] callMethod( @Nonnull ILuaContext context, int method, @Nonnull O
case 11:
{
// time
Object value = args.length > 0 ? args[0] : null;
if( value instanceof Map ) return new Object[] { LuaDateTime.fromTable( (Map<?, ?>) value ) };
String param = optString( args, 0, "ingame" );
switch( param.toLowerCase( Locale.ROOT ) )
{
@ -355,9 +364,8 @@ public Object[] callMethod( @Nonnull ILuaContext context, int method, @Nonnull O
}
return null;
}
case 15:
case 15: // epoch
{
// epoch
String param = optString( args, 0, "ingame" );
switch( param.toLowerCase( Locale.ROOT ) )
{
@ -385,6 +393,34 @@ public Object[] callMethod( @Nonnull ILuaContext context, int method, @Nonnull O
throw new LuaException( "Unsupported operation" );
}
}
case 16: // date
{
String format = optString( args, 0, "%c" );
long time = optLong( args, 1, Instant.now().getEpochSecond() );
Instant instant = Instant.ofEpochSecond( time );
ZonedDateTime date;
ZoneOffset offset;
boolean isDst;
if( format.startsWith( "!" ) )
{
offset = ZoneOffset.UTC;
date = ZonedDateTime.ofInstant( instant, offset );
format = format.substring( 1 );
}
else
{
ZoneId id = ZoneId.systemDefault();
offset = id.getRules().getOffset( instant );
date = ZonedDateTime.ofInstant( instant, id );
}
if( format.equals( "*t" ) ) return new Object[] { LuaDateTime.toTable( date, offset, instant ) };
DateTimeFormatterBuilder formatter = new DateTimeFormatterBuilder();
LuaDateTime.format( formatter, format, offset );
return new Object[] { formatter.toFormatter( Locale.ROOT ).format( date ) };
}
default:
return null;
}

View File

@ -0,0 +1,121 @@
describe("The os api", function()
describe("os.date and os.time", function()
it("round trips correctly", function()
local t = math.floor(os.epoch("local") / 1000)
local T = os.date("*t", t)
expect(os.time(T)):eq(t)
end)
it("dst field is guessed", function()
local T = os.date("*t")
local t = os.time(T)
expect(T.isdst):type("boolean")
T.isdst = nil
expect(os.time(T)):eq(t) -- if isdst is absent uses correct default
end)
it("has 365 days in a year", function()
local T = os.date("*t")
local t = os.time(T)
T.year = T.year - 1
local t1 = os.time(T)
local delta = (t - t1) / (24 * 3600) - 365
-- allow for leap years
assert(math.abs(delta) < 2, ("expected abs(%d )< 2"):format(delta))
end)
it("os.date uses local timezone", function()
local epoch = os.epoch("local") / 1000
local date = os.time(os.date("*t"))
assert(date - epoch <= 2, ("expected %d - %d <= 2, but not the case"):format(date, epoch))
end)
end)
describe("os.date", function()
it("formats as expected", function()
-- From the PUC Lua tests, hence the weird style
local t = os.epoch("local")
local T = os.date("*t", t)
_G.T = T
loadstring(os.date([[assert(T.year==%Y and T.month==%m and T.day==%d and
T.hour==%H and T.min==%M and T.sec==%S and
T.wday==%w+1 and T.yday==%j and type(T.isdst) == 'boolean')]], t))()
T = os.date("!*t", t)
_G.T = T
loadstring(os.date([[!assert(T.year==%Y and T.month==%m and T.day==%d and
T.hour==%H and T.min==%M and T.sec==%S and
T.wday==%w+1 and T.yday==%j and type(T.isdst) == 'boolean')]], t))()
end)
describe("produces output consistent with PUC Lua", function()
-- Create a separate test for each code, just so it's easier to see what's broken
local t1 = os.time { year = 2000, month = 10, day = 1, hour = 23, min = 12, sec = 17 }
local function exp_code(code, value)
it(("for code '%s'"):format(code), function()
expect(os.date(code, t1)):eq(value)
end)
end
exp_code("%a", "Sun")
exp_code("%A", "Sunday")
exp_code("%b", "Oct")
exp_code("%B", "October")
exp_code("%c", "Sun Oct 1 23:12:17 2000")
exp_code("%C", "20")
exp_code("%d", "01")
exp_code("%D", "10/01/00")
exp_code("%e", " 1")
exp_code("%F", "2000-10-01")
exp_code("%g", "00")
exp_code("%G", "2000")
exp_code("%h", "Oct")
exp_code("%H", "23")
exp_code("%I", "11")
exp_code("%j", "275")
exp_code("%m", "10")
exp_code("%M", "12")
exp_code("%n", "\n")
exp_code("%p", "PM")
exp_code("%r", "11:12:17 PM")
exp_code("%R", "23:12")
exp_code("%S", "17")
exp_code("%t", "\t")
exp_code("%T", "23:12:17")
exp_code("%u", "7")
exp_code("%U", "40")
exp_code("%V", "39")
exp_code("%w", "0")
exp_code("%W", "39")
exp_code("%x", "10/01/00")
exp_code("%X", "23:12:17")
exp_code("%y", "00")
exp_code("%Y", "2000")
exp_code("%%", "%")
it("zones are numbers", function()
local zone = os.date("%z", t1)
if not zone:match("^[+-]%d%d%d%d$") then
error("Invalid zone: " .. zone)
end
end)
it("zones id is made of letters", function()
local zone = os.date("%Z", t1)
if not zone:match("^%a%a+$") then
error("Non letter character in zone: " .. zone)
end
end)
end)
end)
describe("os.time", function()
it("maps directly to seconds", function()
local t1 = os.time { year = 2000, month = 10, day = 1, hour = 23, min = 12, sec = 17 }
local t2 = os.time { year = 2000, month = 10, day = 1, hour = 23, min = 10, sec = 19 }
expect(t1 - t2):eq(60 * 2 - 2)
end)
end)
end)