[C] 간단한 파서 (Parser) 직접 만들기 (작성중)

극한의 환경이라고도 알려져 있는 C, 여기서 우리는 간단한 파싱 작업을 할 수 있는 프로그램을 만들고자 합니다.

목표

우리의 목표는 간단한 파서(Parser)를 만드는 것입니다. 다음 파일을 규칙에 맞게 잘 읽어들이는 것입니다.

R   1920 1080
A   0.2                                    255,255,255

c   -50,0,20       0,0,0      70
l   -40,0,30                  0.7          255,255,255

pl  0,0,0          0,1.0,0                 255,0,225
sp  0,0,20                    20           255,0,0
sq  0,100,40       0,0,1.0    30           42,42,0
cy  50.0,0.0,20.6  0,0,1.0    14.2  21.42  10,0,255
tr  10,20,10       10,10,20   20,10,10     0,0,255

본 파일은 어떤 그래픽 렌더링 프로그램에 전달할 입력 파일입니다. 이 파일은 렌더링 프로그램의 각종 설정과 렌더링할 도형을 정의합니다. 정의하는 방법은 다음과 같이 정의되어 있습니다.

지시자설명입력할 값
R해상도 설정정수(가로 해상도), 정수(세로 해상도)
A주변광 설정밝기(0.0~1.0), 색상(RGB)
c카메라 설정좌표값(x, y, z), 카메라 방향(x, y, z), 카메라 각도(0~180)
l빛 설정좌표값(x, y, z), 빛의 세기(0.0~1.0), 색상(RGB)
pl평면 추가좌표값(x, y, z), 방향(x, y, z)
sp구 추가좌표값(x, y, z), 반지름, 색상(RGB)
sq정사각형 추가좌표값(x, y, z), 방향(x, y, z), 한 변의 길이, 색상(RGB)
cy원기둥 추가좌표값(x, y, z), 방향(x, y, z), 반지름, 높이, 색상(RGB)
tr삼각형 추가좌표값1(x, y, z), 좌표값2(x, y, z), 좌표값3(x, y, z), 색상(RGB)
입력 파일 설명

세부 목표는 다음과 같습니다.

  • 입력 파일을 읽어서 어떤 구조체 안에 데이터를 전부 담아 놓으십시오. 데이터는 외부에서 가져다 쓰기 편하도록 구조화되어 있어야 합니다.
  • 입력 파일의 규칙이 잘못 되었을 때, 왜 잘못되었는지, 어디서 잘못되었는지 등의 에러 정보를 출력하고 프로그램을 즉시 종료하세요.

해당 과제는 다음과 같은 조건 아래에 진행됩니다.

  • 주어진 함수 외의 함수는 사용이 불가합니다. 주어진 함수는 다음과 같습니다: malloc, free, exit, write, open, read, close.
  • 전역 변수는 사용이 불가합니다.
  • 아주 큰 파일이 들어올 수 있으므로, 버퍼사이즈가 지정되고, 버퍼사이즈만큼 파일을 읽어야 합니다.
  • 컴파일은 gcc 또는 clang 으로 진행합니다.

누가 왜 저런 조건을 만들었는가는 알 수 없지만, 해당 조건을 만족시키기 위하여 몇 가지 기능들을 먼저 모듈화한 후 진행하도록 하겠습니다. 우선 무언가를 출력하려면 <unistd.h> 헤더에 있는 write 함수를 사용해야 합니다. printf 조차 사용할 수 없기에, 출력하는 부분을 모듈화하여 사용하기 (그나마) 편하도록 진행할 예정입니다. 그리고 전역 함수를 사용하지 못하므로 static 함수를 이용하여 전역 변수처럼 사용할 예정입니다.

우리가 지금 만드는 프로그램은 렌더링 프로그램이 아닙니다. 해당 파일을 잘 읽어들이는 파서 프로그램입니다!

본 글에서는 헤더 파일에 프로토타입을 일일히 기록하지 않습니다. 만약 직접 따라하면서 실습한다면, 헤더 파일에 함수 프로토타입을 모두 넣어주시기 바랍니다.

파싱과 토큰

파서를 본격적으로 만들어보기 전에 먼저 개념을 정리해봅시다. 파서를 한국어로 좀 더 부드럽게 옮긴다면 구문 분석기라고 말할 수 있습니다.

파싱은 일련의 문자열을 의미있는 토큰(token)으로 분해하고 이들로 이루어진 파스 트리(parse tree)를 만드는 과정을 말한다.

위키백과

토큰은 파싱 목적을 위해 분류화를 명시적으로 지시하는 어휘소를 표현하는 구조의 하나이다.

역시 위키백과

대~충 개념적이므로 이 글에서는 파서(파싱)과 토큰을 다음과 같이 특수하게 정의내리도록 하겠습니다.

  • 파싱: 입력된 문자열로부터 토큰을 뽑아내고, 문법에 맞게 토큰을 배열/결합/변형하여 의미있는 값과 사용하기 간편한 구조를 만들어내는 것.
  • 토큰: 최소의 의미를 가지는 문자열(혹은 문자)의 묶음. 공백(=단순 토큰 구분용 문자)을 제외한 모든 문자 데이터가 살아있어야 함.

예를 들어 아래 한 줄을 가지고 설명해보도록 하겠습니다. 이 한 줄을 토큰으로 바꿔보고자 합니다.

l   -40,0,30                  0.7          255,255,255

토큰은 단순한 묶음이므로 그 순서도 입력 문자열의 순서와 같습니다. 해당 순서가 옳은 건지 아닌지는 파서가 판단할 일입니다.

[type:name, value:l]
[type:number, value:-40]
[type:comma, value:,]
[type:number, value:0]
[type:comma, value:,]
[type:number, value:30]
[type:number, value:0.7]
[type:integer, value:255]
[type:comma, value:,]
[type:integer, value:255]
[type:comma, value:,]
[type:integer, value:255]
[type:newline, value:\n]

그렇다면 문자열에서 토큰을 생성하는 과정에서 일어나는 에러란 무엇일까요? 바로 지원하지 않는 문자가 있을 때 에러가 발생합니다. 예를 들어 위 입력 파일에서 우리는 + 혹은 * 과 같은 기호를 사용하지 않습니다. 사용할 일이 없습니다. 이 문자는 아무런 의미를 가질 수 없으므로 바로 에러를 내는 것이 합당합니다. 또한 토큰을 생성하는 과정에서 예상하지 못한 문자가 나왔을 때에도 에러가 발생할 수 있습니다. 예를 들어 .은 숫자 안에 포함이 됩니다. 그러나 123.456.789 와 같은 입력이 들어가게 되면 123.456 까지는 숫자로 인식할 수 있지만 그 뒤에 바로 .이 등장하므로 기대하지 않은 토큰 값이라고 처리할 수 있습니다.

그렇다면 만약 파싱까지 완료한다면 어떤 구조가 생길까요? 의사 코드로 나타내보도록 하겠습니다.

CreateLight {
  light: Coordinate {
    x: -40
    y: 0
    z: 30
  }
  strength: 0.7
  color: RGB {
    r: 255
    g: 255
    b: 255
  }
}

그렇다면 이제 create_light.light.x 등으로 값에 바로 접근해볼 수도 있겠습니다! 만약 파싱 과정에서 에러가 일어난다면, 예상하는 토큰 타입이 어긋날 때 발생합니다. 예를 들어 색상 값을 받아와야 하는데 255,255, 와 같이 입력이 들어온다면, 마지막 숫자가 존재하지 않으므로 에러가 발생합니다. 토큰 단계에서는 에러가 나지 않습니다. 왜냐하면 지원하지 않는 문자열도 없고, 기대하지 않은 토큰 값도 없기 때문이지요.

역할을 나누어봅시다. 이 글에서 파서는 크게 세 가지 역할로 나눌 수 있으며, 서로의 역할을 다하기 위해 해당 기능을 구현하여야 합니다.

역할 이름역할 설명기능
FileReader파일을 읽습니다.fr_read : 파일로부터 하나의 글자를 읽습니다. 문자들을 읽을 때의 버퍼 관리를 모두 책임집니다. 만약 파일의 끝에 다다랐을 경우 FileReader 의 상태를 변경하거나 '\0'를 리턴합니다.
위치값 저장 : 마지막으로 읽은 문자의 위치를 저장합니다.
Tokenizer토큰을 생성합니다.consume : 현재 가리키고 있는 토큰을 소모하고 다음 토큰을 구합니다. 토큰이 생성되는 규칙에 대한 것을 전담합니다.
lookahead : 현재 가리키고 있는 토큰의 정보를 보여줍니다. 이 함수는 보여주기만 할 뿐입니다.
FileReader 로부터 읽은 문자를 임시 저장 : 한번 fr_read 하게 되면 되돌릴 수 없으므로, 계산하고자 하는 문자열이 어떠한 토큰으로 판명날 때까지 데이터를 모두 지니고 있어야 합니다.
Token 의 위치 저장 : 추후 문법적으로 예상치 못한 토큰일 때 해당 토큰의 위치를 알려주면서 에러 메시지를 띄워야 하므로 토큰의 시작 위치 등을 저장해놓습니다.
Parser파싱을 수행합니다.parse : 파싱을 수행합니다. 성공적으로 파싱된다면 파싱된 구조체가 나올 것이고, 실패했다면 에러를 출력하고 프로그램이 종료됩니다.
Tokenizer 로부터 얻은 token 을 임시 저장: 한번 consume 하게 되면 이전 토큰을 얻을 수 없으므로, 토큰들이 문법에 맞는지 판단할 수 있을 때까지 데이터를 모두 지니고 있어야 합니다.
역할들

프로젝트 세팅

본 프로젝트는 gcc 또는 clang 컴파일러를 사용하고, make 로 빌드 과정을 간소화합니다. mac이나 linux 라면 기본 컴파일러와 make 가 이미 설치되어 있지만, Windows 라면 아마 직접 설치하셔야 할 것입니다. 설치 법은 본 블로그에서는 소개드리지 않습니다.

폴더 구조는 다음과 같습니다.

.
├── input.txt
├── Makefile
├── src
│   ├── parser.h
│   └── 각종 소스파일들.c
└── test.c # main 함수

input.txt 는 상단에 우리가 읽어야 할 입력 파일입니다. 우선 Makefile 먼저 간단하게 봅시다.

# Makefile

SRCS = $(wildcard src/*.c)
OBJS = $(SRCS:.c=.o)
INC = -I./src
LIB = -lm
ARGS = -g

test : $(OBJS)
	gcc $(ARGS) $(INC) $^ test.c -o do_test $(LIB)
	./do_test

$(OBJS) : %.o: %.c
	gcc $(ARGS) $(INC) -c $^ -o $@

clean:
	rm -rf $(OBJS)

re:	clean test

valgrind: test
	valgrind --leak-check=full \
         --show-leak-kinds=all \
         --track-origins=yes \
         --verbose \
         ./do_test


.PHONY: test clean re valgrind

Makefile 에 의해서, make, make test, make re 명령어를 쳐서 테스트를 수행할 수 있습니다.

다음은 test.c 파일입니다. 여기에 main 함수가 있습니다.

// test.c
#include <stdio.h>
#include "parser.h"

int main(void)
{
	// 내용
	return (0);
}

전역처럼 쓸 수 있는 static 구조체 만들기

// src/parser.h
typedef struct s_parser
{
	// something
}				t_parser;
// src/parser.c
t_parser	*parser()
{
	static t_parser	p;
	return &p;
}

이렇게 만들어두면 언제 어디에서든지 parser()를 호출하기만 해도 같은 p에 접근할 수 있게 됩니다.

매크로 정의

미리 필요한 매크로를 아래와 같이 정의해 놓읍시다.

// src/parser.h

# define TOKEN_NOT_SPECIFIED	0
# define TOKEN_INT				1
# define TOKEN_DOUBLE			2
# define TOKEN_NAME				3
# define TOKEN_NEWLINE			5
# define TOKEN_COMMA			6
# define TOKEN_END				100

# define ERR_READ 					1
# define ERR_FILE_OPEN 				2
# define ERR_PARSE					3
# define ERR_STR_ALLOC				4
# define ERR_STR_PUSH_CHAR_ALLOC	5
# define ERR_TOKEN_CONSUME			6

# define BUF_SIZE 100

파일 읽기

과제의 조건을 충족시키기 위해 우선 기능들을 모듈화 해놓도록 하겠습니다. 파일을 읽기 위해서는 버퍼를 활용해야 합니다.

 // src/parser.h
 
+typedef struct s_filereader
+{
+	const char	*path;
+	int			fd;
+	char		buf[BUF_SIZE];
+	int			c_col;
+	int			c_row;
+}				t_filereader;

 typedef struct s_parser
 {
+	t_filereader	fr;
 }				t_parser;
  • path : 파일 경로를 저장합니다.
  • fd : 파일을 open 하고 난 후 파일 디스크립션을 저장합니다.
  • buf[BUF_SIZE] : 버퍼입니다.
  • c_col, c_row : 현재 읽고 있는 위치를 저장합니다.

자, 이제 파일을 열어봅시다.

// src/file_reader.c
#include <fcntl.h>
#include "parser.h"

int			file_open(int *fd, const char *path)
{
	*fd = open(path, O_RDONLY);
	if (*fd == -1)
		return (0);
	return (1);
}

t_filereader	fr(const char *path)
{
	t_filereader	fr;
	int				fd;

	if(!file_open(&fd, path))
		exit(ERR_FILE_OPEN);
	fr.fd = fd;
	fr.path = path;
	fr.c_row = 1;
	fr.c_col = 0;
	return (fr);
}
 // src/parser.c
+void	init_parser(const char *path)
+{
+	t_parser *p;
+
+	p = parser();
+	p->fr = fr(path);
+}
 // test.c
 #include <stdio.h>
 #include "parser.h"
 
 int main(void)
 {
+	init_parser("./input.txt");
	return (0);
 }

이제 fr 함수를 통해서 t_filereader 구조체를 생성할 수 있게 되었습니다. 여기 내부에서는 file_open 함수를 부릅니다. 파일을 열 때는 읽기 전용 모드로 열고 있습니다. fr 함수를 init_parser 함수에서 호출하고 있고, 이 init_parser 함수는 main 에서 호출되고 있습니다. init_parser 를 호출할 때 읽을 파일 이름도 설정하고 있음을 보실 수 있습니다.

이제 파일의 내용을 읽으려면 어떻게 해야 할까요? 우선 read 함수를 써서 buf 에 데이터들을 넣어놓은 다음, buf 에서 한 글자씩 읽어오면 되겠지요. 우리는 하나의 글자만 읽어서 보내주는 char fr_read() 함수를 구현하면 됩니다. 우선 한 가지 생각이 듭니다. fr_read 함수는 한번 호출할 때마다 한 글자씩 내보내지만, 우리가 실제로 read 함수를 쓸 때에는 내용을 왕창 읽어옵니다. 그렇다면, 호출 빈도로 따진다면 fr_readread 보다 훨씬 자주 호출될 것 같습니다.

그렇다면 일단 가정을 해 봅시다. bufread 한 값들이 온전히 잘 들어가있다고 가정합시다. 그렇게 되었을 때, 우리는 한 글자씩 뽑아내야 하기 때문에 뽑아낼 위치 정보를 기억하고 있어야 합니다.

 // src/parser.h
 typedef struct s_filereader
 {
 	const char	*path;
 	int			fd;
+	int			pos;
 	char		buf[BUF_SIZE];
 	int			c_col;
 	int			c_row;
 }				t_filereader;
// src/file_reader.c
char			fr_read()
{
	t_filereader	*fr;
	char			c;

	fr = &parser()->fr;
	c = fr->buf[fr->pos];
	fr->pos++;
	fr->c_col++;
	if (c == '\n')
	{
		fr->c_row ++;
		fr->c_col = 0;
	}
	return (c);
}

c = fr->buf[fr->pos] 에서 pos 의 값에 의거하여 문자를 buf 에서 뽑아내고 있고, 그 다음 fr->pos++ 를 통해 pos의 값을 갱신시켜주고 있습니다. 뒤의 코드들은 위치 값을 갱신시켜주고 있습니다.

자, 그렇다면 이제 pos 가 유효한 값을 가리키도록 보장만 해주면 본 함수는 끝이 날 것 같습니다!

 // src/parser.h
 typedef struct s_filereader
 {
 	const char	*path;
 	int			fd;
 	int			pos;
 	char		buf[BUF_SIZE];
+	int			eof_reached;
+	int			eof_reached_len;
 	int			c_col;
 	int			c_row;
 }				t_filereader;
 // src/file_reader.c
 t_filereader	fr(const char *path)
 {
 	t_filereader	fr;
 	int				fd;
 
 	if(!file_open(&fd, path))
 		exit(ERR_FILE_OPEN);
 	fr.fd = fd;
 	fr.path = path;
+	fr.eof_reached = 0;
+	fr.eof_reached_len = 0;
 	fr.c_row = 1;
 	fr.c_col = 0;
 	return (fr);
 }
 
+int				make_frpos_safe(t_filereader *fr)
+{
+	int				border;
+
+	if (fr->eof_reached)
+		border = fr->eof_reached_len;
+	else
+		border = BUF_SIZE;
+	if (fr->eof_reached && fr->pos >= border)
+		return (0);
+	if (fr->pos >= border)
+		// 여기서 뭔가를 해야 함!
+	return (1);
+}
 
 
 char			fr_read()
 {
 	t_filereader	*fr;
 	char			c;
 
 	fr = &parser()->fr;
+	if (!make_frpos_safe(fr))
+		return ('\0');
 	c = fr->buf[fr->pos];
 	fr->pos++;
 	fr->c_col++;
 	if (c == '\n')
 	{
 		fr->c_row ++;
 		fr->c_col = 0;
 	}
 	return (c);
 }

우선 eof_reachedeof_reached_len 를 추가해줬습니다. eof 일 때와 그렇지 않을 때 pos 가 안전한 구간이 다르므로 그 구간까지 계산하기 위해 새로운 변수를 추가하였습니다. eof_reached 는 현재 eof 에 도달했는지의 여부를 알려주고 (eof 여도 여전히 버퍼에 읽을 것이 남아있다면 계속해서 읽어야 합니다.) eof_reached_len 은 eof에 다다른 시점에 읽었던 버퍼의 길이입니다. eof 가 아니라면 그 경계(border)는 단순히 BUF_SIZE 가 되겠지만, 이미 eof 에 도달했다면 그만큼 읽은 사이즈(eof_reached_len)가 경계가 됩니다. 만약 eof에 도달했고 buf에 남은 데이터도 모조리 읽었다면, 이제 fr_read 는 더이상 read 함수를 호출할 필요가 없어집니다. 그럴 때에는 단순히 '\0' 를 리턴합니다.

eof에 도달하지 않은 채로 그냥 경계(border) 를 넘었다면, 새롭게 버퍼에 데이터를 채워주면서 pos 를 초기화시켜주면 됩니다. 아래에서 봅시다.

 // src/file_reader.c
 
+void			read_to_buffer(t_filereader *fr)
+{
+	int				n_read;
+	int				i;
+
+	fr->pos = 0;
+	n_read = read(fr->fd, fr->buf, BUF_SIZE);
+	if (n_read == -1)
+		exit(ERR_READ);
+	if (n_read < BUF_SIZE)
+	{
+		fr->eof_reached = 1;
+		fr->eof_reached_len = n_read;
+		i = n_read - 1;
+		while (++i < BUF_SIZE)
+			fr->buf[i] = '\0';
+	}
+}
 
 int				make_frpos_safe(t_filereader *fr)
 {
 	int				border;
 
 	if (fr->eof_reached)
 		border = fr->eof_reached_len;
 	else
 		border = BUF_SIZE;
 	if (fr->eof_reached && fr->pos >= border)
 		return (0);
 	if (fr->pos >= border)
+		read_to_buffer(fr);
 	return (1);
 }

read_to_buffer 함수에서 예외 처리, 초기화, eof 감지 등을 합니다. 만약 eof 일 시 buf에 남은 데이터를 재사용하지 않도록 나머지 값을 while 문을 활용해 '\0' 으로 초기화시킵니다. 이제 간단한 테스트를 수행해봅시다.

 // test.c
+void	test_filereader()
+{
+	char c;
+
+	init_parser("./input.txt");
+	c = 1;
+	while(c)
+	{
+		c = fr_read();
+		printf("%c", c);
+	}
+}
 
 int main(void)
 {
+	test_filereader();
 	return (0);
 }

테스트를 수행하면 아주 결과가 잘 나온다는 걸 확인하실 수 있습니다. 우리는 테스트를 하기 위한 목적으로만 printf 를 사용할 것입니다!

string 구조체

c 는 문자열 관련 기능이 빈약합니다. <string.h> 헤더에서 많은 것을 지원해주지만, 어차피 이 표준 라이브러리에는 길이가 정해지지 않은 문자열을 다루는 데 상당한 애로사항이 있습니다. 우리는 토큰 하나가 어느 정도의 길이로 들어올 지 모릅니다. 그러므로 충분한 길이의 문자열 (예: char str[1000]) 등으로 처리하려고 해도 길이가 모자랄 지 모릅니다. 물론 평균적인 토큰의 길이를 생각한다면 1000 칸은 너무 과도한 것 같기도 합니다. 고정 길이는 이렇게 상황에 유연하게 대처하지 못하는 문제가 있으므로 가변적으로 크기를 할당하여 사용하는 t_str 구조체를 만들어서 활용해보도록 합시다.

아이디어는 이렇습니다. 처음에 조그맣게 공간을 할당해놓습니다. 그러고 문자열에 공간을 채워넣어갑니다. 만약 전체 할당된 공간(max)의 1/2 에 다다랐다면, max 를 두 배 확장하고 크기도 두 배로 다시 할당합니다. 항상 문자열의 길이를 즉시 알 수 있도록 길이 값(len)을 저장해놓습니다.

// src/parser.h
# define INITIAL_STR_LEN 10

typedef unsigned long t_ul;

typedef struct s_str
{
	t_ul	len;
	t_ul	max;
	char	*ptr;
}				t_str;
// src/string.c
t_str	create_str()
{
	t_str	str;

	str.len = 0;
	str.max = INITIAL_STR_LEN;
	str.ptr = (char *)malloc(sizeof(char) * INITIAL_STR_LEN);
	if (!str.ptr)
		exit(ERR_STR_ALLOC);
	return (str);
}

void	destroy_str(t_str *str)
{
	free(str->ptr);
	str->ptr = 0;
	str->len = 0;
	str->max = 0;

}
  • len : 항상 문자열의 길이를 나타냅니다.
  • max : 문자열이 할당된 길이를 나타냅니다.
  • ptr : 할당한 공간의 포인터입니다.

일단 문자열의 초기 최대치(max)는 10으로 설정해놓았습니다. 생성(create_str)하며 파괴(destroy_str)하는 함수도 만들었습니다. (malloc 하나에 free 하나가 대응되는 것처럼 create 하나와 destroy 하나가 대응되도록 설계합니다.) 그렇다면 이제 이 t_str 에 문자열을 추가할 수 있는 방법을 주어야겠지요?

// src/string.c
void	str_push_char(t_str *str, char c)
{
	str->ptr[str->len] = c;
	str->len += 1;
	if (str->len > str->max / 2)
	{
		// str->ptr 의 크기를 확장하자!
	}
}

len 은 항상 문자열의 길이를 나타내므로, ptr[len] 은 항상 마지막 문자의 다음 위치를 가리킵니다. 즉 새롭게 추가할 문자가 들어갈 위치를 가리키고 있다고 말할 수 있습니다. 여기에 문자를 대입하고 len 을 1 증가시켜줍니다. 대입을 마친 뒤에 lenmax / 2 보다 길어졌을 경우에는 문자열을 새롭게 할당하도록 합니다.

 // src/string.c
 void	str_push_char(t_str *str, char c)
 {
+	t_ul	i;
+	char	*new_ptr;
 
 	str->ptr[str->len] = c;
 	str->len += 1;
 	if (str->len > str->max / 2)
 	{
+		str->max *= 2;
+		new_ptr = (char *)malloc(sizeof(char) * str->max);
+		if (!new_ptr)
+			exit(ERR_STR_PUSH_CHAR_ALLOC);
+		i = 0;
+		while (i < str->len)
+		{
+			new_ptr[i] = str->ptr[i];
+			i++;
+		}
+		free(str->ptr);
+		str->ptr = new_ptr;
 	}
 }

새롭게 할당하는 방법은 간단합니다. 공간만 2배로 키워서 새롭게 할당한 다음, 모든 내용을 복사하고, ptr 를 교체합니다.

이제 문자열에 접근하는 방법을 제공해주도록 합시다.

// src/string.c
const char	*raw(t_str str)
{
	str.ptr[str.len] = '\0';
	return (str.ptr);
}

그냥 직접 str.ptr 에 바로 접근하면 되지 않느냐고 생각하실 수 있지만, 그래도 상관없습니다. 하지만 좀 더 명확한 쓰임새를 보여줄 수 있습니다. raw 를 통해 불러오는 문자열 포인터는 c-style 문자열에 호환되도록 보장해줄 수 있습니다. 또한 const char * 로 리턴해줌으로써 직접 포인터로 수정할 수 없도록 합니다.

우리는 기본적으로 문자열을 수정하는 방법을 제공하지 않을 것입니다. 왜냐하면 굳이 필요가 없기 때문이지요. 토큰을 읽어드리는 대로 그대로 받아들이면 될 뿐입니다. 만약 읽고 있었던 토큰이 잘못되어서 수정해야 한다면, 그냥 기존 것을 폐기한 다음 새롭게 만드는 것이 더 쉽습니다. 우리가 만드는 프로그램에서 t_str 의 기능은 이정도 기능만으로도 충분합니다.

앞으로 필요한 기능이 생긴다면 그때 그때 추가하도록 하겠습니다.

Tokenizer 만들기

우선 토큰과 Tokenizer 를 먼저 정의해보도록 합시다.

// src/parser.h

typedef int t_token_type;

typedef struct s_token
{
	int				file_row;
	int				file_col;
	t_str			str;
	t_token_type	type;
}				t_token;

typedef struct s_tokenizer
{
	t_token		current_token;
	t_str		reading;
}				t_tokenizer;
  • t_token::file_row : 해당 토큰의 줄 정보입니다.
  • t_token::file_col : 해당 토큰의 행 정보입니다.
  • t_token::str : 해당 토큰의 실제 문자열입니다.
  • t_token::type : 해당 토큰의 종류입니다.
  • t_tokenizer::current_token : 현재 가리키고 있는 토큰을 의미합니다.
  • t_tokenizer::reading : 다음 토큰을 읽을 때 문자열들을 임시 저장할 문자열입니다.
 // src/parser.h
 typedef struct s_parser
 {
 	t_filereader	fr;
+	t_tokenizer		tknzr;
 }				t_parser;
 // src/parser.c
 void	init_parser(const char *path)
 {
 	t_parser *p;
 
 	p = parser();
+	p->tknzr = create_tokenizer();
 	p->fr = fr(path);
 }
// src/tokenizer.c

t_tokenizer		create_tokenizer()
{
	t_tokenizer tknzr;

	tknzr.current_token = create_token();
	tknzr.reading = create_str();
	return (tknzr);
}

t_token			create_token()
{
	t_token	tk;

	tk.str = create_str();
	tk.type = TOKEN_NOT_SPECIFIED;
	tk.file_col = 0;
	tk.file_row = 0;
	return (tk);
}

t_token			token_lookahead()
{
	t_tokenizer	*tknzr;

	tknzr = &parser()->tknzr;
	if (tknzr->current_token.type == TOKEN_NOT_SPECIFIED)
		token_consume();
	return (tknzr->current_token);
}

void	token_consume()
{
	t_tokenizer *tknzr;

	tknzr = &parser()->tknzr;
	if (tknzr->current_token.type == TOKEN_END)
		return ;
	// 뭔가 한다!
	// 만약 성공했을 시 return
	// 만약 실패했을 시 exit
}

이제 토큰과 Tokenizer 를 생성할 수 있습니다.그리고 token_lookahead 함수와 token_consume 함수의 기본적인 형태를 만들었습니다. token_lookahead 함수는 간단히 현재 토큰을 리턴하는 함수입니다. 초기화 상태(TOKEN_NOT_SPECIFIED)일 때에만 token_consume 함수를 호출해서 토큰을 갱신합니다. token_consume 함수는 토큰을 읽어들이는 핵심 부분입니다. 지금부터 본격적으로 작성해볼 예정입니다.

이제 간단하게 숫자만을 받아들이는 Tokenizer 를 만들어봅시다.

// src/is.c
int		is_digit(char c)
{
	return (c >= '0' && c <= '9');
}
 // src/tokenizer.c
 void	token_consume()
 {
 	t_tokenizer *tknzr;
 
 	tknzr = &parser()->tknzr;
 	if (tknzr->current_token.type == TOKEN_END)
 		return ;
+ 	if (try_token_int())
+		return ;
+	exit(ERR_TOKEN_CONSUME);	
 }
// src/tokenizer_try.c
int	try_token_int()
{
	char		c;
	t_tokenizer	*tknzr;

	tknzr = &parser()->tknzr;
	c = fr_read();
	str_push_char(&tknzr->reading, c);
	if (is_digit(c))
	{
		while (1)
		{
			c = fr_read();
			if (!is_digit(c))
				break ;
			str_push_char(&tknzr->reading, c);
		}
		tknzr->current_token.type = TOKEN_INT;
		tknzr->current_token.str = tknzr->reading;
		return (1);
	}
	return (0);
}

try_token_int 함수를 새롭게 만들었습니다. 우리는 이제 앞으로 try_token_... 형태의 함수를 여러 개 만들텐데, 이 함수들의 목표는 다음과 같습니다.

  • fr_read 를 적절하게 호출해서 다음 문자를 읽어옴
  • 읽어온 문자들을 날리지 않도록 reading 에 차곡히 저장
  • 만약 성공했으면 current_token 에 적절한 데이터를 저장한 후 1을 리턴함.
  • 만약 실패했다면 0을 리턴함.

우선 fr_read 로 첫 번째 문자를 불러온 후에 이것이 숫자라면 반복문을 시작합니다. 반복문에서는 숫자가 아닐 때까지 반복되며, 숫자라고 판명난 문자들은 모두 reading 에 저장됩니다. 반복문이 끝나고 데이터 정보를 current_token.str에 기록합니다.

try_token... 에서 성공과 실패를 10으로 리턴함으로써 token_consume 에서 분기를 조정할 수 있습니다. 하나라도 성공하게 되었을 때 즉시 return 하여 1회 token_consume 을 성공적으로 마무리시킬 수 있고, 모든 시도가 실패하게 되었을 때 이윽고 마지막 exit 함수가 호출되면서 프로그램이 강제로 종료되도록 합니다.

자 그럼 제대로 동작하는지 테스트해봅시다. input.txt 파일에는 그냥 단순히 13245 라는 숫자 하나만 넣어두도록 합니다.

 // test.c
+void	test_first_token()
+{
+	t_token	token;
+	init_parser("./input.txt");
+	token_consume();
+	token = token_lookahead();
+	printf("%s, %d\n", raw(token.str), token.type);
+}

 int main(void)
 {
-	test_filereader();
+	// test_filereader();
+	test_first_token();

 	return (0);
 }

make re 등으로 테스트를 수행시키면 다음과 같이 제대로 출력이 됩니다.

13245, 1

token.type 부분에서 1이 출력된 이유는 우리가 #define TOKEN_INT 1 과 같이 설정했기 때문입니다.

assign_str 구현

지금 읽은 데이터를 토큰에 저장하는 과정에서 단순한 대입문이 사용되고 있습니다. 우리는 current_token.str 에 진작에 할당해놓았던 문자열들을 잃을 생각도 없고, 또한 current_token.strreading 이 같은 문자열을 가리키게 할 생각도 없으므로 t_str의 기능을 확장하여 코드를 잠깐 손을 봅시다.

// src/string.c

t_str	duplicate_str(t_str str)
{
	t_str	new_str;
	t_ul	i;

	new_str.max = str.max;
	new_str.len = str.len;
	new_str.ptr = (char *)malloc(sizeof(char) * str.max);
	i = 0;
	while (i < str.len)
	{
		(new_str.ptr)[i] = (str.ptr)[i];
		i++;
	}
	return (new_str);
}

void	assign_str(t_str *str, t_str other)
{
	t_str	temp;

	temp = duplicate_str(other);
	destroy_str(str);
	*str = temp;
}
 // src/tokenizer_try.c
 int	try_token_int()
 {
 	char		c;
 	t_tokenizer	*tknzr;
 
 	tknzr = &parser()->tknzr;
 	c = fr_read();
 	str_push_char(&tknzr->reading, c);
 	if (is_digit(c))
 	{
 		while (1)
 		{
 			c = fr_read();
 			if (!is_digit(c))
 				break ;
 			str_push_char(&tknzr->reading, c);
 		}
 		tknzr->current_token.type = TOKEN_INT;
-		tknzr->current_token.str = tknzr->reading;
+		assign_str(&tknzr->current_token.str, tknzr->reading);
 		return (1);
 	}
 	return (0);
 }

이제 이렇게 하면, 일단 reading 의 복제본을 가지고 있다가, current_token.str 의 메모리를 해제한 후, 그 자리에 복제본을 집어넣습니다. 이 과정에서 reading 은 아무런 영향을 받지 않고, current_token.str 은 파괴되었다가 다시 할당된 것이 생깁니다. 결과적으로 할당된 t_str 의 개수는 변하지 않습니다. 어때요, current_token.str 을 직접 하나하나 수정하는 것 보다는 훨씬 간편하지 않나요?

매번 reading 갱신해놓기

일단 우리는 1회 토큰화시키기를 성공했습니다! 이제 반복적으로 동작하여야 합니다. 더 세부적인 구현을 하기 전에 한 가지 생각해봅시다. 우리가 프로그램의 중간부터 숫자 토큰을 읽고 있는 상황이라고 가정합니다. 우리는 한 번에 하나의 문자를 가져올 수 있는 fr_read 함수를 자유롭게 사용할 수 있습니다. 그렇다면 어디까지가 숫자인지 어떻게 판단할 수 있을까요?

abc,def,1234????????
        ^  ^
        a  b

우리는 a 지점부터 읽기 시작했습니다. a 지점은 1이니까 숫자가 맞고, b 지점까지도 숫자가 명확합니다. 하지만 b 지점이 숫자의 끝일까요? 그건 아무도 모릅니다. b 지점 이후를 읽어보아야 알 수 있습니다.

abc,def,1234567??????
        ^     ^
        a     b

아직까지 숫자입니다.

abc,def,1234567,?????
        ^      ^
        a      b

좋습니다! 쉼표가 등장했습니다. 이제 1234567 이 완전한 숫자임을 확신할 수 있습니다. 이제 다음 토큰으로 넘어가도록 합시다. 하지만 우리는 이미 ,를 읽은 상태이므로, fr_read 를 수행하면 안 됩니다. 만약 수행하게 된다면 , 를 건너뛰게 될 것입니다. 이미 읽은 문자이지만, 해당 문자가 어떤 토큰인지 아직 모르는 상태에 있는 문자열들을 우리는 reading 에 저장하고 있었습니다. 토크나이저가 1234567 에 대한 토큰 검사가 끝나는 즉시 reading"," 여야 합니다. 그리고 다음 토큰을 검사할 때 무작정 fr_read 하는 것이 아니라 reading 에 담겨져있는 첫번째 문자인 , 부터 적절하게 처리해야 합니다.

 // src/tokenizer_try.c
 int	try_token_int()
 {
 	char		c;
 	t_tokenizer	*tknzr;
 
 	tknzr = &parser()->tknzr;
-	c = fr_read();
-	str_push_char(&tknzr->reading, c);
+	c = raw(tknzr->reading)[0];
 	if (is_digit(c))
 	{
 		while (1)
 		{
 			c = fr_read();
 			if (!is_digit(c))
 				break ;
 			str_push_char(&tknzr->reading, c);
 		}
 		tknzr->current_token.type = TOKEN_INT;
 		assign_str(&tknzr->current_token.str, tknzr->reading);
+		destroy_str(&tknzr->reading);
+		tknzr->reading = create_str();
+		str_push_char(&tknzr->reading, c);
 		return (1);
 	}
 	return (0);
 }

하지만 이 상태로 테스트를 그대로 실행시킨다면 에러가 발생합니다!

make: *** [Makefile:10: test] Error 6

왜일까요? 바로 제일 첫번째 try_token_int 시도에는 raw(tknzr->reading)[0]'\0' 이기 때문입니다. 이전 단계에서 미리 읽어놓았던 문자가 단 하나도 없으므로 아무것도 가리킬 게 없습니다. 그러므로 제일 첫번째에는 raw(tknzr->reading)[0] 이 첫 번째 문자를 가리킬 수 있도록 초기화를 시켜줘야 합니다.

 // src/parser.c
 void	init_parser(const char *path)
 {
 	t_parser *p;
 
 	p = parser();
 	p->fr = fr(path);
 	p->tknzr = create_tokenizer();
+	init_tokenizer();
 }
// src/tokenizer.c
void			init_tokenizer()
{
	char	c;

	c = fr_read();
	str_push_char(&parser()->tknzr.reading, c);
}

이렇게 하면 다시 결과는 잘 나온다는 걸 확인할 수 있습니다.

공백 패스하기

위에서도 잠깐 이야기했지만, 정말로 아무런 뜻이 없는 (오직 토큰 구분자용으로만 사용되는) 토큰은 굳이 토큰화시키지 않아도 됩니다. 그래서 우리는 공백을 패스해보도록 하겠습니다.

// src/tokenizer.c

void	skip_blank()
{
	char		c;
	t_tokenizer *tknzr;

	tknzr = &parser()->tknzr;
	c = raw(parser()->tknzr.reading)[0];
	if (c == ' ')
	{
		while (1)
		{
			c = fr_read();
			if (c != ' ')
				break;
		}
	}
	destroy_str(&tknzr->reading);
	tknzr->reading = create_str();
	str_push_char(&tknzr->reading, c);
}
 // src/tokenizer.c
 void	token_consume()
 {
 	t_tokenizer *tknzr;
 
 	tknzr = &parser()->tknzr;
 	if (tknzr->current_token.type == TOKEN_END)
 		return ;
+	skip_blank(); 
 	if (try_token_int())
 		return ;
 	exit(ERR_TOKEN_CONSUME);	
 }

공백은 적당히 공백만큼 읽어들인 다음 공백이 아닌 문자가 등장했을 때 그것을 reading 에 채워넣어주면 됩니다. try_token_int 함수와 비슷한 점은 reading 에 마지막 글자를 채워넣는다는 행동이지만, 다른 점은 current_token 의 데이터를 전혀 만지지 않는다는 점입니다. 어찌보면 합당합니다. 공백은 토큰조차 되지 못하는 신세이니까요.. ㅠㅠ

편의성을 위해 다음 next_token 함수를 먼저 추가해주세요.

// src/parser.c
t_token		next_token()
{
	token_consume();
	return (token_lookahead());
}

그 다음 아래 내용을 input.txt 에 집어 넣고

13245   12837 2232

아래 테스트 코드로 제대로 동작하는지 확인해봅시다.

 // text.c
+void	test_several_int()
+{
+	t_token token;
+	init_parser("./input.txt");
+	token = next_token();
+	printf("%s, %d\n", raw(token.str), token.type);
+	token = next_token();
+	printf("%s, %d\n", raw(token.str), token.type);
+	token = next_token();
+	printf("%s, %d\n", raw(token.str), token.type);
+	token = next_token();
+	printf("%s, %d\n", raw(token.str), token.type);
+}

 int main(void)
 {
 	// test_filereader();
-	test_first_token();
+	// test_first_token();
+	test_several_int();

 	return (0);
 }

아래는 그 결과입니다.

13245, 1
12837, 1
2232, 1
make: *** [Makefile:10: test] Error 6

네 번째에서 에러가 일어난다는 것은 지극히 자연스럽습니다. 왜냐하면 next_token()은 4번 실행되었던 데에 반해 실제 숫자는 3개밖에 없기 때문입니다.

reading 초기화하는 부분을 함수로 묶기

skip_blank 함수와 try_token_int 함수의 반복되는 부분을 없애보도록 합시다.

 // src/tokenizer.c
+int	init_reading(char c)
+{
+	t_tokenizer *tknzr;
+
+	tknzr = &parser()->tknzr;
+	destroy_str(&tknzr->reading);
+	tknzr->reading = create_str();
+	str_push_char(&tknzr->reading, c);
+	return (1);
+}

 void	skip_blank()
 {
 	char		c;
 	t_tokenizer *tknzr;

  	tknzr = &parser()->tknzr;
 	c = raw(parser()->tknzr.reading)[0];
 	if (c == ' ')
 	{
 		while (1)
 		{
 			c = fr_read();
 			if (c != ' ')
 				break;
 		}
 	}
-	destroy_str(&tknzr->reading);
-	tknzr->reading = create_str();
-	str_push_char(&tknzr->reading, c);
+	init_reading(c);
 }
 // src/tokenizer_try.c
 int	try_token_int()
 {
 	char		c;
 	t_tokenizer	*tknzr;

 	tknzr = &parser()->tknzr;
	c = raw(tknzr->reading)[0];
 	if (is_digit(c))
 	{
 		while (1)
 		{
 			c = fr_read();
 			if (!is_digit(c))
 				break ;
 			str_push_char(&tknzr->reading, c);
 		}
 		tknzr->current_token.type = TOKEN_INT;
 		assign_str(&tknzr->current_token.str, tknzr->reading);
-		destroy_str(&tknzr->reading);
-		tknzr->reading = create_str();
-		str_push_char(&tknzr->reading, c);
- 		return (1);
+		return (init_reading(c));
 	}
 	return (0);
 }

계속해서 비슷한 역할을 하는 코드들은 적극적으로 묶을 것입니다.

try_token_end 구현하고 반복문으로 모든 토큰 돌려보기

이제 토큰의 끝을 의미하는 TOKEN_END 도 구현해보도록 합시다.

// src/tokenizer_try.c
int	try_token_end()
{
	char		c;
	t_tokenizer	*tknzr;

	tknzr = &parser()->tknzr;
	c = raw(tknzr->reading)[0];
	if (c == '\0')
	{
		tknzr->current_token.type = TOKEN_END;
		assign_str(&tknzr->current_token.str, tknzr->reading);
		c = fr_read();
		return (init_reading(c));
	}
	return (0);
}

fr_read 를 했을 적에 더이상 읽을 문자가 없다면 그냥 '\0' 이 리턴되었던 걸 기억하시나요? 그래서 그냥 raw(tknzr->reading)[0]'\0' 라면 그냥 끝났다고 판단하면 됩니다. 여기서는 하나의 글자만 파악하면 되기 때문에 별 다른 반복문이 필요없습니다. 바로 fr_read 를 한 번만 호출하고 init_reading 을 수행합니다. (사실 다 끝난 마당에 fr_read 도 필요없고, init_reading 도 필요없지만, 문자 하나만을 받는 다른 try_token_... 과 비슷한 구조를 만들기 위함이라는 점을 알립니다.)

 // src/tokenizer.c
 void	token_consume()
 {
 	t_tokenizer *tknzr;

 	tknzr = &parser()->tknzr;
 	if (tknzr->current_token.type == TOKEN_END)
 		return ;
 	skip_blank();
 	if (try_token_int())
 		return ;
+	if (try_token_end())
+		return ;
 	exit(ERR_TOKEN_CONSUME);
 }

try_token_end 함수도 token_consume 함수에 추가시켜줍니다. 그리고 테스트를 좀 더 깔끔하게 만들어봅시다.

 // test.c
+void	test_until_end()
+{
+	t_token token;
+	init_parser("./input.txt");
+	token.type = TOKEN_NOT_SPECIFIED;
+	while (token.type != TOKEN_END)
+	{
+		token = next_token();
+		printf("%s, %d\n", raw(token.str), token.type);
+	}
+}

 int main(void)
 {
 	// test_filereader();
 	// test_first_token();
+	// test_several_int();
-	test_several_int();
+	test_until_end();
 	return (0);
 }
13245, 1
12837, 1
2232, 1
, 100

while 문이 무사히 종료되었습니다!

가끔 에디터에 따라 파일을 저장할 때 파일의 가장 끝부분에 줄바꿈 문자(\n)를 삽입하는 경우가 있습니다. 아직 우리의 코드는 줄바꿈 문자를 인식할 수 없으므로 input.txt 파일의 마지막에 줄바꿈 문자가 포함되게 된다면 에러가 발생합니다. 추후 줄바꿈 문자도 인식하도록 할테지만, 테스트를 빠르게 해보고 싶다면 아래 명령어를 실행한 후 테스트를 시도해보세요.

echo -n "13245 12837 2232" > input.txt

current_token 설정하는 부분을 묶기

// src/tokenizer.c
int		confirm_trial(t_token_type type, char next)
{
	t_tokenizer	*tknzr;

	tknzr = &parser()->tknzr;
	tknzr->current_token.type = type;
	assign_str(&tknzr->current_token.str, tknzr->reading);
	return (init_reading(next));
}

int		confirm_trial_init_next(t_token_type type)
{
	return (confirm_trial(type, fr_read()));
}

current_token 을 설정하고 init_reading 하는 부분이 동일하므로 최대한 묶어줍니다.

 // src/tokenizer_try.c

 int try_token_int()
 {
 	char c;
-	t_tokenizer *tknzr;

-	tknzr = &parser()->tknzr;
-	c = raw(tknzr->reading)[0];
+	c = raw(parser()->tknzr.reading)[0];
 	if (is_digit(c))
 	{
 		while (1)
 		{
 			c = fr_read();
 			if (!is_digit(c))
 				break;
-			str_push_char(&tknzr->reading, c);
+			str_push_char(&parser()->tknzr.reading, c);
 		}
-		tknzr->current_token.type = TOKEN_INT;
-		tknzr->current_token.str = tknzr->reading;
-		return (1);
+		return (confirm_trial(TOKEN_INT, c));
 	}
 	return (0);
 }

 int	try_token_end()
 {
 	char		c;
-	t_tokenizer	*tknzr;

-	tknzr = &parser()->tknzr;
-	c = raw(tknzr->reading)[0];
+	c = raw(parser()->tknzr.reading)[0];
 	if (c == '\0')
-	{
-		tknzr->current_token.type = TOKEN_END;
-		assign_str(&tknzr->current_token.str, tknzr->reading);
-		c = fr_read();
-		return (init_reading(c));
-	}
+		return (confirm_trial_init_next(TOKEN_END));
 	return (0);
 }

자 이제 구조가 훨씬 단순해졌습니다!

토큰에 위치 정보를 삽입하여 Unexpected Token 에러 출력시키기

그렇습니다. fr_read 할 때마다 위치를 갱신시키고, 예상치 못한 토큰이 있을 때 unexpected token 에러를 출력해보도록 합시다.

 // src/parser.h
 typedef struct s_tokenizer
 {
 	t_token		current_token;
 	t_str		reading;
+	t_ul		unresolved_row;
+	t_ul		unresolved_col;
+	char		unresolved_char;
 }				t_tokenizer;

우선 문자열을 분석하고 있는 Tokenizer 입장에서 아직까지 토큰화되지 않은 문자 정보를 저장할 수 있도록 공간을 만들어둡니다.

// src/tokenizer.c
char	fr_read_track()
{
	char		result;
	t_tokenizer *tknzr;

	tknzr = &parser()->tknzr;
	result = fr_read();
	tknzr->unresolved_row = parser()->fr.c_row;
	tknzr->unresolved_col = parser()->fr.c_col;
	tknzr->unresolved_char = result;
	return (result);
}

기존의 fr_read 와 기능적으로 똑같이 동작하되, 위치 정보를 Tokenizer 에 기록하는 래퍼 함수 fr_read_track 함수를 만듭니다.

그래서 기존에 사용하고 있었던 fr_read 모두 대체하도록 합니다.

 // src/tokenizer_try.c
 int try_token_int()
 {
 	char c;
 	c = raw(parser()->tknzr.reading)[0];
 	if (is_digit(c))
 	{
 		while (1)
 		{
-			c = fr_read();
+			c = fr_read_track();
 			if (!is_digit(c))
 				break;
 			str_push_char(&parser()->tknzr.reading, c);
 		}
 		return (confirm_trial(TOKEN_INT, c));
 	}
 	return (0);
 }
 // src/tokenizer.c
 
 void			init_tokenizer()
 {
 	char	c;
 
-	c = fr_read();
+	c = fr_read_track();
 	str_push_char(&parser()->tknzr.reading, c);
 }

 int		confirm_trial_init_next(t_token_type type)
 {
-	return (confirm_trial(type, fr_read()));
+	return (confirm_trial(type, fr_read_track()));
 }

 void	skip_blank()
 {
 	char		c;
 	t_tokenizer *tknzr;

 	tknzr = &parser()->tknzr;
 	c = raw(parser()->tknzr.reading)[0];
 	if (c == ' ')
 	{
 		while (1)
 		{
-			c = fr_read();
+			c = fr_read_track();
 			if (c != ' ')
 				break;
 		}
 	}
	init_reading(c);
 }

그 다음 에러 메시지를 만들기 위해 t_str 를 손 좀 보도록 합시다.

// src/string.c

#include <unistd.h>

void	str_push_chars(t_str *str, const char *s)
{
	t_ul	i;

	i = 0;
	while (s[i])
	{
		str_push_char(str, s[i]);
		i++;
	}
}

void	str_push_long(t_str *str, long num)
{
	if (num < 0)
	{
		str_push_char(str, '-');
		num *= -1;
	}
	if (num >= 10)
		str_push_long(str, num / 10);
	str_push_char(str, (num % 10) + '0');
}

void	write_str(t_str str)
{
	write(1, raw(str), str.len);
}

총 3가지의 함수를 추가했습니다.

  • str_push_chars : 기존의 t_str 내용에 c-style 문자열 내용을 덧붙입니다.
  • str_push_long : 기존의 t_str 내용에 숫자를 문자화하여 내용을 덧붙입니다. 재귀적으로 구성되었습니다. 간단하게 구현한 특성상 numlong 의 최소값일 경우 이상하게 작동합니다.
  • write_str : 표준 출력으로 t_str 의 내용을 출력합니다.

그 다음 에러 메시지를 출력하고 종료하는 abort_tokenizer 함수를 만들어줍니다.

// src/abort.c

void	abort_tokenizer()
{
	t_str		str;
	t_tokenizer	tknzr;

	tknzr = parser()->tknzr;
	str = create_str();
	str_push_chars(&str, "Unexpected Token '");
	str_push_char(&str, tknzr.unresolved_char);
	str_push_chars(&str, "' at ");
	str_push_chars(&str, parser()->fr.path);
	str_push_char(&str, ':');
	str_push_long(&str, tknzr.unresolved_row);
	str_push_char(&str, ':');
	str_push_long(&str, tknzr.unresolved_col);
	str_push_char(&str, '\n');
	write_str(str);
	destroy_str(&str);
	exit(ERR_TOKEN_CONSUME);
}

이 함수를 consume 에서 exit 대신 넣어줍니다.

 // src/tokenizer.c
 void	token_consume()
 {
 	t_tokenizer *tknzr;

 	tknzr = &parser()->tknzr;
 	if (tknzr->current_token.type == TOKEN_END)
 		return ;
 	skip_blank();
 	if (try_token_int())
 		return ;
	if (try_token_end())
		return ;
-	exit(ERR_TOKEN_CONSUME);
+	abort_tokenizer();
 }

이제 123 456 7+9 이렇게 input.txt 에 넣고 테스트를 해봅시다. 테스트 코드는 이전과 동일하게 test_until_end 함수로 진행합니다.

123, 1
456, 1
7, 1
Unexpected Token '+' at ./input.txt:1:10
make: *** [Makefile:10: test] Error 6

와 정말 멋잇지 않나요!!

newline(줄바꿈), comma(쉼표), name(식별자)토큰 지원하기

줄바꿈 문자를 지원하는 건 파일의 끝을 인식하는 것과 거의 동일하게 진행하면 됩니다. 아래가 그 코드입니다.

// src/tokenizer_try.c

int	try_token_newline()
{
	char	c;

	c = raw(parser()->tknzr.reading)[0];
	if (c == '\n')
		return (confirm_trial_init_next(TOKEN_NEWLINE));
	return (0);
}

int	try_token_comma()
{
	char	c;

	c = raw(parser()->tknzr.reading)[0];
	if (c == ',')
		return (confirm_trial_init_next(TOKEN_COMMA));
	return (0);
}

name(이름)을 불러오는 것도 쉬운데요, 여기서 말하는 이름이란 우리가 흔히 변수명으로 쓰는 것처럼 어떤 이름이라고 생각하면 됩니다. 이것도 꽤 쉽게 구현할 수 있습니다.

// src/is.c
int		is_alpha(char c)
{
	return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
}

int		one_of(char c, const char *s)
{
	int	i;

	i = 0;
	while (s[i] != '\0')
	{
		if (s[i] == c)
			return (1);
		i++;
	}
	return (0);
}

int		is_c_name(char c)
{
	return (is_alpha(c) || one_of(c, "_"));
}

is_alpha 함수는 c 라는 글자가 알파벳인지 아닌지 판단합니다. one_of 함수는 c 라는 문자 하나가 s라는 문자열에 포함되어 있는지 아닌지를 판단합니다. 이 두개를 조합하여 is_c_name 을 만듭니다. is_c_name 함수는 해당 글자가 알파벳이거나 _ 라면 TRUE(1) 를 리턴하고, 그렇지 않다면 FALSE(0)를 리턴합니다. C 를 작성할 때 숫자도 변수명에 포함될 수 있지만, 일단 우리가 만드는 파서는 일단 지원하지 않는다고 가정합시다.

그런 다음 try_token_name 함수를 작성합니다.

int	try_token_name()
{
	char	c;

	c = raw(parser()->tknzr.reading)[0];
	if (is_c_name(c))
	{
		while (1)
		{
			c = fr_read_track();
			if (!is_c_name(c))
				break ;
			reading_push_char(c);
		}
		return (confirm_trial(TOKEN_NAME, c));
	}
	return (0);
}

우선 첫번째 글자를 읽어와서, name(이름) 에 해당하는 글자라면 계속 읽어나갑니다. 이윽고 name 에 해당하지 않는 글자가 나온다면, breakwhile 문을 빠져간 후 함수를 종료합니다.

정수를 더 정확하게 읽어오기

우리의 정수는 아직까지 미완성입니다. 왜냐하면 일단 음수를 받아들이지 못하고, 그 다음 00001234 와 같이 숫자 형식에 좀 벗어난 것도 일단 숫자라고 알아보기 때문입니다. 이러한 조건을 더 세세하게 들어가보겠습니다. 지금은 이면지나 아이패드를 준비해주세요. 여러가지 숫자일 가능성이 있는 문자의 나열을 끄적여봅시다. 0, -2, 234, 100, -235, --123, 0042, -0. 기타 등등등..

이 중에서 --123이나 0042 같은 건 틀리다고 가정하겠습니다. 다만 -0 같은 경우는 일단 옳다고 가정하겠습니다. 이 정수 판별기를 가지고 추후 소수점 읽을 때도 사용할 거라서 말입니다.. 엄밀한 규칙은 이제 숙제로 남겨놓도록 하겠습니다. 이럼 이제 분류가 되었으니 좀 정리를 해볼까요?

  1. 처음에 - 기호 하나가 나오거나 나오지 않아야 합니다. (선택) 여러 번 나올 수 없습니다.
  2. 그 다음 0이 나오거나, 0이 아닌 숫자가 나옵니다.
    1. 0이 나온다면, 0으로, 정수로 처리해 버리고 바로 종료합니다. 0은 두 번 이상 연속으로 나올 수 없습니다.
    2. 0이 아닌 숫자가 나온다면, 그 다음부터 연속적으로 나오는 0~9의 모든 숫자까지 정수로 처리합니다.

위 논리에 따르면 --123 같은 경우는 두 번째 -에서 2번 규칙에 걸리게 되므로 올바른 토큰이 될 수 없습니다. 0042 같은 경우는 0을 만나면 바로 종료하는 조건때문에 토큰이 3개가 생깁니다. 0, 0, 42 이렇게요. 논리적인 결함이 있는 듯 하지만, 앞으로 만들 파서에 큰 걸림돌이 되는 것은 아니므로 가볍게 넘어가도록 하겠습니다.

그렇다면 바로 코딩해봅시다.

// src/is.c
int		is_no_zero_digit(char c)
{
	return (c >= '1' && c <= '9');
}
// src/tokenizer_try.c
int	try_token_int()
{
	char	c;

	c = raw(parser()->tknzr.reading)[0];
	if (c == '-')
	{
		c = fr_read_track();
		reading_push_char(c);
	}
	if (c == '0')
		return (confirm_trial_init_next(TOKEN_INT));
	if (is_no_zero_digit(c))
	{
		while (1)
		{
			c = fr_read_track();
			if (!is_digit(c))
				break ;
			reading_push_char(c);
		}
		return (confirm_trial(TOKEN_INT, c));
	}
	return (0);
}

이제 아래 내용을 입력으로 설정하고 테스트를 수행해봅시다.

123 456 79 -123 -0 0 05

그렇다면 이제 아래와 같은 결과가 나옵니다.

123, 1
456, 1
79, 1
-123, 1
-0, 1
0, 1
0, 1
5, 1
234, 1

좋습니다. 적당히 잘 동작합니다.

소수점이 있는 숫자 읽어오기

앞서 만든 정수 try 함수를 이용하여 소수점이 있는 숫자(이하 실수)까지 불러와보도록 하겠습니다. 실수은 알고보면 정수를 포함합니다. 그러니까 1, 2, 3과 같은 정수는 number 이 될 수 있지만, 1.2, 2.5와 같은 number 은 정수가 될 수 없지요. 그리고 실수와 정수를 가르는 가장 큰 기준점은 점(.) 입니다. 그렇다면 순서를 한번 또 정리해봅시다.

  1. 일단 실수의 앞에는 정수가 나와야 합니다.
  2. 그 다음 점이 올 수도 있고, 안 올수도 있습니다. 온다면 실수부 검색을 들어갈 준비를 해야 하고, 안온다면 앞에까지를 그냥 정수로 성공 처리합니다.
  3. 점 다음에 숫자들이 온다면, 숫자들을 기존 정수 검사 결과에 덧붙입니다.
  4. 점 다음에 숫자가 오지 않는다면, 잘못된
// src/tokenizer_try.c

int	try_token_real()
{
	char	c;

	if (!try_token_int())
		return (0);
	c = raw(parser()->tknzr.reading)[0];
	if (c != '.')
		return (init_reading(c));
	c = fr_read_track();
	if (is_digit(c))
		return (iterate_dot_right(c));
	else
		return (0);
}

int iterate_dot_right(char c)
{
	t_str		temp;
	t_tokenizer	*tknzr;

	tknzr = &parser()->tknzr;
	reading_push_char(c);
	while (1)
	{
		c = fr_read_track();
		if (!is_digit(c))
			break;
		reading_push_char(c);
	}
	temp = duplicate_str(tknzr->current_token.str);
	concat_str(&temp, tknzr->reading);
	confirm_trial_value(TOKEN_DOUBLE, c, temp);
	destroy_str(&temp);
	return (1);
}
// src/string.c

void	concat_str(t_str *target, t_ststr source)
{
	t_ul		i;
	const char	*raw_str;

	raw_str = raw(source);
	i = 0;
	while (i < source.len)
	{
		str_push_char(target, raw_str[i]);
		i++;
	}
}

Parser 만들기

// test.c
#include <stdio.h>
#include "parser.h"

int main(void)
{
	init_parser("./input");
	parse();
	return (0);
}

One thought on “[C] 간단한 파서 (Parser) 직접 만들기 (작성중)

  1. 설치 법은 본 블로그에서는 소개드리지 않습니다. -> 설치법은
    그 다음 점이 올 수도 있고, 안 올수도 있습니다. -> 안 올 수도
    기타 등등등.. | 말입니다.. -> 말줄임표(…)
    토큰을 읽어드리는 대로 그대로 받아들이면 될 뿐입니다. -> 읽어들이는 대로

    좋은 글입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다

Scroll to top