Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby로 직접 만드는 나만의 파서: StringScanner 완벽 활용 가이드

파싱(Parsing)이란 문자열 덩어리를 분석해 우리가 이해할 수 있는 형태로 변환하는 작업입니다. 정규표현식(regular expression)을 사용할 수도 있지만, 모든 상황에 적합한 도구는 아닙니다.

예를 들어, HTML을 정규표현식으로 파싱하는 것은 좋지 않은 방법이라는 사실은 이미 널리 알려져 있습니다.

Ruby에는 이런 작업을 대신 처리해 주는 nokogiri라는 훌륭한 라이브러리가 있지만, 직접 파서를 만들어 보면 많은 것을 배울 수 있습니다. 그럼 시작해 보겠습니다!

Ruby로 파싱하기

우리 파서의 핵심은 StringScanner 클래스입니다.

이 클래스는 문자열의 복사본과 위치 포인터(position pointer)를 가지고 있습니다. 이 포인터를 통해 문자열을 탐색하면서 특정 토큰(token)을 찾을 수 있습니다.

사용할 메서드는 다음과 같습니다:

  • .peek
  • .scan_until
  • .getch

그 외에도 .scan(until이 붙지 않은 버전)이라는 유용한 메서드가 있습니다.

참고:

StringScanner를 사용할 수 없다면 require 'strscan'을 추가해 보세요.

이 클래스가 어떻게 동작하는지 이해할 수 있도록 문서 역할을 하는 두 개의 테스트를 작성했습니다:

describe StringScanner do
  let (:buff) { StringScanner.new "testing" }

  it "can peek one step ahead" do
    expect(buff.peek 1).to eq "t"
  end

  it "can read one char and return it" do
    expect(buff.getch).to eq "t"
    expect(buff.getch).to eq "e"
  end
end

이 클래스에서 주목할 중요한 점은 일부 메서드(getch, scan)는 위치 포인터를 앞으로 이동시키지만, 다른 메서드(peek)는 그렇지 않다는 것입니다. 언제든지 스캐너를 검사(.inspect 또는 p)하여 현재 위치를 확인할 수 있습니다.

Parser 클래스

대부분의 실질적인 작업은 Parser 클래스에서 이루어집니다. 파싱하려는 텍스트 조각으로 초기화하면 해당 문자열에 대한 StringScanner를 생성하고 parse 메서드를 호출합니다:

def initialize(str)
  @buffer = StringScanner.new(str)
  @tags   = []
  parse
end

테스트 코드에서는 다음과 같이 정의합니다:

let(:parser) { Parser.new "<body>testing</body> <title>parsing with ruby</title>" }

이 클래스가 어떻게 동작하는지는 잠시 후 자세히 살펴보고, 먼저 프로그램의 마지막 구성 요소를 확인해 보겠습니다.

Tag 클래스

이 클래스는 매우 단순하며, 주로 파싱 결과를 담는 컨테이너이자 데이터 클래스 역할을 합니다.

class Tag
  attr_reader :name
  attr_accessor :content

  def initialize(name)
    @name = name
  end
end

파싱해 봅시다!

무언가를 파싱하려면 입력 텍스트에서 패턴을 찾아야 합니다. 예를 들어, HTML 코드는 다음과 같은 형태를 가집니다:

<tag>contents</tag>

여기서 명확하게 식별할 수 있는 두 가지 구성 요소가 있습니다. 바로 태그 이름과 태그 내부의 텍스트입니다. BNF 표기법으로 형식 문법(formal grammar)을 정의한다면 다음과 같을 것입니다:

tag = <opening_tag> <contents> <closing_tag>
opening_tag = "<" <tag_name> ">"
closing_tag = "</" <tag_name> ">"

StringScanner의 peek를 사용해 입력 버퍼의 다음 심볼이 여는 태그인지 확인합니다. 그렇다면 Parser 클래스의 find_tagfind_content 메서드를 호출합니다:

def parse_element
  if @buffer.peek(1) == '<'
    @tags << find_tag
    last_tag.content = find_content
  end
end

find_tag 메서드는 다음 작업을 수행합니다:

  • 여는 태그 문자('<')를 '소비(consume)'합니다
  • 닫는 심볼(">")을 찾을 때까지 스캔합니다
  • 태그 이름을 담은 새 Tag 객체를 생성하고 반환합니다

코드는 다음과 같습니다. 마지막 문자를 chop으로 잘라내야 한다는 점에 주목하세요. scan_until이 결과에 '>'까지 포함하기 때문에, 우리는 그것을 원하지 않습니다.

def find_tag
  @buffer.getch
  tag = @buffer.scan_until />/
  Tag.new(tag.chop)
end

다음 단계는 태그 내부의 내용을 찾는 것입니다. scan_until 메서드가 위치 포인터를 올바른 지점까지 이동시켜 주기 때문에 크게 어렵지 않습니다. 다시 scan_until을 사용해 닫는 태그를 찾고 태그의 내용을 반환합니다.

def find_content
  tag = last_tag.name
  content = @buffer.scan_until /<\/#{tag}>/
  content.sub("</#{tag}>", "")
end

이제:

입력 버퍼에서 더 이상 태그를 찾을 수 없을 때까지 parse_element를 반복 호출하기만 하면 됩니다.

def parse
  until @buffer.eos?
    skip_spaces
    parse_element
  end
end

전체 코드는 https://github.com/matugm/simple-parser 에서 확인할 수 있습니다. 또한 'nested_tags' 브랜치에서는 태그 안에 다른 태그가 중첩된 경우를 처리할 수 있는 확장 버전도 살펴볼 수 있습니다.

마무리

파서를 작성하는 것은 흥미로운 주제이지만, 때로는 꽤 복잡해질 수 있습니다.

처음부터 직접 파서를 만들고 싶지 않다면 이른바 '파서 제너레이터(parser generator)'를 사용하는 것도 좋은 선택입니다. Ruby 진영에서는 treetop과 parslet이 널리 사용됩니다.