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.("%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: %m → mm
(month) and %M → mm (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
%pfails ("%I:%M"writes"01:45"and reads back 1 o'clock), minute ≠ hour so an%H/%Mswap 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. Time.utc(2000, 1, 1, 13, 45, 0)
- HINTS =
The directives humanize can turn into a placeholder.
%pearns a place where DateFormats'%bdid not:mmmwould be an invented token, whileAMis literally what the field prints, and a placeholder is a typing sample. { "%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).
{ "%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+0000and reads it back, whileDate._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. ["%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 asec_fractiona time of day cannot hold. ["%L", "%N"].freeze
Class Method Summary collapse
-
.expand(format) ⇒ String
Rewrites libc's compound directives (EXPANSIONS) as their components, leaving everything else alone.
-
.humanize(format) ⇒ String?
@param
format. -
.in_range?(hour, min, sec) ⇒ Boolean
The one range gate, shared by TimeFormats.parse and Component::TimeField.time_of_day so a parsed time and a constructed one cannot disagree on what is legal.
- .malformed ⇒ String
-
.parse(text, format, epoch) ⇒ Time?
Parses
textas a time of day, onepoch's date in UTC. -
.parses?(format) ⇒ Boolean
@param
format. -
.reject_by_name(format) ⇒ void
@param
format. -
.rejection(format, primary: true) ⇒ String
@param
format. -
.round_trips?(format) ⇒ Boolean
@param
format. -
.seconds?(format) ⇒ Boolean
Whether
formatwrites at least one whole second. -
.strip_seconds(format) ⇒ String
Drops
%Sand the literal run immediately before it, so a format keeps its spelling and loses only its precision. -
.validate(list) ⇒ ::Array[String]
Normalizes one format or a list of them into a frozen
Arrayof frozenStrings, validating each — the primary by round-trip, the rest by whetherstrptimecan use them at all (DateFormats.validate has the argument; it is the same split). -
.validate_one(format, primary: true) ⇒ String
@param
format.
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.
372 373 374 375 376 |
# File 'lib/tuile/locale.rb', line 372 def (format) = +"" Formats.each_directive(format) { << (EXPANSIONS[_1] || _1) } .freeze end |
.humanize(format) ⇒ String?
@param format
@return — frozen; nil when format holds a directive
HINTS does not cover.
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
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) |
.malformed ⇒ 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.
@return — nil unless format consumes text whole and
the fields it yields are a real time of day.
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.
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
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.
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
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
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.
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.
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.
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 |