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

Предыдущая запись: Довольно много ошибок

Постановка задачи

Я давно не разбирался в TypeScript, и у меня было немного свободного времени, поэтому я хотел испытать себя. Этот вклад включал более глубокий анализ и сканирование TypeScript. Чтобы найти проблему, я искал проблемы с тегами Effort: Moderate и help wanted, а затем отсортировал их по самому старому (ссылка на запрос). Один из 2015 года показался интересным:

# 4702: IdentifierStart не может появляться после NumericLiteral

Перефразируя сообщение о проблеме: согласно спецификации ECMA, раздел 11.8.3 - NumericLiterals, SourceCharacter после NumericLiteral не должен быть IdentifierStart . Например:

  • 3in: должно стать ошибкой, а не рассматриваться как 3 in (относится к проверке наличия 3 в чем-либо)
  • let hasFour = 3in[null, null, null, null]; как расширение ☝

tl;dr

Вам нужен пробел между числовыми литералами (3) и идентификаторами или ключевыми словами (a, in). 3in - это плохо; 3 in разрешено.

Терминология

Что, черт возьми, означают эти термины?

  • SourceCharacter: просматривая спецификацию ECMA с помощью Ctrl + F, чтобы найти другие места, где она упоминается, похоже, что это относится к символам в исходном коде - имеет смысл, учитывая название .
  • NumericLiteral: ts.SyntaxKind.NumericLiteral обозначает числа, которые вы вводите, например 123 (обычные числа с плавающей запятой), 1_234_567 (числа с плавающей запятой с разделителями) и 1234n (BigInts). Литерал обычно относится к встроенным примитивным значениям, таким как логические значения, числа или строки.
  • IdentifierStart: обычно относится к имени чего-либо, например переменной или класса. Давайте интерпретируем это в данном контексте как означающее, что любое имя является либо встроенным ключевым словом (например, in), либо идентификатором (например, a).

Для NumericLiteral и IdentifierStart вы можете увидеть эквивалентные узлы NumericLiteral и Identifier по копировать и вставлять в ts-ast-viewer:

let value = 7;

Он показывает, что value - это узел идентификатора (в данном случае имя переменной), а 7 - это NumericLiteral.

Если вы скопируете простой пример, например 3in[null], в ts-ast-explorer, он покажет дерево с узлами (и выделением синтаксиса), эквивалентным 3 in [null]. Похоже, цель этой проблемы - добавить проверку после числового литерала, чтобы убедиться, что идентификатор не может быть началом.

Войдите в компилятор

Как TypeScript анализирует исходный файл и преобразует его в AST?

Согласно Basarat Gitbook, это хорошо названные src/parser.ts и src/scanner.ts в исходном коде TypeScript.

  • синтаксический анализатор - это движущая сила для понимания исходного файла. Он использует сканер для чтения узлов, а затем объединяет их как AST.
  • Сканер используется синтаксическим анализатором каждый раз, когда новый узел необходимо прочитать в дереве и проанализировать. Он отслеживает исходный текст и положение символа в нем.

Отправная точка

Как TypeScript запускает синтаксический анализ и сканирование файла?

Просматривая parser.ts по порядку, мы видим…

  1. Несколько объявлений типа, например enum Signature Flags
  2. Утилиты общедоступных узлов, такие как visitNodes и forEachChild
  3. createSourceFile функция, которая принимает имя файла, текст и некоторые другие данные для создания SourceFile

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

  1. Звонки на Parser.parseSourceFile (это тоже хороший знак!), который…
  2. Вызывает parseSourceFileWorker для файлов, отличных от JSON, которые…
  3. Создает новый пустой sourceFile с createSourceFile, обрабатывает директивы, создает sourceFile.statements с parseList и устанавливает некоторые метаданные в исходный файл.

Меня не интересуют прагмы или разные метаданные исходного файла, но, судя по веб-сайтам, просматривающим AST, свойство statements содержит узлы корневого уровня файла. Следовательно, parseList должен быть местом, где создаются эти узлы, поэтому он должен вызывать вещи, которые анализируют узлы.

parseList принимает parseElement лямбду, которую он передает parseListElement, , который либо вызовет некоторую consumeNode функцию, либо parseElement.

Вопрос: что актуально? Мы заботимся о consumeNode или parseElement?

В сторону: отладка

На этом этапе кто-то, обладающий инструментами JavaScript, может прикрепить точки останова отладчика и выполнить отладку с помощью TypeScript, чтобы ответить на этот вопрос.

Только не я!

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

  • C:/Code/typescript: клонированный репозиторий Git для навигации по источнику. Я сделал его глобально связанным с npm с npm link и построил с npm run build.
  • C:/Code/tsdevtest: Заглушка проекта с минимальными package.json и index.ts. Я связал его с TypeScript с помощью npm link typescript.

index.ts содержал простой тестовый пример:

let hasFour = 3in[null]

При такой настройке при запуске tsc index.ts в C:/Code/tsdevtest используется C:/Code/typescript/lib/tsc.js.

Я также добавил следующую строку в начало createSourceFile:

global.wat = fileName === "index.ts"

Эта строка создает глобальную переменную с именем wat, которая верна только для файлов с именем index.ts. Позже в коде запуск wat && console.log(":)") будет печатать только при синтаксическом анализе index.ts.

Это полезно, потому что TypeScript также будет компилировать файлы, содержащие встроенные типы, такие как lib.d.ts. Запуск с --noLib удаляет их, но приводит к «Не удается найти массив глобального типа» и аналогичным ошибкам в случае успешного синтаксического анализа.

Назад к охоте

… В любом случае, мы хотели посмотреть, как узлы извлекаются из исходного файла, и смотрели на parseListElement, чтобы увидеть, какую подфункцию он вызывает. Я поставил wat && console.log("consumeNode") перед return consumeNode(node); и wat && console.log("parseElement") перед return parseElement();.

Печатался только parseElement, поэтому вызывали только parseStatement. Он содержит оператор switch, который, в зависимости от значения token(), может вызывать набор различных parse* функций или по умолчанию parseExpressionOrLabeledStatement.

Функция token возвращает переменную с именем currentToken типа SyntaxKind, поэтому можно с уверенностью предположить, что это текущий вид синтаксиса, который анализируется. Он анализируется с помощью scanner.scan, который, как мы знаем из страницы сканера Basarat Gitbook, начинается с начального индекса узла и находит его конец. scan содержит другой оператор switch для ch переменной, которая является символьным кодом просматриваемого SourceCharacter.

Учитывая, что соответствующее содержимое для синтаксического анализа начинается с 3in, ch должно быть "3".charCodeAt(0) или 51 для начала синтаксического анализа 3 как NumericLiteral. tsc.js показал case 51:, скомпилированный из эквивалентной строки в scanner.ts: case CharacterCodes._3:. Эта линия вызывает scanNumber.

… Так что функция, которая сканирует число, которое нужно проанализировать, называется scanNumber. 💪

Сканирование номеров

Как scanNumber сканирует исходный код в NumericLiterals?

scanNumber был примерно 50 строками реального кода, и его было трудно понять. Мои первые предположения, коснувшись его первых трех if операторов корневого уровня, заключались в том, что они, по порядку:

  1. Убедитесь, что номер начинается с ., например, с .5
  2. Проверьте, является ли число «научным», то есть оно начинается с E или e, например с 1e3 (1000).
  3. Проверьте, есть ли в числе разделитель _

Последняя пара _101 _ / _ 102_ проверяет, является ли число десятичным или научным:

  • В любом случае возвращается числовой литерал.
  • Если нет, перед возвратом он проверяет, является ли это BigInt.

Потрясающие! Вот единственное место, где можно создать NumericLiteral. Только будьте уверены, я добавил wat && console.log({ result }) в конце:

Подтвержденный. Большой.

Проверка идентификаторов

Как мы можем узнать, является ли следующий токен идентификатором?

Теоретически мы могли бы проверить, является ли код символа следующего объекта буквой A-Z, но почти наверняка существуют эзотерические правила. Как сканер сканирует идентификатор, а не числовой литерал? Предположительно, должна быть какая-то внутренняя логика TypeScript, которая проверяет, начинаем ли мы с идентификатора?

Возвращаясь к scan, случай default для любого кода символа, не обрабатываемого как специальный символ, немедленно вызывает isIdentifierStart функцию. isIdentifierStart проверяет, находится ли код символа в пределах A-Z или a-z, $ или _ (которые являются допустимыми начальными именами), является ли это символом ASCII с высоким значением или идентификатором Unicode. Я не знаю, что означают эти два последних слова, но похоже, это подходящее место!

Усиленное сканирование номеров

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

"An identifier cannot follow a numeric literal.": {
    "category": "Error",
    "code": 1351
},

… И в scanNumber, перед возвратом _109 _ / _ 110_, добавлено:

if (isIdentifierStart(text.charCodeAt(pos), languageVersion)) {
    error(Diagnostics.An_identifier_cannot_follow_a_numeric_literal, pos, 1);
}

После запуска tsc index.ts, вуаля! Ошибка показала!

Время тестирования

Согласно моему предыдущему посту о проверке изменений парсера, TypeScript имеет базовую систему, в которой вы проверяете исходные файлы с ожидаемыми типами и ошибками. Я добавил tests/cases/compiler/identifierStartAfterNumericLiteral.ts, чтобы явно протестировать эти изменения:

let valueIn = 3in[null]

… Но после повторного запуска jake baseline были обнаружены новые ошибки в группе файлов, например bigIntIndex.errors.txt. 😢

… Которые напомнили мне, что BigInts, такие как 1n, являются действительными числами, которые я должен проверить, все еще работает. Добавление 3in[null], 3nin[null], 1e9 и 1n в index.ts:

😕. Похоже, TypeScript во втором и третьем случаях подумал, что n начинает новый идентификатор вместо того, что было после числа.

Спустя несколько минут ругательства и ковыряния scanNumber я заметил, что checkBigIntSuffix увеличивает pos в случае обнаружения суффикса n. Взрыв мозгов! 💡 Ага! Мы не можем узнать конечную позицию числа до тех пор, пока не просканируем последний n; поэтому проверка на предмет последующего IdentifierStart должна выполняться после.

Я извлек новую логику в метод checkForIdentifierAfterNumericLiteral и вызвал его непосредственно перед каждым return в scanNumber.

Запрос на вытягивание



По запросу на вытягивание было дано несколько отзывов:

  • Следует пожаловаться на весь идентификатор, а не на его первый символ.
  • Сообщение об ошибке было немного расплывчатым.

Звучит разумно. Я довольно просто обновил сообщение об ошибке. Расширение красной волнистой линии было немного менее простым ...

Жалобы на полный идентификатор

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

Я уже не знал, где это найти, но числа сканируются с помощью scanNumber, имело смысл сначала проверить наличие scanIdentifier.

… Которого не существует…

… Но существует scanIdentifierParts , который состоит из while цикла, который непрерывно продвигается, пока он находит символы, соответствующие функции с именем isIdentifierPart, или некоторые сложные вещи, следующие за символом, совпадающим с CharacterCodes.backslash. Выглядит вполне законно.

Одна из проблем, связанных со сканированием идентификатора, заключается в том, что функция scanIdentifierParts обновляет pos как обычно, хотя мы не хотели пропускать анализ нового идентификатора. Я решил эту проблему, отслеживая исходное значение pos и восстанавливая его после вызова функции.

Я добавил несколько тестовых примеров для идентификаторов большей длины и обновил PR.

PR был объединен, и была отправлена ​​дополнительная информация, чтобы помочь еще больше улучшить диагностические сообщения. Ура! 🎉

Может, я займусь этой другой проблемой в другой день…

Ключевые выводы

  • Совершенно нормально, а иногда даже лучше, использовать console.log отладку в крайнем случае! (хотя я люблю отладчики для более длительного использования)
  • Разберитесь в крайних случаях: числа - это сложно.
  • Обратите внимание на свои сообщения об ошибках. Это стоит того, чтобы помочь вашим пользователям понять свои ошибки.

Спасибо danielrossenwaser и sheetalkamat за быстрые отзывы!

Примечание об оптимизации

После двух последних публикаций мне было сказано, что последовательность шагов, описанных в них, может создать впечатление, будто я без усилий пролистываю расследования линейно, без каких-либо реальных моментов «Я в тупике». Не так! Я намеренно опускаю тупики в этих сообщениях блога, потому что они будут в семь раз длиннее. Я почти не понимаю, что делаю. 💯