본문 바로가기
빌드노트 검색

빌드노트

Bash =~에서 정규식 변수를 따옴표로 감쌌더니 매칭이 실패한다

Bash의 =~ 오른쪽에서 정규식 변수를 인용하면 패턴 전체가 리터럴이 된다. 같은 입력의 상태·캡처와 패턴 문자 자체의 매칭을 비교하고, 리터럴 접두사와 숫자 정규식을 섞어 인용하는 방법 및 compat31 예외를 확인한다.

Bash의 [[ "$text" =~ "$regex" ]]가 따옴표를 붙인 뒤부터 실패한다면, =~ 오른쪽을 확인한다. 기본 호환 모드에서는 인용한 부분을 정규식 문법이 아닌 문자 그대로 비교한다. 정규식 전체를 변수에 넣었다면 [[ "$text" =~ $regex ]]로 쓴다. 왼쪽 데이터의 따옴표는 유지해도 된다.

일반 명령에 변수를 전달할 때 인용하는 습관과 충돌해 보이지만, [[ ... ]] 안에서는 변수 확장 결과에 단어 분리와 파일명 확장이 일어나지 않는다. 공백이 든 정규식 변수도 이 위치에서는 인용하지 않고 쓸 수 있다. 이 규칙을 grep 같은 일반 명령의 인자에 그대로 옮기면 안 된다. GNU Bash 조건식 문서 (새 탭에서 열림)는 오른쪽 변수 확장을 인용하면 패턴 전체가 리터럴이 된다고 설명한다.

같은 값에서 따옴표만 바꿔 본다

Linux나 WSL의 GNU Bash가 필요하다. 기존 셸 설정과 분리하려면 터미널에서 env -i PATH=/usr/bin:/bin LC_ALL=C /bin/bash --noprofile --norc로 새 셸을 열고 아래 예제를 순서대로 실행한다. 끝나면 exit로 돌아온다. 여기서는 WSL Ubuntu 24.04의 Bash 5.2.21(1)-release, LC_ALL=C, BASH_COMPAT 미설정, compat31과 nocasematch가 꺼진 상태에서 확인했다.

먼저 같은 입력과 같은 정규식으로 오른쪽 인용 유무에 따른 상태와 캡처를 비교한다.

text='build 42'
regex='^build ([0-9]+)$'

[[ "$text" =~ $regex ]]
printf 'unquoted: status=%s full=<%s> group1=<%s>\n' \
  "$?" "${BASH_REMATCH[0]-none}" "${BASH_REMATCH[1]-none}"

[[ "$text" =~ "$regex" ]]
printf 'quoted: status=%s full=<%s> group1=<%s>\n' \
  "$?" "${BASH_REMATCH[0]-none}" "${BASH_REMATCH[1]-none}"
unquoted: status=0 full=<build 42> group1=<42>
quoted: status=1 full=<none> group1=<none>

인용하지 않은 쪽에서는 ^와 $가 문자열의 양 끝을 제한하고, ([0-9]+)가 숫자를 캡처했다. BASH_REMATCH[0]은 매칭된 전체 부분, [1]은 첫 괄호가 잡은 부분이다. none은 예제에서 배열 요소가 없을 때 대신 출력한 표시다.

인용한 쪽은 ^build ([0-9]+)$라는 문자 나열을 찾는다. 입력 build 42에는 그 문자 나열이 없으므로 상태 1이다. regex='...'처럼 대입할 때 값을 인용하는 것은 괜찮다. 그 따옴표는 변수 값에 저장되지 않으며, 이번 차이를 만든 것은 비교식 오른쪽의 "$regex"다.

실제 코드에서는 상태 0일 때만 캡처를 사용한다. 상태 1은 불일치이고, 2는 올바르지 않은 정규식이다. 별도 비교에서 regex='['는 상태 2를 냈고 이전 캡처 42가 남아 있었다. 배열에 값이 있다는 이유만으로 방금 매칭이 성공했다고 판단하면 안 된다. 따옴표를 고쳐도 1이면 실제 입력과 양 끝의 공백 등을, 2이면 POSIX 확장 정규식 문법을 확인한다.

찾으려던 것이 패턴 문자 자체라면 인용한다

이번에는 입력을 정규식의 기호까지 들어 있는 문자열로 바꿔, 인용한 비교가 무엇을 찾는지 확인한다. regex는 앞 예제의 값을 그대로 쓴다.

text='^build ([0-9]+)$'

[[ "$text" =~ $regex ]]
printf 'literal input, unquoted: status=%s\n' "$?"

[[ "$text" =~ "$regex" ]]
printf 'literal input, quoted: status=%s full=<%s> group1=<%s>\n' \
  "$?" "${BASH_REMATCH[0]-none}" "${BASH_REMATCH[1]-none}"
literal input, unquoted: status=1
literal input, quoted: status=0 full=<^build ([0-9]+)$> group1=<none>

인용한 패턴은 이번에는 성공한다. 괄호도 문자이므로 캡처 그룹은 생기지 않는다. 반대로 인용하지 않은 정규식은 build 다음에 숫자가 오는 구조를 요구하므로 이 입력과 맞지 않는다.

다만 =~는 문자열의 일부분만 맞아도 성공한다. text="prefix $regex suffix"로 바꾼 추가 비교에서도 [[ "$text" =~ "$regex" ]]는 상태 0이었다. 인용한 ^와 $까지 문자로 바뀌었기 때문이다. 값 전체가 똑같아야 한다면 [[ "$text" == "$regex" ]]를 쓴다. 같은 추가 입력에서 이 등식 비교는 상태 1이었다. 이때도 오른쪽을 인용해야 *나 ?를 패턴으로 해석하지 않는다.

리터럴 접두사 뒤의 숫자만 캡처하려면

전체를 인용하거나 전체에서 따옴표를 빼는 두 선택만 있는 것은 아니다. 접두사는 데이터로, 뒤의 숫자는 정규식으로 다룰 수 있다. 다음 비교는 build.+의 점과 더하기 기호를 그대로 찾으면서 숫자만 캡처한다.

prefix='build.+'
text='build.+ 42'
[[ "$text" =~ ^"$prefix"' '([0-9]+)$ ]]
printf 'mixed: status=%s group1=<%s>\n' "$?" "${BASH_REMATCH[1]-none}"

text='buildXYZ 42'
[[ "$text" =~ ^"$prefix"' '([0-9]+)$ ]]
printf 'other prefix: status=%s\n' "$?"
mixed: status=0 group1=<42>
other prefix: status=1

"$prefix"와 ' '는 문자 그대로 비교하고, 따옴표 밖의 ^, ([0-9]+), $는 정규식으로 동작한다. 추가 실험에서 접두사 변수의 따옴표까지 빼면 buildXYZ 42도 상태 0으로 통과했다. .+가 정규식의 의미를 되찾았기 때문이다. 자기 코드에서도 변수 값이 정규식인지, 그대로 찾아야 할 데이터인지부터 정해 인용 범위를 고른다.

위 결과와 다르다면 printf '%s\n' "$BASH_VERSION" "${BASH_COMPAT-미설정}"과 shopt -p compat31로 실행 셸과 호환 설정을 확인한다. 기본 모드에서는 shopt -u compat31이 출력됐다. GNU 호환 모드 문서 (새 탭에서 열림)는 compat31에서 오른쪽 인용의 효과가 달라진다고 설명한다. 같은 Bash 5.2.21의 별도 자식 셸에만 BASH_COMPAT=31을 적용했을 때는 첫 예제의 인용한 패턴도 상태 0, 캡처 42가 됐다. 결과를 맞추려고 전역 호환 설정부터 바꾸기보다 해당 스크립트의 패턴 의도를 분명히 한다.

참조한 GNU 온라인 매뉴얼은 5.3판이며, BASH_COMPAT 설명 (새 탭에서 열림)에서 미설정이면 실행 버전의 기본 호환 수준을 쓴다는 조건도 확인했다. 직접 실행한 버전은 5.2.21 하나다. Bash 3.1 실행 파일, Bash 5.3 실행 파일이나 다른 셸은 대조하지 않았다. 예제의 매칭과 출력에는 Bash 내장 기능만 사용했다.

← 전체 글 보기