Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

การแทนที่นิพจน์ทั่วไปที่ซับซ้อนด้วย Simple Parser

เวลาสารภาพ:ฉันไม่ชอบทำงานกับสำนวนทั่วไปเป็นพิเศษ ในขณะที่ฉันใช้มันตลอดเวลา มีอะไรที่ซับซ้อนกว่า /^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:jane@email.com 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 เป็นโบนัสที่ดี ฉันมีความมั่นใจมากขึ้นในโค้ดที่เป็นผลลัพธ์มากกว่าที่ฉันเคยเป็นด้วยโค้ดที่อิงจากนิพจน์ทั่วไปที่ซับซ้อน