Введення в Roslyn. Використання для розробки інструментів статичного аналізу


Roslyn є платформою, що надає розробнику різні потужні засоби для розбору та аналізу коду. Але наявність таких коштів недостатньо, потрібно розуміти, що і для чого необхідно використовувати. Дана стаття несе мета відповісти на подібні питання. Крім цього, буде розказано про особливості розробки статичних аналізаторів, використовують Roslyn API.


Введення
Знання, наведені в цій статті, отримані при розробці статичного аналізатора коду PVS-Studio, частина якого, що відповідає за перевірку C#-проектів, написана з використанням Roslyn API.

Статтю можна розділити на 2 великих логічних розділу:
  • Загальна інформація про Roslyn. Огляд інструментів, що надаються їм для розбору та аналізу коду. Наводиться як загальне опис сутностей і інтерфейсів, так і погляд на них з точки зору розробника статичного аналізатора.
  • Особливості, які слід враховувати при розробці статичних аналізаторів. Про те, як використовувати Roslyn для розробки продуктів цього класу, що потрібно враховувати при розробці діагностичних правил, як їх писати, приклад діагностики і т. п.


Якщо ж розбити статтю на розділи більш детально, можна виділити наступні частини:
  • Roslyn. Що це і навіщо нам потрібно?
  • Підготовка до розбору проектів та аналізу файлів.
  • Синтаксичне дерево і семантична модель як 2 основні компоненти, необхідні для статичного аналізу.
  • Syntax Visualizer – розширення середовища розробки Visual Studio, а також наш помічник у розборі коду.
  • Особливості, які необхідно приймати до уваги при розробці статичного аналізатора коду.
  • Приклад діагностичного правила.


Примітка Додатково пропоную вашій увазі споріднену статтю "Керівництво по розробці модулів розширень на C# для Visual Studio 2005-2012 і Atmel Studio".

Roslyn
Roslyn – платформа з відкритим вихідним кодом, що розробляється корпорацією Microsoft, і містить у собі компілятори та засоби для розбору та аналізу коду, написаного на мові програмування C# і Visual Basic.

Roslyn використовується в середовищі розробки Microsoft Visual Studio 2015. Різні нововведення на зразок code fixes реалізуються як раз за рахунок використання Roslyn.

З допомогою засобів аналізу, наданими платформою Roslyn, можна виробляти повний розбір коду, аналізуючи всі підтримувані конструкції мови.

Середовище Visual Studio дозволяє створювати на основі Roslyn як вбудовані в саму IDE інструменти (розширення Visual Studio), так і незалежні програми (standalone інструменти).



Вихідний код Roslyn доступний у відповідному репозиторії на GitHub. Це дозволяє подивитися, що і як працює, а в разі виявлення будь-якої помилки – повідомити про неї розробникам.

Розглянутий нижче варіант створення статичного аналізатора і діагностичних правил є не єдиним. Можливе створення діагностик, засноване на використанні стандартного класу DiagnosticAnalyzer. Вбудовані діагностики Roslyn використовують саме це рішення. Це дозволить, наприклад, провести інтеграцію зі стандартним списком помилок Visual Studio, надає можливість підсвічування помилок у текстовому редакторі і т. д. Але варто пам'ятати, що якщо ці діагностики будуть існувати всередині процесу devenv.exe, що є 32-бітним, накладають серйозні обмеження на обсяг використовуваної пам'яті. У деяких випадках це критично і не дозволить провести глибокий аналіз великих проектів (того ж Roslyn). До того ж у цьому випадку Roslyn залишає розробнику менше контролю з обходу дерева і самостійно займається розпаралелюванням цього процесу.

C# аналізатор PVS-Studio є standalone-додатком, що вирішує проблему з обмеженням на використання пам'яті. Крім цього, ми отримуємо більший контроль над обходом дерева, реалізуємо розпаралелювання необхідним нам чином, тим самим більше контролюючи процес розбору і аналізу коду. Так як досвід у створенні аналізатора, який працює за таким принципом (PVS-Studio С++), вже є, його було б доцільно використовувати і при написанні C# аналізатора. Інтеграція з середовищем розробки Visual Studio здійснюється аналогічно C++ аналізатору – за допомогою плагіна, що викликає це standalone-додаток. Таким чином, використовуючи вже наявні напрацювання, вдалося створити аналізатор для нової мови і пов'язати його з вже наявними рішеннями, вмонтувавши в повноцінний продукт – PVS-Studio.

Підготовка до аналізу файлів
Перед тим, як приступати до самого аналізу, необхідно отримати список файлів, вихідний код яких буде перевірятися, а також отримати сутності, необхідні для коректного аналізу. Можна виділити кілька пунктів, які потрібно виконати для отримання необхідних для аналізу даних:
  1. Створення workspace;
  2. Отримання solution (опціонально);
  3. Отримання проектів;
  4. Розбір проекту: отримання компіляції, списку файлів;
  5. Розбір файлу: отримання синтаксичного дерева і семантичної моделі;




На кожному пункті варто зупинитися трохи докладніше.

Створення робочого простору
Створення робочого простору (workspace) необхідно для отримання рішення або проектів. Для отримання workspace'a необхідно викликати статичний метод Create класу MSBuildWorkspace, що повертає об'єкт типу MSBuildWorkspace.

Отримання рішення
Отримання solution'a актуально, коли необхідно проаналізувати, наприклад, кілька входять в дане рішення проектів, або їх всі. Тоді, отримавши solution, легко можна отримати список всіх вхідних у нього проектів.

Для отримання solution'a використовується метод OpenSolutionAsync об'єкта MSBuildWorkspace. У підсумку отримуємо колекцію, яка містить в собі перелік проектів (тобто об'єкт IEnumerable<Project>).

Отримання проектів
Якщо відсутня необхідність в аналізі всіх проектів, можна отримати конкретний, нас зацікавив проект, використовуючи асинхронний метод OpenProjectAsync об'єкта MSBuildWorkspace. Використовуючи цей метод, отримуємо об'єкт типу Project.

Розбір проекту: отримання компіляції та списку файлів для аналізу
Після того, як отримано список проектів для аналізу, можна приступати до їх розбору. Результатом аналізу проекту має стати список файлів для аналізу та компіляція.

Список файлів отримати просто – для цього використовується властивість Documents екземпляра класу Project.

Для отримання компіляції використовується метод TryGetCompilation або GetCompilationAsync.

Отримання компіляції – один з ключових моментів, так як саме вона використовується для отримання семантичної моделі (докладніше про яку буде розказано пізніше), необхідної для проведення глибокого і складного аналізу вихідного коду.

Для того, щоб отримати коректну компіляцію, проект повинен бути скомпільованим – у ньому не повинно бути помилок компіляції, а всі залежно повинні лежати на місці.

Приклад використання. Отримання проектів
Нижче наведено код, що демонструє різні варіанти отримання проектних файлів з використанням класу MSBuildWorkspace:
void GetProjects(String solutionPath, String projectPath)
{
MSBuildWorkspace workspace = MSBuildWorkspace.Create();
Solution currSolution = workspace.OpenSolutionAsync(solutionPath)
.Result;
IEnumerable<Project> projects = currSolution.Projects;
Project currProject = workspace.OpenProjectAsync(projectPath)
.Result; 
}

Дані дії не повинні викликати жодних питань, бо все, що тут відбувається, було описано вище.

Розбір файлу: отримання синтаксичного дерева і семантичної моделі
Наступний етап – розбір файлу. Зараз необхідно отримати 2 суті, на яких і базується повноцінний аналіз – синтаксичне дерево і семантичну модель. Синтаксичне дерево будується на основі вихідного коду програми і використовується для аналізу різних конструкцій мови. Семантична модель надає інформацію про об'єкти та їх типах.

Для отримання синтаксичного дерева (об'єкт типу SyntaxTree) використовується метод TryGetSyntaxTree або GetSyntaxTreeAsync екземпляра класу Document.

Семантична модель (об'єкт типу SemanticModel) виходить з компіляції з використанням синтаксичного дерева, отриманого раніше. Для цього використовується метод GetSemanticModel екземпляра класу Compilation, що приймає в якості обов'язкового параметра об'єкт типу SyntaxTree.

Клас, який буде обходити синтаксичне дерево і проводити аналіз, повинен бути успадкований від класу CSharpSyntaxWalker, що дозволить перевизначити методи обходу різних вузлів. Викликаючи метод Visit, що приймає в якості параметра корінь дерева (для його отримання використовується метод GetRoot єкта типу SyntaxTree), ми тим самим запускаємо рекурсивний обхід вузлів синтаксичного дерева.

Нижче наведено код, в якому демонструється реалізація описаних вище дій:
void ProjectAnalysis(Project project)
{
Compilation compilation = project.GetCompilationAsync().Result;
foreach (var file in project.Documents)
{
SyntaxTree tree = file.GetSyntaxTreeAsync().Result;
SemanticModel model = compilation.GetSemanticModel(tree);
Visit(tree.GetRoot());
}
}


Перевизначені методи обходу вузлів
Для кожної конструкції мови визначені вузли свого типу. А для кожного типу вузла визначений метод, що виконують обхід вузлів такого типу. Таким чином, додаючи обробники (діагностичні правила) методи обходу тих чи інших вузлів, ми можемо аналізувати тільки цікавлять нас конструкції мови.

Приклад переопределенного методу обходу вузлів, відповідних оператору if:
public override void VisitIfStatement(IfStatementSyntax node)
{
base.VisitIfStatement(node);
}

Додаючи в тіло методу відповідні правила, ми тим самим будемо аналізувати всі оператори if, які зустрінуться в коді програми.

Синтаксичне дерево
Синтаксичне дерево є базовим елементом, необхідним для аналізу коду. Саме по ньому відбувається переміщення в ході аналізу. Дерево будується на основі коду, наведеного у файлі, з чого випливає висновок, що кожен файл має своє синтаксичне дерево. Окрім цього, варто враховувати той факт, що синтаксичне дерево є незмінним. Ні, змінити його, звичайно, можна, викликавши відповідний метод, але результатом його роботи буде нове синтаксичне дерево, а не змінене старе.

Наприклад, для наступного коду:
class C
{
void M()
{ }
}

Синтаксичне дерево буде мати наступний вигляд:



Тут синім кольором позначені вузли дерева (Syntax nodes ), а зеленим – лексеми (Syntax tokens ).

В синтаксичному дереві, яке будує Roslyn на основі програмного коду, можна виділити 3 елементи:
  • Syntax nodes;
  • Syntax tokens;
  • Syntax trivia.
Кожен з цих елементів дерева варто розглянути докладніше, оскільки всі вони так чи інакше використовуються в ході статичного аналізу. Інша справа, що одні з них використовуються регулярно, а інші – на порядок рідше.

Syntax nodes
Syntax nodes (далі – вузли) являють собою синтаксичні конструкції, такі як оголошення, оператори, вираження і т. д. Основна робота, що відбувається при аналізі коду, припадає на обробку вузлів. Саме з ним відбувається переміщення, на обході тих чи інших видів вузлів базуються діагностичні правила.

Розглянемо приклад дерева, що відповідає виразу
a *= (b + 4);

На відміну від попереднього малюнка, тут наведені тільки вузли і коментарі до них, які дозволять легше зорієнтуватись в тому, який вузол якої конструкції відповідає.



Базовий тип

Базовим типом вузлів є абстрактний клас SyntaxNode. Цей клас надає в розпорядження розробника методи, загальні для всіх вузлів. Перерахуємо деякі найбільш розповсюджені з них (якщо якісь речі, на зразок того, що таке SyntaxKind або т. п. будуть вам зараз незрозумілі – не хвилюйтеся, про це буде розказано нижче):
  • ChildNodes – отримує список вузлів, які є дочірніми для поточного. Повертає об'єкт типу IEnumerable<SyntaxNode>;
  • DescendantNodes — отримує список всіх вузлів, що знаходяться в дереві нижче поточного. Також повертає об'єкт типу IEnumerable<SyntaxNode>;
  • Contains – перевіряє, чи включає в себе поточний вузол інший, переданий в якості аргументу;
  • GetLeadingTrivia – дозволяє отримати елементи syntax trivia, попередні даного вузла, якщо вони є;
  • GetTrailingTrivia –дозволяє отримати елементи syntax trivia, наступні за цим вузлом, якщо вони є;
  • Kind – повертає елемент перерахування SyntaxKind, що конкретизує даний вузол;
  • IsKind – приймає як параметр елемент перерахування SyntaxKind, і повертає логічне значення, яке відповідає конкретний тип вузла типу, переданим в якості аргументу.


Крім цього в класі визначено ряд властивостей. Деякі з них:
  • Parent – повертає посилання на батьківський вузол. Вкрай необхідне властивість, так як саме воно дозволяє переміщатися вгору по дереву;
  • HasLeadingTrivia – повертає логічне значення, що означає наявність або відсутність елементів syntax trivia, що передують даному вузлу;
  • HasTrailingTrivia – повертає логічне значення, що означає наявність або відсутність елементів syntax trivia, наступне за цим вузлом.


Похідні типи

Але повернемося до типів вузлів. Кожен вузол, який представляє ту чи іншу конструкцію мови, має свій тип, визначає низка властивостей, що спрощують навігацію по дереву і отримання необхідних даних. Цих типів – безліч. Наведемо деякі з них і те, яким конструкціям мови вони відповідають:
  • IfStatementSyntax – оператор if;
  • InvocationExpressionSyntax – виклик методу;
  • BinaryExpressionSyntax – инфиксная операція;
  • ReturnStatementSyntax – вираз з оператором return;
  • MemberAccessExpressionSyntax – доступ до члена класу;
  • І безліч інших типів.


Приклад. Розбір оператора if

Розглянемо приклад того, як використовувати ці знання на практиці на прикладі оператора if.

Нехай в аналізованому коді є фрагмент наступного виду:
if (a == b)
c *= d;
else
c /= d;

В синтаксичному дереві цей фрагмент буде представлений вузлом типу IfStatementSyntax. Тоді можна легко отримати цікаву для нас інформацію, звертаючись до різних властивостей цього класу:
  • Condition – повертає умова, що перевіряється, в операторі. Значення, що повертається – посилання типу ExpressionSyntax;
  • Else – повертає гілка else оператора if, якщо вона є. Значення, що повертається – посилання типу ElseClauseSyntax;
  • Statement – повертає тіло оператора if. Значення, що повертається – посилання типу StatementSyntax.


На практиці це виглядає так само, як і в теорії:
void Foo(IfStatementSyntax node)
{
ExpressionSyntax condition = node.Condition; // a == b
StatementSyntax statement = node.Statement; // c *= d
ElseClauseSyntax elseClause = node.Else; /* else
c /= d;
*/
}

Таким чином, знаючи тип вузла, легко отримувати інші вузли, що входять в його склад. Подібний набір властивостей визначено і для інших типів вузлів, що характеризують певні конструкції – оголошення методів, цикли for, лямбды і т. д.

Конкретизація типу вузла. Перерахування SyntaxKind

Часом буває недостатньо знати тип вузла. Один з випадків – префіксні операції. Наприклад, нам потрібно виділити префіксні операції инкремента і декремента. Можна було б перевірити тип вузла.
if (node is PrefixUnaryExpressionSyntax)

Але такої перевірки буде недостатньо, так як під цю умову підійдуть оператори '!', '+', '-', '~', адже вони теж є префиксными унарными операціями. Як же бути?

На допомогу приходить перерахування SyntaxKind. У цьому переліку визначено всі можливі конструкції мови, а також ключові слова, модифікатори і ін. За допомогою елементів цього перерахування можна встановити конкретний тип вузла. Для конкретизації типу вузла в класі SyntaxNode визначені наступні властивості і методи:
  • RawKind – властивість типу Int32, що зберігає цілочисельне значення, що конкретизує даний вузол. На практиці частіше застосовуються методи Kind IsKind;
  • Kind – метод, не приймає аргументів і повертає елемент перерахування SyntaxKind;
  • IsKind – метод, що приймає в якості аргументу елемент перерахування SyntaxKind і повертає значення true або false, залежно від того, відповідає чи точний тип вузла типу переданого аргументу.
Використовуючи методи Kind або IsKind, можна легко визначити, чи є вузол префиксной операцією инкремента або декремента:
if (node.Kind() == SyntaxKind.PreDecrementExpression ||
node.IsKind(SyntaxKind.PreIncrementExpression))

Особисто мені більше подобається використання методу IsKind, так як код виглядає лаконічніше і більше читаемо.

Syntax tokens
Syntax tokens (далі – лексеми) є терміналами граматики мови. Лексеми представляють собою елементи, які не підлягають подальшому розбору – ідентифікатори, ключові слова, спеціальні символи. В ході аналізу коду безпосередньо з ними доводиться працювати значно рідше, ніж з вузлами дерева. Однак якщо все ж доводиться працювати з лексемами, як правило, це обмежується отриманням текстового подання лексеми або ж перевірки її типу.

Розглянемо згадуване раніше вираз.
a *= (b + 4);

На малюнку нижче представлено синтаксичне дерево, що отримується з цього виразу. Але тут, на відміну від попереднього малюнка, також зображено лексеми. Наочно видно зв'язок між вузлами і лексемами, що входять до їх складу.



Використання при аналізі

Всі лексеми представлені значущим типом SyntaxToken. Тому для того, щоб дізнатися, чим же саме є лексема, використовуються згадувані раніше методи Kind IsKind і елементи перерахування SyntaxKind.

Якщо ж необхідно отримати текстове представлення лексеми, досить звернутися до властивості ValueText.

Можна отримати і значення лексеми (наприклад, число, якщо лексема представлена числовим литералом), для чого достатньо звернутися до властивості Value, повертає посилання типу Object. Однак для отримання константных значень зазвичай застосовується семантична модель і більш зручний метод GetConstantValue, який буде розглянуто у відповідному розділі.

Крім того, до лексем (фактично – до них, а не до вузлів) прив'язані syntax trivia (про те, що це написано в наступному розділі).

Для роботи з syntax trivia визначені наступні властивості:
  • HasLeadingTrivia – логічне значення, відповідні наявності або відсутності елементів syntax trivia перед лексемою;
  • HasTrailingTrivia — логічне значення, відповідні наявності або відсутності елементів syntax trivia після лексеми;
  • LeadingTrivia – елементи syntax trivia, попередні лексеме;
  • TrailingTrivia – елементи syntax trivia, наступні за лексемою.


Приклад

Розглянемо простий оператор if:
if (a == b) ;

Цей оператор буде розбитий на кілька лексем:
  • Ключові слова: 'if';
  • Ідентифікаторів: 'a', 'b';
  • Спеціальні символи: '(', ')', '==', ';'.
Приклад отримання значень лексеми:
a = 3;

Нехай в якості аналізованого вузла нам приходить літерал '3'. Тоді отримати його текстове і чисельне представлення можна наступним чином:
void GetTokenValues(LiteralExpressionSyntax node)
{
String tokenText = node.Token.ValueText;
Int32 tokenValue = (Int32)node.Token.Value;
}


Syntax trivia
Syntax trivia (додаткова синтаксична інформація) – це ті елементи дерева, які не будуть скомпільовані в IL-код. До таких елементів відносяться елементи форматування (пробіли, символи переведення рядка), коментарі, директиви препроцесора.

Розглянемо просте вираз виду:
a = b; // Comment

Тут можна виділити наступну додаткову синтаксичну інформацію: прогалини, однорядковий коментарі, символ кінця рядка. Зв'язку між додатковою синтаксичною інформацією і лексемами наочно відображено на рисунку, наведеному нижче.



Використання при аналізі

Додаткова синтаксична інформація, як згадувалося раніше, пов'язана з лексемами. Поділяють Leading trivia і Trailing trivia. Leading trivia – попередня лексеме додаткова синтаксична інформація, trailing trivia – додаткова синтаксична інформація, наступна за лексемою.

Всі елементи додаткової синтаксичної інформації мають тип SyntaxTrivia. Для визначення того, чим конкретно є елемент (пробіл, однорядковий коментар, багаторядковий коментар тощо) використовується перерахування SyntaxKind та вже відомі вам методи Kind IsKind.

Як правило, при статичному аналізі вся робота з додатковою синтаксичною інформацією зводиться до визначення того, чим є її елементи, іноді – до аналізу тексту елемента.

Приклад

Нехай у нас є наступний аналізований код:
// it's a leading trivia for 'a' token
a = b; /* it's a trailing trivia for 
';' token */

Тут однорядковий коментар буде прив'язаний до лексеме 'a', а багаторядковий коментар – до лексеме ';'.

Якщо в якості вузла нам приходить вираз a = b;, легко отримати текст однорядкового і багаторядкового коментарів наступним чином:
void GetComments(ExpressionSyntax node)
{
String singleLineComment = 
node.GetLeadingTrivia()
.SingleOrDefault(p => p.IsKind(
SyntaxKind.SingleLineCommentTrivia))
.ToString();

String multiLineComment = 
node.GetTrailingTrivia()
.SingleOrDefault(p => p.IsKind(
SyntaxKind.MultiLineCommentTrivia))
.ToString();
}


Коротке узагальнення
Коротко узагальнивши інформацію даного розділу, можна виділити наступні пункти, щодо синтаксичного дерева:
  • Синтаксичне дерево – базовий елемент, необхідний для статичного аналізу;
  • Синтаксичне дерево неизменяемо;
  • Виконуючи обхід синтаксичного дерева, ми обходимо різні конструкції мови, для кожної з якої визначений свій тип;
  • Для кожного типу, відповідного будь-якої синтаксичної конструкції мови, є метод обходу, якщо перевизначити який, можна ставити логіку обробки вузла;
  • Три основних елемента дерева – syntax nodes, syntax tokens, syntax trivia ;
  • Syntax nodes – синтаксичні конструкції мови. До цієї категорії відносяться оголошення, визначення, оператори тощо;
  • Syntax tokens – лексеми, кінцеві символи граматики мови. До цієї категорії відносяться ідентифікатори, ключові слова, спец. символи і т. п.;
  • Syntax trivia – додаткова синтаксична інформація. До цієї категорії відносяться коментарі, директиви препроцесора, прогалини і т. п.


Семантична модель
Семантична модель надає інформацію про об'єктах і про типах об'єктів. Це дуже потужний інструмент, що дозволяє проводити глибокий і складний аналіз. Саме тому важливо мати коректну компіляцію і коректну семантичну модель. Нагадаю, що для цього проект повинен бути скомпільованим.

Слід пам'ятати, що при аналізі ми працюємо з вузлами, а не з об'єктами. Тому для отримання інформації, наприклад, про тип об'єкта не спрацюють ні оператор is, ні метод GetType, так як вони надають інформацію про сайт, а не про об'єкт. Нехай, наприклад, ми аналізуємо наступний код:
a = 3;

Про те, що таке a, з цього коду можна лише будувати припущення. Не можна сказати, локальна це змінна, або властивість, або поле, можна зробити лише приблизні припущення про тип. Але здогадки нікого не цікавлять, потрібна точна інформація.

Можна було б спробувати пройтися вверх по дереву до оголошення змінної, але це було б занадто дорого з точки зору продуктивності і обсягу коду. До того ж, оголошення запросто може перебувати де-небудь в іншому файлі, або навіть у сторонньої бібліотеці, вихідний код якої у нас немає

Тут на допомогу і приходить семантична модель.



Можна виділити 3 найбільш часто використовувані функції, надані семантичною моделлю:
  • Отримання інформації про об'єкт;
  • Отримання інформації про тип об'єкта;
  • Отримання константных значень.


На кожному з цих пунктів слід зупинитися детальніше, оскільки всі вони важливі і повсюдно застосовуються при статичному аналізі коду.

Отримання інформації про об'єкт. Symbol
Інформацію про об'єкт надають так звані символи (symbols).

Базовий інтерфейс символу ISymbol, надає методи і властивості, загальні для всіх об'єктів, незалежно від того, чим вони є – полем, властивість або чимось ще.

Існує ряд похідних типів, виконуючи приведення до яких можна отримувати більш специфічну інформацію про об'єкті. До таких інтерфейсів відносяться IFieldSymbol, IPropertySymbol, IMethodSymbol та інші.

Наприклад, використовуючи приведення до інтерфейсу IFieldSymbol і звернувшись до поля IsConst можна дізнатися, чи є вузол константным полем. А якщо використовувати інтерфейс IMethodSymbol, можна дізнатися, повертає-метод яке-небудь значення.

Для символів визначено властивість Kind, повертає елементи перерахування SymbolKind. За своїм призначенням це перерахування аналогічно перерахуванню SyntaxKind. Тобто з допомогою властивості Kind можна дізнатися, з чим ми зараз працюємо – локальним об'єктом, полем, властивість, збиранням тощо

Приклад використання. Дізнаємося, чи є вузол константным полем.

Припустимо, що є визначення поля наступного виду:
private const Int32 a = 10;

А десь нижче – наступний код:
var b = a;

Припустимо, що нам потрібно дізнатися, чи є a константным полем. З вищенаведеного виразу можна отримати необхідну інформацію про вузол , використовуючи семантичну модель. Код одержання необхідної інформації виглядає наступний чином:
Boolean? IsConstField(SemanticModel model, 
IdentifierNameSyntax identifier)
{
ISymbol smb = model.GetSymbolInfo(identifier).Symbol;
if (smb == null)
return null;
return smb.Kind == SymbolKind.Field && 
(smb as IFieldSymbol).IsConst;
}

Спочатку отримуємо символ для ідентифікатора, використовуючи метод GetSymbolInfo єкта типу SemanticModel, після чого відразу звертаємося до поля Symbol (саме воно містить цікаву для нас інформацію, тому в даному випадку немає сенсу зберігати десь структуру SymbolInfo, що повертається методом GetSymbolInfo).

Після перевірки на null, використовуючи властивість Kind, що конкретизує символ, переконуємося, що ідентифікатор насправді є полем. Якщо це дійсно так – виконуємо приведення до похідним інтерфейсу IFieldSymbol, який дозволить звернутися до властивості IsConst, отримавши інформацію про константності поля.

Отримання інформації про тип об'єкта. Інтерфейс ITypeSymbol
Часто необхідно визначити тип об'єкта, що представляється вузлом. Як я писав вище, оператор is і метод GetType не підходять, так як вони оперують з типом вузла, а не аналізованого об'єкта.

На щастя, вихід є, причому дуже елегантний. Потрібну інформацію можна отримати, використовуючи інтерфейс ITypeSymbol. Для його отримання використовується метод GetTypeInfo єкта типу SemanticModel. Взагалі цей метод повертає структуру TypeInfo, що містить 2 важливі властивості:
  • ConvertedType – повертає інформацію про тип виразу після виконання неявного приведення. Якщо привиди не було, повернуте значення аналогічно тому, що повертає властивість Type;
  • Type – повертає тип виразу, представленого у вузлі. Якщо отримати тип виразу неможливо, повертається значення null. Якщо тип не може бути визначений через якусь помилку, повертається інтерфейс IErrorTypeSymbol.
Використовуючи інтерфейс ITypeSymbol, повертається цими властивостями, можна отримати всю необхідну інформацію про тип. Ця інформація витягується за рахунок звернення до властивостей, деякі з яких наведено нижче:
  • AllInterfaces – список всіх реалізованих типом інтерфейсів. Враховуються також і інтерфейси, що реалізуються базовими типами;
  • BaseType – базовий тип;
  • Interfaces – список інтерфейсів, що реалізуються конкретно даним типом;
  • IsAnonymousType – інформація про те, чи є тип анонімним;
  • IsReferenceType – інформація про те, чи є тип посилальних;
  • IsValueType – інформація про те, чи є тип значущим;
  • TypeKind – конкретизує тип (аналогічно властивості Kind для інтерфейсу ISymbol). Містить інформацію про те, що з себе представляє тип – клас, структуру, перерахування і т. д.


Варто відзначити, що можна дізнаватися не тільки тип об'єкта, але і тип всього виразу цілком. Наприклад, ви можете отримати тип виразу a + b, і окремо типи змінних a b. Так як ці типи можуть відрізнятися, можливість отримання типів для всього виразу цілком є досить корисною при розробці деяких діагностичних правил.

Крім того, як і для інтерфейсу ISymbol, існує ряд похідних інтерфейсів, що дозволяють отримати більш специфічну інформацію.

Приклад використання. Отримання назв всіх реалізованих типом інтерфейсів

Для того, щоб отримати назви всіх інтерфейсів, що реалізуються типом, а також базовими типами, можна використовувати наступний код:
List < String> GetInterfacesNames(SemanticModel model, 
IdentifierNameSyntax identifier)
{
ITypeSymbol nodeType = model.GetTypeInfo(identifier).Type;
if (nodeType == null)
return null;
return nodeType.AllInterfaces
.Select(p => p.Name)
.ToList();
}

Все досить просто, використовувані тут методи і властивості були описані вище, тому ніяких труднощів з розумінням даного коду, думаю, виникнути не повинно.

Отримання константных значень
Семантичну модель можна використовувати також для отримання константных значень. Ці значення можна отримати для константных полів, символьних, строкових і числових символів. Вище було описано, як можна отримати константные значення, використовуючи лексеми. Семантична модель надає більш зручний інтерфейс для цього. У цьому випадку нам не потрібні лексеми, достатньо мати сайт, з якого можна отримати константне значення – інше модель зробить самостійно. Це дуже зручно, так як, нагадую, при аналізі основна робота ведеться саме з вузлами.

Для отримання константных значень використовується метод GetConstantValue, повертає структуру Optional<Object>, використовуючи яку легко перевірити успішність операції і отримати нас цікавить значення.

Приклад використання. Отримання константного значення поля

Припустимо, що є аналізований код:
private const String str = "Some string";

Якщо де-то в коді програми зустрінеться об'єкт str, використовуючи семантичну модель можна буде легко отримати рядок, на яку посилається поле:
String GetConstStrField(SemanticModel model, 
IdentifierNameSyntax identifier)
{
Optional<Object> optObj = model.GetConstantValue(identifier);
if (!optObj.HasValue)
return null;
return optObj.Value as String;
}
Коротке узагальнення
Коротко узагальнивши інформацію даного розділу, можна виділити наступні пункти, стосовно семантичної моделі:
  • Семантична модель надає семантичну інформацію (про об'єкти, їх типи та ін.);
  • Необхідна для проведення глибокого і складного аналізу;
  • Для отримання коректної семантичної моделі проект повинен бути скомпільованим;
  • Семантичну інформацію про об'єкт надає інтерфейс ISymbol;
  • Семантичну інформацію про тип об'єкта надає інтерфейс ITypeSymbol;
  • За допомогою семантичної моделі можливе отримання значень константных полів і символів.


Syntax visualizer
Syntax visualizer (далі – візуалізатор) – розширення для середовища розробки Visual Studio, що входить в комплект Roslyn SDK (який можна завантажити в галереї Visual Studio). Даний інструмент, як випливає з назви, виконує функції відображення синтаксичного дерева.



Як видно з малюнка, синіми елементами відображаються вузли, зеленими – лексеми, червоними – додаткова синтаксична інформація. Крім цього для кожного вузла можна дізнатися його тип, значення Kind, значення властивостей. Крім того є можливість отримання інтерфейсів ISymbol ITypeSymbol для вузлів дерева.

Цей інструмент зручний при використанні методології TDD, коли перед реалізацією діагностичного правила ви пишете набір юніт-тестів, а лише потім приступаєте до програмування логіки правила. Візуалізатор дозволяє легше орієнтуватися по написаному кодом, дізнатися, на обхід якогось вузла потрібно підписатися і куди рухатися по дереву, для яких вузлів необхідно (і можна) отримати тип і символ, спрощуючи тим самим процес розробки діагностичного правила.

Крім представлення дерева у форматі, наведеному на малюнку вище, можна відобразити його в більш наочній формі. Для цього досить викликати контекстне меню для вас цікавить елемента і вибрати пункт View Directed Syntax Graph. За допомогою цього механізму я отримував дерева різних синтаксичних конструкцій, використовуваних і приводилися раніше в статті.



Історія з життя.

В ході розробки PVS-Studio був випадок, коли виникало виняток переповнення стека. Як виявилося, справа в тому, що в одному з перевірених проектів — ILSpy — є автосгенерированный файл Parser.cs, в якому присутня просто якесь нереальне кількість вкладених операторів if. У підсумку, при спробі обходу дерева просто закінчувалася стекова пам'ять. В аналізаторі ми цю проблему перемогли, просто збільшивши максимальний розмір стека для потоків, в яких відбувається обхід, але синтаксичний візуалізатор, заодно з Visual Studio, досі «відвалюється» на цьому файлі.

Можете перевірити самі. Відкрийте потрібний файл, знайдіть цю «безодню» операторів if і спробуйте подивитися синтаксичне дерево (наприклад, рядок 3218).

Особливості, які необхідно враховувати при розробці статичного аналізатора
Існує ряд правил, яких необхідно дотримуватися, розробляючи статичні аналізатори. Дотримання цих правил дозволить зробити більш якісний продукт і реалізовувати функціональні діагностичні правила.
  1. Для глибокого і якісного аналізу потрібна повна інформація про всіх типах, що зустрілися в коді. У більшості діагностичних правил недостатньо простого обходу вузлів дерева, часто доводиться обробляти типи виразів і отримувати інформацію про аналізованих об'єктах. Для цього і потрібна семантична модель, яка повинна бути коректною. Нагадаю, що для цього проект повинен бути скомпільованим, всі залежно повинні бути на місці. Тим не менш, навіть якщо це так, не варто нехтувати різними перевірками результатів, одержуваних з використанням семантичної моделі;
  2. Важливо правильно вибирати тип вузла для початку аналізу. Це дозволить зменшити кількість переміщень по дереву і різних привидів. Природно, це також зменшить обсяг коду, спростивши його підтримку. Для того, щоб краще визначитися зі стартовим вузлом аналізу, використовуйте синтаксичний візуалізатор;
  3. Якщо немає впевненості в тому, що код є помилковим, краще не сваритися. Але в межах розумного, звичайно. Справа в тому, що якщо аналізатор буде лаятися по справі і без діла, з'явиться занадто велика кількість шуму у вигляді помилкових спрацьовувань, на тлі яких реальні помилки знайти буде важко. З іншого боку, якщо зовсім ні на що не лаятися, толку від аналізатора теж не буде. Тому іноді доводиться вибирати компроміс, але кінцева мета – звести кількість помилкових спрацьовувань до мінімуму, в ідеалі – до 0;
  4. При розробці діагностичних правил важливо передбачити всі можливі, і неможливі, а також ряд неймовірних випадків, з якими ви можете зіткнутися в ході аналізу. Для цього необхідно розробляти велика кількість юніт-тестів, як позитивних – фрагментів коду, де повинна спрацьовувати ваша діагностика, так і негативних – тих фрагментів, на які не варто видавати попередження;
  5. У процес розробки діагностичних правил відмінно вписується методологія TDD. Спочатку розробляються набори позитивних і негативних юніт-тестів, і лише після цього починається реалізація діагностичного правила. Це дозволить легше орієнтуватися з синтаксичним деревом по мірі реалізації, так як перед очима вже будуть приклади різних дерев. До того ж на цьому етапі знадобиться синтаксичний візуалізатор;
  6. Важливо тестувати аналізатор на реальних проектах. Як би ви не старалися, швидше за все не вдасться покрити юніт-тестами всі випадки, з якими доведеться зіткнутися діагностичним правилами аналізатора. Перевірка ж аналізатора на реальних проектах дозволить виявити місця, де правила відпрацьовують некоректно, стежити за змінами роботи аналізатора, збільшувати базу юніт-тестів.


Алгоритм написання діагностичних правил
Пошук помилок, здійснюваний статичним аналізатором, реалізується за рахунок різних діагностичних правил. Для реалізації всіх правил існує набір спільних дій, виділивши які можна отримати загальний алгоритм написання діагностики.



  1. Першим кроком необхідно сформулювати суть правила. Перед розробкою необхідно обміркувати, на які ж фрагменти коду варто видавати попередження, а на які – ні;
  2. Коли діагностичне правило вже знайшло певну форму і стало зрозуміло, на які ситуації варто видавати попередження, необхідно зайнятися розробкою юніт-тестів, а саме – реалізувати набори позитивних і негативних тестів. На позитивних тестах повинна спрацьовувати ваша діагностика. На початкових етапах розробки важливо зробити базу позитивних юніт тестів якомога більше, так як це дозволить відловлювати більше підозрілих ситуацій. Не меншу увагу варто приділити і негативним юніт тестів. По мірі розробки і тестування діагностики, база негативних юніт-тестів буде постійно поповнюватися. Саме за рахунок цього буде знижуватися кількість помилкових спрацьовувань, виводячи співвідношення хороших спрацьовувань до поганим в потрібну сторону;
  3. Після того, як розроблений базовий набір юніт-тестів, можна приступати до реалізації діагностики. Не забувайте користуватися синтаксичним візуалізатором – цей інструмент може сильно допомогти в процесі програмування;
  4. Після того, як діагностика буде готова, а всі юніт-тести будуть успішно проходити, необхідно приступати до наступного етапу – тестування на реальних проектах. Це дозволить виявити помилкові спрацьовування (а може і падіння) виший діагностики, розширити базу юніт-тестів. Чим більше відкритих проектів використовується для тестування, тим більше можливих варіантів аналізованого коду ви розглядаєте, тим краще і потужніше стає ваша діагностика;
  5. Після тестування на реальних проектах швидше за все доведеться доопрацьовувати діагностику, так як сходу рідко можна потрапити прямо в яблучко. Що ж, нічого страшного, це нормальний процес! Внесіть необхідні зміни і знову тестуйте правило;
  6. Повторюйте попередній пункт до тих пір, поки діагностика не покаже бажаний результат. Після цього можна пишатися виконаною роботою.


Приклад діагностичного правила. Пошук пропущеного оператора throw



У статичному аналізаторі коду PVS-Studio є діагностика V3006, яка шукає пропущений оператор throw. Логіка така – створюється об'єкт виключення, але при цьому він ніяк не використовується (посилання на нього нікуди не передається, не повертається з методу і т.п.). Тоді, швидше за все, можна сказати, що програміст пропустив оператор throw. В результаті виключення не буде згенеровано, а створений об'єкт буде знищений при наступній збірці сміття.

Так як з правилом ми вже визначилися, можна починати писати юніт-тести.

Приклад позитивного тесту:
if (cond)
new ArgumentOutOfRangeException();

Приклад негативного тесту:
if (cond)
throw new FieldAccessException();

Можна виділити наступні пункти в алгоритмі роботи діагностики:
  1. Підписуємося на обхід вузлів типу ObjectCreationExpressionSyntax. Цей тип вузлів відповідає створенню об'єкта з використанням оператора new – як раз те, що нам потрібно;
  2. Переконуємося, що тип створюваного об'єкта є сумісним з System.Exception (тобто або цим типом, або похідних). Якщо це так, будемо вважати, що тип є типом виключення. Для отримання типу будемо використовувати семантичну модель (нагадую, що модель надає можливість отримувати тип виразу);
  3. Перевіряємо, що об'єкт не використовується (посилання на об'єкт нікуди не записується і не передається);
  4. Якщо попередні пункти дотримані – видаємо попередження.
Нижче буде наведена можлива реалізація даного діагностичного правила. Я спеціально кілька переписав і спростив код, щоб зробити його коротше і легше для розуміння. Але навіть таке невелике правило справляється зі своїм завданням і знаходить реальні помилки.

Загальний код пошуку пропущеного оператора throw:
readonly String ExceptionTypeName = typeof(Exception).FullName;
Boolean IsMissingThrowOperator(SemanticModelAdapter model, 
ObjectCreationExpressionSyntax node)
{ 
if (!IsExceptionType(model, node))
return false;

if (IsReferenceUsed(model, node.Parent))
return false;

return true; 
}

Як видно з коду, тут виконуються дії, описані в алгоритмі, наведеному вище. У першому умови виконується перевірка того, що тип створюваного об'єкта – тип-винятку. Друга перевірка використовується для визначення, чи створений об'єкт.

Когось може збентежити тип SemanticModelAdapter. Нічого хитрого тут немає, це обгортка над семантичною моделлю. У даному прикладі вона використовується для тих же цілей, що і звичайна семантична модель (об'єкт типу SemanticModel).

Метод перевірки, є тип винятком:
Boolean IsExceptionType(SemanticModelAdapter model,
SyntaxNode node)
{
ITypeSymbol nodeType = model.GetTypeInfo(node).Type;

while (nodeType != null && !(Equals(nodeType.FullName(),
ExceptionTypeName)))
nodeType = nodeType.BaseType;

return Equals(nodeType?.FullName(),
ExceptionTypeName);

}


Логіка проста – отримуємо інформацію про тип, перевіряємо всю ієрархію успадкування. Якщо в результаті виявляється, що один з базових типів – System.Exception, вважаємо, що тип створюваного об'єкта – тип винятку.

Метод перевірки, що посилання нікуди не передається і не зберігається:
Boolean IsReferenceUsed(SemanticModelAdapter model, 
SyntaxNode parentNode)
{
if (parentNode.IsKind(SyntaxKind.ExpressionStatement))
return false;

if (parentNode is LambdaExpressionSyntax)
return (model.GetSymbol(parentNode) as IMethodSymbol)
?.ReturnsVoid == false;

return true;
} 


Можна було б перевірити, чи посилання, але довелося б розглядати дуже багато випадків: повернення з методу, передачу в метод, запис в змінну і т. п. Набагато простіше розглянути випадки, коли посилання нікуди не передається і не записується. Це покривається описаними вище перевірками.

З першої, думаю, все зрозуміло – ми перевіряємо, що батьківський вузол – просте вираження. Друга перевірка теж не таїть у собі секретів. Якщо батьківський вузол – лямбда-вираз, перевіримо, що посилання не повертається з лямбды.

Roslyn. Переваги та недоліки
Roslyn – не панацея. Незважаючи на те, що це потужна платформа для розбору та аналізу коду, як і у будь-якого проекту, у нього є свої недоліки. У той же час переваг у платформи теж маса. Що ж, давайте виділимо деякі пункти з обох категорій.

Гідності
  • Безліч типів вузлів. Це може налякати на початкових стадіях роботи з платформою, але на ділі виявляється її великою гідністю. Можна підписатися на обхід визначених вузлів, відповідних тим або іншим конструкціям мови, тим самим аналізуючи нас цікавлять ділянки. Крім того, кожен тип вузла пропонує характерний для нього набір властивостей, полегшуючи завдання отримання необхідних даних;
  • Зручна навігація по дереву. Для переміщення по дереву або отримання необхідних даних досить просто звертатися до властивостей вузлів. Як сказано вище, кожен тип вузла містить свій набір властивостей, що ще більше спрощує завдання;
  • Семантична модель. Сутність, дозволяє отримувати інформацію про об'єктах і типах, надаючи до того ж зручний інтерфейс для цього, є дуже сильною стороною платформи;
  • Відкритий вихідний код. Можна стежити за процесом розвитку платформи, при бажанні подивитися, що і як влаштовано. До того ж можна долучитися до процесу розробки, повідомляючи розробникам про знайдені помилки – це піде на користь всім.
Недоліки
  • Відкриття проектів може породжувати різні проблеми. Деколи Roslyn не може коректно відкрити проект (не чіпляє якусь залежність, файл тощо), з-за чого не вдається отримати коректну компіляцію і, як підсумок – семантичну модель. Це рубає на кореню глибокий аналіз, так як без семантичної моделі глибокий аналіз неможливий. Доводиться використовувати додаткові засоби (наприклад, MSBuild) для коректного аналізу рішень/проектів;
  • Доводиться винаходити власні механізми для, здавалося б, простих речей. Наприклад, порівняння сайтів. Метод Дорівнює для вузлів просто порівнює посилання, чого явно недостатньо. Доводиться винаходити свої механізми порівняння.
  • Програма, побудована на базі Roslyn, може споживати багато пам'яті (гігабайти). Для сучасних 64-бітових комп'ютерів з великим обсягом пам'яті це не є критичним, але враховувати цю особливість варто. Цілком можливо, що розроблений вами продукт буде марний на слабких застарілих комп'ютерах.


PVS-Studio – статичний аналізатор коду, що використовує Roslyn API
PVS-Studio – це статичний аналізатор коду, призначений для виявлення помилок у вихідному коді програм, написаних на C, C++, C#.



Та частина аналізатора, яка відповідає за перевірку C# код, написана з використанням Roslyn API. Знання і правила, викладені вище, не взяті зі стелі – вони отримані і сформульовані в ході роботи над аналізатором.

PVS-Studio є прикладом того, який продукт можна створити, використовуючи Roslyn. В даний момент в аналізаторі реалізовано понад 80 діагностичних правил. PVS-Studio вже знайшов помилки в безлічі проектів. Деякі з них:
  • Roslyn;
  • MSBuild;
  • CoreFX;
  • SharpDevelop;
  • MonoDevelop;
  • Microsoft Code Contracts;
  • NHibernate;
  • Space engineers;
  • І багато інші.


Але краще один раз побачити, ніж сто разів почути, в нашому випадку – краще власними очима глянути на аналізатор. Його можна завантажити за посилання – подивіться, що вдасться виявити в ваших проектах.

У декого може виникнути питання: «А що ж цікавого вдалося знайти в ході перевірки проектів?». Багато чого цікавого! Якщо хтось думає, що професіонали не допускають помилок, рекомендую ознайомитися з базою помилок, знайдених в open source проектах. Крім цього в блог можна почитати статті про перевірку тих чи інших проектів.

Підсумки
Загальне
  • Roslyn дозволяє розбирати і аналізувати код до найдрібніших подробиць. Це відкриває простір для створення різних додатків на його основі, у тому числі статичних аналізаторів;
  • Для серйозного аналізу проект повинен бути скомпільованим, так як це обов'язкова умова для отримання коректної семантичної моделі;
  • 2 суті, на яких базується статичний аналіз– синтаксичне дерево і семантична інформація. Тільки використовуючи їх в сукупності, можна проводити дійсно серйозний аналіз;
  • Відкритий вихідний код – завантажуйте і користуйтеся;
  • Syntax visualizer – корисне розширення, яке допоможе вам при роботі з платформою.
Синтаксичне дерево
  • Будується для кожного файлу і є незмінним.
  • Складається з 3-ох основних елементів – syntax nodes, syntax tokens, syntax trivia;
  • Вузли – основний елемент дерева, з яким доводиться працювати у ході аналізу коду;
  • Для кожної конструкції вузла визначено вузол свого типу, що дозволяє легко отримати необхідні дані, звертаючись до властивостей об'єкта вузла;
  • Лексеми – термінали граматики мови, що представляють ідентифікатори, ключові слова, розділені тощо;
  • Додаткова синтаксична інформація – коментарі, пробільні символи, директиви препроцесора та ін;
  • Використовуйте метод IsKind і перерахування SyntaxKind для конкретизації типу елемента дерева.
Семантична модель
  • Для якісного аналізу повинна бути коректною;
  • Дозволяє отримувати інформацію про об'єкти та їх типи;
  • Використовуйте метод GetSymbolInfo, інтерфейс ISymbol і похідні від нього для отримання інформації про самому об'єкті;
  • Використовуйте метод GetTypeInfo, інтерфейс ITypeSymbol і похідні від нього для отримання інформації про тип об'єкта або вирази;
  • Використовуйте метод GetConstantValue для отримання константных значень.
Статичний аналіз
  • Якщо немає впевненості в тому, що код є помилковим, краще не сваритися. Не варто засмічувати результат роботи аналізатора помилковими спрацьовуваннями;
  • При написанні діагностик можна виділити загальний алгоритм, дотримання якого дозволить реалізовувати потужні і функціональні діагностичні правила;
  • Використовуйте синтаксичний візуалізатор;
  • Чим більше юніт-тестів, тим краще;
  • При розробці діагностичних правил важливо перевіряти їх на різних реальних проектах.


Висновок


Підводячи підсумок, хотілося б сказати, що Roslyn — це дійсно потужна платформа, на основі якої можна створювати різні багатофункціональні інструменти – аналізатори, інструменти рефакторінгу і багато чого ще. Низький уклін Microsoft за Roslyn, а також за можливість його вільного використання.

Проте наявності платформи мало – потрібно знати, як з нею працювати. Основні поняття і принципи роботи і були описані в даній статті. Отримані знання допоможуть вам легше і швидше вникнути в процес розробки з використанням Roslyn API, було б бажання.



Якщо хочете поділитися цією статтею з англомовної аудиторією, то прошу використовувати посилання на переклад: Sergey Vasiliev. Introduction to Roslyn and its use in development program.

Прочитали статтю і є питання?Часто до наших статей задають одні і ті ж питання. Відповіді на них ми зібрали тут: Відповіді на питання читачів статей про PVS-Studio, версія 2015. Будь ласка, ознайомтеся зі списком.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.