. 문법 들여쓰기

문법 들여쓰기는 입력된 문자와 현재 선택된 분석기의 정보를 참조하여 문서를 가장 보기 좋게 들여쓰기 하는 기능이다. 앞쪽 문단의 모양만 보고 다음 문단의 들여쓰기를 결정하는 자동 들여쓰기보다는 한 단계 더 고급 기능이라 할 수 있다. 이 기능도 bAutoIndent 옵션으로 사용 여부를 결정하는데 따로 옵션을 둘 수도 있으나 자동 들여쓰기가 안되는 상황에서 문법 들여쓰기를 할 수는 없기 때문에 두 기능의 선택 여부를 한 옵션으로 통합해두었다.

문법 들여쓰기는 분석기가 정의하는 블록 시작 문자와 블록 끝 문자를 인식하여 들여쓰기를 조정하는 것인데 현재 ApiEdit의 분석기 중에 블록을 지원하는 문법은 C 언어 분석기밖에 없다. 구체적으로 예를 들자면 { 문자 후에 엔터키를 치면 현재 문단보다 한 칸 더 들여쓰기를 하는 것이고 } 문자를 치면 내어 쓰기를 하는 것이라 할 수 있다. 현재는 C 문서에 대해서만 이 기능이 작동하지만 이후 파스칼이나 자바 분석기를 추가하면 똑같이 적용될 수 있도록 작성해야 한다.

앞서 작성한 들여쓰기 보조 함수 중 문법 들여쓰기를 지원하는 나머지 세 함수들을 이제 분석해보도록 하자. 문법이 고려된 들여쓰기를 할 때는 앞 문단의 들여쓰기 문자열을 그대로 보존하기가 무척 어렵다. 유효 앞 문단에 탭 두 개가 있고 다음 문단을 한 칸 더 들여쓰려면 탭을 하나 더 늘려 세 개를 삽입하면 될 것이다. 그러나 만약 \t----\t-- 이런 식으로 탭과 공백을 섞어서 들여쓰기를 했다면 이런 편리한 방법으로 들여쓰기를 조정할 수 없다.

그래서 탭과 공백 문자에 상관없이 들여쓰기 레벨을 수치화할 수 있는 방법이 필요한데 그 역할을 하는 함수가 GetIndentLevel 함수이다. 이 함수는 문단 앞쪽의 공백 문자 들여쓰기 정도를 수치화하는데 탭문자는 TabWidth로 계산하고 스페이스는 1로 계산한다. 즉 수치화의 단위는 스페이스 단위이다. 들여쓰기 레벨을 수치화해야 한 칸 더 들여쓰거나 내어쓰기를 할 때 얼마만큼 들여쓸 것인가를 정확하게 계산할 수 있다.

MakeIndentString 함수는 들여쓰기 레벨을 수치로 전달받아 이 수치만큼의 들여쓰기 문자열을 만들되 bSpaceForTab 옵션에 따라 들여쓰기 문자열을 작성하는 방법이 달라진다. 탭 대신 공백 문자를 사용한다면 들여쓰기 문자열을 공백으로 작성하고 그렇지 않다면 탭문자만으로 들여쓰기 문자열을 만들어 낸다. GetIndentLevel은 정확한 조사를 위해 공백 단위로 들여쓰기 레벨을 조사하는 반면 MakeIndentString은 탭 단위를 사용하는데 어차피 문법 들여쓰기는 탭문자 폭의 배수만큼만 들여쓰기와 내어쓰기를 하기 때문이다.

IsPrevParaBlockOpen 함수는 유효 앞 문단의 끝이 블록 시작 문자열인지 조사한다. C 언어라면 {가 블록 시작 문자가 되고 파스칼이라면 begin이 될 것이다. 현재 선택된 분석기의 GetInfo(5) 함수를 호출하면 블록 시작 문자열을 구할 수 있는데 이 문자열이 정의되어 있지 않다면 문법 들여쓰기를 할 필요가 없다. 유효 앞 문단의 유효 마지막 문자 위치를 구한 후 이 문자열이 분석기의 블록 시작 문자열인지 비교하여 그 결과를 리턴했다.

이 보조 함수들의 도움을 받으면 문법 들여쓰기도 쉽게 구현할 수 있다. 먼저 { 문자 후에 <Enter>키로 개행할 때 한 칸 더 들여쓰기 하는 코드를 작성해보고 이 코드가 어떻게 문법 들여쓰기를 하는지 분석해보자. ProcessIndent 함수에 다음 코드를 추가한다. 길이는 짧지만 아주 함축적으로 작성되어 있다.

 

void CApiEdit::ProcessIndent(TCHAR ch)

{

     TCHAR szIndent[4096];

     int toff;

     if (ch == ‘\r’) {

        if (IsPrevParaBlockOpen(off)) {          

           MakeIndentString(1,szIndent,4096);

        } else {

           lstrcpy(szIndent,"");

        }

 

        GetIndentString(GetPrevPara(off),szIndent+lstrlen(szIndent),             

           4096-lstrlen(szIndent));

 

          StartUndoGroup();

          toff=off;

          while (AeIsWhiteSpace(buf[toff]) && !AeIsLineEnd(buf[toff])) toff++;

          if (toff != off) {

              Delete(off,toff-off);

          }

 

          if (lstrlen(szIndent)) {

              Insert(off,szIndent);     

              off+=lstrlen(szIndent);

          }

          EndUndoGroup();

     }

}

 

코드의 각 부분에 붙여진 번호는 분석의 편의상 붙여 놓은 것이다. 다음 그림은 소스의 각 번호에서 문서가 어떻게 처리되는지를 그린 것인데 코드의 흐름과 잘 비교해 가면서 보면 이해하기 쉬울 것이다.

① 앞 문단의 끝에 캐럿이 있는 상태이다. 이 문단의 끝 문자가 블록 끝 문자인 { 이므로 문법 들여쓰기를 하기 직전의 모습이다. 아직 <Enter>키를 누른 것이 아니므로 OnChar도 호출되지 않았으며 ProcessIndent 함수도 호출되기 전의 모습이다.

<Enter>키를 누르면 OnChar에서 개행코드를 문서에 삽입하며 캐럿이 다음 줄의 처음으로 이동한다. 이 시점에서 OnChar ProcessIndent 함수를 호출한다. ProcessIndent 함수는 이 상태에서 문서의 상황을 판단해서 자동 들여쓰기와 문법 들여쓰기를 같이 처리해야 한다.

③ 입력된 키가 개행코드이므로 if (ch==\r) 조건이 성립한다. 유효 앞 문단의 끝이 { 인지 검사한다. 앞 문단에서 { 다음에 <Enter>를 눌렀으므로 이 조건이 만족되며 MakeIndentString 함수에 의해 탭문자 하나분의 들여쓰기 문자열이 szIndent에 작성된다. 여기서 작성된 탭문자 하나는 이전 문단보다 한 칸 더 들여쓴다는 의미이며 이 코드에 의해 문법 들여쓰기가 구현된다.

④ 자동 들여쓰기를 위해 유효 앞 문단의 들여쓰기 문자열을 조사하되 szIndent에 이미 작성된 문자열을 유지하고 그 뒷부분에 조사함으로써 문법 들여쓰기된 부분을 보존한 채로 자동 들여쓰기 문자열을 덧붙인다. szIndent에는 탭문자 두 개가 들어가는데 각 탭의 의미는 분명히 다르다. 앞쪽의 탭은 문법 들여쓰기에 의해 추가된 것이고 뒤쪽의 탭은 자동 들여쓰기에 의해 추가된 것이다.

⑤ 새 문단의 앞부분에 szIndent의 탭문자 두 개가 삽입되고 캐럿은 그 뒤쪽으로 이동된다. 모든 처리가 완료되었다.

 

이 코드는 아주 짧지만 굉장히 많은 예외 상황을 처리하고 있다. 탭과 공백이 섞여 있어도 상관없으며 앞 문단의 들여쓰기 문자열은 가급적 보존하도록 되어 있고 bSpaceForTab 옵션도 제대로 적용하고 있으며 문법 들여쓰기와 자동 들여쓰기의 충돌도 해소하고 있다. 잘 분석이 되지 않으면 다양한 예문을 입력해보면서 디버거를 돌려 보기 바란다. 특히 szIndent가 어떻게 변하는지를 잘 관찰해보아라.

다음은 문법 내어쓰기를 구현해보자. 문법 들여쓰기는 { 다음에 한 칸 더 들여쓰기를 하는 것이고 문법 내어쓰기는 } 가 입력되는 즉시 앞 문단보다 한 칸 앞쪽으로 들여쓰는 기능이다. 두 기능이 같이 구현되어야 소스가 제대로 블록을 구성할 수 있으며 사용자들은 들여쓰기에 신경쓰지 않고 소스만 입력하면 된다.

이 기능도 역시 ProcessIndent 함수가 처리한다. 이번에는 코드가 좀 길다.

 

void CApiEdit::ProcessIndent(TCHAR ch)

{

     TCHAR szIndent[4096];

     int toff;

    int tlen;

    BOOL bAllSpace;

    int nTab;

 

     if (ch == ‘\r’) {

          ....

     }

 

     tlen=lstrlen(Parser->GetInfo(6));

     if (ch!=‘\r’ && tlen && off >= tlen) {

          toff=off-tlen;

          if (_strnicmp(buf+toff,Parser->GetInfo(6),tlen)==0) {

              for (;;) {

                   toff--;

                   if (buf[toff] == ‘\n’ || toff==0) {

                        bAllSpace=TRUE;

                        break;

                   }

                   if (!AeIsWhiteSpace(buf[toff])) {

                        bAllSpace=FALSE;

                        break;

                   }

              }

 

              if (bAllSpace) {

                   GetIndentString(GetPrevPara(off),szIndent,4096);

 

                   nTab=GetIndentLevel(szIndent)/TabWidth;

 

                   if (!IsPrevParaBlockOpen(off)) {

                        nTab=max(0,nTab-1);

                   }

 

                   MakeIndentString(nTab, szIndent,4096);

 

                   toff=FindParaStart(off);

                   StartUndoGroup();

                   Delete(toff,off-toff-tlen);

                   if (lstrlen(szIndent)) {

                        Insert(toff,szIndent);

                   }

                   EndUndoGroup();

 

                   off=off-(off-toff-tlen)+lstrlen(szIndent);

              }

          }

     }

}

 

문법 내어쓰기를 해야 할 조건은 3가지가 있다. 3가지 중 하나라도 만족되지 않으면 문법 내어쓰기가 불가능하거나 아니면 할 필요가 없다.

 

① 우선 분석기가 블록 닫기 문자열을 정의해야 한다. 일반 텍스트나 SQL 문법같이 들여쓰기를 하지 않는 문서는 문법 들여쓰기와 문법 내어쓰기 모두를 할 필요가 없다.

② 입력된 문자열이 블록 닫기 문자열이어야 한다. 블록 닫기 문자가 아니라 문자열이라는 점을 유의하도록 하자. C 언어는 } 문자 하나로 블록 닫기를 하지만 파스칼 같은 경우는 end문자열로 블록을 닫기 때문에 방금 입력된 문자 하나만 보고는 블록을 닫아야 할 때인지 판단할 수 없다. 그래서 블록 닫기 문자열의 길이만큼 앞쪽으로 이동한 후 블록 닫기 문자열이 완전히 입력되었는지 문자열 비교를 해 봐야 한다.

③ 블록 닫기 문자열이 입력되었더라도 이 문자열 앞쪽이 모두 공백이어야 한다. 예를 들어 while (*p) { 어쩌고 저쩌고 } 에서 제일 끝에 블록 닫기 문자열이 입력되었더라도 이때는 내어쓰기를 할 때가 아니다. } 앞에 다른 문자가 있다는 말은 이 줄이 아직 블록 안이라는 뜻이기 때문이다. 그러나 } 뒤쪽에 다른 문자열, 예를 들어 주석이 있다거나 할 때는 내어쓰기를 해야 한다.

블록 닫기 문자열 이전이 모두 공백인지는 직접 앞쪽으로 이동하면서 조사해 봐야 한다. 빈 줄인지 점검하는 용도로 IsParaEmpty라는 보조 함수를 만들어 놓았지만 이 함수는 지정한 번지의 뒤쪽만 검사할 수 있기 때문에 이 경우에는 써 먹을 수 없다.

 

이 세 가지 조건이 모두 만족되면 내어쓰기를 하는데 방법은 간단하다. 유효 앞 문단의 들여쓰기 레벨을 조사한 후 탭 단위로 바꾼다. 이때 나누기 연산에 의해 잘려나가는 소수부는 무시함으로써 탭 하나보다 작은 공백은 없는 것으로 취급한다. 조사된 이전 문단의 들여쓰기 레벨보다 한 칸 더 작은 들여쓰기 문자열을 szIndent 버퍼에 작성하고 이 버퍼를 문단 앞의 공백과 바꿔치기 하면 내어쓰기가 완료된다.

유효 앞 문단이 두 칸 들여쓰기 되어 있다면 새 문단은 한 칸만 들여쓰면 될 것이다. 이 과정에서 탭과 공백으로 뒤섞인 들여쓰기 문자열은 MakeIndentString 함수에 의해 bSpaceForTab 옵션이 지정하는 대로 깔끔하게 정리된다.

문법 내어쓰기는 유효 앞 문단보다 한 칸 작게 들여쓰는데 예외가 한 가지 있다. 블록을 열었다가 바로 닫는 경우에는 내어쓰기를 하지 않는다. 정확하게 표현하자면 내어쓰기를 하기는 하되 참조하는 문단이 다르다. 문법적으로 빈 블록을 만들 필요는 없지만 코딩 습관상 블록을 먼저 만들어 놓고 블록 안의 코드를 작성하는 경우가 많기 때문에 일시적인 빈 블록이 있을 수 있다. 예를 들어 switch 문을 작성한다면 다음과 같이 switch문 자체를 먼저 만들고 난 후에 블록 안에 case를 추가하는 것이 보통이다.

 

switch (i)

{

}

 

이때의 블록 닫기는 내어쓰기 대상이 아닌데 왜냐하면 아직 들여쓰기를 하지 않았기 때문이다. switch 블록에 case문을 작성하다가 블록을 닫는다면 case가 벌써 들여쓰기되었기 때문에 case 문단을 기준으로 내어쓰기를 하면 된다. 그러나 빈 블록의 경우는 블록 열기 문자열에 의해 들여쓰기가 되긴 했지만 블록의 내용없이 바로 닫혔으므로 유효 앞 문단보다 한 칸 덜 들여쓰는 것이 아니라 똑같이 들여쓰기 해야 한다. 그래서 유효 앞 문단이 블록의 시작이었으면 조사된 탭 단위를 그대로 정리만 해서 다시 써 주었다.