เวลาสารภาพ:ฉันไม่ชอบทำงานกับสำนวนทั่วไปเป็นพิเศษ ในขณะที่ฉันใช้มันตลอดเวลา มีอะไรที่ซับซ้อนกว่า /^foo.*$/
ทำให้ฉันต้องหยุดคิด ฉันแน่ใจว่ามีคนที่สามารถถอดรหัสนิพจน์เช่น \A(?=\w{6,10}\z)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3})
แต่ต้องใช้เวลาหลายนาทีในการค้นหา Google และทำให้ฉันไม่มีความสุข มันค่อนข้างแตกต่างจากการอ่าน Ruby
หากคุณสงสัย ตัวอย่างข้างต้นนำมาจากบทความนี้เกี่ยวกับ regex lookaheads
สถานการณ์
ที่ Honeybadger ฉันกำลังปรับปรุง UI การค้นหาของเรา เช่นเดียวกับระบบการค้นหาอื่นๆ ระบบของเราใช้ภาษาที่ใช้ค้นหาอย่างง่าย ก่อนการเปลี่ยนแปลงของฉัน หากคุณต้องการค้นหาช่วงวันที่ที่กำหนดเอง คุณต้องพิมพ์ข้อความค้นหาด้วยตนเองดังนี้:
occurred:[2017-06-12T16:10:00Z TO 2017-06-12T17:10:00Z]
อุ๊ย!
ใน UI การค้นหาใหม่ เราต้องการตรวจหาเมื่อคุณเริ่มพิมพ์ข้อความค้นหาที่เกี่ยวข้องกับวันที่และแสดงตัวเลือกวันที่ที่เป็นประโยชน์ และแน่นอนว่า datepicker เป็นเพียงจุดเริ่มต้นเท่านั้น ในที่สุด เราจะขยายคำใบ้ตามบริบทเพื่อให้ครอบคลุมข้อความค้นหาประเภทต่างๆ มากขึ้น ต่อไปนี้คือตัวอย่างบางส่วน:
assigned:[email protected] context.user.id=100
resolved:false ignored:false occurred:[
params.article.title:"Starr's parser post" foo:'ba
ฉันต้องการ tokenize สตริงเหล่านี้ในลักษณะที่:
- ช่องว่างแยกโทเค็น ยกเว้นเมื่อล้อมรอบด้วย '', "" หรือ []
- ช่องว่างที่ไม่มีเครื่องหมายคำพูดเป็นโทเค็นของตัวเอง
- ฉันสามารถเรียกใช้
tokens.join("")
เพื่อสร้างสตริงอินพุตใหม่ทั้งหมด
ตัวอย่างเช่น:
tokenize(%[params.article.title:"Starr's parser post" foo:'ba])
=> ["params.article.title:\"Starr's parser post\"", " ", "foo:'ba"]
การใช้นิพจน์ทั่วไป
ความคิดแรกของฉันคือการใช้การจับภาพนิพจน์ทั่วไปเพื่อกำหนดว่าโทเค็นที่ถูกต้องควรมีลักษณะอย่างไร จากนั้นใช้ String#split
เพื่อแยกสตริงออกเป็นโทเค็น เป็นเคล็ดลับที่เจ๋งจริง ๆ :
# The parens in the regexp mean that the separator is added to the array
"foo bar baz".split(/(foo|bar|baz)/)
=> ["", "foo", " ", "bar", " ", "baz"]
สิ่งนี้ดูมีแนวโน้มในตอนแรก แม้ว่าจะมีสตริงว่างแปลก ๆ แต่การแสดงออกปกติในโลกแห่งความเป็นจริงของฉันนั้นซับซ้อนกว่ามาก ร่างแรกของฉันมีลักษณะดังนี้:
/
( # Capture group is so split will include matching and non-matching strings
(?: # The first character of the key, which is
(?!\s)[^:\s"'\[]{1} # ..any valid "key" char not preceeded by whitespace
|^[^:\s"'\[]{0,1} # ..or any valid "key" char at beginning of line
)
[^:\s"'\[]* # The rest of the "key" chars
: # a colon
(?: # The "value" chars, which are
'[^']+' # ..anything surrounded by single quotes
| "[^"]+" # ..or anything surrounded by double quotes
| \[\S+\sTO\s\S+\] # ..or anything like [x TO y]
| [^\s"'\[]+ # ..or any string not containing whitespace or special chars
)
)
/xi
การทำงานกับสิ่งนี้ทำให้ฉันรู้สึกจม ทุกครั้งที่ฉันพบ edge case ฉันต้องแก้ไขนิพจน์ทั่วไป ทำให้มันซับซ้อนยิ่งขึ้น นอกจากนี้ มันจำเป็นต้องทำงานใน JavaScript และ Ruby ดังนั้นจึงไม่มีฟีเจอร์บางอย่าง เช่น เนกาทีฟ lookbehind
...ช่วงเวลานี้เองที่ความไร้สาระทั้งหมดนี้ทำให้ฉันหลง วิธีนิพจน์ทั่วไปที่ฉันใช้อยู่นั้นซับซ้อนกว่าการเขียน parser ง่ายๆ ตั้งแต่เริ่มต้น
กายวิภาคของ Parser
ฉันไม่ใช่ผู้เชี่ยวชาญ แต่ตัวแยกวิเคราะห์ง่าย ๆ นั้นเรียบง่าย ทั้งหมดที่พวกเขาทำคือ:
- ก้าวผ่านสตริง ทีละอักขระ
- ผนวกอักขระแต่ละตัวเข้ากับบัฟเฟอร์
- เมื่อพบเงื่อนไขการแยกโทเค็น ให้บันทึกบัฟเฟอร์ลงในอาร์เรย์แล้วล้างข้อมูล
เมื่อทราบสิ่งนี้ เราสามารถตั้งค่า parser อย่างง่ายที่แยกสตริงด้วยช่องว่าง มันเทียบเท่ากับ "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"]
นี่เป็นขั้นตอนในทิศทางของสิ่งที่ฉันต้องการ แต่ไม่มีการสนับสนุนสำหรับเครื่องหมายคำพูดและวงเล็บ โชคดีที่การเพิ่มนั้นใช้โค้ดเพียงไม่กี่บรรทัด:
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
end
else
flush() if previous().match(WHITESPACE) && !surround
@buffer << c
end
end
flush
@output
end
รหัสนี้ยาวกว่าวิธีการที่ใช้นิพจน์ทั่วไปเพียงเล็กน้อยเท่านั้น แต่ตรงไปตรงมามากกว่ามาก
ความคิดที่พรากจากกัน
อาจมีนิพจน์ทั่วไปที่สามารถทำงานได้ดีกับกรณีการใช้งานของฉัน ถ้าประวัติศาสตร์เป็นแนวทาง มันคงง่ายพอที่จะทำให้ฉันดูเหมือนคนโง่ :)
แต่ฉันมีความสุขมากที่ได้มีโอกาสเขียน parser ตัวน้อยนี้ มันทำให้ฉันหลุดพ้นจากปัญหาที่ฉันอยู่ในแนวทาง regex เป็นโบนัสที่ดี ฉันมีความมั่นใจมากขึ้นในโค้ดที่เป็นผลลัพธ์มากกว่าที่ฉันเคยเป็นด้วยโค้ดที่อิงจากนิพจน์ทั่วไปที่ซับซ้อน