├── .gitattributes ├── config ├── config.exs ├── dev.exs ├── bench.exs └── test.exs ├── images └── taxjar_sponsor.jpg ├── .formatter.exs ├── lib ├── parse_error.ex ├── parser │ ├── date_us.ex │ ├── date_time_us.ex │ ├── tokenizer.ex │ ├── time.ex │ ├── date.ex │ ├── epoch.ex │ ├── serial.ex │ └── date_time.ex ├── parser.ex ├── formatters.ex ├── date_time_parser.ex ├── combinators.ex.exs └── date_time_parser │ ├── timezone_parser.ex │ └── timezone_abbreviations.ex ├── test ├── test_helper.exs ├── support │ ├── recorder.ex │ └── macros.ex ├── fixture │ ├── playground_header.md │ ├── date_formats_samples.txt │ └── ruby-dates.csv └── date_time_parser │ └── timezone_parser_test.exs ├── CONTRIBUTING.md ├── bench ├── profile.exs ├── self.exs ├── ruby.rb └── rails.rb ├── bin └── release ├── .gitignore ├── pages ├── upgrading_0_to_1.md └── Future-UTC-DateTime.md ├── LICENSE.md ├── .github └── workflows │ └── ci.yml ├── priv └── tzdata2022g │ ├── etcetera │ ├── antarctica │ └── backward ├── CODE_OF_CONDUCT.md ├── mix.exs ├── CHANGELOG.md ├── .credo.exs ├── mix.lock ├── README.md └── EXAMPLES.livemd /.gitattributes: -------------------------------------------------------------------------------- 1 | lib/combinators.ex binary 2 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{config_env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir, :time_zone_database, Tz.TimeZoneDatabase 4 | -------------------------------------------------------------------------------- /config/bench.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir, :time_zone_database, Tz.TimeZoneDatabase 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir, :time_zone_database, Tz.TimeZoneDatabase 4 | -------------------------------------------------------------------------------- /images/taxjar_sponsor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbernheisel/date_time_parser/HEAD/images/taxjar_sponsor.jpg -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/parse_error.ex: -------------------------------------------------------------------------------- 1 | defmodule DateTimeParser.ParseError do 2 | @moduledoc "An error when parsing fails" 3 | 4 | defexception [:message] 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | alias DateTimeParserTest.Recorder 2 | 3 | {:ok, _recorder_pid} = Recorder.start_link() 4 | 5 | write_examples = fn 6 | %{excluded: excluded, skipped: skipped} when excluded > 0 or skipped > 0 -> 7 | :ok 8 | 9 | _ -> 10 | Recorder.write_results() 11 | end 12 | 13 | if Version.match?(System.version(), ">= 1.8.0"), do: ExUnit.after_suite(write_examples) 14 | ExUnit.start() 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Do not submit PRs with compiled nimble parsec artifacts. The maintainer 4 | will generate these accordingly before a release. 5 | 6 | To run tests: 7 | ```shell 8 | $ # This will run tests, regenerate EXAMPLES.livemd, 9 | $ # and run Credo in strict mode. 10 | $ mix tests 11 | ``` 12 | 13 | To build a release: 14 | ```shell 15 | $ # bin/release {old_version} {new_version} 16 | $ bin/release 1.1.3 1.1.4 17 | ``` 18 | 19 | Please review and agree to the [code of conduct](./CODE_OF_CONDUCT.md) before 20 | contributing. 21 | -------------------------------------------------------------------------------- /bench/profile.exs: -------------------------------------------------------------------------------- 1 | defmodule Profiler do 2 | import ExProf.Macro 3 | require DateTimeParser 4 | 5 | def run(samples) do 6 | {results, _return} = profile do 7 | Enum.map(samples, &DateTimeParser.parse_datetime/1) 8 | end 9 | 10 | results 11 | |> Enum.sort_by(fn %{time: total_time, percent: total_percent, us_per_call: call_cost} -> 12 | [total_time, total_percent, call_cost] 13 | end) 14 | |> Enum.reverse() 15 | |> Enum.take(10) 16 | |> IO.inspect(label: "TOP OFFENDERS") 17 | end 18 | end 19 | 20 | samples = "test/fixture/date_formats_samples.txt" |> File.read!() |> String.split("\n") 21 | Profiler.run(samples) 22 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Usage: ./bin/release {old_version} {new_version} 3 | set -e 4 | 5 | previous_version="${1}" 6 | release_version="${2}" 7 | 8 | mix tests 9 | git add EXAMPLES.livemd 10 | git add lib/combinators.ex 11 | 12 | sed -i "" "s/$previous_version/$release_version/" README.md 13 | sed -i "" "s/$previous_version/$release_version/" mix.exs 14 | sed -i "" "s/unreleased/$release_version ($(date +%F))/" CHANGELOG.md 15 | git add mix.exs 16 | git add README.md 17 | git add CHANGELOG.md 18 | 19 | git commit 20 | git tag -a "$release_version" -m "Release version $release_version" 21 | git push origin "$release_version" 22 | 23 | mix hex.build 24 | mix hex.publish 25 | -------------------------------------------------------------------------------- /lib/parser/date_us.ex: -------------------------------------------------------------------------------- 1 | defmodule DateTimeParser.Parser.DateUS do 2 | @moduledoc """ 3 | Tokenizes the string for date formats. This prioritizes the US format for representing dates. 4 | """ 5 | @behaviour DateTimeParser.Parser 6 | alias DateTimeParser.Combinators 7 | 8 | @impl DateTimeParser.Parser 9 | def preflight(parser), do: {:ok, parser} 10 | 11 | @impl DateTimeParser.Parser 12 | def parse(%{string: string} = parser) do 13 | case Combinators.parse_date_us(string) do 14 | {:ok, tokens, _, _, _, _} -> 15 | DateTimeParser.Parser.Date.from_tokens(parser, tokens) 16 | 17 | _ -> 18 | {:error, :failed_to_parse} 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.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 | date_time_parser-*.tar 24 | *.benchee 25 | .tool-versions 26 | -------------------------------------------------------------------------------- /lib/parser/date_time_us.ex: -------------------------------------------------------------------------------- 1 | defmodule DateTimeParser.Parser.DateTimeUS do 2 | @moduledoc """ 3 | Tokenizes the string for both date and time formats. This prioritizes the US format for 4 | representing dates. 5 | """ 6 | @behaviour DateTimeParser.Parser 7 | 8 | alias DateTimeParser.Combinators 9 | 10 | @impl DateTimeParser.Parser 11 | def preflight(parser), do: {:ok, parser} 12 | 13 | @impl DateTimeParser.Parser 14 | def parse(%{string: string} = parser) do 15 | case Combinators.parse_datetime_us(string) do 16 | {:ok, tokens, _, _, _, _} -> 17 | DateTimeParser.Parser.DateTime.from_tokens(parser, tokens) 18 | 19 | _ -> 20 | {:error, :failed_to_parse} 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /bench/self.exs: -------------------------------------------------------------------------------- 1 | require DateTimeParser 2 | 3 | Benchee.run(%{ 4 | "parse_datetime" => fn input -> 5 | Enum.map(input, &DateTimeParser.parse_datetime/1) 6 | end, 7 | 8 | "parse_date" => fn input -> 9 | Enum.map(input, &DateTimeParser.parse_date/1) 10 | end, 11 | 12 | "parse_time" => fn input -> 13 | Enum.map(input, &DateTimeParser.parse_time/1) 14 | end 15 | }, [ 16 | inputs: %{ 17 | "date_formats_samples.txt" => 18 | "test/fixture/date_formats_samples.txt" 19 | |> File.read!() 20 | |> String.split("\n") 21 | }, 22 | save: [file: "benchmark.benchee", tag: Date.to_iso8601(DateTime.utc_now())], 23 | load: "benchmark.benchee", 24 | after_scenario: fn input -> 25 | input 26 | |> Enum.reduce(0, fn i, errors -> 27 | case DateTimeParser.parse_datetime(i) do 28 | {:ok, _} -> errors 29 | {:error, _} -> errors + 1 30 | end 31 | end) 32 | |> IO.inspect(label: "Failed to parse") 33 | end 34 | ]) 35 | -------------------------------------------------------------------------------- /pages/upgrading_0_to_1.md: -------------------------------------------------------------------------------- 1 | # Upgrading from 0.x to 1.0 2 | 3 | * If you use `parse_datetime/1`, then change to `parse_datetime/2` with the 4 | second argument as a keyword list to `assume_time: true` and `to_utc: true`. 5 | In 0.x, it would merge `~T[00:00:00]` if the time tokens could not be parsed; 6 | in 1.x, you have to opt into this behavior. Also in 0.x, a non-UTC timezone 7 | would automatically convert to UTC; in 1.x, the original timezone will be 8 | kept instead. 9 | * If you use `parse_date/1`, then change to `parse_date/2` with the second 10 | argument as a keyword list to `assume_date: true`. In 0.x, it would merge 11 | `Date.utc_today()` with the found date tokens; in 1.x, you need to opt into 12 | this behavior. 13 | * If you use `parse_time`, there is no breaking change but parsing has been 14 | improved. 15 | * Not a breaking change, but 1.x introduces `parse/2` that will return the best 16 | struct from the tokens. This may influence your usage. 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 David Bernheisel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /bench/ruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/inline' 4 | 5 | gemfile do 6 | source 'https://rubygems.org' 7 | gem 'benchmark-ips' 8 | end 9 | 10 | require 'time' 11 | require 'benchmark/ips' 12 | 13 | puts "\nBenchmarking Ruby Time.parse" 14 | samples = File.read('test/fixture/date_formats_samples.txt').split("\n").freeze 15 | could_not_parse = 0 16 | 17 | Benchmark.ips do |x| 18 | x.time = 5 19 | x.warmup = 2 20 | x.report('Time.parse') do 21 | could_not_parse = 0 22 | samples.map do |v| 23 | begin 24 | Time.parse(v) 25 | rescue ArgumentError 26 | could_not_parse += 1 27 | end 28 | end 29 | end 30 | 31 | x.report('Date.parse') do 32 | could_not_parse = 0 33 | samples.map do |v| 34 | begin 35 | Date.parse(v) 36 | rescue ArgumentError 37 | could_not_parse += 1 38 | end 39 | end 40 | end 41 | 42 | x.report('DateTime.parse') do 43 | could_not_parse = 0 44 | samples.map do |v| 45 | begin 46 | DateTime.parse(v) 47 | rescue ArgumentError 48 | could_not_parse += 1 49 | end 50 | end 51 | end 52 | 53 | x.compare! 54 | end 55 | 56 | puts "Failed to parse #{could_not_parse}" 57 | -------------------------------------------------------------------------------- /bench/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/inline' 4 | 5 | gemfile do 6 | source 'https://rubygems.org' 7 | gem 'activesupport', '= 4.2.9' 8 | gem 'benchmark-ips' 9 | end 10 | 11 | require 'time' 12 | require 'benchmark/ips' 13 | require 'active_support' 14 | require 'active_support/core_ext/date_time' 15 | 16 | puts "\nBenchmarking Ruby Time.parse with ActiveSupport" 17 | samples = File.read('test/fixture/date_formats_samples.txt').split("\n").freeze 18 | could_not_parse = 0 19 | 20 | Benchmark.ips do |x| 21 | x.config(time: 5, warmup: 2) 22 | 23 | x.report('Time.parse') do 24 | could_not_parse = 0 25 | samples.map do |v| 26 | begin 27 | Time.parse(v) 28 | rescue ArgumentError 29 | nil 30 | end 31 | end 32 | end 33 | 34 | x.report('Date.parse') do 35 | could_not_parse = 0 36 | samples.map do |v| 37 | begin 38 | Date.parse(v) 39 | rescue ArgumentError 40 | could_not_parse += 1 41 | end 42 | end 43 | end 44 | 45 | x.report('DateTime.parse') do 46 | could_not_parse = 0 47 | samples.map do |v| 48 | begin 49 | DateTime.parse(v) 50 | rescue ArgumentError 51 | could_not_parse += 1 52 | end 53 | end 54 | end 55 | 56 | x.compare! 57 | end 58 | 59 | puts "Failed to parse #{could_not_parse}" 60 | -------------------------------------------------------------------------------- /lib/parser/tokenizer.ex: -------------------------------------------------------------------------------- 1 | defmodule DateTimeParser.Parser.Tokenizer do 2 | @moduledoc """ 3 | This parser doesn't parse, instead it checks the string and assigns the appropriate parser during 4 | preflight. The appropriate parser is determined by whether there is a `"/"` present in the string, 5 | and if so it will assume the string is a US-formatted date or datetime, and therefore use the 6 | US-optimized tokenizer module (ie, `DateTimeParser.Parser.DateUS` or 7 | `DateTimeParser.Parser.DateTimeUS`) for them. Time will always be parsed with 8 | `DateTimeParser.Parser.Time`. 9 | """ 10 | @behaviour DateTimeParser.Parser 11 | 12 | alias DateTimeParser.Parser 13 | 14 | @impl DateTimeParser.Parser 15 | def preflight(%{string: string, context: context} = parser) do 16 | {:ok, %{parser | mod: get_token_parser(context, string)}} 17 | end 18 | 19 | @impl DateTimeParser.Parser 20 | def parse(_parser) do 21 | raise DateTimeParser.ParseError, "Cannot parse with DateTimeParser.Parser.Tokenizer" 22 | end 23 | 24 | defp get_token_parser(:datetime, string) do 25 | if String.contains?(string, "/") do 26 | Parser.DateTimeUS 27 | else 28 | Parser.DateTime 29 | end 30 | end 31 | 32 | defp get_token_parser(:date, string) do 33 | if String.contains?(string, "/") do 34 | Parser.DateUS 35 | else 36 | Parser.Date 37 | end 38 | end 39 | 40 | defp get_token_parser(:time, _string) do 41 | Parser.Time 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | style: 11 | runs-on: ubuntu-latest 12 | name: Check Style 13 | env: 14 | MIX_ENV: test 15 | CI: "true" 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: erlef/setup-beam@v1 19 | with: 20 | otp-version: '27' 21 | elixir-version: '1.18.4' 22 | - run: mix deps.get --only dev 23 | - run: mix format --check-formatted 24 | - run: mix credo --strict 25 | 26 | test-oldest: 27 | runs-on: windows-2022 28 | name: Tests on oldest version 29 | env: 30 | MIX_ENV: test 31 | CI: "true" 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: erlef/setup-beam@v1 35 | with: 36 | otp-version: '22' 37 | elixir-version: '1.12.3-otp-22' 38 | - run: rm mix.lock 39 | - run: mix deps.get 40 | - run: mix compile --force --warnings-as-errors 41 | - run: mix compile.nimble 42 | - run: mix test 43 | 44 | test: 45 | runs-on: ubuntu-latest 46 | name: Tests on latest version 47 | env: 48 | MIX_ENV: test 49 | CI: "true" 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: erlef/setup-beam@v1 53 | with: 54 | otp-version: '27' 55 | elixir-version: '1.18.4' 56 | - run: rm mix.lock 57 | - run: mix deps.get 58 | - run: mix compile --force --warnings-as-errors 59 | - run: mix compile.nimble 60 | - run: mix test 61 | -------------------------------------------------------------------------------- /test/support/recorder.ex: -------------------------------------------------------------------------------- 1 | defmodule DateTimeParserTest.Recorder do 2 | @moduledoc "Record results from tests into a Markdown file" 3 | @external_resource "test/fixture/playground_header.md" 4 | 5 | if Version.match?(System.version(), ">= 1.5.0") do 6 | use Agent 7 | @name {:global, :recorder} 8 | @examples_header File.read!(@external_resource) 9 | @example_file "EXAMPLES.livemd" 10 | 11 | def start_link(initial_value \\ []) do 12 | Agent.start_link(fn -> initial_value end, name: @name) 13 | end 14 | 15 | def add(input, output, method, opts) do 16 | Agent.update(@name, fn state -> 17 | [{input, output, method, opts} | state] 18 | end) 19 | end 20 | 21 | def list do 22 | Agent.get(@name, fn state -> 23 | Enum.sort(state) 24 | end) 25 | end 26 | 27 | def write_results do 28 | write_headers() 29 | Enum.each(list(), &write_result/1) 30 | 31 | :ok 32 | end 33 | 34 | defp write_headers do 35 | File.write(@example_file, @examples_header) 36 | end 37 | 38 | defp write_result({input, output, method, opts}) do 39 | File.write( 40 | @example_file, 41 | "|`#{input}`|`#{DateTimeParserTestMacros.to_iso(output)}`|#{method}|#{format_options(opts)}|\n", 42 | [:append] 43 | ) 44 | end 45 | 46 | defp format_options([]), do: " " 47 | defp format_options(options), do: "`#{inspect(options)}`" 48 | else 49 | def start_link(_ \\ []), do: {:ok, nil} 50 | def add(_, _, _, _), do: :ok 51 | def list, do: :ok 52 | def write_results, do: :ok 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/fixture/playground_header.md: -------------------------------------------------------------------------------- 1 | # DateTimeParser Playground 2 | 3 | ## Installation 4 | 5 | Install DateTimeParser in your project 6 | 7 | ```elixir 8 | Mix.install([:date_time_parser, :tz]) 9 | Application.put_env(:elixir, :time_zone_database, Tz.TimeZoneDatabase) 10 | ``` 11 | 12 | ## Usage 13 | 14 | Use DateTimeParser to parse strings into DateTime, NaiveDateTime, Date, or Time 15 | structs. For example: 16 | 17 | ```elixir 18 | [ 19 | DateTimeParser.parse_datetime("2021-11-09T10:30:00Z"), 20 | DateTimeParser.parse_datetime("2021-11-09T10:30:00"), 21 | DateTimeParser.parse_date("2021-11-09T10:30:00Z"), 22 | DateTimeParser.parse_time("2021-11-09T10:30:00Z"), 23 | DateTimeParser.parse("2021-32-32T10:30:00Z"), 24 | DateTimeParser.parse("2021-11-09T10:30:00Z"), 25 | DateTimeParser.parse("2021-11-09T10:30:00 EST") 26 | ] 27 | ``` 28 | 29 | or use the bang functions: 30 | 31 | ```elixir 32 | [ 33 | DateTimeParser.parse_datetime!("2021-11-09T10:30:00Z"), 34 | DateTimeParser.parse_datetime!("2021-11-09T10:30:00"), 35 | DateTimeParser.parse_date!("2021-11-09T10:30:00Z"), 36 | DateTimeParser.parse_time!("2021-11-09T10:30:00Z"), 37 | DateTimeParser.parse!("2021-32-32T10:30:00Z"), 38 | DateTimeParser.parse!("2021-11-09T10:30:00Z"), 39 | DateTimeParser.parse!("2021-11-09T10:30:00 EST") 40 | ] 41 | ``` 42 | 43 | Errors sometimes occur when it can't parse the input: 44 | 45 | ```elixir 46 | [ 47 | DateTimeParser.parse("wat"), 48 | DateTimeParser.parse(123), 49 | DateTimeParser.parse([:foo]) 50 | ] 51 | ``` 52 | 53 | ## Options 54 | 55 | You can configure some convenient options as well, for example to automatically 56 | convert to UTC or to assume a time when not present. 57 | 58 | ```elixir 59 | [ 60 | DateTimeParser.parse("12:30PM", assume_date: Date.utc_today()), 61 | DateTimeParser.parse("2022-01-01", assume_time: ~T[12:43:00]), 62 | DateTimeParser.parse("2022-01-01T15:30 EST", to_utc: true), 63 | DateTimeParser.parse("2022-01-01T15:30 EST", to_utc: false), 64 | # Excel time 65 | DateTimeParser.parse("30134"), 66 | # old Mac Excel spreadsheet time 67 | DateTimeParser.parse("30134.4321", use_1904_date_system: true) 68 | ] 69 | ``` 70 | 71 | ## Examples 72 | 73 | |**Input**|**Output (ISO 8601)**|**Method**|**Options**| 74 | |:-------:|:-------------------:|:--------:|:---------:| 75 | -------------------------------------------------------------------------------- /lib/parser/time.ex: -------------------------------------------------------------------------------- 1 | defmodule DateTimeParser.Parser.Time do 2 | @moduledoc """ 3 | Tokenizes the string for time elements. This will also attempt to extract the time out of the 4 | string first before tokenizing to reduce noise in an attempt to be more accurate. For example, 5 | 6 | ```elixir 7 | iex> DateTimeParser.parse_time("Hello Johnny 5, it's 9:30PM") 8 | {:ok, ~T[21:30:00]} 9 | ``` 10 | 11 | This will use a regex to extract the part of the string that looks like time, ie, `"9:30PM"` 12 | """ 13 | @behaviour DateTimeParser.Parser 14 | @time_regex ~r|(?