Common Lisp을 위한 정규표현식

CL-PPCRE

cl-ppcre는 PCREPerl Compatible Regular Expression를 커먼 리습에서 쓸 수 있도록 구현한 것이다. ppcre는 순수하게 리습으로 구현되어있어 다른 네이티브 라이브러리에 의존하지 않는다(PPCRE의 첫 글자인 P는 portable의 머리글자인데, 바로 이러한 이유이다). 그럼에도 불구하고 커먼 리습의 컴파일러 매크로를 이용하므로, 상당히 좋은 성능으로 동작한다. 커먼 리습 생태계에는 정규표현식을 처리하는 다양한 다른 라이브러리가 있지만, 그 중에서도 ppcre가 가장 대중적이다. 따라서 이 글은 ppcre 라이브러리의 사용법을 설명한다.

설치는 아주 쉽다. QuickLisp를 이용하면 쉽게 설치할 수 있다.

(ql:quickload :cl-ppcre)

목차

주목할만한 특징

CL-PPCRE는 PCRE를 리습으로 바닥부터 구현한 라이브러리이다. 따라서 c로 구현된 pcrelib의 동작과 일대일 대응하지 않는다. 이에 대해선 매뉴얼에 자세히 설명되어 있지만, 눈여겨볼만한 사항들만 정리해보겠다.

  • 유니코드 문자 클래스 \X를 지원하지 않는다. 지원하지 않는 이유는 그 대안이 있기 때문이다. 이에 대해선 이 글의 유니코드 단락에서 다시 설명한다.
  • [:alpha:]와 같은 POSIX 문자클래스를 지원하지 않는다. 대신 더 일반적으로 만들어진 property resolver를 사용할 수 있고, 유니코드의 다양한 문자집합들을 아우르는 unicode property를 사용할 수 있다. 유니코드 단락에서 다시 살펴보겠다.
  • S-표현식을 이용해 구문 트리parse tree를 생성할 수 있다. 직접 생성한 구문 트리는 등가의 정규표현식에 비해 더 읽기 쉽다(그러나 그만큼 표현이 야단스러워진다).
  • 문자 클래스들(\w, \W, \s, \S, \d, \D)과 앵커들(\b, \B, \A, \Z, \z)을 지원한다.
  • 이스케이프 블록(\Q ... \E)을 지원한다. 이스케이프 블록 단락에서 살펴본다.
  • 네임드 레퍼런스((?<name>...), \k<name>)을 지원한다. 명명된 캡처 그룹 단락에서 살펴본다.
  • 문자 블록 프로퍼티(\p\P)를 지원한다. 유니코드 단락에서 살펴보겠다.
  • 인라인 변경자embedded modifier((?i), (?m), (?s))를 지원한다. 변경자와 캡처 그룹 단락에서 살펴본다.

cl-ppcre의 함수들

들어가기 전에, ppcre라는 네임스페이스는 접두어로 쓰기에 너무 기므로, 별명을 만들어두자.

(ql:quickload :cl-ppcre)
(defpackage :re (:use :cl-ppcre))

혹시 원한다면, use-packagein-package 매크로로 아예 네임스페이스 접두어를 쓰지 않을 수도 있다.

scan

scan 함수로 정규표현식을 검색을 수행할 수 있다.

(re::scan "[0-9]+" "area51")
;;=> 4, 6, #(), #()
(re::scan "(?i)hello\\s?(?:world)?!" "Hello!")
;;=> 0, 6, #(), #()

scan 함수의 첫번째 인자로 정규표현식을, 두번째 인자로 시험할 문자열을 넣는다.

첫 두 개의 반환값은 첫번째 일치하는 항목의 시작 위치와 끝 위치이다. 세번째와 네번째 인자는 캡처 그룹의 시작 위치와 끝 위치의 배열이다. 이에 대해선 [변경자와 캡처 그룹] 단락에서 살펴본다.

scan-to-strings

scan과 비슷하나, 일치하는 항목을 문자열로 돌려준다.

(re::scan-to-strings "[0-9]+" "area51")
;;=> "51", #()
(re::scan-to-strings "(?i)hello\\s?(?:world)?!" "Hello!")
;;=> "Hello!", #()

scan-to-strings의 api는 scan 함수와 같다.

첫번째 반환값은 일치하는 항목이고, 두번째 반환값은 일치하는 캡처 그룹의 배열이다.

create-scanner

자주 쓰이는 정규식을 미리 컴파일하면 그 때 그 때 컴파일하여 쓰는 것보다 나은 성능을 기대할 수 있다. 아래 예시는 다양한 인삿말에 일치하는 정규식을 시험하는 예시이다.

(defvar sc 
  (re::create-scanner "(?i)hello\\s?(?:world)?!"))
(re::scan sc "hello!")       ;=> 0, 6
(re::scan sc "hello world!") ;=> 0, 12
(re::scan sc "HELLO world!") ;=> 0, 12

regex-replace

ppcre의 단 한 가지 아쉬운 점은 검색치환을 위한 표현이 없다는 점이다. 대신 ppcreregex-replace를 이용한 대안을 제공한다. 첫번째 인자로 검색을 위한 정규식이, 두번째 인자로 대상 문자열이, 세번째 인자로 대체 문자열을 넣는다.

(re::regex-replace "cafe" "Legendre's cafe" "coffee")
;;=> "Legendre's coffee"

(re::regex-replace "(?i)cafe" "Legendre's Cafe" "coffee"
		   :preserve-case t)
;;=> "Legendre's Coffee"

(re::regex-replace "name:(\\w+), age:(\\d+)"
		   "[name:junho, age:31]"
		   "\\1,\\2")
;;=> "[junho,31]"

첫번째 예시는 단순 치환이다. Legendre's cafe에서 cafe를 찾아 coffee로 교환했다.

두번째 예시는 case와 관련한 변환이다. cafe를 대소문자에 관계없이 찾아 coffee로 바꾸되, 대소문자 형식을 원본에 유지하도록 했다. preserve-case가 그 역할을 하는데, 아주 단순한 몇 경우 말고는 생각처럼 잘 적용되지 않으니 충분한 이해가 필요할 것이다.

세번째 예시는 레지스터(캡처 그룹)를 이용한 치환이다. 대체 문자열에 쓰인 \N이나 \{N}은 N번째 캡처 그룹을 가리키고, \&는 매치된 문자열을 가리킨다.

ppcre는 다양한 검색치환 방법을 제공하니 관심이 있다면 매뉴얼을 읽어도록 하자.

regex-replace-all

regex-replace은 대상 문자열에 검색치환을 단 한번 수행한다. 대상 문자열 내부의 모든 일치 항목에 대해 검색치환을 수행하려면 regex-replace-all 함수를 사용한다.

(defvar target "[name:junho, age:31], [name:gidong, age:40]")
(re::regex-replace-all "name:(\\w+), age:(\\d+)"
		       target
		       "\\1,\\2")
;;=> [junho,31], [gidong,40]

그 밖에

지금까지 소개한 함수들은 기본적인 기능에 불과하다. 이 외에도 register-group-bind, do-scans, all-matches 등 유용하고 재미난 매크로와 함수들이 있다. 이러한 함수들은 매뉴얼에서 더 찾아볼 수 있다.

이스케이프 블록

다음과 같은 문자열을 검색한다고 하자.

(defvar *test-string* "*^.^*")

단순 이스케이핑

모두 정규표현식의 메타문자임에 주목하라. 이를 검색하기 위해 단순히 백슬래시 이스케이핑을 생각해볼 수 있다.

(re::scan "\\*\\^\\.\\^\\*" *test-string*) ;=> 0, 5

\Q\E 사용하기

PCRE에서는 \Q ... \E 사이의 모든 패턴이 이스케이프된다. 이를 이용하기 위해서는 *allow-quoting* 파라미터가 t로 설정되어 있어야 한다.

(let ((re::*allow-quoting* t))
  (re::scan "\\Q*^.^*\\E" *test-string*))
;;=> 0, 5

Lisp-ier한 방법

또 한 가지는 ppcre에 내장된 구문 트리 DSL을 이용하는 것이다. 구문 트리는 코드로 부터 생성할 수 있어 동적으로 정규표현식을 생성할 때 매우 편리하며, 정규표현식의 의미를 풀어 쓴 것이므로 읽기에 편리하다.

(re::scan '(:sequence "*^.^*") *test-string*)
;;=> 0, 5

PPCRE의 구문 트리에 대한 자세한 내용은 매뉴얼을 참고하라.

유니코드

다음과 같은 문자열을 검색한다고 치자.

(setq *test-string* "낭낭총총")

커먼 리습은 기본적으로 유니코드를 지원하므로 \w.(dot) 문자 클래스에 유니코드 문자가 포함된다. 따라서 다음 세가지 정규식이 모두 제시된 검색어를 찾는데 사용될 수 있다.

(re::scan "[가-힣]*" *test-string*)	;=> 0, 4
(re::scan "\\w*" *test-string*)		;=> 0, 4
(re::scan ".*" *test-string*)		;=> 0, 4

더 정확히 설명하자면, PPCRE의 \w 문자 클래스는 커먼 리습의 alphanumericp 함수를 쓰도록 구현되어있다. 따라서 구현체가 alphanumeric을 정의한 방식에 따라, PPCRE의 \w 문자클래스의 동작도 달라질 것이다.

Unicode Property 문자 클래스

PCRE 명세에서 \p{property} 문자 클래스는 property를 만족하는 문자들의 집합이다. 이 기능은 cl-ppcre-unicode 패키지에 의존하고, 특별히 cl-unicode 라이브러리에 정의된 unicode property들을 사용할 수 있다. 우선, 한글의 유니코드 스크립트를 확인해보자.

(ql:quickload :cl-unicode)
(cl-unicode::script #\가)
;;=> "Hangul"

PPCRE에서는 유니코드 스크립트 영역을 지정하기 위해 \p{Script:Hangul}과 같은 표현을 사용할 수 있다.

(ql:quickload :cl-ppcre-unicode)
(re::scan "\\p{Script:Hangul}*" *test-string*)
;;=> 0, 4

한편, 다른 문자클래스와 비슷하게(\d가 숫자라면 \D가 숫자 아닌 것을 의미하듯이), \P{property}는 property를 만족하지 않는 문자들의 집합을 의미한다.

변경자와 캡처 그룹

다음과 같은 예시 문자열을 사용한다.

(setq *test-string* "caseINSENSITIVEsensitive")

지역 변경자

PPCRE는 인라인 변경자embedded modifier를 지원한다. 예를 들어, 대소문자에 관계없이 매칭되는 정규식을 살펴보자.

(re::scan "case(?i)insensitive(?-i)sensitive" *test-string*)
;;=> 0, 24

PPCRE에서는 다음 변경자들이 지원된다.

  • (?i) :: insenstivie 모드. 대소문자를 구별하지 않고 매칭한다.
  • (?s) :: single-line 모드. .(dot) 문자 클래스가 개행문자도 포함하여 검색하도록 한다.
  • (?m) :: multiline 모드. ^$ 앵커가 행마다 적용되도록 한다.
  • (?x) :: free-spacing 모드. 정규식 프로세서가 공백을 무시하고 # blabla로 주석을 달 수 있게 한다.

변경자를 포함한 비 캡처 그룹으로 검색하기

비 캡처그룹은 (?:패턴)의 형식이다. 변경자를 포함하여 (?i:패턴)의 형식으로 검색할 수 있다.

(re::scan "case(?i:insensitive)sensitive" *test-string*)
;;=> 0, 24

캡처 그룹으로 검색하기

캡처 그룹은 (패턴)의 형식이다. 캡처 그룹 내의 변경자는 지역적으로 적용된다.

(re::scan "case((?i)insensitive)sensitive" *test-string*)
;;=> 0, 24, #(4), #(15)

세번째와 네번째 반환 값은 각각 그룹의 시작 인덱스의 배열, 끝 인덱스의 배열이다.

예를 들어, 다음과 같이 scan의 반환값을 이용할 수 있다.

(multiple-value-bind (s e gs ge)
    (re::scan "case((?i)insensitive)sensitive" *test-string*)
  (declare (ignore s e)) ;; to avoid unused-variable warning
  (subseq *test-string* (svref gs 0) (svref ge 0)))
;;=> "INSENSITIVE"

캡처 그룹의 백 레퍼런스

백 레퍼런스는 이전에 매칭된 그룹과 같은 텍스트에 매칭되는 \N 형식의 정규식이다. 이러한 정규식은 정규식 내의 N번째 그룹에 매칭된다.

(re::scan-to-strings "(.)(.).\\2\\1" "abcba")
;;=> "abcba", #("a" "b")

백 레퍼런스는 동일한 텍스트에 매칭되지, 동일한 패턴에 매칭되는 것이 아님에 유의하라.

(re::scan ".*((?i)sensitive)\\1" "INSENSITIVEsensitive")
;;=> NIL
(re::scan ".*((?i)sensitive)\\1" "INsensitivesensitive")
;;=> 0, 20

명명된 캡처 그룹

named register은 PPCRE 1.3에서 추가된 기능이다. 백 레퍼런스를 숫자로 지정하는 대신, 원하는 이름으로 지정하여 의미를 부여할 수 있다. 캡처 그룹에 이름을 지으려면, (?<이름>패턴) 형식을 사용한다. 명명된 이름의 백 레퍼런스는 \\k<이름> 형식으로 불러낼 수 있다. PPCRE의 하위 호환성을 유지하기 위해, 기본적으로 명명 캡쳐 그룹 기능은 꺼져있다. *allowed-named-registers* 파라미터를 t로 설정하여 named group을 만들 수 있게 설정할 수 있다. 아래는 (20xx-xx-xx) 패턴의 마법 날짜를 찾는 정규표현식이다.

(setq re::*allow-named-registers* t)
(re::scan "\\d\\d(?<magic>\\d\\d)-\\k<magic>-\\k<magic>" 
	  "2008-08-08") ;=> 0, 10

의도되지 않은 동작

(quicklisp 2019-10-08) 버전 기준으로, 특수 변수인 *allowed-named-registers* 를 렉시컬 스코프에서 덮어씌우면 (scan (regex string) ...) 메서드에서 명명된 캡처 그룹을 사용할 수 없다. 의도된 동작은 아닌 것 같은데, 다만 문자열이 아닌 스캐너를 전달하는 대안으로 해결 가능하다. 당연히 전역 스코프에서 바꿔도 해결할 수 있다.

(let ((re::*allow-named-registers* t))
  (re::scan "\\d\\d(?<magic>\\d\\d)-\\k<magic>-\\k<magic>"
	    "2008-08-08")) ;=> Error
(let* ((re::*allow-named-registers* t)
       (sc (re::create-scanner  ;; 스캐너를 만든다.
	    "\\d\\d(?<magic>\\d\\d)-\\k<magic>-\\k<magic>")))
    (cl-ppcre::scan sc "2008-08-08"))	;=> 0, 10

매뉴얼

이 글은 단순한 소개글에 불과하다. PPCRE는 더 많은 기능과 퍼포먼스 옵션을 갖추고 있다. 공식 매뉴얼은 여기에서 확인할 수 있다:

https://edicl.github.io/cl-ppcre/