Урок 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 требует проверки внутри круглых скобок");
}
Аналогичные правила можно вывести и для других языковых конструкций.
Итог
Это был первый урок, описывающий выдачу ошибок в компиляторе. Пока что мы сделали выдачу не самой точной, но уже стало примерно понятно по какому пути следует двигаться. В последующих уроках мы ещё вернёмся к вопросу выдачи сообщений, а пока что здесь можно посмотреть на эталонный пример.