stamp 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f41c8f35a8e2cd599d4cff288999cadec0daecd942e963617a97c30fe2b6af16
4
+ data.tar.gz: 7f2da591b6b8c66fc6862b93bf93db1e86aac7212f4a043abfde0e6e0d3d4a1e
5
+ SHA512:
6
+ metadata.gz: 1cf409a95607824e5758705c2f152270e9294549bafcbb32e74f877575bed567310899170c9ae88c5c4ab411ac666c7f54bef133b67d3375fc5822e938b9b1e7
7
+ data.tar.gz: 9755ca6a5e9d1ff3c8a48e0adede18577699187985240580e77ba93c8912e5feb1ebf6197b31a3451394f1800e344ad3dac248fbdbcc5fec7a18be681d6004ae
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ ruby: ["3.1", "3.2", "3.3", "3.4"]
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: ruby/setup-ruby@v1
15
+ with:
16
+ ruby-version: ${{ matrix.ruby }}
17
+ bundler-cache: true
18
+ - run: bundle exec rake
data/Gemfile CHANGED
@@ -1,3 +1,3 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  gemspec
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2011 Jeremy Weiskotten
1
+ Copyright (c) 2011-2026 Jeremy Weiskotten
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy
4
4
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Format dates and times based on human-friendly examples, not arcane
4
4
  [strftime](http://strfti.me) directives.
5
5
 
6
- [![Build Status](https://secure.travis-ci.org/jeremyw/stamp.png)](http://travis-ci.org/jeremyw/stamp)
6
+ [![CI](https://github.com/jeremyw/stamp/actions/workflows/ci.yml/badge.svg)](https://github.com/jeremyw/stamp/actions/workflows/ci.yml)
7
7
 
8
8
  ## Installation
9
9
 
@@ -58,6 +58,44 @@ time.stamp("Jan 1 at 01:00 AM") #=> "Jun 9 at 08:52 PM"
58
58
  time.stamp("23:59 UTC") #=> "20:52 PST"
59
59
  ```
60
60
 
61
+ UTC offsets are recognized too, in both `+HHMM` and `+HH:MM` styles:
62
+
63
+ ```ruby
64
+ time.stamp("23:59 +00:00") #=> "20:52 -05:00"
65
+ ```
66
+
67
+ ### Without monkey patches
68
+
69
+ If you'd rather not patch `Date` and `Time`, use the module function:
70
+
71
+ ```ruby
72
+ require "stamp/core" # instead of "stamp"
73
+
74
+ Stamp.format(Date.new(2011, 6, 9), "Jan 1, 1999") #=> "Jun 9, 2011"
75
+ ```
76
+
77
+ Or a refinement, scoped to the files that opt in:
78
+
79
+ ```ruby
80
+ require "stamp/refinement"
81
+ using Stamp::Refinement
82
+
83
+ Date.new(2011, 6, 9).stamp("Jan 1, 1999") #=> "Jun 9, 2011"
84
+ ```
85
+
86
+ ### Converting examples to strftime formats
87
+
88
+ When you need the equivalent strftime string — for `Time::DATE_FORMATS`,
89
+ I18n configs, or APIs that want a format rather than a formatted value:
90
+
91
+ ```ruby
92
+ Stamp.strftime_format("Jan 1, 1999") #=> "%b %-d, %Y"
93
+ Stamp.strftime_format("23:59:59") #=> "%H:%M:%S"
94
+ ```
95
+
96
+ Ordinal days (e.g. "1st") have no strftime equivalent and raise an
97
+ `ArgumentError`.
98
+
61
99
  ## Features
62
100
 
63
101
  * Abbreviated and full names of months and weekdays are recognized.
@@ -102,5 +140,4 @@ Time.now.to_s(:military) #=> "16 July 15:35"
102
140
 
103
141
  ## Copyright
104
142
 
105
- Copyright (c) 2011 Jeremy Weiskotten (@doctorzaius). See LICENSE.txt for
106
- further details.
143
+ Copyright (c) 2011-2026 Jeremy Weiskotten. See LICENSE.txt for further details.
data/Rakefile CHANGED
@@ -1,10 +1,10 @@
1
1
  require 'bundler/gem_tasks'
2
- require 'cucumber/rake/task'
2
+ require 'rake/testtask'
3
3
 
4
- Cucumber::Rake::Task.new(:features)
5
-
6
- Cucumber::Rake::Task.new('features:wip', 'Run Cucumber features that are a work in progress') do |t|
7
- t.profile = 'wip'
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.pattern = "test/**/*_test.rb"
8
8
  end
9
9
 
10
- task :default => :features
10
+ task :default => :test
data/lib/stamp/core.rb ADDED
@@ -0,0 +1,73 @@
1
+ require "date"
2
+ require "time"
3
+
4
+ require "stamp/emitters/am_pm"
5
+ require "stamp/emitters/ambiguous"
6
+ require "stamp/emitters/composite"
7
+ require "stamp/emitters/delegate"
8
+ require "stamp/emitters/lookup"
9
+ require "stamp/emitters/ordinal"
10
+ require "stamp/emitters/string"
11
+ require "stamp/emitters/twelve_hour"
12
+ require "stamp/emitters/two_digit"
13
+ require "stamp/emitters/utc_offset"
14
+ require "stamp/disambiguator"
15
+ require "stamp/translator"
16
+ require "stamp/version"
17
+
18
+ module Stamp
19
+ # Formats a date/time using a human-friendly example as a template,
20
+ # without monkey patching Date or Time.
21
+ #
22
+ # @param [Date|Time|DateTime] target the date/time to format
23
+ # @param [String] example a human-friendly date/time example
24
+ #
25
+ # @return [String] the formatted date or time
26
+ #
27
+ # @example
28
+ # Stamp.format(Date.new(2012, 12, 21), "Jan 1, 1999") #=> "Dec 21, 2012"
29
+ def self.format(target, example)
30
+ emitters(example).format(target)
31
+ rescue NoMethodError => e
32
+ raise e if target.respond_to?(e.name)
33
+ raise ArgumentError,
34
+ "#{example.inspect} requires ##{e.name}, which #{target.class} doesn't implement (e.g. a Date can't format time-of-day examples)"
35
+ end
36
+
37
+ # Translates a human-friendly example into a strftime format string.
38
+ #
39
+ # @param [String] example a human-friendly date/time example
40
+ #
41
+ # @return [String] the equivalent strftime format
42
+ #
43
+ # @raise [ArgumentError] if the example uses a feature strftime can't
44
+ # express (e.g. ordinal days like "1st")
45
+ #
46
+ # @example
47
+ # Stamp.strftime_format("Jan 1, 1999") #=> "%b %-d, %Y"
48
+ def self.strftime_format(example)
49
+ emitters(example).strftime_format
50
+ end
51
+
52
+ # Memoizes the emitters for each example to improve performance.
53
+ def self.emitters(example)
54
+ cache = (@emitters ||= {})
55
+ cache.clear if cache.size >= 1000 # ponytail: crude cap to bound memory; LRU if it ever matters
56
+ cache[example] ||= Disambiguator.new(Translator.new.translate(example)).disambiguate!
57
+ end
58
+ private_class_method :emitters
59
+
60
+ # Formats a date/time using a human-friendly example as a template.
61
+ #
62
+ # @param [String] example a human-friendly date/time example
63
+ #
64
+ # @return [String] the formatted date or time
65
+ #
66
+ # @example
67
+ # Date.new(2012, 12, 21).stamp("Jan 1, 1999") #=> "Dec 21, 2012"
68
+ def stamp(example)
69
+ Stamp.format(self, example)
70
+ end
71
+ alias :stamp_like :stamp
72
+ alias :format_like :stamp
73
+ end
@@ -0,0 +1,25 @@
1
+ module Stamp
2
+ class Disambiguator
3
+ attr_reader :emitters
4
+
5
+ def initialize(emitters)
6
+ @emitters = emitters
7
+ end
8
+
9
+ def disambiguate!
10
+ emitters.replace_each! do |emitter|
11
+ disambiguate(emitter)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def disambiguate(emitter)
18
+ if emitter.respond_to?(:disambiguate)
19
+ emitter.disambiguate(emitters)
20
+ else
21
+ emitter
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,22 +1,24 @@
1
1
  module Stamp
2
2
  module Emitters
3
3
  class AmPm
4
- include Modifiable
4
+ attr_reader :strftime_directive
5
5
 
6
- AM = 'am'
7
- PM = 'pm'
8
-
9
- def initialize(&block)
10
- @modifier = block
6
+ # @param [Array<String>]values for am pm text
7
+ def initialize(values, strftime_directive)
8
+ @values = values
9
+ @strftime_directive = strftime_directive
11
10
  end
12
11
 
13
12
  def format(target)
14
- modify(target.hour < 12 ? AM : PM)
13
+ @values[target.hour < 12 ? 0 : 1]
15
14
  end
16
15
 
17
16
  def field
18
17
  nil
19
18
  end
19
+
20
+ UPPERCASE = new(%w(AM PM), "%p")
21
+ LOWERCASE = new(%w(am pm), "%P")
20
22
  end
21
23
  end
22
- end
24
+ end
@@ -0,0 +1,24 @@
1
+ module Stamp
2
+ module Emitters
3
+ class Ambiguous
4
+ attr_reader :potential_emitters
5
+
6
+ def initialize(*emitters)
7
+ @potential_emitters = emitters
8
+ end
9
+
10
+ def field
11
+ nil
12
+ end
13
+
14
+ def disambiguate(emitters)
15
+ other_emitters = emitters - self
16
+ known_fields = other_emitters.map { |e| e.field }.compact
17
+
18
+ potential_emitters.reject do |potential_emitter|
19
+ known_fields.include?(potential_emitter.field)
20
+ end.first
21
+ end
22
+ end
23
+ end
24
+ end
@@ -3,29 +3,50 @@ module Stamp
3
3
  class Composite
4
4
  include Enumerable
5
5
 
6
- def initialize
7
- @emitters = []
6
+ attr_reader :emitters
7
+
8
+ def initialize(emitters=[])
9
+ @emitters = emitters
8
10
  end
9
11
 
10
12
  def format(target)
11
13
  # NOTE using #each to build string because benchmarking shows
12
14
  # that it's ~20% faster than .map.join('')
13
15
  result = ''
14
- @emitters.each { |e| result << e.format(target).to_s }
16
+ emitters.each { |e| result << e.format(target).to_s }
15
17
  result
16
18
  end
17
19
 
20
+ # Builds the equivalent strftime format string.
21
+ #
22
+ # @raise [ArgumentError] if any emitter has no strftime equivalent
23
+ def strftime_format
24
+ emitters.map do |e|
25
+ e.strftime_directive ||
26
+ raise(ArgumentError, "#{e.class.name.split('::').last} (e.g. \"1st\") has no strftime equivalent")
27
+ end.join
28
+ end
29
+
18
30
  def <<(emitter)
19
- if emitter.is_a?(Enumerable)
20
- emitter.each { |e| @emitters << e }
21
- else
22
- @emitters << emitter
23
- end
31
+ Array(emitter).each { |e| emitters << e }
24
32
  end
25
33
 
26
34
  def each(&block)
27
- @emitters.each(&block)
35
+ emitters.each(&block)
36
+ end
37
+
38
+ def -(others)
39
+ emitters - Array(others)
40
+ end
41
+
42
+ # Replace each element as we iterate with the result of the given block.
43
+ def replace_each!
44
+ emitters.each_with_index do |emitter, index|
45
+ emitters[index] = yield(emitter)
46
+ end
47
+
48
+ self
28
49
  end
29
50
  end
30
51
  end
31
- end
52
+ end
@@ -1,19 +1,23 @@
1
1
  module Stamp
2
2
  module Emitters
3
3
  class Delegate
4
- include Modifiable
4
+ attr_reader :field, :strftime_directive
5
5
 
6
- attr_reader :field
7
-
8
- # @param [field] the field to be formatted (e.g. +:month+, +:year+)
9
- def initialize(field, &block)
6
+ # @param [Symbol|String] field the field to be formatted (e.g. +:month+, +:year+)
7
+ def initialize(field, strftime_directive)
10
8
  @field = field
11
- @modifier = block
9
+ @strftime_directive = strftime_directive
12
10
  end
13
11
 
12
+ # @param [Date|Time|DateTime] target the date to be formatted
14
13
  def format(target)
15
- modify(target.send(field))
14
+ target.send(field)
16
15
  end
16
+
17
+ ZONE = new(:zone, "%Z")
18
+ YEAR = new(:year, "%Y")
19
+ MONTH = new(:month, "%-m")
20
+ DAY = new(:day, "%-d")
17
21
  end
18
22
  end
19
- end
23
+ end
@@ -1,14 +1,14 @@
1
1
  module Stamp
2
2
  module Emitters
3
3
  class Lookup
4
- attr_reader :field
4
+ attr_reader :field, :strftime_directive
5
5
 
6
- # @param [field] the field to be formatted (e.g. +:month+, +:year+)
7
- # @param [lookup] an array of the string values to be formatted (e.g. +Date::DAYNAMES+)
8
- # or a +call+able that returns the formatted value
9
- def initialize(field, lookup=nil)
6
+ # @param [Symbol|String] field the field to be formatted (e.g. +:month+, +:year+)
7
+ # @param [Array<String>] lookup an array of the string values to be formatted (e.g. +Date::DAYNAMES+)
8
+ def initialize(field, lookup, strftime_directive)
10
9
  @field = field
11
10
  @lookup = lookup
11
+ @strftime_directive = strftime_directive
12
12
  end
13
13
 
14
14
  def format(target)
@@ -16,12 +16,13 @@ module Stamp
16
16
  end
17
17
 
18
18
  def lookup(value)
19
- if @lookup.respond_to?(:call)
20
- @lookup.call(value)
21
- else
22
- @lookup[value]
23
- end
19
+ @lookup[value]
24
20
  end
21
+
22
+ MONTH = new(:month, Date::MONTHNAMES, "%B")
23
+ ABBR_MONTH = new(:month, Date::ABBR_MONTHNAMES, "%b")
24
+ DAY = new(:wday, Date::DAYNAMES, "%A")
25
+ ABBR_DAY = new(:wday, Date::ABBR_DAYNAMES, "%a")
25
26
  end
26
27
  end
27
- end
28
+ end
@@ -3,11 +3,12 @@ module Stamp
3
3
  class Ordinal
4
4
  attr_reader :field
5
5
 
6
- # @param [field] the field to be formatted (e.g. +:month+, +:year+)
6
+ # @param [Symbol|String] field the field to be formatted (e.g. +:month+, +:year+)
7
7
  def initialize(field)
8
8
  @field = field
9
9
  end
10
10
 
11
+ # @param [Date|Time|DateTime] target the date to be formatted
11
12
  def format(target)
12
13
  ordinalize(target.send(field))
13
14
  end
@@ -26,6 +27,13 @@ module Stamp
26
27
  end
27
28
  end
28
29
  end
30
+
31
+ # strftime has no directive for ordinals ("1st", "22nd", ...)
32
+ def strftime_directive
33
+ nil
34
+ end
35
+
36
+ DAY = new(:day)
29
37
  end
30
38
  end
31
- end
39
+ end
@@ -15,6 +15,12 @@ module Stamp
15
15
  value << emitter.value
16
16
  end
17
17
 
18
+ # Literal text, with % escaped so strftime passes it through verbatim.
19
+ def strftime_directive
20
+ # value can be nil (unmatched optional capture groups become tokens)
21
+ value.to_s.gsub("%", "%%")
22
+ end
23
+
18
24
  def field
19
25
  nil
20
26
  end
@@ -0,0 +1,38 @@
1
+ module Stamp
2
+ module Emitters
3
+ # Emits hours as a two-digit number with a leading
4
+ # zero if necessary.
5
+ class TwelveHour
6
+ attr_reader :leading_zero
7
+
8
+ # @param [String|Symbol] field the field to be formatted (e.g. +:month+, +:year+)
9
+ # @param [Hash] options the options for output
10
+ # @option options [String] :leading_zero (default: false) pass true add a leading 0 to output
11
+ def initialize(options = {})
12
+ @leading_zero = options[:leading_zero]
13
+ end
14
+
15
+ # @param [Date|Time|DateTime] target the date to be formatted
16
+ def format(target)
17
+ value = target.hour
18
+ value = ((value - 1) % 12) + 1
19
+ if leading_zero && (value < 10)
20
+ "0#{value}"
21
+ else
22
+ value
23
+ end
24
+ end
25
+
26
+ def field
27
+ :hour
28
+ end
29
+
30
+ def strftime_directive
31
+ leading_zero ? "%I" : "%-I"
32
+ end
33
+
34
+ LEADING_ZERO = new(leading_zero: true)
35
+ NON_LEADING_ZERO = new(leading_zero: false)
36
+ end
37
+ end
38
+ end
@@ -3,20 +3,29 @@ module Stamp
3
3
  # Emits the given field as a two-digit number with a leading
4
4
  # zero if necessary.
5
5
  class TwoDigit
6
- include Modifiable
6
+ attr_reader :field, :strftime_directive
7
7
 
8
- attr_reader :field
9
-
10
- # @param [field] the field to be formatted (e.g. +:month+, +:year+)
11
- def initialize(field, &block)
8
+ # @param [String|Symbol] field the field to be formatted (e.g. +:month+, +:year+)
9
+ def initialize(field, strftime_directive)
12
10
  @field = field
13
- @modifier = block
11
+ @strftime_directive = strftime_directive
14
12
  end
15
13
 
16
14
  def format(target)
17
- value = modify(target.send(field))
18
- value < 10 ? "0#{value}" : value
15
+ value = target.send(field) % 100
16
+ # by definition, leading_zero option is true
17
+ if value < 10
18
+ "0#{value}"
19
+ else
20
+ value
21
+ end
19
22
  end
23
+ YEAR = new(:year, "%y")
24
+ MONTH = new(:month, "%m")
25
+ DAY = new(:day, "%d")
26
+ HOUR = new(:hour, "%H")
27
+ MIN = new(:min, "%M")
28
+ SEC = new(:sec, "%S")
20
29
  end
21
30
  end
22
- end
31
+ end
@@ -0,0 +1,27 @@
1
+ module Stamp
2
+ module Emitters
3
+ # Emits the target's UTC offset, e.g. "-0500" or "-05:00".
4
+ class UtcOffset
5
+ # @param [String] directive the strftime directive to delegate to
6
+ # (+"%z"+ or +"%:z"+)
7
+ def initialize(directive)
8
+ @directive = directive
9
+ end
10
+
11
+ def format(target)
12
+ target.strftime(@directive)
13
+ end
14
+
15
+ def strftime_directive
16
+ @directive
17
+ end
18
+
19
+ def field
20
+ nil
21
+ end
22
+
23
+ PLAIN = new("%z")
24
+ COLON = new("%:z")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ require "stamp/core"
2
+
3
+ module Stamp
4
+ # An alternative to the default Date/Time monkey patches:
5
+ #
6
+ # require "stamp/refinement"
7
+ # using Stamp::Refinement
8
+ #
9
+ # Date.today.stamp("Jan 1, 1999")
10
+ module Refinement
11
+ [Date, Time].each do |klass|
12
+ refine klass do
13
+ def stamp(example)
14
+ Stamp.format(self, example)
15
+ end
16
+ alias_method :stamp_like, :stamp
17
+ alias_method :format_like, :stamp
18
+ end
19
+ end
20
+ end
21
+ end
@@ -5,7 +5,7 @@ module Stamp
5
5
  # http://en.wikipedia.org/wiki/List_of_time_zone_abbreviations
6
6
  TIME_ZONE_ABBREVIATIONS = %w{
7
7
  ACDT ACST ACT ADT AEDT AEST AFT AKDT AKST AMST AMT ART AST AWDT AWST AZOST AZT
8
- BDT BIOT BIT BOT BRT BST BTTCAT CCT CDT CEDT CEST CET CHADT CHAST CHOT ChST CHUT
8
+ BDT BIOT BIT BOT BRT BST BTT CAT CCT CDT CEDT CEST CET CHADT CHAST CHOT ChST CHUT
9
9
  CIST CIT CKT CLST CLT COST COT CST CT CVT CWST CXT DAVT DDUT DFT EASST EAST EAT
10
10
  ECT EDT EEDT EEST EET EGST EGT EIT EST FET FJT FKST FKT FNT GALT GAMT GET GFT
11
11
  GILT GIT GMT GST GYT HADT HAEC HAST HKT HMT HOVT HST ICT IDT IOT IRDT IRKT IRST
@@ -28,54 +28,46 @@ module Stamp
28
28
 
29
29
  TIME_REGEXP = /(\d{1,2})(:)(\d{2})(\s*)(:)?(\d{2})?(\s*)?([ap]m)?/i
30
30
 
31
+ # UTC offsets like "+05:00" or "-0500". The lookbehind/lookahead keep
32
+ # date fragments like "12-31-1999" from matching; hours are capped at 14.
33
+ UTC_OFFSET_REGEXP = /(?<![-\d])[+-](?:0\d|1[0-4]):?[0-5]\d(?!\d)/
34
+
31
35
  MERIDIAN_LOWER_REGEXP = /^(a|p)m$/
32
36
  MERIDIAN_UPPER_REGEXP = /^(A|P)M$/
33
37
 
34
38
  ORDINAL_DAY_REGEXP = /^(\d{1,2})(st|nd|rd|th)$/
35
39
 
36
40
  # Disambiguate based on value
37
- OBVIOUS_YEARS = 60..99
38
- OBVIOUS_MONTHS = 12
39
- OBVIOUS_DAYS = 13..31
40
41
  OBVIOUS_24_HOUR = 13..23
41
-
42
- TWO_DIGIT_YEAR_EMITTER = Emitters::TwoDigit.new(:year) { |year| year % 100 }
43
- TWO_DIGIT_MONTH_EMITTER = Emitters::TwoDigit.new(:month)
44
- TWO_DIGIT_DAY_EMITTER = Emitters::TwoDigit.new(:day)
45
- HOUR_TO_12_HOUR = lambda { |h| ((h - 1) % 12) + 1 }
46
-
47
- OBVIOUS_DATE_MAP = {
48
- OBVIOUS_YEARS => TWO_DIGIT_YEAR_EMITTER,
49
- OBVIOUS_MONTHS => TWO_DIGIT_MONTH_EMITTER,
50
- OBVIOUS_DAYS => TWO_DIGIT_DAY_EMITTER
51
- }
52
-
53
- TWO_DIGIT_DATE_SUCCESSION = {
54
- :month => TWO_DIGIT_DAY_EMITTER,
55
- :day => TWO_DIGIT_YEAR_EMITTER,
56
- :year => TWO_DIGIT_MONTH_EMITTER
57
- }
58
-
59
- TWO_DIGIT_TIME_SUCCESSION = {
60
- :hour => Emitters::TwoDigit.new(:min),
61
- :min => Emitters::TwoDigit.new(:sec)
62
- }
42
+ OBVIOUS_DAY = 13..31
43
+ OBVIOUS_YEAR = 32..99
63
44
 
64
45
  def translate(example)
46
+ # extract any substrings that look like UTC offsets, like "+05:00" or
47
+ # "-0500", before the time logic can mistake them for clock times
48
+ before, offset_example, after = example.partition(UTC_OFFSET_REGEXP)
49
+ unless offset_example.empty?
50
+ emitters = Emitters::Composite.new
51
+ emitters << translate(before) unless before.empty?
52
+ emitters << (offset_example.include?(":") ? Emitters::UtcOffset::COLON : Emitters::UtcOffset::PLAIN)
53
+ emitters << translate(after) unless after.empty?
54
+ return emitters
55
+ end
56
+
65
57
  # extract any substrings that look like times, like "23:59" or "8:37 am"
66
58
  before, time_example, after = example.partition(TIME_REGEXP)
67
59
 
68
60
  # build emitters from the example date
69
61
  emitters = Emitters::Composite.new
70
- emitters << build_emitters(before.split(/\b/)) do |token, previous_part|
71
- date_emitter(token, previous_part)
62
+ emitters << build_emitters(before.split(/\b/)) do |token|
63
+ date_emitter(token)
72
64
  end
73
65
 
74
66
  # build emitters from the example time
75
67
  unless time_example.empty?
76
68
  time_parts = time_example.scan(TIME_REGEXP).first
77
- emitters << build_emitters(time_parts) do |token, previous_part|
78
- time_emitter(token, previous_part)
69
+ emitters << build_emitters(time_parts) do |token|
70
+ time_emitter(token)
79
71
  end
80
72
  end
81
73
 
@@ -86,83 +78,83 @@ module Stamp
86
78
 
87
79
  # Transforms tokens that look like date/time parts to emitter objects.
88
80
  def build_emitters(tokens)
89
- previous_part = nil
90
81
  tokens.map do |token|
91
- emitter = yield(token, previous_part)
92
- previous_part = emitter.field unless emitter.nil?
93
-
94
- emitter || Emitters::String.new(token)
82
+ yield(token) || Emitters::String.new(token)
95
83
  end
96
84
  end
97
85
 
98
- def time_emitter(token, previous_part)
86
+ def time_emitter(token)
99
87
  case token
100
88
  when MERIDIAN_LOWER_REGEXP
101
- Emitters::AmPm.new
89
+ Emitters::AmPm::LOWERCASE
102
90
 
103
91
  when MERIDIAN_UPPER_REGEXP
104
- Emitters::AmPm.new { |v| v.upcase }
92
+ Emitters::AmPm::UPPERCASE
105
93
 
106
94
  when TWO_DIGIT_REGEXP
107
- TWO_DIGIT_TIME_SUCCESSION[previous_part] ||
108
- case token.to_i
109
- when OBVIOUS_24_HOUR
110
- # 24-hour clock
111
- Emitters::TwoDigit.new(:hour)
112
- else
113
- # 12-hour clock with leading zero
114
- Emitters::TwoDigit.new(:hour, &HOUR_TO_12_HOUR)
115
- end
95
+ Emitters::Ambiguous.new(
96
+ two_digit_hour_emitter(token),
97
+ Emitters::TwoDigit::MIN,
98
+ Emitters::TwoDigit::SEC)
116
99
 
117
100
  when ONE_DIGIT_REGEXP
118
101
  # 12-hour clock without leading zero
119
- Emitters::Delegate.new(:hour, &HOUR_TO_12_HOUR)
102
+ Emitters::TwelveHour::NON_LEADING_ZERO
120
103
  end
121
104
  end
122
105
 
123
- def date_emitter(token, previous_part)
106
+ def two_digit_hour_emitter(token)
107
+ case token.to_i
108
+ when OBVIOUS_24_HOUR
109
+ # 24-hour clock
110
+ Emitters::TwoDigit::HOUR
111
+ else
112
+ # 12-hour clock with leading zero
113
+ Emitters::TwelveHour::LEADING_ZERO
114
+ end
115
+ end
116
+
117
+ def date_emitter(token)
124
118
  case token
125
119
  when MONTHNAMES_REGEXP
126
- Emitters::Lookup.new(:month, Date::MONTHNAMES)
120
+ Emitters::Lookup::MONTH
127
121
 
128
122
  when ABBR_MONTHNAMES_REGEXP
129
- Emitters::Lookup.new(:month, Date::ABBR_MONTHNAMES)
123
+ Emitters::Lookup::ABBR_MONTH
130
124
 
131
125
  when DAYNAMES_REGEXP
132
- Emitters::Lookup.new(:wday, Date::DAYNAMES)
126
+ Emitters::Lookup::DAY
133
127
 
134
128
  when ABBR_DAYNAMES_REGEXP
135
- Emitters::Lookup.new(:wday, Date::ABBR_DAYNAMES)
129
+ Emitters::Lookup::ABBR_DAY
136
130
 
137
131
  when TIMEZONE_REGEXP
138
- Emitters::Delegate.new(:zone)
132
+ Emitters::Delegate::ZONE
139
133
 
140
134
  when FOUR_DIGIT_REGEXP
141
- Emitters::Delegate.new(:year)
135
+ Emitters::Delegate::YEAR
142
136
 
143
137
  when ORDINAL_DAY_REGEXP
144
- Emitters::Ordinal.new(:day)
138
+ Emitters::Ordinal::DAY
145
139
 
146
140
  when TWO_DIGIT_REGEXP
147
- value = token.to_i
148
-
149
- obvious_mappings =
150
- OBVIOUS_DATE_MAP.reject { |k,v| v.field == previous_part }
151
-
152
- obvious_directive = obvious_mappings.find do |range, directive|
153
- break directive if range === value
141
+ case token.to_i
142
+ when OBVIOUS_DAY
143
+ Emitters::TwoDigit::DAY
144
+ when OBVIOUS_YEAR
145
+ Emitters::TwoDigit::YEAR
146
+ else
147
+ Emitters::Ambiguous.new(
148
+ Emitters::TwoDigit::MONTH,
149
+ Emitters::TwoDigit::DAY,
150
+ Emitters::TwoDigit::YEAR)
154
151
  end
155
152
 
156
- # if the intent isn't obvious based on the example value, try to
157
- # disambiguate based on context
158
- obvious_directive ||
159
- TWO_DIGIT_DATE_SUCCESSION[previous_part] ||
160
- TWO_DIGIT_MONTH_EMITTER
161
-
162
153
  when ONE_DIGIT_REGEXP
163
- Emitters::Delegate.new(:day)
154
+ Emitters::Ambiguous.new(
155
+ Emitters::Delegate::MONTH,
156
+ Emitters::Delegate::DAY)
164
157
  end
165
158
  end
166
-
167
159
  end
168
160
  end
data/lib/stamp/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Stamp
2
- VERSION = "0.5.0"
2
+ VERSION = "0.7.0"
3
3
  end
data/lib/stamp.rb CHANGED
@@ -1,50 +1,4 @@
1
- require "date"
2
- require "time"
1
+ require "stamp/core"
3
2
 
4
- require "stamp/emitters/modifiable"
5
- require "stamp/emitters/am_pm"
6
- require "stamp/emitters/composite"
7
- require "stamp/emitters/delegate"
8
- require "stamp/emitters/lookup"
9
- require "stamp/emitters/ordinal"
10
- require "stamp/emitters/string"
11
- require "stamp/emitters/two_digit"
12
- require "stamp/translator"
13
- require "stamp/version"
14
-
15
- module Stamp
16
- # Limits the number of formats that we memoize to prevent unbounded
17
- # memory consumption.
18
- MEMOIZATION_CAP = 999
19
-
20
- # Formats a date/time using a human-friendly example as a template.
21
- #
22
- # @param [String] example a human-friendly date/time example
23
- # @param [Hash] options
24
- # @option options [Boolean] :memoize (true)
25
- #
26
- # @return [String] the formatted date or time
27
- #
28
- # @example
29
- # Date.new(2012, 12, 21).stamp("Jan 1, 1999") #=> "Dec 21, 2012"
30
- def stamp(example)
31
- memoize_stamp_emitters(example).format(self)
32
- end
33
- alias :stamp_like :stamp
34
- alias :format_like :stamp
35
-
36
- # Memoizes the set of emitter objects for the given +example+ in
37
- # order to improve performance.
38
- def memoize_stamp_emitters(example)
39
- @@memoized_stamp_emitters ||= {}
40
- @@memoized_stamp_emitters.clear if @@memoized_stamp_emitters.size > MEMOIZATION_CAP
41
- @@memoized_stamp_emitters[example] ||= stamp_emitters(example)
42
- end
43
-
44
- def stamp_emitters(example)
45
- Translator.new.translate(example)
46
- end
47
- end
48
-
49
- Date.send(:include, ::Stamp)
50
- Time.send(:include, ::Stamp)
3
+ Date.include(::Stamp)
4
+ Time.include(::Stamp)
data/stamp.gemspec CHANGED
@@ -6,16 +6,22 @@ Gem::Specification.new do |s|
6
6
  s.name = "stamp"
7
7
  s.version = Stamp::VERSION
8
8
  s.authors = ["Jeremy Weiskotten"]
9
- s.email = ["jeremy@weiskotten.com"]
9
+ s.email = ["jeremy@terriblelabs.com"]
10
10
  s.homepage = "https://github.com/jeremyw/stamp"
11
11
  s.summary = %Q{Date and time formatting for humans.}
12
12
  s.description = %Q{Format dates and times based on human-friendly examples, not arcane strftime directives.}
13
+ s.license = "MIT"
13
14
 
14
- s.files = `git ls-files`.split("\n")
15
- s.test_files = `git ls-files -- features/*`.split("\n")
16
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
15
+ s.metadata = {
16
+ "source_code_uri" => "https://github.com/jeremyw/stamp",
17
+ "bug_tracker_uri" => "https://github.com/jeremyw/stamp/issues"
18
+ }
19
+
20
+ s.required_ruby_version = ">= 3.1"
21
+
22
+ s.files = `git ls-files`.split("\n").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
23
  s.require_paths = ["lib"]
18
24
 
19
- s.add_development_dependency 'cucumber'
25
+ s.add_development_dependency 'minitest'
20
26
  s.add_development_dependency 'rake'
21
27
  end
metadata CHANGED
@@ -1,111 +1,99 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stamp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
5
- prerelease:
4
+ version: 0.7.0
6
5
  platform: ruby
7
6
  authors:
8
7
  - Jeremy Weiskotten
9
8
  autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2012-12-17 00:00:00.000000000 Z
11
+ date: 2026-08-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
- name: cucumber
14
+ name: minitest
16
15
  requirement: !ruby/object:Gem::Requirement
17
- none: false
18
16
  requirements:
19
- - - ! '>='
17
+ - - ">="
20
18
  - !ruby/object:Gem::Version
21
19
  version: '0'
22
20
  type: :development
23
21
  prerelease: false
24
22
  version_requirements: !ruby/object:Gem::Requirement
25
- none: false
26
23
  requirements:
27
- - - ! '>='
24
+ - - ">="
28
25
  - !ruby/object:Gem::Version
29
26
  version: '0'
30
27
  - !ruby/object:Gem::Dependency
31
28
  name: rake
32
29
  requirement: !ruby/object:Gem::Requirement
33
- none: false
34
30
  requirements:
35
- - - ! '>='
31
+ - - ">="
36
32
  - !ruby/object:Gem::Version
37
33
  version: '0'
38
34
  type: :development
39
35
  prerelease: false
40
36
  version_requirements: !ruby/object:Gem::Requirement
41
- none: false
42
37
  requirements:
43
- - - ! '>='
38
+ - - ">="
44
39
  - !ruby/object:Gem::Version
45
40
  version: '0'
46
41
  description: Format dates and times based on human-friendly examples, not arcane strftime
47
42
  directives.
48
43
  email:
49
- - jeremy@weiskotten.com
44
+ - jeremy@terriblelabs.com
50
45
  executables: []
51
46
  extensions: []
52
47
  extra_rdoc_files: []
53
48
  files:
54
- - .gitignore
55
- - .travis.yml
56
- - .yardopts
49
+ - ".github/workflows/ci.yml"
50
+ - ".gitignore"
51
+ - ".yardopts"
57
52
  - CONTRIBUTING.md
58
53
  - Gemfile
59
54
  - LICENSE.txt
60
55
  - README.md
61
56
  - Rakefile
62
- - cucumber.yml
63
- - features/stamp.feature
64
- - features/step_definitions/stamp_steps.rb
65
- - features/support/env.rb
66
57
  - lib/stamp.rb
58
+ - lib/stamp/core.rb
59
+ - lib/stamp/disambiguator.rb
67
60
  - lib/stamp/emitters/am_pm.rb
61
+ - lib/stamp/emitters/ambiguous.rb
68
62
  - lib/stamp/emitters/composite.rb
69
63
  - lib/stamp/emitters/delegate.rb
70
64
  - lib/stamp/emitters/lookup.rb
71
- - lib/stamp/emitters/modifiable.rb
72
65
  - lib/stamp/emitters/ordinal.rb
73
66
  - lib/stamp/emitters/string.rb
67
+ - lib/stamp/emitters/twelve_hour.rb
74
68
  - lib/stamp/emitters/two_digit.rb
69
+ - lib/stamp/emitters/utc_offset.rb
70
+ - lib/stamp/refinement.rb
75
71
  - lib/stamp/translator.rb
76
72
  - lib/stamp/version.rb
77
73
  - stamp.gemspec
78
74
  homepage: https://github.com/jeremyw/stamp
79
- licenses: []
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ source_code_uri: https://github.com/jeremyw/stamp
79
+ bug_tracker_uri: https://github.com/jeremyw/stamp/issues
80
80
  post_install_message:
81
81
  rdoc_options: []
82
82
  require_paths:
83
83
  - lib
84
84
  required_ruby_version: !ruby/object:Gem::Requirement
85
- none: false
86
85
  requirements:
87
- - - ! '>='
86
+ - - ">="
88
87
  - !ruby/object:Gem::Version
89
- version: '0'
90
- segments:
91
- - 0
92
- hash: 167698609462982419
88
+ version: '3.1'
93
89
  required_rubygems_version: !ruby/object:Gem::Requirement
94
- none: false
95
90
  requirements:
96
- - - ! '>='
91
+ - - ">="
97
92
  - !ruby/object:Gem::Version
98
93
  version: '0'
99
- segments:
100
- - 0
101
- hash: 167698609462982419
102
94
  requirements: []
103
- rubyforge_project:
104
- rubygems_version: 1.8.23
95
+ rubygems_version: 3.0.3.1
105
96
  signing_key:
106
- specification_version: 3
97
+ specification_version: 4
107
98
  summary: Date and time formatting for humans.
108
- test_files:
109
- - features/stamp.feature
110
- - features/step_definitions/stamp_steps.rb
111
- - features/support/env.rb
99
+ test_files: []
data/.travis.yml DELETED
@@ -1,8 +0,0 @@
1
- rvm:
2
- - 1.8.7
3
- - 1.9.2
4
- - 1.9.3
5
- - 2.0.0
6
- - ree
7
- - rbx
8
- - jruby
data/cucumber.yml DELETED
@@ -1,3 +0,0 @@
1
- ---
2
- default: --tags ~@wip --strict
3
- wip: --tags @wip --wip
@@ -1,131 +0,0 @@
1
- @stamp
2
- Feature: Stamping a date
3
- In order to format dates in a more programmer-friendly way
4
- the stamp method
5
- formats a date given a human-readable example.
6
-
7
- @date
8
- Scenario Outline: Formatting dates by example
9
- Given the date September 8, 2011
10
- When I stamp the example "<example>"
11
- Then I produce "<output>"
12
-
13
- Examples:
14
- | example | output |
15
- | January | September |
16
- | Jan | Sep |
17
- | Jan 1 | Sep 8 |
18
- | Jan 01 | Sep 08 |
19
- | Jan 10 | Sep 08 |
20
- | Jan 1, 1999 | Sep 8, 2011 |
21
- | Jan 12, 1999 | Sep 08, 2011 |
22
- | 13 January 1999 | 08 September 2011 |
23
- | Monday | Thursday |
24
- | Tue, Jan 1 | Thu, Sep 8 |
25
- | Tuesday, January 1, 1999 | Thursday, September 8, 2011 |
26
- | 01/1999 | 09/2011 |
27
- | 01/01 | 09/08 |
28
- | 01/31 | 09/08 |
29
- | 01/99 | 09/11 |
30
- | 01/01/1999 | 09/08/2011 |
31
- | 12/31/99 | 09/08/11 |
32
- | 31/12 | 08/09 |
33
- | 31/12/99 | 08/09/11 |
34
- | 31-Jan-1999 | 08-Sep-2011 |
35
- | 1999-12-31 | 2011-09-08 |
36
- | DOB: 12-31-1999 | DOB: 09-08-2011 |
37
-
38
- @date
39
- Scenario Outline: Formatting dates with ordinal days
40
- Given the date <date>
41
- When I stamp the example "<example>"
42
- Then I produce "<output>"
43
-
44
- Examples:
45
- | date | example | output |
46
- | Jan 1, 1999 | July 4th | January 1st |
47
- | Jan 2, 1999 | Dec 3rd | Jan 2nd |
48
- | Jan 3, 1999 | Dec 2nd | Jan 3rd |
49
- | Jan 4, 1999 | Jul 1st | Jan 4th |
50
- | Jan 5, 1999 | Dec 1st | Jan 5th |
51
- | Jan 6, 1999 | Dec 1st | Jan 6th |
52
- | Jan 7, 1999 | Dec 1st | Jan 7th |
53
- | Jan 8, 1999 | Dec 1st | Jan 8th |
54
- | Jan 9, 1999 | Dec 1st | Jan 9th |
55
- | Jan 10, 1999 | Dec 1st | Jan 10th |
56
- | Jan 11, 1999 | Dec 1st | Jan 11th |
57
- | Jan 12, 1999 | Dec 1st | Jan 12th |
58
- | Jan 13, 1999 | Dec 1st | Jan 13th |
59
- | Jan 14, 1999 | Dec 1st | Jan 14th |
60
- | Jan 20, 1999 | Dec 1st | Jan 20th |
61
- | Jan 21, 1999 | Dec 1st | Jan 21st |
62
- | Jan 22, 1999 | Dec 1st | Jan 22nd |
63
- | Jan 23, 1999 | Dec 1st | Jan 23rd |
64
- | Jan 24, 1999 | Dec 1st | Jan 24th |
65
- | Jan 1, 1999 | 4th of July | 1st of January |
66
- | Jan 1, 1999 | 4th of July, 1999 | 1st of January, 1999 |
67
-
68
- @time
69
- Scenario Outline: Formatting times by example
70
- Given the time zone is "EST"
71
- And the time February 8, 2011 at 13:31:27
72
- When I stamp the example "<example>"
73
- Then I produce "<output>"
74
-
75
- Examples:
76
- | example | output |
77
- | 8:59 am | 1:31 pm |
78
- | 8:59am | 1:31pm |
79
- | 08:59 AM | 01:31 PM |
80
- | 08:59 PM | 01:31 PM |
81
- | 23:59 | 13:31 |
82
- | 8:59:59 am | 1:31:27 pm |
83
- | 08:59:59 AM | 01:31:27 PM |
84
- | 08:59:59 PM | 01:31:27 PM |
85
- | 23:59:59 | 13:31:27 |
86
- | 8:59 PST | 1:31 EST |
87
-
88
- @date
89
- @time
90
- Scenario Outline: Formatting dates and times by example
91
- Given the time September 8, 2011 at 13:31:27
92
- When I stamp the example "<example>"
93
- Then I produce "<output>"
94
-
95
- Examples:
96
- | example | output |
97
- | Jan 1, 1999 8:59 am | Sep 8, 2011 1:31 pm |
98
- | 08:59 AM 1999-12-31 | 01:31 PM 2011-09-08 |
99
- | Date: Jan 1, 1999 Time: 8:59 am | Date: Sep 8, 2011 Time: 1:31 pm |
100
-
101
- Scenario: strftime directives just get passed through
102
- Given the date December 21, 2012
103
- When I stamp the example "John Cusack was in a movie about Jan (%-m) %e, %Y, but it wasn't very good."
104
- Then I produce "John Cusack was in a movie about Dec (%-m) %e, %Y, but it wasn't very good."
105
-
106
- Scenario: Plain text just gets passed through
107
- Given the date June 1, 1926
108
- When I stamp the example "Marilyn Monroe was born on January 9, 1999."
109
- Then I produce "Marilyn Monroe was born on June 1, 1926."
110
-
111
- Scenario Outline: Aliases for the stamp method
112
- Given the date December 9, 2011
113
- When I call "<alias>" with "1999-01-31"
114
- Then I produce "2011-12-09"
115
-
116
- Examples:
117
- | alias |
118
- | stamp_like |
119
- | format_like |
120
-
121
- @wip
122
- Scenario Outline: Examples that aren't supported yet
123
- Given the time September 8, 2011 at 13:31:27
124
- When I stamp the example "<example>"
125
- Then I produce "<output>"
126
-
127
- Examples:
128
- | example | output |
129
- | 8 am | 1 pm |
130
- | 8am | 1pm |
131
- | 8AM | 1PM |
@@ -1,30 +0,0 @@
1
- module StampStepHelpers
2
- def month(month_name)
3
- Date::MONTHNAMES.index(month_name) || Date::ABBR_MONTHNAMES.index(month_name)
4
- end
5
- end
6
- World(StampStepHelpers)
7
-
8
- Given /^the date (\w+) (\d+), (\d{4})$/ do |month_name, day, year|
9
- @target = Date.new(year.to_i, month(month_name), day.to_i)
10
- end
11
-
12
- Given /^the time (\w+) (\d+), (\d+) at (\d{2}):(\d{2}):(\d{2})$/ do |month_name, day, year, hours, minutes, seconds|
13
- @target = Time.local(year.to_i, month(month_name), day.to_i, hours.to_i, minutes.to_i, seconds.to_i)
14
- end
15
-
16
- Given /^the time zone is "(.*?)"$/ do |zone|
17
- ENV['TZ'] = zone
18
- end
19
-
20
- When /^I stamp the example "([^"]*)"$/ do |example|
21
- @stamped = @target.stamp(example)
22
- end
23
-
24
- Then /^I produce "([^"]*)"$/ do |expected|
25
- assert_equal expected.strip, @stamped.strip
26
- end
27
-
28
- When /^I call "([^"]*)" with "([^"]*)"$/ do |method, arg|
29
- @stamped = @target.send(method, arg)
30
- end
@@ -1,14 +0,0 @@
1
- require 'bundler'
2
- begin
3
- Bundler.setup(:default, :development)
4
- rescue Bundler::BundlerError => e
5
- $stderr.puts e.message
6
- $stderr.puts "Run `bundle install` to install missing gems"
7
- exit e.status_code
8
- end
9
-
10
- $LOAD_PATH.unshift(File.dirname(__FILE__) + '/../../lib')
11
- require 'stamp'
12
-
13
- require 'test/unit/assertions'
14
- World(Test::Unit::Assertions)
@@ -1,9 +0,0 @@
1
- module Modifiable
2
- def modify(value)
3
- if @modifier
4
- @modifier.call(value)
5
- else
6
- value
7
- end
8
- end
9
- end