├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── README.md ├── lib └── nimble_strftime.ex ├── mix.exs ├── mix.lock └── test ├── nimble_strftime_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | strftime-*.tar 24 | 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.1.1 (2019-10-15) 2 | 3 | ### Bug fixes 4 | 5 | * Drop default padding for %u to match other strftime implementations 6 | * Use `keyword()` type for options 7 | * Fix `-` modifier to actually drop padding per documentation 8 | 9 | ## v0.1.0 (2019-09-12) 10 | 11 | * First release 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NimbleStrftime 2 | 3 | Note: [`NimbleStrftime` will be added to Elixir v1.11](https://github.com/elixir-lang/elixir/issues/9503). 4 | 5 | `nimble_strftime` is a simple and fast library for formatting datetimes into 6 | strings based on the `strftime` tool found on UNIX-like systems. 7 | 8 | ## Examples 9 | 10 | Once installed, you can format your calendar types right away: 11 | 12 | ```elixir 13 | iex> datetime = ~U[2019-08-26 13:52:06.0Z] 14 | # year(2 digits)-month-day hour(in a 12 hour clock):minute:second AM/PM 15 | iex> NimbleStrftime.format(datetime, "%y-%m-%d %I:%M:%S %p") 16 | "19-08-26 01:52:06 PM" 17 | 18 | # day_of_week_abbreviated, month day_of_month year 19 | iex> NimbleStrftime.format(datetime, "%a, %B %d %Y") 20 | "mon, august 26 2019" 21 | 22 | # preferred datetime, default setting "%Y-%m-%d %H:%M:%S" 23 | iex> NimbleStrftime.format(datetime, "%c") 24 | "2019-08-26 13:52:06" 25 | ``` 26 | 27 | You can also pass configuration parameters to set preferred formats, 28 | set the size of abbreviated names and change the names of months, 29 | week days, am and pm: 30 | 31 | ```elixir 32 | iex> datetime = ~U[2019-08-26 13:52:06.0Z] 33 | 34 | # preferred datetime, configured to something else 35 | iex> NimbleStrftime.format(datetime, "%c", preferred_datetime: "%H:%M:%S %d-%m-%y") 36 | "13:52:06 26-08-19" 37 | 38 | # day_of_week configured to another language 39 | iex> NimbleStrftime.format( 40 | ...> datetime, 41 | ...> "%A", 42 | ...> day_of_week_names: fn day_of_week -> 43 | ...> {"segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", 44 | ...> "sexta-feira", "sábado", "domingo"} 45 | ...> |> elem(day_of_week - 1) 46 | ...> end 47 | ...>) 48 | "segunda-feira" 49 | 50 | # month_name with settings for custom abbreviation and names 51 | iex> NimbleStrftime.format( 52 | ...> datetime, 53 | ...> "%B", 54 | ...> abbreviated_month_names: fn month -> 55 | ...> {"янв", "февр", "март", "апр", "май", "июнь", 56 | ...> "июль", "авг", "сент", "окт", "нояб", "дек"} 57 | ...> |> elem(month - 1) 58 | ...> end 59 | ...>) 60 | # => "авг" 61 | ``` 62 | 63 | For more information, please consult the [online documentation](https://hexdocs.pm/nimble_strftime/NimbleStrftime.html) 64 | 65 | ## Installation 66 | 67 | Add `nimble_strftime` to your dependencies: 68 | 69 | ```elixir 70 | def deps do 71 | [ 72 | {:nimble_strftime, "~> 0.1.0"} 73 | ] 74 | end 75 | ``` 76 | 77 | # License 78 | 79 | Copyright 2019 Plataformatec 80 | 81 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 82 | 83 | http://www.apache.org/licenses/LICENSE-2.0 84 | 85 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 86 | -------------------------------------------------------------------------------- /lib/nimble_strftime.ex: -------------------------------------------------------------------------------- 1 | defmodule NimbleStrftime do 2 | @moduledoc """ 3 | Simple datetime formatting based on the strftime format 4 | found on UNIX-like systems. 5 | 6 | ## Formatting syntax 7 | 8 | The formatting syntax for strftime is a sequence of characters in the following format: 9 | 10 | % 11 | 12 | where: 13 | 14 | * `%`: indicates the start of a formatted section 15 | * ``: set the padding (see below) 16 | * ``: a number indicating the minimum size of the formatted section 17 | * ``: the format iself (see below) 18 | 19 | ### Accepted padding options 20 | 21 | * `-`: no padding, removes all padding from the format 22 | * `_`: pad with spaces 23 | * `0`: pad with zeroes 24 | 25 | ### Accepted formats 26 | 27 | The accepted formats are: 28 | 29 | Format | Description | Examples (in ISO) 30 | :----- | :-----------------------------------------------------------------------| :------------------------ 31 | a | Abbreviated name of day | Mon 32 | A | Full name of day | Monday 33 | b | Abbreviated month name | Jan 34 | B | Full month name | January 35 | c | Preferred date+time representation | 2018-10-17 12:34:56 36 | d | Day of the month | 01, 12 37 | f | Microseconds *(does not support width and padding modifiers)* | 000000, 999999, 0123 38 | H | Hour using a 24-hour clock | 00, 23 39 | I | Hour using a 12-hour clock | 01, 12 40 | j | Day of the year | 001, 366 41 | m | Month | 01, 12 42 | M | Minute | 00, 59 43 | p | "AM" or "PM" (noon is "PM", midnight as "AM") | AM, PM 44 | P | "am" or "pm" (noon is "pm", midnight as "am") | am, pm 45 | q | Quarter | 1, 2, 3, 4 46 | S | Second | 00, 59, 60 47 | u | Day of the week | 1 (Monday), 7 (Sunday) 48 | x | Preferred date (without time) representation | 2018-10-17 49 | X | Preferred time (without date) representation | 12:34:56 50 | y | Year as 2-digits | 01, 01, 86, 18 51 | Y | Year | -0001, 0001, 1986 52 | z | +hhmm/-hhmm time zone offset from UTC (empty string if naive) | +0300, -0530 53 | Z | Time zone abbreviation (empty string if naive) | CET, BRST 54 | % | Literal "%" character | % 55 | 56 | Any other character will be interpreted as an invalid format and raise an error 57 | """ 58 | 59 | @doc """ 60 | Formats received datetime into a string. 61 | 62 | The datetime can be any of the Calendar types (`Time`, `Date`, 63 | `NaiveDateTime`, and `DateTime`) or any map, as long as they 64 | contain all of the relevant fields necessary for formatting. 65 | For example, if you use `%Y` to format the year, the datatime 66 | must have the `:year` field. Therefore, if you pass a `Time`, 67 | or a map without the `:year` field to a format that expects `%Y`, 68 | an error will be raised. 69 | 70 | ## Options 71 | 72 | * `:preferred_datetime` - a string for the preferred format to show datetimes, 73 | it can't contain the `%c` format and defaults to `"%Y-%m-%d %H:%M:%S"` 74 | if the option is not received 75 | 76 | * `:preferred_date` - a string for the preferred format to show dates, 77 | it can't contain the `%x` format and defaults to `"%Y-%m-%d"` 78 | if the option is not received 79 | 80 | * `:preferred_time` - a string for the preferred format to show times, 81 | it can't contain the `%X` format and defaults to `"%H:%M:%S"` 82 | if the option is not received 83 | 84 | * `:am_pm_names` - a function that receives either `:am` or `:pm` and returns 85 | the name of the period of the day, if the option is not received it defaults 86 | to a function that returns `"am"` and `"pm"`, respectively 87 | 88 | * `:month_names` - a function that receives a number and returns the name of 89 | the corresponding month, if the option is not received it defaults to a 90 | function thet returns the month names in english 91 | 92 | * `:abbreviated_month_names` - a function that receives a number and returns the 93 | abbreviated name of the corresponding month, if the option is not received it 94 | defaults to a function thet returns the abbreviated month names in english 95 | 96 | * `:day_of_week_names` - a function that receives a number and returns the name of 97 | the corresponding day of week, if the option is not received it defaults to a 98 | function that returns the day of week names in english 99 | 100 | * `:abbreviated_day_of_week_names` - a function that receives a number and returns 101 | the abbreviated name of the corresponding day of week, if the option is not received 102 | it defaults to a function that returns the abbreviated day of week names in english 103 | 104 | ## Examples 105 | 106 | Without options: 107 | 108 | iex> NimbleStrftime.format(~U[2019-08-26 13:52:06.0Z], "%y-%m-%d %I:%M:%S %p") 109 | "19-08-26 01:52:06 PM" 110 | 111 | iex> NimbleStrftime.format(~U[2019-08-26 13:52:06.0Z], "%a, %B %d %Y") 112 | "Mon, August 26 2019" 113 | 114 | iex> NimbleStrftime.format(~U[2019-08-26 13:52:06.0Z], "%c") 115 | "2019-08-26 13:52:06" 116 | 117 | With options: 118 | 119 | iex> NimbleStrftime.format(~U[2019-08-26 13:52:06.0Z], "%c", preferred_datetime: "%H:%M:%S %d-%m-%y") 120 | "13:52:06 26-08-19" 121 | 122 | iex> NimbleStrftime.format( 123 | ...> ~U[2019-08-26 13:52:06.0Z], 124 | ...> "%A", 125 | ...> day_of_week_names: fn day_of_week -> 126 | ...> {"segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", 127 | ...> "sexta-feira", "sábado", "domingo"} 128 | ...> |> elem(day_of_week - 1) 129 | ...> end 130 | ...>) 131 | "segunda-feira" 132 | 133 | iex> NimbleStrftime.format( 134 | ...> ~U[2019-08-26 13:52:06.0Z], 135 | ...> "%B", 136 | ...> month_names: fn month -> 137 | ...> {"январь", "февраль", "март", "апрель", "май", "июнь", 138 | ...> "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"} 139 | ...> |> elem(month - 1) 140 | ...> end 141 | ...>) 142 | "август" 143 | """ 144 | @spec format(map(), String.t(), keyword()) :: String.t() 145 | def format(date_or_time_or_datetime, string_format, user_options \\ []) do 146 | parse( 147 | string_format, 148 | date_or_time_or_datetime, 149 | options(user_options), 150 | [] 151 | ) 152 | |> IO.iodata_to_binary() 153 | end 154 | 155 | defp parse("", _datetime, _format_options, acc), 156 | do: Enum.reverse(acc) 157 | 158 | defp parse("%" <> rest, datetime, format_options, acc), 159 | do: parse_modifiers(rest, nil, nil, {datetime, format_options, acc}) 160 | 161 | defp parse(<>, datetime, format_options, acc), 162 | do: parse(rest, datetime, format_options, [char | acc]) 163 | 164 | defp parse_modifiers("-" <> rest, width, nil, parser_data) do 165 | parse_modifiers(rest, width, "", parser_data) 166 | end 167 | 168 | defp parse_modifiers("0" <> rest, width, nil, parser_data) do 169 | parse_modifiers(rest, width, ?0, parser_data) 170 | end 171 | 172 | defp parse_modifiers("_" <> rest, width, nil, parser_data) do 173 | parse_modifiers(rest, width, ?\s, parser_data) 174 | end 175 | 176 | defp parse_modifiers(<>, width, pad, parser_data) when digit in ?0..?9 do 177 | new_width = 178 | case pad do 179 | ?- -> 0 180 | _ -> (width || 0) * 10 + (digit - ?0) 181 | end 182 | 183 | parse_modifiers(rest, new_width, pad, parser_data) 184 | end 185 | 186 | # set default padding if none was specfied 187 | defp parse_modifiers(<> = rest, width, nil, parser_data) do 188 | parse_modifiers(rest, width, default_pad(format), parser_data) 189 | end 190 | 191 | # set default width if none was specified 192 | defp parse_modifiers(<> = rest, nil, pad, parser_data) do 193 | parse_modifiers(rest, default_width(format), pad, parser_data) 194 | end 195 | 196 | defp parse_modifiers(rest, width, pad, {datetime, format_options, acc}) do 197 | format_modifiers(rest, width, pad, datetime, format_options, acc) 198 | end 199 | 200 | defp am_pm(hour, format_options) when hour > 11 do 201 | format_options.am_pm_names.(:pm) 202 | end 203 | 204 | defp am_pm(hour, format_options) when hour <= 11 do 205 | format_options.am_pm_names.(:am) 206 | end 207 | 208 | defp default_pad(format) when format in 'aAbBpPZ', do: ?\s 209 | defp default_pad(_format), do: ?0 210 | 211 | defp default_width(format) when format in 'dHImMSy', do: 2 212 | defp default_width(?j), do: 3 213 | defp default_width(format) when format in 'Yz', do: 4 214 | defp default_width(_format), do: 0 215 | 216 | # Literally just % 217 | defp format_modifiers("%" <> rest, width, pad, datetime, format_options, acc) do 218 | parse(rest, datetime, format_options, [pad_leading("%", width, pad) | acc]) 219 | end 220 | 221 | # Abbreviated name of day 222 | defp format_modifiers("a" <> rest, width, pad, datetime, format_options, acc) do 223 | result = 224 | datetime 225 | |> Date.day_of_week() 226 | |> format_options.abbreviated_day_of_week_names.() 227 | |> pad_leading(width, pad) 228 | 229 | parse(rest, datetime, format_options, [result | acc]) 230 | end 231 | 232 | # Full name of day 233 | defp format_modifiers("A" <> rest, width, pad, datetime, format_options, acc) do 234 | result = 235 | datetime 236 | |> Date.day_of_week() 237 | |> format_options.day_of_week_names.() 238 | |> pad_leading(width, pad) 239 | 240 | parse(rest, datetime, format_options, [result | acc]) 241 | end 242 | 243 | # Abbreviated month name 244 | defp format_modifiers("b" <> rest, width, pad, datetime, format_options, acc) do 245 | result = 246 | datetime.month 247 | |> format_options.abbreviated_month_names.() 248 | |> pad_leading(width, pad) 249 | 250 | parse(rest, datetime, format_options, [result | acc]) 251 | end 252 | 253 | # Full month name 254 | defp format_modifiers("B" <> rest, width, pad, datetime, format_options, acc) do 255 | result = datetime.month |> format_options.month_names.() |> pad_leading(width, pad) 256 | 257 | parse(rest, datetime, format_options, [result | acc]) 258 | end 259 | 260 | # Preferred date+time representation 261 | defp format_modifiers( 262 | "c" <> _rest, 263 | _width, 264 | _pad, 265 | _datetime, 266 | %{preferred_datetime_invoked: true}, 267 | _acc 268 | ) do 269 | raise RuntimeError, 270 | "tried to format preferred_datetime within another preferred_datetime format" 271 | end 272 | 273 | defp format_modifiers("c" <> rest, width, pad, datetime, format_options, acc) do 274 | result = 275 | format_options.preferred_datetime 276 | |> parse(datetime, %{format_options | preferred_datetime_invoked: true}, []) 277 | |> pad_preferred(width, pad) 278 | 279 | parse(rest, datetime, format_options, [result | acc]) 280 | end 281 | 282 | # Day of the month 283 | defp format_modifiers("d" <> rest, width, pad, datetime, format_options, acc) do 284 | result = datetime.day |> Integer.to_string() |> pad_leading(width, pad) 285 | parse(rest, datetime, format_options, [result | acc]) 286 | end 287 | 288 | # Microseconds 289 | defp format_modifiers("f" <> rest, _width, _pad, datetime, format_options, acc) do 290 | {microsecond, precision} = datetime.microsecond 291 | 292 | result = 293 | microsecond 294 | |> Integer.to_string() 295 | |> String.pad_leading(6, "0") 296 | |> binary_part(0, max(precision, 1)) 297 | 298 | parse(rest, datetime, format_options, [result | acc]) 299 | end 300 | 301 | # Hour using a 24-hour clock 302 | defp format_modifiers("H" <> rest, width, pad, datetime, format_options, acc) do 303 | result = datetime.hour |> Integer.to_string() |> pad_leading(width, pad) 304 | parse(rest, datetime, format_options, [result | acc]) 305 | end 306 | 307 | # Hour using a 12-hour clock 308 | defp format_modifiers("I" <> rest, width, pad, datetime, format_options, acc) do 309 | result = (rem(datetime.hour() + 23, 12) + 1) |> Integer.to_string() |> pad_leading(width, pad) 310 | parse(rest, datetime, format_options, [result | acc]) 311 | end 312 | 313 | # Day of the year 314 | defp format_modifiers("j" <> rest, width, pad, datetime, format_options, acc) do 315 | result = datetime |> Date.day_of_year() |> Integer.to_string() |> pad_leading(width, pad) 316 | parse(rest, datetime, format_options, [result | acc]) 317 | end 318 | 319 | # Month 320 | defp format_modifiers("m" <> rest, width, pad, datetime, format_options, acc) do 321 | result = datetime.month |> Integer.to_string() |> pad_leading(width, pad) 322 | parse(rest, datetime, format_options, [result | acc]) 323 | end 324 | 325 | # Minute 326 | defp format_modifiers("M" <> rest, width, pad, datetime, format_options, acc) do 327 | result = datetime.minute |> Integer.to_string() |> pad_leading(width, pad) 328 | parse(rest, datetime, format_options, [result | acc]) 329 | end 330 | 331 | # “AM” or “PM” (noon is “PM”, midnight as “AM”) 332 | defp format_modifiers("p" <> rest, width, pad, datetime, format_options, acc) do 333 | result = datetime.hour |> am_pm(format_options) |> String.upcase() |> pad_leading(width, pad) 334 | 335 | parse(rest, datetime, format_options, [result | acc]) 336 | end 337 | 338 | # “am” or “pm” (noon is “pm”, midnight as “am”) 339 | defp format_modifiers("P" <> rest, width, pad, datetime, format_options, acc) do 340 | result = 341 | datetime.hour 342 | |> am_pm(format_options) 343 | |> String.downcase() 344 | |> pad_leading(width, pad) 345 | 346 | parse(rest, datetime, format_options, [result | acc]) 347 | end 348 | 349 | # Quarter 350 | defp format_modifiers("q" <> rest, width, pad, datetime, format_options, acc) do 351 | result = datetime |> Date.quarter_of_year() |> Integer.to_string() |> pad_leading(width, pad) 352 | parse(rest, datetime, format_options, [result | acc]) 353 | end 354 | 355 | # Second 356 | defp format_modifiers("S" <> rest, width, pad, datetime, format_options, acc) do 357 | result = datetime.second |> Integer.to_string() |> pad_leading(width, pad) 358 | parse(rest, datetime, format_options, [result | acc]) 359 | end 360 | 361 | # Day of the week 362 | defp format_modifiers("u" <> rest, width, pad, datetime, format_options, acc) do 363 | result = datetime |> Date.day_of_week() |> Integer.to_string() |> pad_leading(width, pad) 364 | parse(rest, datetime, format_options, [result | acc]) 365 | end 366 | 367 | # Preferred date (without time) representation 368 | defp format_modifiers( 369 | "x" <> _rest, 370 | _width, 371 | _pad, 372 | _datetime, 373 | %{preferred_date_invoked: true}, 374 | _acc 375 | ) do 376 | raise RuntimeError, 377 | "tried to format preferred_date within another preferred_date format" 378 | end 379 | 380 | defp format_modifiers("x" <> rest, width, pad, datetime, format_options, acc) do 381 | result = 382 | format_options.preferred_date 383 | |> parse(datetime, %{format_options | preferred_date_invoked: true}, []) 384 | |> pad_preferred(width, pad) 385 | 386 | parse(rest, datetime, format_options, [result | acc]) 387 | end 388 | 389 | # Preferred time (without date) representation 390 | defp format_modifiers( 391 | "X" <> _rest, 392 | _width, 393 | _pad, 394 | _datetime, 395 | %{preferred_time_invoked: true}, 396 | _acc 397 | ) do 398 | raise RuntimeError, 399 | "tried to format preferred_time within another preferred_time format" 400 | end 401 | 402 | defp format_modifiers("X" <> rest, width, pad, datetime, format_options, acc) do 403 | result = 404 | format_options.preferred_time 405 | |> parse(datetime, %{format_options | preferred_time_invoked: true}, []) 406 | |> pad_preferred(width, pad) 407 | 408 | parse(rest, datetime, format_options, [result | acc]) 409 | end 410 | 411 | # Year as 2-digits 412 | defp format_modifiers("y" <> rest, width, pad, datetime, format_options, acc) do 413 | result = datetime.year |> rem(100) |> Integer.to_string() |> pad_leading(width, pad) 414 | parse(rest, datetime, format_options, [result | acc]) 415 | end 416 | 417 | # Year 418 | defp format_modifiers("Y" <> rest, width, pad, datetime, format_options, acc) do 419 | result = datetime.year |> Integer.to_string() |> pad_leading(width, pad) 420 | parse(rest, datetime, format_options, [result | acc]) 421 | end 422 | 423 | # +hhmm/-hhmm time zone offset from UTC (empty string if naive) 424 | defp format_modifiers( 425 | "z" <> rest, 426 | width, 427 | pad, 428 | datetime = %{utc_offset: utc_offset, std_offset: std_offset}, 429 | format_options, 430 | acc 431 | ) do 432 | absolute_offset = abs(utc_offset + std_offset) 433 | 434 | offset_number = 435 | Integer.to_string(div(absolute_offset, 3600) * 100 + rem(div(absolute_offset, 60), 60)) 436 | 437 | sign = if utc_offset + std_offset >= 0, do: "+", else: "-" 438 | result = "#{sign}#{pad_leading(offset_number, width, pad)}" 439 | parse(rest, datetime, format_options, [result | acc]) 440 | end 441 | 442 | defp format_modifiers("z" <> rest, _width, _pad, datetime, format_options, acc) do 443 | parse(rest, datetime, format_options, ["" | acc]) 444 | end 445 | 446 | # Time zone abbreviation (empty string if naive) 447 | defp format_modifiers("Z" <> rest, width, pad, datetime, format_options, acc) do 448 | result = datetime |> Map.get(:zone_abbr, "") |> pad_leading(width, pad) 449 | parse(rest, datetime, format_options, [result | acc]) 450 | end 451 | 452 | defp pad_preferred(result, width, pad) when length(result) < width do 453 | pad_preferred([pad | result], width, pad) 454 | end 455 | 456 | defp pad_preferred(result, _width, _pad), do: result 457 | 458 | defp pad_leading(string, count, padding) do 459 | to_pad = count - byte_size(string) 460 | if to_pad > 0, do: do_pad_leading(to_pad, padding, string), else: string 461 | end 462 | 463 | defp do_pad_leading(0, _, acc), do: acc 464 | 465 | defp do_pad_leading(count, padding, acc), 466 | do: do_pad_leading(count - 1, padding, [padding | acc]) 467 | 468 | defp options(user_options) do 469 | default_options = %{ 470 | preferred_date: "%Y-%m-%d", 471 | preferred_time: "%H:%M:%S", 472 | preferred_datetime: "%Y-%m-%d %H:%M:%S", 473 | am_pm_names: fn 474 | :am -> "am" 475 | :pm -> "pm" 476 | end, 477 | month_names: fn month -> 478 | {"January", "February", "March", "April", "May", "June", "July", "August", "September", 479 | "October", "November", "December"} 480 | |> elem(month - 1) 481 | end, 482 | day_of_week_names: fn day_of_week -> 483 | {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} 484 | |> elem(day_of_week - 1) 485 | end, 486 | abbreviated_month_names: fn month -> 487 | {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} 488 | |> elem(month - 1) 489 | end, 490 | abbreviated_day_of_week_names: fn day_of_week -> 491 | {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} |> elem(day_of_week - 1) 492 | end, 493 | preferred_datetime_invoked: false, 494 | preferred_date_invoked: false, 495 | preferred_time_invoked: false 496 | } 497 | 498 | Enum.reduce(user_options, default_options, fn {key, value}, acc -> 499 | if Map.has_key?(acc, key) do 500 | %{acc | key => value} 501 | else 502 | acc 503 | end 504 | end) 505 | end 506 | end 507 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule NimbleStrftime.MixProject do 2 | use Mix.Project 3 | @version "0.1.1" 4 | @source_url "https://github.com/plataformatec/nimble_strftime" 5 | 6 | def project do 7 | [ 8 | app: :nimble_strftime, 9 | version: @version, 10 | elixir: "~> 1.9", 11 | name: "NimbleStrftime", 12 | description: "strftime-based datetime formatter for Elixir", 13 | deps: deps(), 14 | docs: docs(), 15 | package: package() 16 | ] 17 | end 18 | 19 | defp docs do 20 | [ 21 | main: "NimbleStrftime", 22 | source_ref: "v#{@version}", 23 | source_url: @source_url 24 | ] 25 | end 26 | 27 | defp package do 28 | %{ 29 | licenses: ["Apache-2.0"], 30 | maintainers: ["Gustavo Santos Ferreira", "José Valim"], 31 | links: %{"GitHub" => @source_url} 32 | } 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:dialyxir, "~> 0.5", only: :dev}, 38 | {:ex_doc, "~> 0.21", only: :dev}, 39 | {:benchee, "~> 1.0", only: :dev} 40 | ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm"}, 4 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 5 | "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm"}, 6 | "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /test/nimble_strftime_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NimbleStrftimeTest do 2 | use ExUnit.Case 3 | doctest NimbleStrftime 4 | 5 | describe "format/3" do 6 | test "return received string if there is no datetime formatting to be found in it" do 7 | assert NimbleStrftime.format(~N[2019-08-20 15:47:34.001], "muda string") == "muda string" 8 | end 9 | 10 | test "format all time zones blank when receiving a NaiveDateTime" do 11 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.001], "%z%Z") == "" 12 | end 13 | 14 | test "raise error when trying to format a date with a map that has no date fields" do 15 | time_without_date = %{hour: 15, minute: 47, second: 34, microsecond: {0, 0}} 16 | 17 | assert_raise(KeyError, fn -> NimbleStrftime.format(time_without_date, "%x") end) 18 | end 19 | 20 | test "raise error when trying to format a time with a map that has no time fields" do 21 | date_without_time = %{year: 2019, month: 8, day: 20} 22 | 23 | assert_raise(KeyError, fn -> NimbleStrftime.format(date_without_time, "%X") end) 24 | end 25 | 26 | test "raise error when the format is invalid" do 27 | assert_raise(FunctionClauseError, fn -> 28 | NimbleStrftime.format(~N[2019-08-20 15:47:34.001], "%-2-ç") 29 | end) 30 | end 31 | 32 | test "raise error when the preferred_datetime calls itself" do 33 | assert_raise(RuntimeError, fn -> 34 | NimbleStrftime.format(~N[2019-08-20 15:47:34.001], "%c", preferred_datetime: "%c") 35 | end) 36 | end 37 | 38 | test "raise error when the preferred_date calls itself" do 39 | assert_raise(RuntimeError, fn -> 40 | NimbleStrftime.format(~N[2019-08-20 15:47:34.001], "%x", preferred_date: "%x") 41 | end) 42 | end 43 | 44 | test "raise error when the preferred_time calls itself" do 45 | assert_raise(RuntimeError, fn -> 46 | NimbleStrftime.format(~N[2019-08-20 15:47:34.001], "%X", preferred_time: "%X") 47 | end) 48 | end 49 | 50 | test "raise error when the preferred formats create a circular chain" do 51 | assert_raise(RuntimeError, fn -> 52 | NimbleStrftime.format(~N[2019-08-20 15:47:34.001], "%c", 53 | preferred_datetime: "%x", 54 | preferred_date: "%X", 55 | preferred_time: "%c" 56 | ) 57 | end) 58 | end 59 | 60 | test "format with no errors is the preferred formats are included multiple times on the same string" do 61 | assert( 62 | NimbleStrftime.format(~N[2019-08-15 17:07:57.001], "%c %c %x %x %X %X") == 63 | "2019-08-15 17:07:57 2019-08-15 17:07:57 2019-08-15 2019-08-15 17:07:57 17:07:57" 64 | ) 65 | end 66 | 67 | test "`-` removes padding" do 68 | assert NimbleStrftime.format(~D[2019-01-01], "%-j") == "1" 69 | assert NimbleStrftime.format(~T[17:07:57.001], "%-999M") == "7" 70 | end 71 | 72 | test "format time zones correctly when receiving a DateTime" do 73 | datetime_with_zone = %DateTime{ 74 | year: 2019, 75 | month: 8, 76 | day: 15, 77 | zone_abbr: "EEST", 78 | hour: 17, 79 | minute: 7, 80 | second: 57, 81 | microsecond: {0, 0}, 82 | utc_offset: 7200, 83 | std_offset: 3600, 84 | time_zone: "UK" 85 | } 86 | 87 | assert NimbleStrftime.format(datetime_with_zone, "%z %Z") == "+0300 EEST" 88 | end 89 | 90 | test "format AM and PM correctly on the %P and %p options" do 91 | am_time_almost_pm = ~U[2019-08-26 11:59:59.001Z] 92 | pm_time = ~U[2019-08-26 12:00:57.001Z] 93 | pm_time_almost_am = ~U[2019-08-26 23:59:57.001Z] 94 | am_time = ~U[2019-08-26 00:00:01.001Z] 95 | 96 | assert NimbleStrftime.format(am_time_almost_pm, "%P %p") == "am AM" 97 | assert NimbleStrftime.format(pm_time, "%P %p") == "pm PM" 98 | assert NimbleStrftime.format(pm_time_almost_am, "%P %p") == "pm PM" 99 | assert NimbleStrftime.format(am_time, "%P %p") == "am AM" 100 | end 101 | 102 | test "format all weekdays correctly with %A and %a formats" do 103 | sunday = ~U[2019-08-25 11:59:59.001Z] 104 | monday = ~U[2019-08-26 11:59:59.001Z] 105 | tuesday = ~U[2019-08-27 11:59:59.001Z] 106 | wednesday = ~U[2019-08-28 11:59:59.001Z] 107 | thursday = ~U[2019-08-29 11:59:59.001Z] 108 | friday = ~U[2019-08-30 11:59:59.001Z] 109 | saturday = ~U[2019-08-31 11:59:59.001Z] 110 | 111 | assert NimbleStrftime.format(sunday, "%A %a") == "Sunday Sun" 112 | assert NimbleStrftime.format(monday, "%A %a") == "Monday Mon" 113 | assert NimbleStrftime.format(tuesday, "%A %a") == "Tuesday Tue" 114 | assert NimbleStrftime.format(wednesday, "%A %a") == "Wednesday Wed" 115 | assert NimbleStrftime.format(thursday, "%A %a") == "Thursday Thu" 116 | assert NimbleStrftime.format(friday, "%A %a") == "Friday Fri" 117 | assert NimbleStrftime.format(saturday, "%A %a") == "Saturday Sat" 118 | end 119 | 120 | test "format all months correctly with the %B and %b formats" do 121 | assert NimbleStrftime.format(%{month: 1}, "%B %b") == "January Jan" 122 | assert NimbleStrftime.format(%{month: 2}, "%B %b") == "February Feb" 123 | assert NimbleStrftime.format(%{month: 3}, "%B %b") == "March Mar" 124 | assert NimbleStrftime.format(%{month: 4}, "%B %b") == "April Apr" 125 | assert NimbleStrftime.format(%{month: 5}, "%B %b") == "May May" 126 | assert NimbleStrftime.format(%{month: 6}, "%B %b") == "June Jun" 127 | assert NimbleStrftime.format(%{month: 7}, "%B %b") == "July Jul" 128 | assert NimbleStrftime.format(%{month: 8}, "%B %b") == "August Aug" 129 | assert NimbleStrftime.format(%{month: 9}, "%B %b") == "September Sep" 130 | assert NimbleStrftime.format(%{month: 10}, "%B %b") == "October Oct" 131 | assert NimbleStrftime.format(%{month: 11}, "%B %b") == "November Nov" 132 | assert NimbleStrftime.format(%{month: 12}, "%B %b") == "December Dec" 133 | end 134 | 135 | test "format all weekdays correctly on %A with day_of_week_names option" do 136 | sunday = ~U[2019-08-25 11:59:59.001Z] 137 | monday = ~U[2019-08-26 11:59:59.001Z] 138 | tuesday = ~U[2019-08-27 11:59:59.001Z] 139 | wednesday = ~U[2019-08-28 11:59:59.001Z] 140 | thursday = ~U[2019-08-29 11:59:59.001Z] 141 | friday = ~U[2019-08-30 11:59:59.001Z] 142 | saturday = ~U[2019-08-31 11:59:59.001Z] 143 | 144 | day_of_week_names = fn day_of_week -> 145 | {"segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", 146 | "domingo"} 147 | |> elem(day_of_week - 1) 148 | end 149 | 150 | assert NimbleStrftime.format(sunday, "%A", day_of_week_names: day_of_week_names) == 151 | "domingo" 152 | 153 | assert NimbleStrftime.format(monday, "%A", day_of_week_names: day_of_week_names) == 154 | "segunda-feira" 155 | 156 | assert NimbleStrftime.format(tuesday, "%A", day_of_week_names: day_of_week_names) == 157 | "terça-feira" 158 | 159 | assert NimbleStrftime.format(wednesday, "%A", day_of_week_names: day_of_week_names) == 160 | "quarta-feira" 161 | 162 | assert NimbleStrftime.format(thursday, "%A", day_of_week_names: day_of_week_names) == 163 | "quinta-feira" 164 | 165 | assert NimbleStrftime.format(friday, "%A", day_of_week_names: day_of_week_names) == 166 | "sexta-feira" 167 | 168 | assert NimbleStrftime.format(saturday, "%A", day_of_week_names: day_of_week_names) == 169 | "sábado" 170 | end 171 | 172 | test "format all months correctly on the %B with month_names option" do 173 | month_names = fn month -> 174 | {"январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", 175 | "октябрь", "ноябрь", "декабрь"} 176 | |> elem(month - 1) 177 | end 178 | 179 | assert NimbleStrftime.format(%{month: 1}, "%B", month_names: month_names) == "январь" 180 | assert NimbleStrftime.format(%{month: 2}, "%B", month_names: month_names) == "февраль" 181 | assert NimbleStrftime.format(%{month: 3}, "%B", month_names: month_names) == "март" 182 | assert NimbleStrftime.format(%{month: 4}, "%B", month_names: month_names) == "апрель" 183 | assert NimbleStrftime.format(%{month: 5}, "%B", month_names: month_names) == "май" 184 | assert NimbleStrftime.format(%{month: 6}, "%B", month_names: month_names) == "июнь" 185 | assert NimbleStrftime.format(%{month: 7}, "%B", month_names: month_names) == "июль" 186 | assert NimbleStrftime.format(%{month: 8}, "%B", month_names: month_names) == "август" 187 | assert NimbleStrftime.format(%{month: 9}, "%B", month_names: month_names) == "сентябрь" 188 | assert NimbleStrftime.format(%{month: 10}, "%B", month_names: month_names) == "октябрь" 189 | assert NimbleStrftime.format(%{month: 11}, "%B", month_names: month_names) == "ноябрь" 190 | assert NimbleStrftime.format(%{month: 12}, "%B", month_names: month_names) == "декабрь" 191 | end 192 | 193 | test "format all weekdays correctly on the %a format with abbreviated_day_of_week_names option" do 194 | sunday = ~U[2019-08-25 11:59:59.001Z] 195 | monday = ~U[2019-08-26 11:59:59.001Z] 196 | tuesday = ~U[2019-08-27 11:59:59.001Z] 197 | wednesday = ~U[2019-08-28 11:59:59.001Z] 198 | thursday = ~U[2019-08-29 11:59:59.001Z] 199 | friday = ~U[2019-08-30 11:59:59.001Z] 200 | saturday = ~U[2019-08-31 11:59:59.001Z] 201 | 202 | abbreviated_day_of_week_names = fn day_of_week -> 203 | {"seg", "ter", "qua", "qui", "sex", "sáb", "dom"} 204 | |> elem(day_of_week - 1) 205 | end 206 | 207 | assert NimbleStrftime.format(sunday, "%a", 208 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 209 | ) == "dom" 210 | 211 | assert NimbleStrftime.format(monday, "%a", 212 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 213 | ) == "seg" 214 | 215 | assert NimbleStrftime.format(tuesday, "%a", 216 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 217 | ) == "ter" 218 | 219 | assert NimbleStrftime.format(wednesday, "%a", 220 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 221 | ) == "qua" 222 | 223 | assert NimbleStrftime.format(thursday, "%a", 224 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 225 | ) == "qui" 226 | 227 | assert NimbleStrftime.format(friday, "%a", 228 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 229 | ) == "sex" 230 | 231 | assert NimbleStrftime.format(saturday, "%a", 232 | abbreviated_day_of_week_names: abbreviated_day_of_week_names 233 | ) == "sáb" 234 | end 235 | 236 | test "format all months correctly on the %b format with abbreviated_month_names option" do 237 | abbreviated_month_names = fn month -> 238 | {"янв", "февр", "март", "апр", "май", "июнь", "июль", "авг", "сент", "окт", "нояб", "дек"} 239 | |> elem(month - 1) 240 | end 241 | 242 | assert NimbleStrftime.format(%{month: 1}, "%b", 243 | abbreviated_month_names: abbreviated_month_names 244 | ) == "янв" 245 | 246 | assert NimbleStrftime.format(%{month: 2}, "%b", 247 | abbreviated_month_names: abbreviated_month_names 248 | ) == "февр" 249 | 250 | assert NimbleStrftime.format(%{month: 3}, "%b", 251 | abbreviated_month_names: abbreviated_month_names 252 | ) == "март" 253 | 254 | assert NimbleStrftime.format(%{month: 4}, "%b", 255 | abbreviated_month_names: abbreviated_month_names 256 | ) == "апр" 257 | 258 | assert NimbleStrftime.format(%{month: 5}, "%b", 259 | abbreviated_month_names: abbreviated_month_names 260 | ) == "май" 261 | 262 | assert NimbleStrftime.format(%{month: 6}, "%b", 263 | abbreviated_month_names: abbreviated_month_names 264 | ) == "июнь" 265 | 266 | assert NimbleStrftime.format(%{month: 7}, "%b", 267 | abbreviated_month_names: abbreviated_month_names 268 | ) == "июль" 269 | 270 | assert NimbleStrftime.format(%{month: 8}, "%b", 271 | abbreviated_month_names: abbreviated_month_names 272 | ) == "авг" 273 | 274 | assert NimbleStrftime.format(%{month: 9}, "%b", 275 | abbreviated_month_names: abbreviated_month_names 276 | ) == "сент" 277 | 278 | assert NimbleStrftime.format(%{month: 10}, "%b", 279 | abbreviated_month_names: abbreviated_month_names 280 | ) == "окт" 281 | 282 | assert NimbleStrftime.format(%{month: 11}, "%b", 283 | abbreviated_month_names: abbreviated_month_names 284 | ) == "нояб" 285 | 286 | assert NimbleStrftime.format(%{month: 12}, "%b", 287 | abbreviated_month_names: abbreviated_month_names 288 | ) == "дек" 289 | end 290 | 291 | test "microseconds format ignores padding and width options" do 292 | datetime = ~U[2019-08-15 17:07:57.001234Z] 293 | assert NimbleStrftime.format(datetime, "%f") == "001234" 294 | assert NimbleStrftime.format(datetime, "%f") == NimbleStrftime.format(datetime, "%_20f") 295 | assert NimbleStrftime.format(datetime, "%f") == NimbleStrftime.format(datetime, "%020f") 296 | assert NimbleStrftime.format(datetime, "%f") == NimbleStrftime.format(datetime, "%-f") 297 | end 298 | 299 | test "microseconds format formats properly dates with different precisions" do 300 | assert NimbleStrftime.format(~U[2019-08-15 17:07:57.5Z], "%f") == "5" 301 | assert NimbleStrftime.format(~U[2019-08-15 17:07:57.45Z], "%f") == "45" 302 | assert NimbleStrftime.format(~U[2019-08-15 17:07:57.345Z], "%f") == "345" 303 | assert NimbleStrftime.format(~U[2019-08-15 17:07:57.2345Z], "%f") == "2345" 304 | assert NimbleStrftime.format(~U[2019-08-15 17:07:57.12345Z], "%f") == "12345" 305 | assert NimbleStrftime.format(~U[2019-08-15 17:07:57.012345Z], "%f") == "012345" 306 | end 307 | 308 | test "microseconds formats properly different precisions of zero" do 309 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.0], "%f") == "0" 310 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.00], "%f") == "00" 311 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.000], "%f") == "000" 312 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.0000], "%f") == "0000" 313 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.00000], "%f") == "00000" 314 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57.000000], "%f") == "000000" 315 | end 316 | 317 | test "microseconds returns a single zero if there's no precision at all" do 318 | assert NimbleStrftime.format(~N[2019-08-15 17:07:57], "%f") == "0" 319 | end 320 | 321 | test "return the formatted datetime when all format options and modifiers are received" do 322 | assert NimbleStrftime.format( 323 | ~U[2019-08-15 17:07:57.001Z], 324 | "%04% %a %A %b %B %-3c %d %f %H %I %j %m %_5M %p %P %q %S %u %x %X %y %Y %z %Z" 325 | ) == 326 | "000% Thu Thursday Aug August 2019-08-15 17:07:57 15 001 17 05 227 08 7 PM pm 3 57 4 2019-08-15 17:07:57 19 2019 +0000 UTC" 327 | end 328 | 329 | test "format according to received custom configs" do 330 | assert NimbleStrftime.format( 331 | ~U[2019-08-15 17:07:57.001Z], 332 | "%A %a %p %B %b %c %x %X", 333 | am_pm_names: fn 334 | :am -> "a" 335 | :pm -> "p" 336 | end, 337 | month_names: fn month -> 338 | {"Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", 339 | "Setembro", "Outubro", "Novembro", "Dezembro"} 340 | |> elem(month - 1) 341 | end, 342 | day_of_week_names: fn day_of_week -> 343 | {"понедельник", "вторник", "среда", "четверг", "пятница", "суббота", 344 | "воскресенье"} 345 | |> elem(day_of_week - 1) 346 | end, 347 | abbreviated_month_names: fn month -> 348 | {"Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", 349 | "Dez"} 350 | |> elem(month - 1) 351 | end, 352 | abbreviated_day_of_week_names: fn day_of_week -> 353 | {"ПНД", "ВТР", "СРД", "ЧТВ", "ПТН", "СБТ", "ВСК"} 354 | |> elem(day_of_week - 1) 355 | end, 356 | preferred_date: "%05Y-%m-%d", 357 | preferred_time: "%M:%_3H%S", 358 | preferred_datetime: "%%" 359 | ) == "четверг ЧТВ P Agosto Ago % 02019-08-15 07: 1757" 360 | end 361 | end 362 | end 363 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------