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-package
나 in-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
의 단 한 가지 아쉬운 점은 검색치환을 위한 표현이 없다는 점이다. 대신 ppcre
는 regex-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는 더 많은 기능과 퍼포먼스 옵션을 갖추고 있다. 공식 매뉴얼은 여기에서 확인할 수 있다: