ขอ कबूल: ผมไม่ค่อยชอบทำงานกับ Regular Expressions แม้จะใช้ตลอดเวลา แต่วันที่ซับซ้อนกว่า /^foo.*$/ ก็ต้องหยุดคิดนาน แม้จะมีคนอ่าน Expression แบบ \A(?=\w{6,10}\z)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3}) ได้ในทันที แต่ผมใช้เวลาหลายนาที Googling และทำให้ผมไม่มีความสุข มันต่างจากการอ่าน Ruby มาก
ถ้าสนใจ ตัวอย่างข้างต้นมาจากบทความเกี่ยวกับ Regex Lookaheads
สถานการณ์
ที่ Honeybadger ผมกำลังปรับปรุง Search UI ของเรา ในระบบ Search หลายๆ ระบบ รวมถึงของเรา ใช้ Query Language เรียบง่าย ก่อนที่ผมจะเปลี่ยน ถ้าอยาก Search Custom Date Range ต้องพิมพ์ Query เองแบบนี้:
occurred:[2017-06-12T16:10:00Z TO 2017-06-12T17:10:00Z]
เจ็บ!
ใน Search UI ใหม่ เราต้องการ Detect ตอนเริ่มพิมพ์ Date-related Query แล้วโชว์ Datepicker ช่วยเหลือ และ Datepicker 只是 เริ่มต้น ต่อไปเราจะขยาย Context-sensitive Hinting ครอบคลุม Search Terms มากขึ้น นี่คือตัวอย่าง:
assigned:jane@email.com context.user.id=100 resolved:false ignored:false occurred:[ params.article.title:"Starr's parser post" foo:'ba
ผมต้อง Tokenize Strings เหล่านี้ให้:
- Whitespace แยก Tokens เว้นแต่วรอบด้วย '', "" หรือ []
- Unquoted Whitespace เป็น Token ของตัวเอง
- รัน
tokens.join("")ได้ Input String เดิมทุกตัว
ตัวอย่าง:
tokenize(%[params.article.title:"Starr's parser post" foo:'ba]) => ["params.article.title:\"Starr's parser post\"", " ", "foo:'ba"]
ใช้ Regular Expression
ความคิดแรกคือ用 Capturing Regex กำหนด Valid Token 長什么样 แล้ว用 String#split แยก String เป็น Tokens มันเป็น Trick ค่อนข้าง Cool:
# Parentheses ใน Regexp ทำให้ Separator เพิ่มเข้า Array ด้วย
"foo bar baz".split(/(foo|bar|baz)/) # => ["", "foo", " ", "bar", " ", "baz"]
ดูมีหวัง แต่ Real-world Regex ของผมซับซ้อนมาก Draft แรกเป็นแบบนี้:
/ (
# Capture group ทำให้ split รวม matching และ non-matching strings
(?:
# ตัวอักษรแรกของ Key ซึ่งคือ
(?!\s)[^:\s"'\[]{1}
# ..ตัวอักษร "key" ใดๆ ที่ไม่มี whitespace นำหน้า
|^[^:\s"'\[]{0,1}
# ..หรือตัวอักษร "key" ใดๆ ที่ต้นบรรทัด
)
[^:\s"'\[]* # ตัวอักษร "key" ที่เหลือ
: # colon
(?:
# ตัวอักษร "value" ซึ่งคือ
'[^']+' # ..อะไรก็ได้รอบๆ single quotes
| "[^"]+" # ..หรืออะไรก็ได้รอบๆ double quotes
| \[\S+\sTO\s\S+\] # ..หรืออะไรแบบ [x TO y]
| [^\s"'\[]+ # ..หรือ string ใดๆ ที่ไม่มี whitespace หรือ special chars
)
) /xi
ทำงานกับมันรู้สึกท้องเสีย ทุกครั้งเจอ Edge Case ต้องแก้ Regex ทำให้ซับซ้อนขึ้นเรื่อยๆ อีกทั้งต้องทำงานใน JavaScript ด้วย ทำให้ Feature บางอย่าง เช่น Negative Lookbehind ใช้ไม่ได้
...ประมาณเวลานี้ ความไร้เหตุผลของทั้งนี้โจมตีผม Regex Approach ที่ผมใช้ซับซ้อนกว่าเขียน Simple Parser เองจากศูนย์มาก
สรีระวิทยาของ Parser
ผมไม่ใช่ Expert แต Simple Parsers เรียบง่าย ทุกอย่างที่มันทำคือ:
- Step ผ่าน String ทีละ Character
- Append แต่ละ Character ไป Buffer
- เมื่อเจอ Token-separating Condition ให้ Save Buffer ลง Array และล้าง Buffer
รู้แล้ว เราสามารถตั้ง Parser เรียบง่ายที่แยก String ด้วย Whitespace ได้ ประมาณเทียบเท่า "foo bar".split(/(\s+)/)
class Parser
WHITESPACE = /\s/
NON_WHITESPACE = /\S/
def initialize
@buffer = []
@output = []
end
def parse(text)
text.each_char do |c|
case c
when WHITESPACE
flush if previous.match(NON_WHITESPACE)
@buffer << c
else
flush if previous.match(WHITESPACE)
@buffer << c
end
end
flush
@output
end
protected
def flush
if @buffer.any?
@output << @buffer.join("")
@buffer = []
end
end
def previous
@buffer.last || ""
end
end
puts Parser.new().parse("foo bar baz").inspect
# Outputs ["foo", " ", "bar", " ", "baz"]
นี่เป็นขั้นตอนหนึ่งไปสู่สิ่งที่ผมอยาก แต่ยังขาด Quotes และ Brackets โชคดี เพิ่มได้แค่ไม่กี่บรรทัด:
def parse(text)
surround = nil
text.each_char do |c|
case c
when WHITESPACE
flush if previous.match(NON_WHITESPACE) && !surround
@buffer << c
when '"', "'"
@buffer << c if !surround
surround = c
elsif surround == c
flush
surround = nil
end
when "["
@buffer << c
surround = c if !surround
when "]"
@buffer << c if surround == "["
flush
surround = nil
else
flush() if previous().match(WHITESPACE) && !surround
@buffer << c
end
end
flush
@output
end
โค้ดนี้ยาวกว่า Regex Approach เพียงเล็กน้อย แต่น่าอ่านและเข้าใจง่ายมาก
ข้อคิดเหลือ
อาจมี Regex ใดๆ ที่ทำงานได้กับ Use Case ของผม ถ้าประวัติบ่งบอก มันอาจ Simple พอที่จะทำให้ผมดูโง่ :)
แตผมเพลินมากที่ได้เขียน Parser น้อยๆ นี้ มันดึงผมออกจาก Rut ที่ติดกับ Regex Approach โบนัสคือ ผมมั่นใจในโค้ดผลลัพธ์มากกว่าโค้ดที่อิง Complex Regular Expressions มาก