Skip to content

Commit

Permalink
add: email string parser
Browse files Browse the repository at this point in the history
  • Loading branch information
danini-the-panini committed Nov 1, 2021
1 parent fd69f94 commit 675a652
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 3 deletions.
1 change: 1 addition & 0 deletions lib/kdl/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ module Types
require 'kdl/types/base64'
require 'kdl/types/decimal'
require 'kdl/types/hostname'
require 'kdl/types/email'

KDL::Types::MAPPING.freeze
26 changes: 26 additions & 0 deletions lib/kdl/types/email.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require_relative './email/parser'

module KDL
module Types
class Email < Value
attr_reader :local, :domain

LOCAL_PART_CHARS = /[a-zA-Z0-9!#\$%&'*+\-\/=?\^_`{|}~]/
LOCAL_PART_RGX = /^[a-zA-Z0-9!#\$%&'*+\-\/=?\^_`{|}~]{1,64}$/

def initialize(value, local:, domain:, **kwargs)
super(value, **kwargs)
@local = local
@domain = domain
end

def self.call(value, type = 'email')
local, domain = Parser.new(value.value).parse

new(value.value, type: type, local: local, domain: domain)
end

end
MAPPING['email'] = Email
end
end
136 changes: 136 additions & 0 deletions lib/kdl/types/email/parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
module KDL
module Types
class Email < Value
class Parser
def initialize(string)
@string = string
@tokenizer = Tokenizer.new(string)
end

def parse
local = ''
domain = nil
context = :start

loop do
type, value = @tokenizer.next_token

case type
when :part
case context
when :start, :after_dot
local += value
context = :after_part
else
raise ArgumentError, "invalid email #{@string} (unexpected part #{value} at #{context})"
end
when :dot
case context
when :after_part
local += value
context = :after_dot
else
raise ArgumentError, "invalid email #{@string} (unexpected dot at #{context})"
end
when :at
case context
when :after_part
context = :after_at
end
when :domain
case context
when :after_at
raise ArgumentError, "invalid hostname #{value}" unless Hostname.valid_hostname?(value)
domain = value
context = :after_domain
else
raise ArgumentError, "invalid email #{@string} (unexpected domain at #{context})"
end
when :end
case context
when :after_domain
if local.length > 64
raise ArgumentError, "invalid email #{@string} (local part length #{local.length} exceeds maximaum of 64)"
end

return [local, domain]
else
raise ArgumentError, "invalid email #{@string} (unexpected end at #{context})"
end
end
end
end
end

class Tokenizer
def initialize(string)
@string = string
@index = 0
@after_at = false
end

def next_token
if @after_at
if @index < @string.length
domain_start = @index
@index = @string.length
return [:domain, @string[domain_start..-1]]
else
return [:end, nil]
end
end
@context = nil
@buffer = ''
loop do
c = @string[@index]
return [:end, nil] if c.nil?

case @context
when nil
case c
when '.'
@index += 1
return [:dot, '.']
when '@'
@after_at = true
@index += 1
return [:at, '@']
when '"'
@context = :quote
@index += 1
when LOCAL_PART_CHARS
@context = :part
@buffer += c
@index += 1
else
raise ArgumentError, "invalid email #{@string} (unexpected #{c})"
end
when :part
case c
when LOCAL_PART_CHARS
@buffer += c
@index += 1
when '.', '@'
return [:part, @buffer]
else
raise ArgumentError, "invalid email #{@string} (unexpected #{c})"
end
when :quote
case c
when '"'
n = @string[@index + 1]
raise ArgumentError, "invalid email #{@string} (unexpected #{c})" unless n == '.' || n == '@'

@index += 1
return [:part, @buffer]
else
@buffer += c
@index += 1
end
end
end
end
end
end
end
end
58 changes: 58 additions & 0 deletions test/types/email_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
require "test_helper"

class EmailTest < Minitest::Test
def test_email
value = KDL::Types::Email.call(::KDL::Value::String.new('[email protected]'))
assert_equal '[email protected]', value.value
assert_equal 'danielle', value.local
assert_equal 'example.com', value.domain

assert_raises { KDL::Types::Email.call(::KDL::Value::String.new('not an email')) }
end

VALID_EMAILS = [
['[email protected]', 'simple', 'example.com'],
['[email protected]', 'very.common', 'example.com'],
['[email protected]', 'disposable.style.email.with+symbol', 'example.com'],
['[email protected]', 'other.email-with-hyphen', 'example.com'],
['[email protected]', 'fully-qualified-domain', 'example.com'],
['[email protected]', 'user.name+tag+sorting', 'example.com'],
['[email protected]', 'x', 'example.com'],
['[email protected]', 'example-indeed', 'strange-example.com'],
['test/[email protected]', 'test/test', 'test.com'],
['admin@mailserver1', 'admin', 'mailserver1'],
['[email protected]', 'example', 's.example'],
['" "@example.org', ' ', 'example.org'],
['"john..doe"@example.org', 'john..doe', 'example.org'],
['[email protected]', 'mailhost!username', 'example.org'],
['user%[email protected]', 'user%example.com', 'example.org'],
['[email protected]', 'user-', 'example.org']
]

def test_valid_emails
VALID_EMAILS.each do |email, local, domain|
value = KDL::Types::Email.call(::KDL::Value::String.new(email))
assert_equal email, value.value
assert_equal local, value.local
assert_equal domain, value.domain
end
end

INVALID_EMAILS = [
'Abc.example.com',
'A@b@[email protected]',
'a"b(c)d,e:f;g<h>i[j\k][email protected]',
'just"not"[email protected]',
'this is"not\[email protected]',
'this\ still\"not\\[email protected]',
'1234567890123456789012345678901234567890123456789012345678901234+x@example.com',
'[email protected]',
'QA🦄CHOCOLATE🌈@test.com'
]

def test_invalid_emails
INVALID_EMAILS.each do |email|
assert_raises { KDL::Types::Email.call(::KDL::Value::String.new(email)) }
end
end
end
8 changes: 5 additions & 3 deletions test/types_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ def test_types
(uuid)"f81d4fae-7dec-11d0-a765-00a0c91e6bf6" \\
(regex)"asdf" \\
(base64)"U2VuZCByZWluZm9yY2VtZW50cw==\n" \\
(decimal)"10000000000000\n" \\
(hostname)"www.example.com\n" \\
(idn-hostname)"xn--bcher-kva.example\n"
(decimal)"10000000000000" \\
(hostname)"www.example.com" \\
(idn-hostname)"xn--bcher-kva.example" \\
(email)"[email protected]"
KDL

refute_nil doc
Expand All @@ -38,6 +39,7 @@ def test_types
assert_kind_of ::KDL::Types::Decimal, doc.nodes.first.arguments[13]
assert_kind_of ::KDL::Types::Hostname, doc.nodes.first.arguments[14]
assert_kind_of ::KDL::Types::IDNHostname, doc.nodes.first.arguments[15]
assert_kind_of ::KDL::Types::Email, doc.nodes.first.arguments[16]
end

def test_custom_types
Expand Down

0 comments on commit 675a652

Please sign in to comment.