Module: Tuile::Locale::DateFormats

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

Overview

The two rules a strftime date format list obeys: what may be in one (DateFormats.validate) and what one looks like to a human (DateFormats.humanize). Both answer at assignment, so a bad format raises there rather than at the first keystroke. TimeFormats is its sibling over the same Formats lexer.

Constant Summary collapse

REF =

The date every format is round-tripped against. Every property is load-bearing: pre-1969 so %y fails (it cannot carry a century), post-1582-10-15 so the Gregorian reform fails no innocent format, and month ≠ day so a %m/%d swap is not masked. A canary rather than a proof — but a century-lossy directive is lossy in both directions, so one pre-window date catches the class that ships.

Returns:

  • (Date)
Date.new(1962, 9, 4)
HINTS =

The directives humanize can turn into a placeholder. There is deliberately no %b/%B: a month name would need an invented mmm, and an app typing month names sets its own hint.

Returns:

  • (Hash{String => String})
{ "%Y" => "yyyy", "%m" => "mm", "%d" => "dd", "%%" => "%" }.freeze

Class Method Summary collapse

Class Method Details

.humanize(format) ⇒ String?

Translates a format into a typing hint, or nil when it holds any directive HINTS does not cover.

DateFormats.humanize("%d.%m.%Y")   # => "dd.mm.yyyy"
DateFormats.humanize("%Y-%j")      # => nil, rather than "yyyy-%j"

@param format

@return — frozen.

Parameters:

  • format (String)

Returns:

  • (String, nil)


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

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

.malformedString

Returns:

  • (String)


278
279
280
281
# File 'lib/tuile/locale.rb', line 278

def malformed
  "it is incomplete, is write-only (strptime takes no `-` flag), " \
    "or is not the directive you meant"
end

.parses?(format) ⇒ Boolean

@param format

@return — true iff strptime consumes its own strftime output whole. Weaker than round_trips? on purpose: "%d/%m/%y" parses fine, it just parses to the wrong century.

Parameters:

  • format (String)

Returns:

  • (Boolean)


253
254
255
256
257
258
# File 'lib/tuile/locale.rb', line 253

def parses?(format)
  parsed = Date._strptime(REF.strftime(format), format)
  !parsed.nil? && parsed[:leftover].to_s.empty?
rescue ArgumentError
  false
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)


264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/tuile/locale.rb', line 264

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

  reason =
    if format.include?("%y")
      "%y cannot carry a century (Ruby reads 69 as 1969 and 26 as 2026), so write %Y — " \
      "it may still appear later in the list, where it only ever parses"
    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)


243
244
245
246
247
# File 'lib/tuile/locale.rb', line 243

def round_trips?(format)
  Date.strptime(REF.strftime(format), format) == REF
rescue ArgumentError # Date::Error is one; so is an unparseable format
  false
end

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

Normalizes one format or a list of them into a frozen Array of frozen Strings, validating each.

DateFormats.validate("%d.%m.%Y")   # => ["%d.%m.%Y"]

The primary is held to a stricter rule than the rest. formats.first is what a field writes, so it must survive a strftime/strptime round-trip; every later entry only ever parses, so it need only be a usable strptime pattern — which is how a lenient list carries a two-digit-year pattern behind its widened one.

@param list

@return — frozen, as are its elements.

Parameters:

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

Returns:

  • (::Array[String])


183
184
185
186
187
188
189
# File 'lib/tuile/locale.rb', line 183

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 — whether this is formats.first, which is written as well as read and so must round-trip.

@return — a frozen copy.

Parameters:

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

Returns:

  • (String)


228
229
230
231
232
233
234
235
236
237
238
# File 'lib/tuile/locale.rb', line 228

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

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

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

  format.dup.freeze
end

.widen(format) ⇒ String

Rewrites every %y in format as %Y, leaving the rest alone.

DateFormats.widen("%d/%m/%y")   # => "%d/%m/%Y"
DateFormats.widen("100%%y")     # => "100%%y" — that is a literal %

For validate's benefit: a two-digit year cannot round-trip, since Date.new(1962, 9, 4) renders "04/09/62" and reparses as 2062 under Ruby's fixed POSIX window. So Tuile::Locale.system widens a detected d_fmt here rather than losing it, where an app assigning the same pattern gets the rejection instead (D_locale).

@param format

@return — frozen.

Parameters:

  • format (String)

Returns:

  • (String)


214
215
216
217
218
219
220
# File 'lib/tuile/locale.rb', line 214

def widen(format)
  widened = +""
  Formats.each_directive(format) do |directive|
    widened << (directive.end_with?("y") && directive.length > 1 ? "#{directive[0..-2]}Y" : directive)
  end
  widened.freeze
end