Module: Tuile::Locale::TimeFormats

Defined in:
lib/tuile/locale.rb,
sig/tuile.rbs

Overview

DateFormats' sibling for times of day, over the same Formats lexer. Same two rules — what may be in a list (TimeFormats.validate), what one looks like to a human (TimeFormats.humanize) — plus the two operations a time format needs and a date one does not:

TimeFormats.expand("%r")               # => "%I:%M:%S %p"  libc's shorthand
TimeFormats.strip_seconds("%H.%M.%S")  # => "%H.%M"        spelling kept, precision dropped

The tables are kept separate from DateFormats' on purpose: %mmm (month) and %Mmm (minute) are each right in their own table, and merging them is a question only a date-and-time field would have to ask.

Constant Summary collapse

REF =

The time every format is round-tripped against, on Component::TimeField::MIDNIGHT's date. Every property is load-bearing: hour ≥ 13 so a 12-hour directive with no %p fails ("%I:%M" writes "01:45" and reads back 1 o'clock), minute ≠ hour so an %H/%M swap is not masked, and second = 0 so a minute-precision primary is legal — which the shipped default is.

What it deliberately does not catch is a precision truncation: "%H:%M" round-trips itself perfectly, and how precise a field is, is Component::TimeField#step's business.

Returns:

  • (Time)
Time.utc(2000, 1, 1, 13, 45, 0)
HINTS =

The directives humanize can turn into a placeholder. %p earns a place where DateFormats' %b did not: mmm would be an invented token, while AM is literally what the field prints, and a placeholder is a typing sample.

Returns:

  • (Hash{String => String})
{
  "%H" => "hh", "%I" => "hh", "%k" => "hh", "%l" => "hh",
  "%M" => "mm", "%S" => "ss", "%p" => "AM", "%P" => "am", "%%" => "%"
}.freeze
EXPANSIONS =

libc's compound time formats, expanded at the detection boundary so every later consumer sees one vocabulary. A representation change and nothing more — which is why it belongs here and the seconds strip does not (see strip_seconds).

Returns:

  • (Hash{String => String})
{ "%T" => "%H:%M:%S", "%R" => "%H:%M", "%r" => "%I:%M:%S %p" }.freeze
ZONE_DIRECTIVES =

Rejected by name because they round-trip cleanly and lose information anyway: "%H:%M:%S%z" writes +0000 and reads it back, while Date._strptime("13:45:00+0200", "%H:%M:%S%z") hands back an offset a field with no zone drops on the floor — so a user typing one would see it silently reinterpreted as a local wall time.

Returns:

  • (Array<String>)
["%z", "%Z", "%:z", "%::z", "%s"].freeze
SUBSECOND_DIRECTIVES =

Rejected by name for the same reason: "%H:%M:%S.%L" round-trips (the reference's fraction is 0) while "13:45:00.500" parses to a sec_fraction a time of day cannot hold.

Returns:

  • (Array<String>)
["%L", "%N"].freeze

Class Method Summary collapse

Class Method Details

.expand(format) ⇒ String

Rewrites libc's compound directives (EXPANSIONS) as their components, leaving everything else alone.

TimeFormats.expand("%T")   # => "%H:%M:%S"

@param format

@return — frozen.

Parameters:

  • format (String)

Returns:

  • (String)


372
373
374
375
376
# File 'lib/tuile/locale.rb', line 372

def expand(format)
  expanded = +""
  Formats.each_directive(format) { expanded << (EXPANSIONS[_1] || _1) }
  expanded.freeze
end

.humanize(format) ⇒ String?

@param format

@return — frozen; nil when format holds a directive HINTS does not cover.

Parameters:

  • format (String)

Returns:

  • (String, nil)


363
# File 'lib/tuile/locale.rb', line 363

def humanize(format) = Formats.humanize(format, HINTS)

.in_range?(hour, min, sec) ⇒ Boolean

The one range gate, shared by parse and Component::TimeField.time_of_day so a parsed time and a constructed one cannot disagree on what is legal. 24:00 is rejected: a legal ISO 8601 end-of-day that Time cannot hold.

@param hour

@param min

@param sec

Parameters:

  • hour (Integer)
  • min (Integer)
  • sec (Integer)

Returns:

  • (Boolean)


441
# File 'lib/tuile/locale.rb', line 441

def in_range?(hour, min, sec) = (0..23).cover?(hour) && (0..59).cover?(min) && (0..59).cover?(sec)

.malformedString

Returns:

  • (String)


507
508
509
510
# File 'lib/tuile/locale.rb', line 507

def malformed
  "it is incomplete, is write-only (strptime takes no `-` flag), " \
    "or does not write a whole hour and minute"
end

.parse(text, format, epoch) ⇒ Time?

Parses text as a time of day, on epoch's date in UTC.

Three gates, because Time normalizes where Date raised: Date._strptime("24:00", "%H:%M") yields hour: 24 and Time.utc(…, 24, 0, 0) is silently the next day, while "13:45:60" rolls over to 13:46:00 — both wrong values that save cleanly, and the rollover lands on a different date from every other value the field produces, so comparison and sorting quietly break.

@param text

@param format

@param epoch — whose date the result sits on.

@returnnil unless format consumes text whole and the fields it yields are a real time of day.

Parameters:

  • text (String)
  • format (String)
  • epoch (Time)

Returns:

  • (Time, nil)


420
421
422
423
424
425
426
427
428
429
430
# File 'lib/tuile/locale.rb', line 420

def parse(text, format, epoch)
  parsed = Date._strptime(text, format)
  return nil if parsed.nil? || !parsed[:leftover].to_s.empty?

  hour, min, sec = parsed.values_at(:hour, :min, :sec).map { _1 || 0 }
  return nil unless in_range?(hour, min, sec)

  Time.utc(epoch.year, epoch.month, epoch.day, hour, min, sec)
rescue ArgumentError
  nil
end

.parses?(format) ⇒ Boolean

@param format

@return — true iff strptime consumes its own strftime output whole. Weaker than round_trips? on purpose: a later entry only ever parses.

Parameters:

  • format (String)

Returns:

  • (Boolean)


483
484
485
486
487
488
# File 'lib/tuile/locale.rb', line 483

def parses?(format)
  parsed = Date._strptime(REF.strftime(format), format)
  !parsed.nil? && parsed[:leftover].to_s.empty?
rescue ArgumentError
  false
end

.reject_by_name(format) ⇒ void

This method returns an undefined value.

@param format

Parameters:

  • format (String)


462
463
464
465
466
467
468
469
470
471
472
# File 'lib/tuile/locale.rb', line 462

def reject_by_name(format)
  lookalike = Formats.lookalike(format)
  raise ArgumentError, "#{lookalike} is not locale-aware in Ruby (it is a fixed American format)" if lookalike

  zone = ZONE_DIRECTIVES.find { format.include?(_1) }
  raise ArgumentError, "#{zone} carries a time zone, which this field has none of — the offset would be dropped" \
    if zone

  fraction = SUBSECOND_DIRECTIVES.find { format.include?(_1) }
  raise ArgumentError, "#{fraction} carries a fraction of a second, which a time of day does not hold" if fraction
end

.rejection(format, primary: true) ⇒ String

@param format

@param primary

@return — why the check failed, in the terms most likely to be the caller's actual mistake.

Parameters:

  • format (String)
  • primary: (Boolean) (defaults to: true)

Returns:

  • (String)


494
495
496
497
498
499
500
501
502
503
504
# File 'lib/tuile/locale.rb', line 494

def rejection(format, primary: true)
  return "#{format.inspect} is not a usable strptime pattern: #{malformed}" unless primary

  reason =
    if format.match?(/%[Il]/) && !format.match?(/%[pP]/)
      "a 12-hour hour reads back as the morning without a %p or %P beside it"
    else
      malformed
    end
  "#{format.inspect} does not survive a strftime/strptime round-trip: #{reason}"
end

.round_trips?(format) ⇒ Boolean

@param format

@return — true iff formatting REF and parsing the result back yields REF again.

Parameters:

  • format (String)

Returns:

  • (Boolean)


477
# File 'lib/tuile/locale.rb', line 477

def round_trips?(format) = parse(REF.strftime(format), format, REF) == REF

.seconds?(format) ⇒ Boolean

Whether format writes at least one whole second.

@param format

Parameters:

  • format (String)

Returns:

  • (Boolean)


404
# File 'lib/tuile/locale.rb', line 404

def seconds?(format) = format.include?("%S")

.strip_seconds(format) ⇒ String

Drops %S and the literal run immediately before it, so a format keeps its spelling and loses only its precision.

TimeFormats.strip_seconds("%I:%M:%S %p")   # => "%I:%M %p"
TimeFormats.strip_seconds("%H%M%S")        # => "%H%M"
TimeFormats.strip_seconds("%H:%M")         # => "%H:%M" — nothing to drop

Policy, not normalization, which is why Tuile::Locale carries the full-precision spelling and the field applies this: a clock display legitimately wants the seconds t_fmt gave it (D_time_field).

@param format

@return — frozen.

Parameters:

  • format (String)

Returns:

  • (String)


390
391
392
393
394
395
396
397
398
399
# File 'lib/tuile/locale.rb', line 390

def strip_seconds(format)
  tokens = []
  Formats.each_directive(format) { tokens << _1 }
  kept = tokens.each_with_object([]) do |token, out|
    next out << token unless token == "%S"

    out.pop while out.last&.length == 1 # the separator run in front of it
  end
  kept.join.freeze
end

.validate(list) ⇒ ::Array[String]

Normalizes one format or a list of them into a frozen Array of frozen Strings, validating each — the primary by round-trip, the rest by whether strptime can use them at all (DateFormats.validate has the argument; it is the same split).

@param list

@return — frozen, as are its elements.

Parameters:

  • list (String, ::Array[String])

Returns:

  • (::Array[String])


352
353
354
355
356
357
358
# File 'lib/tuile/locale.rb', line 352

def validate(list)
  formats = list.instance_of?(String) ? [list] : list
  raise TypeError, "expected a String or an Array of Strings, got #{list.inspect}" unless formats.is_a?(Array)
  raise ArgumentError, "expected at least one format" if formats.empty?

  formats.each_with_index.map { |format, index| validate_one(format, primary: index.zero?) }.freeze
end

.validate_one(format, primary: true) ⇒ String

@param format

@param primary

@return — a frozen copy.

Parameters:

  • format (String)
  • primary: (Boolean) (defaults to: true)

Returns:

  • (String)


448
449
450
451
452
453
454
455
456
# File 'lib/tuile/locale.rb', line 448

def validate_one(format, primary: true)
  raise TypeError, "expected a String format, got #{format.inspect}" unless format.instance_of?(String)

  reject_by_name(format)
  usable = primary ? round_trips?(format) : parses?(format)
  raise ArgumentError, rejection(format, primary: primary) unless usable

  format.dup.freeze
end