Коли XML стає графом: семантична навігація в IntelliJ через власний плагін

Серія: Розробка IntelliJ-плагінів для складних XML-систем


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

Ви бачите дерево XML-файлів. Система поводиться як граф взаємопов’язаних сутностей. IDE нічого не знає про цей граф — вона знає лише про теги й атрибути. Тому ви навігуєте вручну: файл за файлом, пошук за пошуком, крос-посилання за крос-посиланням.

Ця стаття — про ту саму проблему, чому існуючі інструменти її не вирішують, і як я підійшов до написання плагіна для IntelliJ, який це робить — на прикладі моддингу RimWorld.


Контекст: геймдев моддинг як інженерна задача

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

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

На більших масштабах це відчуття зникає швидко.

Серйозний мод (як наприклад проект X-Rim:Enemy Unknown) може містити десятки файлів і сотні визначень. Комʼюніті-модпаки обʼєднують результати роботи кількох незалежних авторів. У такій точці XML перестає бути «файлами з даними» і стає системою — зі своєю структурою залежностей, ієрархією наслідування та крос-посиланнями, які ламаються неочевидними способами.

Інженерна проблема реальна, але з точки зору інструментів — майже не досліджена. Більшість розробників модів використовують VS Code або текстовий редактор. Вони покладаються на пошук, інтуїцію і ментальні моделі, які доводиться тримати в голові вручну.

Це не масштабується.


Симптоми: що насправді йде не так

Проблеми у великих модах RimWorld не є випадковими. Вони слідують шаблонам.

Зламані крос-посилання. Визначення посилається на інше за іменем — передумова дослідження, батьківський об’єкт, інгредієнт рецепту. Це посилання — звичайний рядок. Якщо ціль не існує, перейменована або живе в моді з неправильним порядком завантаження, IDE нічого не позначить. Гра мовчки ламається в рантаймі або кидає помилку в лог, яку ще треба зуміти пов’язати з джерелом.

Плутанина з наслідуванням. RimWorld використовує XML-наслідування: визначення може оголосити ParentName="WeaponBase" і успадкувати від нього властивості. Базові визначення можуть бути абстрактними — вони не з’являються в грі безпосередньо. Ланцюжок наслідування може бути кілька рівнів завглибшки і розкиданий по кількох файлах, включно з даними ванільної гри. Щоб зрозуміти, які властивості фактично має визначення, потрібно вручну прослідкувати ланцюжок вгору.

Збої крос-модових залежностей. Коли ваш мод посилається на визначення з іншого мода, IDE про це нічого не знає. Індексу зовнішніх визначень не існує. Ви не можете до них перейти. Чи існують вони взагалі — дізнаєтесь лише після запуску гри.

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

Спільний знаменник: IDE трактує XML як текст. Система трактує його як семантичний граф. Цей розрив і генерує постійне тертя.


Чому стандартні інструменти це не вирішують

Перш ніж щось будувати, варто чесно оцінити те, що вже існує.

VS Code з розширеннями для RimWorld XML дає підсвічування синтаксису та часткову валідацію схеми. Він не будує семантичного індексу, не резолвить крос-посилання і не моделює наслідування. Він робить XML читабельнішим — але не робить систему навігабельною.

RimWorld LSP (Language Server Protocol для VS Code) — найбільш просунутий з існуючих варіантів. Надає пошук визначень і часткове резолвінг посилань. Він нативний для VS Code і орієнтований на цю екосистему. Для інженерів, які вже працюють у IntelliJ-based IDE, він нерелевантний: інша модель інтеграції, інші точки розширення, інша аудиторія.

Стандартні XML-інструменти JetBrains дають хороший редактор і XSD-валідацію. У RimWorld немає підтримуваної XSD-схеми, що відображала б реальну семантику. Вбудовані інструменти не знають, що <researchPrerequisite> містить посилання на ResearchProjectDef — вони бачать рядкове значення.

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

Прогалина конкретна: немає інструменту, який будує семантичну модель RimWorld-проекту всередині IntelliJ. Саме в цю прогалину я і будую.


Ключова ідея: відновити граф

Ключове переосмислення:

XML — це синтаксис для кодування даних. У проекті RimWorld-мода ці дані кодують доменну модель — граф визначень, пов’язаних типізованими залежностями. Проблема інструментів не в XML; вона в тому, щоб відновити цю доменну модель і зробити її навігабельною.

Як тільки так формулюєш — підхід стає очевидним.

Витягти сутності. Розпарсити XML-файли і виокремити обʼєкти-визначення з ідентичністю (defName), типом (ThingDef, ResearchProjectDef тощо) та посиланням на батька (ParentName). Перестати трактувати їх як XML-теги. Трактувати як доменні сутності.

Побудувати індекс. Відобразити кожну сутність по логічному ключу — DefType::defName — щоб резолвінг був O(1) незалежно від того, в якому файлі живе визначення.

Відновити ребра. Визначити через конфігурацію, які XML-шляхи несуть посилання на які типи визначень. Поле recipeMaker/researchPrerequisite всередині ThingDef — це не рядок. Це посилання на ResearchProjectDef. Закодувати це правило. Тепер граф має типізовані ребра.

Відкрити граф для IDE. Підключити навігацію до системи резолвінгу посилань IntelliJ (PSI). Коли розробник клікає на значення, яке фактично є посиланням, IDE резолвить його і переходить до цілі — так само, як навігація між символами в коді.

Ідея не нова в просторі IDE-плагінів. Саме це і роблять мовні сервери для мов програмування. Прогалина в тому, що для цього домену ніхто цього не будував у IntelliJ.


Фаза 1: що плагін вміє зараз

Перша фаза встановлює фундамент: парсинг, індексування, резолвінг посилань та інкрементальні оновлення.

Парсинг використовує PSI IntelliJ для обходу XML-файлів і витягнення визначень. Кожне розпарсене визначення стає RimDef — легким доменним об’єктом з типом, defName, батьком і посиланням на PSI-вузол.

Індексування є проєктно-скопованим і тримається в пам’яті. Індекс відображає DefType::defName на RimDef. Формат ключа навмисний — він disambiguates визначення з однаковим іменем, але різними типами. Індекс будується при відкритті проєкту у фоновому потоці, не блокуючи UI.

Резолвінг посилань керується JSON-конфігом, що постачається з плагіном. Він відображає типи визначень на XML-шляхи всередині їх структури, які несуть посилання. Коли reference provider зустрічає тег на одному з цих шляхів — він створює PsiReference, що резолвиться через індекс.

Інкрементальні оновлення слухають PSI change events. При зміні файлу лише він перепарсується і його визначення переіндексуються. Повного переіндексування на кожну правку немає.

Валідація наразі обмежена: виявляє відсутні вузли <defName> — визначення, які не можуть брати участь в індексі. Це відловлюється на рівні анотатора з inline-підсвіткою помилки.

Результат: у межах проєкту можна навігувати від значення-посилання безпосередньо до його визначення. Ctrl+Click працює між файлами, між типами визначень, без ручного пошуку.


Що Фаза 1 не обробляє — і чому це навмисно

Поточна система є коректною в межах обмеженого домену. Для повного середовища RimWorld вона ще не є коректною.

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

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

Патч-файли не обробляються. XML-патчі модифікують існуючі визначення. Вони самі по собі не є визначеннями. Поточна модель не відстежує, що роблять патчі, тому ефективний стан пропатченого визначення невідомий.

Крос-модові посилання не резолваться. Та сама проблема, що з ванільними даними — зовнішні визначення поза скопом проєкту.

Це не упущення через недбалість. Це наступні фази моделі, в порядку, що робить її інкрементально коректнішою. Принцип навмисний: коректність існуючого скопу перед розширенням у новий.


Ризики, які варто назвати прямо

Три інженерних ризики є реальними і їх варто сформулювати відверто.

Нестабільність PSI API IntelliJ між версіями IDE. PSI — це не стабільний публічний контракт у звичному розумінні. Deprecation’и і зміни поведінки трапляються між мажорними релізами IDE. Плагін, що працює на IDEA 2023.2, може вимагати адаптації для 2024.x. Мітигація: CI-тестування по матриці версій, а не припущення сумісності без перевірки.

Зміна XML-структури RimWorld між версіями гри. Схема визначень гри еволюціонує. Підхід з config-driven маппингом посилань є мітигацією: оновлення JSON-конфіга обробляє зміни схеми без торкання коду резолвінгу. Це низькотертєвий шлях оновлення.

Перетин екосистем з RimWorld LSP. LSP для VS Code покриває частину тієї ж землі. Ризик низький, оскільки аудиторії різні — IntelliJ-користувачі не перейдуть в інший редактор заради мод-проекту. Але варто стежити за тим, що реалізує LSP, особливо якщо він додає фічі, що формують очікування користувачів до цього класу інструментів.


Чому саме IntelliJ

Плагін будується для IntelliJ-family IDE з причин, що виходять за межі особистих уподобань.

Архітектура плагінів IntelliJ зріла, добре задокументована і спеціально спроєктована для глибокої IDE-інтеграції, якої вимагає ця задача. PSI дає структурований, придатний до запитів представлення будь-якого типу файлів — не лише мов програмування. Система extension points (reference providers, annotators, indexers, startup activities) прямо відображається на структуру проблеми.

Важливіше: IntelliJ — це те середовище, де вже перебувають інженери, що будують складні мод-системи. Якщо ви пишете великий мод з кастомними Java-компонентами, білд-системою і сотнями XML-файлів конфігурації — ви вже в IntelliJ. Мати XML-інструментарій в тому ж IDE, з тією ж моделлю навігації, тими ж хоткеями, тим самим контекстом проєкту — це суттєве покращення робочого процесу порівняно зі зміною редактора.

Аргумент на користь IntelliJ — не в тому, що VS Code недостатній. А в тому, що IntelliJ — це вже там, де відбувається ця робота. І плагін, що інтегрується з рантаймом IDE, корисніший за окремий інструмент.


Ключові висновки

XML-based конфігураційні системи у масштабі створюють семантичні графи, які текстові редактори не можуть навігувати. Це не специфічна проблема RimWorld. Вона виникає скрізь, де доменні залежності закодовані в XML або JSON: enterprise-конфігурації, інтеграційні схеми, security-policy, legacy-платформи.

Правильне формулювання — доменне моделювання, а не текстова обробка. Плагін, що забезпечує «XML-навігацію», описує механізм. Плагін, що відновлює семантичний граф залежностей між визначеннями, описує мету. Мета визначає архітектуру.

Config-driven поведінка — це не зручна фіча, а стратегія коректності. Хардкодинг шляхів посилань робить плагін крихким до змін схеми і нерозширюваним без змін коду. Винесення маппингу назовні тримає логіку резолвінгу незалежною від доменної специфіки.

Будуйте коректність шарами, а не фічі паралельно. Порядок фаз — core indexing, наслідування, ванільні дані, система патчів, граф-візуалізація — визначається залежностями, а не попитом на фічі. Візуалізація, побудована на некоректній моделі, гірша за її відсутність.

Розрив між staging і production існує і в розробці плагінів. Те, що працює в маленькому тестовому проєкті з чистим XML, може зламатися в 200-файловому мод-проєкті з циклічним наслідуванням, некоректними патчами і визначеннями, що перевизначають ванільні записи. Саме там і виринають реальні failure modes.


Що далі

Наступна стаття серії покриває Фазу 2: резолвінг наслідування.

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

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

Ось де модель стає дійсно цікавою.


Проект: RimWorld XML Toolkit
Серія: Розробка IntelliJ-плагінів для складних XML-систем