Урок 5: Обработка синтаксических ошибок

Вступление

Учебные курсы часто практически не уделяют времени обработке ошибок в программах, но это важная часть созания компилятора. При встрече с ошибкой компилятор не должен сразу молча падать даже с указанием строки ошибки, как это происходит у нас сейчас. Хороший компилятор выведет подробное сообщение об ошибке с описанием причин, а современные компиляторы ещё и с предложением как её можно было бы исправить, и продолжить дальше анализировать код. Мы остановимся на промежуточном варианте и будем выдавать достаточно подробное и осмысленное сообщение, причём по возможности постараемся выдавать по нескольку сообщений за раз. Разумеется, желающие могут посмотреть как выдача ошибок сделана в современных gcc и llvm и попытаться хотя бы немного приблизиться к ней.

Выполнение

В нашей серии уроков мы будем постепенно улучшать качество выдачи сообщений об ошибках. В этом уроке будут только самые простые и неточные диагностики, которые усовершенствуются в следующих уроках.

Рассмотрим актуальную ситуацию с выдачей ошибок в нашем компиляторе:

$ cat tests/in/incorrect/1.1.x
/* Тест на некорректные символы */

ывапвы

??

int main()
{
    return 0;
}

'

$ ./bin/debug/x_d tests/in/incorrect/1.1.x 
Ошибка: 3:1: syntax error

Можем заметить что мы встретили первую попавшуюся ошибку и сразу завершили исполнение. Причём сейчас печатается сообщение syntax error, которое вообще ни о чём не говорит.

Вообще bison не особо подходит для выдачи сообщений об ошибках, но что-то с его помощью сделать вполне можно. Для обработки ошбок существует специальное правило error. Оно начинает применяться если в вывод не удалось подставить ни одно другое правило. Попробуем доработать правило input таким образом чтобы принимать правило с ошибкой:

input:
  func_def          { printNonTerminal("func_def"); }
| func_def input    { printNonTerminal("func_def input"); }
/* Это мы добавляем */
| error input       { yyerror(yytext); }
;

Также в функции yyerror не забудем убрать вызов exit чтобы не выходить по первой же ошибке. Теперь получим следующую выдачу:

$ ./bin/debug/x_d tests/in/incorrect/1.1.x
Ошибка: 3:1: syntax error
<TOK_TYPE_INT, "int", 7:1, 7:3>
<TOK_IDENT, "main", 7:5, 7:8>
<'(' ')'>
<type_token ident_token sign_arg_cons>
<TOK_INT, "0", 9:12, 9:12>
<const_token>
<expr>
<TOK_RET return_expr_tail>
<return_expr>
<single_statement ';'>
<func_body statement>
<func_sign '{' func_body '}'>
Ошибка: 12:1: syntax error

Можно увидеть что у нас уже появился неплохой результат: мы выявили две ошибки из трёх и смогли правильно разобрать саму функцию (т.е. потенциально выявим ошибки ещё и внутри функции). Как это произошло? Обнаружив токен, который bison не смог подставить ни в одно правило он стал пытаться подставить каждый следующий токен так чтобы удовлетворить правилу input, идущему за нашим error. Соответственно, после первой ошибки мы смогли удачно восстановиться и пойти дальше.

Эксперименты с таким правилом для ошибок быстро покажут нам две основные проблемы:

Таким образом, можно видеть что нам необходимо вывести несколько частых повторяющихся ошибок и добавлять правило error более точечно. Например, частой ошибкой является забытая точка с запятой в конце строки. Добавим обработку соответствующего правила:

statement:
/* ... */
/* Обработка ошибок */
| single_statement error    {
      yyerror("не хватает ';' после выражения");
  }
;

Рассмотри как поведёт себя это правило на простом исходнике:

$ cat t.x
int main()
{
    dfsd = 10
}

$ ./bin/debug/x_d t.x
...
Ошибка: 4:1: syntax error
Ошибка: 4:1: не хватает ';' после выражения
...

Из хороших новостей - мы смогли вывести вполне себе осмысленную ошибку к месту. Из плохих новостей - мы имеем странное лишнее сообщение об ошибке и не совсем правильную навигацию в коде.

Начнём с решения проблемы лишней печати. Происходит она потому что применение правила error автоматически вызывает функцию yyerror. Изменить это мы никак не сможем. Но сможем адаптировать для своих целей. Для этого необходимо будет разделить печать ошибки на две функции: неотключаемая функция yyerror будет печатать только универсальную часть сообщения об ошибке, а именно указание положения в тексте и названия модуля, содержащего ошибку. Например вот так:

void yyerror(char const * msg)
{
    fprintf (stderr,
             "%s:%d:%d: '%s' ",
             ModuleName,
             yylloc.first_line,
             yylloc.first_column,
             yytext);
}

Здесь мы видим новую переменную ModuleName, это простая статическая переменная, содержащая имя модуля. Её инициализация происходит в функции main до вызова анализатора. Далее составим вторую функцию, которая будет заниматься печатью фактического сообщения:

static void printErrorMessage(const char * msg)
{
    fprintf(stderr, "%s\n", msg);
}

Как видим, пока что ничего сложного нет, поэтому доработаем соответствующим образом сообщение об ошибке:

| single_statement error    {
      printErrorMessage("не хватает ';' после выражения");
  }

Теперь наше сообщение об ошибке выглядит так:

1.1.x:4:1: '}' не хватает ';' после выражения

У него всё ещё есть недочёты, но от них мы будем избавляться на следующих уроках.

Внимательный читатель сможет выяснить что сообщение выводится для строки dfsd = 10, но оно не выведется для просто строки dfsd. Если подумать, то это довольно логично, т.к. первый вариант допускается правилом single_statement и является присваиванием значения идентификатору, а второй вариант - нет. Поэтому попасть в вывод single_statement error мы не сможем совсем никак. Для обработки таких случаев мы сможем добавить продукцию в правило single_statement:

single_statement:
/* ... */
| ident_token error {
      printErrorMessage("идентификатор неизвестного предназначения");
  }

Посмотрим что нам выведется теперь:

1.1.x:4:1: '}' идентификатор неизвестного предназначения не хватает ';' после выражения

Снова мы получили не совсем то что ожидали. Первая часть практически правильная за исключением печати самого идентификатора. А вот вторая часть совсем не к месту: по сути в ней отработала вторая часть печати сообщения, но не отработала первая. Произошло потому что во вторую ошибку мы попали уже в частично-ошибочном состоянии, которое не обеспечивает повторного вызова функции yyerror. Здесь можно было бы воспользоваться макросом yyerrok, который восстанавливает состояние, но на практике он создаёт больше проблем чем пользы. Поэтому применим более хитрую технику. А именно сделаем флаг, который говорит можем мы печатать сообщение или нет. Он будет включаться при вызове функции yyerror и выключаться при вызове функции printErrorMessage. При этом внутри самой функции printErrorMessage печать разрешена только при взведённом флаге:

static void printErrorMessage(const char * msg)
{
    if( !NeedErrorMessage )
    {
        return;
    }

    fprintf(stderr, "%s\n", msg);
    NeedErrorMessage = false;
}

void yyerror(char const * msg)
{
    assert(!NeedErrorMessage);
    fprintf (stderr,
             "%s:%d:%d: '%s' ",
             ModuleName,
             yylloc.first_line,
             yylloc.first_column,
             yytext);
    NeedErrorMessage    = true;
}

Теперь мы наконец печатаем только одно сообщение и не боимся что случайно будет вызвана печать только части сообщения об ошибке.

Не все диагностики должны выражаться через токен error. Например, одной из возможных ошибочных ситуаций станет выражение, состоящее только из литерала:

$ cat t.x
int main()
{
    10;
}

$ /bin/debug/x_d t.x
...
1.1.x:3:5: '10' 

Снова всё работает не так как мы хотим. Напечаталась только первая половина ошибки, а вторая попросту не предусмотрена. Лечить проблему будем добавлением специального правила:

single_statement:
/* ... */
| literal_token {
      yyerror("");
      printErrorMessage("выражение не может начинаться с литерала");
  }

Смотрим результат:

1.1.x:3:5: '10' выражение не может начинаться с литерала

Это ровно то что нам нужно. На вопрос как избегать ситуаций с половинчатой выдачей сообщения у меня только один ответ: делать проверки (assert в функции yyerror) и наращивать объём тестирования.

В целом мы рассмотрели основные приёмы обработки ошибок в bison, осталось только постараться заполнить достаточно случаев с диагностиками, понятно описывающими проблему и помогающими пользователю её решить. Для примера приведём несколько правил, позволяющих описать возможные проблемы при написании условного оператора:

if_statement_head:
/* ... */
| TOK_IF '(' error ')' '{' func_body '}' elif_statement {
      printErrorMessage("некорректное выражение внутри условного оператора if");
  }
| TOK_IF error '{' func_body '}' elif_statement {
      printErrorMessage("условный оператор if требует проверки внутри круглых скобок");
  }
| TOK_IF error {
      printErrorMessage("условный оператор if требует проверки внутри круглых скобок");
  }

Аналогичные правила можно вывести и для других языковых конструкций.

Итог

Это был первый урок, описывающий выдачу ошибок в компиляторе. Пока что мы сделали выдачу не самой точной, но уже стало примерно понятно по какому пути следует двигаться. В последующих уроках мы ещё вернёмся к вопросу выдачи сообщений, а пока что здесь можно посмотреть на эталонный пример.