Идея

Основная идея по сути в том, чтобы реализовать некое подобие Language Context Protocol (LCP), но с куда более широкими возможностями. Вы можете создавать свой автокомплит, свои GoTo, свои шаблоны кода и экшны прямо внутри IDE на том языке, на котором вы пишете свой проект.

Введение

Большинство скриптовых языков имеют собственные фреймворки/CMS (например Symfony, Drupal в PHP, Nest, Next в JS/TS). И существуют плагины для поддержки некоторых специфических возможностей фреймворков/CMS, например symfony plugin или drupal plugin. Основная проблема этих плагинов в том, что они не зависят от проекта, и вам нужно устанавливать множество плагинов для поддержки разных функций. А что если у вас есть собственные кастомные возможности в вашем проекте и вы хотите обрабатывать ссылки или автодополнение для них?

Как это работает?

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

Каждый раз, когда вы пытаетесь дополнить выражение (например, ctrl + space), плагин создаёт очень простое представление текущего сфокусированного PSI элемента, называемого PSA Context, затем JSON-кодирует его, записывает во временный файл (из-за некоторых ограничений длины аргументов/переменных окружения) и передаёт его в указанный исполняемый файл.

Например, если вы попытаетесь автодополнить следующий PHP код:

<?php

function myFunc() {
    $l = '';
//        ^   курсор здесь
}

то вы получите следующий JSON в пути к файлу, переданном из переменной окружения PSA_CONTEXT:

Развернуть
{
  "elementType": "right single quote",
  "elementName": null,
  "elementFqn": null,
  "text": "'",
  "parent": {
    "elementType": "String",
    "elementName": null,
    "elementFqn": null,
    "text": "''",
    "parent": {
      "elementType": "Assignment expression",
      "elementName": null,
      "elementFqn": null,
      "text": "$l = ''",
      "parent": {
        "elementType": "Statement",
        "elementName": null,
        "elementFqn": null,
        "text": "$l = '';",
        "parent": {
          "elementType": "Group statement",
          "elementName": null,
          "elementFqn": null,
          "text": "{\n    $l = '';\n}",
          "parent": {
            "elementType": "FUNCTION",
            "elementName": null,
            "elementFqn": null,
            "text": "function myFunc() {\n    $l = '';\n}",
            "parent": {
              "elementType": "PsiElement(Non Lazy Group statement)",
              "elementName": null,
              "elementFqn": null,
              "text": "<?php\n\nfunction myFunc() {\n    $l = '';\n}",
              "parent": {
                "elementType": "php.FILE",
                "elementName": null,
                "elementFqn": null,
                "text": "<?php\n\nfunction myFunc() {\n    $l = '';\n}\n",
                "parent": {
                  "elementType": "<null>",
                  "elementName": null,
                  "elementFqn": null,
                  "text": "",
                  "parent": {
                    "elementType": "<null>",
                    "elementName": null,
                    "elementFqn": null,
                    "text": "",
                    "parent": {
                      "elementType": "<null>",
                      "elementName": null,
                      "elementFqn": null,
                      "text": "",
                      "parent": {
                        "elementType": "<null>",
                        "elementName": null,
                        "elementFqn": null,
                        "text": "",
                        "parent": {
                          "elementType": "<null>",
                          "elementName": null,
                          "elementFqn": null,
                          "text": "",
                          "parent": null,
                          "prev": null,
                          "next": null
                        },
                        "prev": {
                          "elementType": "<null>",
                          "elementName": null,
                          "elementFqn": null,
                          "text": "",
                          "parent": null,
                          "prev": null,
                          "next": null
                        },
                        "next": {
                          "elementType": "<null>",
                          "elementName": null,
                          "elementFqn": null,
                          "text": "",
                          "parent": null,
                          "prev": null,
                          "next": null
                        }
                      },
                      "prev": null,
                      "next": {
                        "elementType": "PLAIN_TEXT_FILE",
                        "elementName": null,
                        "elementFqn": null,
                        "text": "",
                        "parent": null,
                        "prev": null,
                        "next": null
                      }
                    },
                    "prev": {
                      "elementType": "<null>",
                      "elementName": null,
                      "elementFqn": null,
                      "text": "",
                      "parent": null,
                      "prev": null,
                      "next": null
                    },
                    "next": {
                      "elementType": "<null>",
                      "elementName": null,
                      "elementFqn": null,
                      "text": "",
                      "parent": null,
                      "prev": null,
                      "next": null
                    }
                  },
                  "prev": {
                    "elementType": "<null>",
                    "elementName": null,
                    "elementFqn": null,
                    "text": "",
                    "parent": null,
                    "prev": null,
                    "next": null
                  },
                  "next": {
                    "elementType": "<null>",
                    "elementName": null,
                    "elementFqn": null,
                    "text": "",
                    "parent": null,
                    "prev": null,
                    "next": null
                  }
                },
                "prev": null,
                "next": null
              },
              "prev": {
                "elementType": "WHITE_SPACE",
                "elementName": null,
                "elementFqn": null,
                "text": "\n",
                "parent": null,
                "prev": null,
                "next": null
              },
              "next": null
            },
            "prev": null,
            "next": {
              "elementType": "WHITE_SPACE",
              "elementName": null,
              "elementFqn": null,
              "text": "\n\n",
              "parent": null,
              "prev": null,
              "next": null
            }
          },
          "prev": null,
          "next": {
            "elementType": "WHITE_SPACE",
            "elementName": null,
            "elementFqn": null,
            "text": " ",
            "parent": null,
            "prev": null,
            "next": null
          }
        },
        "prev": {
          "elementType": "WHITE_SPACE",
          "elementName": null,
          "elementFqn": null,
          "text": "\n",
          "parent": null,
          "prev": null,
          "next": null
        },
        "next": {
          "elementType": "WHITE_SPACE",
          "elementName": null,
          "elementFqn": null,
          "text": "\n    ",
          "parent": null,
          "prev": null,
          "next": null
        }
      },
      "prev": {
        "elementType": "semicolon",
        "elementName": null,
        "elementFqn": null,
        "text": ";",
        "parent": null,
        "prev": null,
        "next": null
      },
      "next": null
    },
    "prev": null,
    "next": {
      "elementType": "left single quote",
      "elementName": null,
      "elementFqn": null,
      "text": "'",
      "parent": null,
      "prev": null,
      "next": null
    }
  },
  "prev": null,
  "next": {
    "elementType": "left single quote",
    "elementName": null,
    "elementFqn": null,
    "text": "'",
    "parent": null,
    "prev": null,
    "next": null
  }
}

В выводе выше параметры options и textRange опущены для уменьшения размера.

Документация

Swagger

Вы можете проверить Swagger UI, сгенерированный моделями внутри классов плагина. Он может быть использован для лучшего понимания того, как работает плагин, а также для генерации DTO классов для PSA.

Смотрите документацию Swagger здесь.

Все методы, описанные в документации Swagger, являются “фейковыми” методами и перечислены только для описания структуры вызовов, которые будут выполнены для вашего PSA скрипта.

Также ознакомьтесь с документацией OpenAPI Generator для получения дополнительной информации о генерации DTO классов для PSA.

Автодополнение и GoTo

Как уже упоминалось во введении, плагин отправляет JSON-кодированное PSI дерево в исполняемый файл.

Вот полный список переменных окружения, передаваемых в исполняемый файл:

  • PSA_CONTEXT - путь к файлу, содержащему JSON-кодированный PSI контекст.

  • PSA_TYPE - может быть либо Completion, либо GoTo. Тип выполнения.

  • PSA_LANGUAGE - язык, вызвавший автодополнение/разрешение ссылки (PHP, JS, …).

  • PSA_DEBUG - 1, если отладка включена в настройках плагина, и 0 в противном случае.

  • PSA_OFFSET - показывает позицию курсора внутри текущего элемента в редакторе.

Таким образом, вы можете разобрать JSON и проанализировать его для своих нужд. Этот JSON имеет древовидную структуру, и каждый элемент будет иметь следующую структуру:

Развернуть
{
  "elementType": "строка",
  "elementName": "строка | null",
  "elementFqn": "строка | null",
  "options": {
    "optionName": "optionValue"
  },
  "text": "строка",
  "parent": "дополнительный элемент дерева",
  "prev": "дополнительный элемент дерева",
  "next": "дополнительный элемент дерева",
  "textRange": {
    "startOffset": "целое число, позиция начала PSI элемента в файле",
    "endOffset": "целое число, позиция конца PSI элемента в файле"
  }
}

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

В результате ваш скрипт должен вернуть:

  • Массив завершений в случае, если PSA_TYPE равен Completion.

  • Массив завершений с одним элементом (этот элемент должен содержать ссылку) в случае, если PSA_TYPE равен GoTo.

  • Опционально, вы можете передать массив уведомлений, которые будут показаны IDEA. Полезно для отладки.

  • Опционально, вы можете вернуть массив типов элементов для фильтрации GoTo по причинам производительности. Для получения дополнительной информации прочитайте раздел производительность.

Полная структура результирующего JSON будет описана ниже:

Развернуть
{
  "completions": [
    {
      "text": "строка, текст завершения",
      "link": "строка, требуется только в случае `PSA_TYPE=GoTo`, абсолютная/относительная ссылка на файл в формате FileName.ext[:line_number][:position]",
      "bold": "boolean, должно ли завершение быть жирным.",
      "priority": "число, опционально. Используется для упорядочивания элементов в автодополнении. Если `bold` равен `true` и `priority` не указан, то значение по умолчанию будет 100.",
      "type": "строка, тип, который будет показан серым текстом справа от завершения."
    }
  ],
  "notifications": [
    {
      "type": "строка, может быть либо `info`, `error` или `warning`.",
      "text": "строка, текст уведомления."
    }
  ]
}

И полный рабочий пример:

Развернуть
{
  "completions": [
    {
      "text": "My Completion",
      "link": "/path/to/file.php:123:123",
      "bold": false,
      "priority": 123,
      "type": "MyType"
    }
  ],
  "notifications": [
    {
      "type": "info",
      "text": "Hello from my custom autocomplete!"
    }
  ]
}

В случае, если ваш исполняемый файл ответит JSON выше, результат автокомплита будет выглядеть так:

example
example

И будет показано следующее уведомление:

example
example

Для рабочих примеров на разных языках ознакомьтесь с папкой examples.

В случае, если PSA_TYPE равен GoTo, вы должны вернуть только одно завершение со ссылкой на ссылку.

Шаблоны кода

Большинство языков предоставляют некоторые общие шаблоны файлов, такие как PHP Class в PHP или TypeScript File в TypeScript. Плагин позволяет вам создавать пользовательские шаблоны файлов, которые будут иметь переменные, переданные из формы. Для поддержки шаблонов файлов вы должны указать все поддерживаемые шаблоны в вашем исполняемом скрипте в разделе templates. Ознакомьтесь с разделом информация об автодополнении для получения дополнительной информации.

Шаблон одного файла

В случае, если вам нужно создать шаблон одного файла, в запросе info ваш JSON должен содержать шаблон со следующими полями:

  • type - строка, обязательно. Поддерживаются single_file или multiple_file. Для шаблона одного файла передайте single_file в качестве значения.

  • name - строка, обязательно. Имя шаблона для ссылки. Будет передано в PSA_CONTEXT во время генерации шаблона.

  • title - строка, обязательно. Заголовок шаблона. Этот текст будет показан в IDE.

  • path_regex - строка, опционально. Регулярное выражение для фильтрации путей, где будет доступно действие создания шаблона.

  • fields - массив объектов со следующей структурой:

    • name - строка, обязательно. Имя поля формы. Будет передано в PSA_CONTEXT во время генерации шаблона.

    • title - строка, обязательно. Заголовок поля, который будет отображаться в форме.

    • type - строка, обязательно. Допустимые значения: Text, Checkbox, Select, Collection, RichText. Тип поля формы.

    • focused - boolean, опционально. Установите в true для поля, которое вы хотите сфокусировать при открытии диалога создания шаблона.

    • options - массив строк.

      • Требуется, если type равен Select. Массив опций выбора.

      • Требуется, если type равен RichText. Массив завершений.

Например, для некоторого простого PHP класса вы можете использовать следующую структуру:

Развернуть
{
  "templates": [
    {
      "type": "single_file",
      "name": "my_awesome_template",
      "title": "My Awesome Template",
      "path_regex": "^\/src\/[^\/]\/$",
      "fields": [
        {
          "name": "className",
          "title": "Class Name",
          "type": "Text",
          "options": []
        },
        {
          "name": "abstract",
          "title": "Is Abstract",
          "type": "Checkbox",
          "options": []
        },
        {
          "name": "comment",
          "title": "Comment",
          "type": "Select",
          "options": ["Option A", "Option B", "Option C"]
        },
        {
          "name": "richText",
          "title": "Rich Text with Completion",
          "type": "RichText",
          "options": ["Completion A", "Completion B", "Completion C"]
        },
        {
          "name": "collection",
          "title": "Collection of text fields",
          "type": "Collection",
          "options": []
        }
      ]
    }
  ]
}

И в случае, если ваш скрипт автодополнения вернёт шаблон, как выше, у вас будет следующая опция меню для генерации нового файла из шаблона в любом пути в структуре проекта (путь может быть отфильтрован опцией path_regex):

Развернуть
file_template_example
file_template_example

Когда вы нажмёте на действие, вы увидите следующую форму:

Развернуть
file_template_example
file_template_example

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

После нажатия кнопки OK файл будет сгенерирован в папке, где вы инициировали действие.

После открытия формы, после изменения любой переменной и нажатия OK, плагин отправит запрос на генерацию кода вашему скрипту автодополнения со следующими переменными:

  • PSA_TYPE - всегда будет GenerateFileFromTemplate

  • PSA_CONTEXT - как и при завершении, это путь к файлу с JSON следующей структуры:

    {
      "templateName": "строка, имя шаблона для генерации.",
      "actionPath": "строка, относительный путь от корня проекта, где было инициировано действие.",
      "formFields": {
        "name": "value"
      },
      "originatorFieldName": "строка, опционально. Если регенерация шаблона была вызвана изменением какого-либо поля, эта опция будет содержать имя этого поля."
    }
    

formFields - будет JSON объектом, где каждый ключ - имя поля, а значение - значение поля формы.

В результате ваш скрипт должен вернуть простой JSON объект со следующими полями:

{
  "file_name": "строка, обязательно. Имя файла вновь сгенерированного файла.",
  "content": "строка, обязательно. Содержимое файла.",
  "form_fields": {
    "{field_name}": {
      "options": "Массив строк, опционально. Здесь вы можете переопределить массив завершений `RichText`.",
      "value": "Строка, опционально. Здесь вы можете переопределить текущее значение любого поля формы, если необходимо."
    }
  }
}

form_fields - опциональное поле. Каждое внутреннее значение form_fields также опционально.

Некоторые примеры для PHP, JavaScript, TypeScript показаны в папке examples/README.md.

Действия редактора (Actions)

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

Для поддержки действий редактора ваш ответ Info должен содержать поле editor_actions. Это должен быть массив со следующей структурой:

Развернуть
{
  "editor_actions": [
    {
      "name": "строка, обязательно. Имя действия, будет передано обратно в ваш скрипт при вызове",
      "title": "строка, обязательно. Заголовок действия для отображения в действиях IDE",
      "source": "строка, обязательно. Может быть либо 'editor', либо 'clipboard'. Источник данных. Либо выделенный текст, либо буфер обмена",
      "target": "строка, обязательно. Может быть либо 'editor', либо 'clipboard'. Цель данных. Либо заменить выделенный текст, либо скопировать результат в буфер обмена",
      "group_name": "строка, опционально. Добавляет подменю в меню PSA Actions",
      "path_regex": "строка, опционально. Регулярное выражение пути для фильтрации, где будет показано это действие"
    }
  ]
}

Например, вы можете создать действие, которое будет преобразовывать JSON в PHP массив:

Развернуть
<?php

$type = getenv('PSA_TYPE');

if ('Info' === $type) {
    echo json_encode([
        'supported_languages' => ['PHP'],
        'editor_actions' => [
            [
                'name' => 'jsonToPhpArray',
                'title' => 'Convert JSON -> PHP array (Copy to Clipboard)',
                'source' => 'editor',
                'target' => 'clipboard',
                'group_name' => 'JSON',
            ],
        ],
    ]);

    exit(0);
}

$context = json_decode(file_get_contents(getenv('PSA_CONTEXT')), true);

if ('PerformEditorAction' === $type && $context['action_name'] === 'jsonToPhpArray') {
    $data = json_decode($context['text'], true);

    echo var_export($data);
}

И в случае, если ваш PSA скрипт вернёт значение, вы сможете вставить его в любое место вашего кода. Например, это полезно с действием Generate Pattern Model, так что вы запускаете это действие, копируете результат в некоторый временный JSON файл, удаляете всё ненужное, затем запускаете своё собственное действие для преобразования этого в PHP массив и вуаля:

convert_json_to_php_array
convert_json_to_php_array

и затем где-то в коде вашего PSA скрипта (перед вставкой):

convert_json_to_php_array_paste_before
convert_json_to_php_array_paste_before

и после вставки:

convert_json_to_php_array_paste_after
convert_json_to_php_array_paste_after

Бонус для PHP разработчиков и людей, использующий PHPStorm

Плагин добавляет опциональную поддержку рендеринга __toString при дебаге

Форматирование значений Xdebug (переопределение отображения __toString) При отладке с Xdebug, PSA может заменить компактное значение по умолчанию, показываемое для не-скалярных значений, используя ваш to_string_value_formatter из Info.

Как это работает:

  • PSA оборачивает отладочные значения PHP и для не-скалярных типов вычисляет небольшой PHP-сниппет в сессии отладки для получения короткого текстового представления.

  • Сниппет проходит от корневой переменной к текущему выбранному свойству/элементу массива с использованием reflection для приватных/защищённых свойств, затем вызывает ваш код как: (function ($value) { YOUR_CODE_HERE })($current)

  • YOUR_CODE_HERE — это в точности строка, которую вы возвращаете в to_string_value_formatter. Она должна использовать $value и возвращать строку.

Пример кода форматтера:

return match (true) {
    is_array($value) => 'array(' . count($value) . ')',
    $value instanceof DateTimeInterface => $value->format(DATE_ATOM),
    is_object($value) => get_class($value),
    default => (string)$value,
};

Безопасность и примечания:

  • Ошибки внутри форматтера подавляются, чтобы не сломать UI отладки; если включены отладочные уведомления PSA, ошибки вычисления будут показаны.

  • Форматтер запускается только если включено PHP-расширение и предоставлен to_string_value_formatter.

  • Плагин добавляет поддержку поиска вызовов функции с конкретным аргументом (если есть больше одно аргумента по умолчанию) Примерно так это выглядит

    find usages by method parameter invocation
    find usages by method parameter invocation
  • Плагин добавляет поиск методов по всему дереву наследования (включая методы трейтов)

P.S. Это частичный перевод README.md файла. За дополнительными подробностями смотрите сам файл.

P.P.S. Этот текст был переведён с использованием ИИ, оригинал - всё тот же README.md файл (при написании оригинала ИИ не использовались).

Комментарии (5)


  1. funca
    25.03.2026 18:57

    Выглядит интересно, но ни чего не понятно.

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


    1. sam0delkin Автор
      25.03.2026 18:57

      как написано было в конце статьи, ИИ использовался исключительно для перевода. Оригинальный файл README.md из репозитория был полностью написан вручную. Как и код плагина. Да, конечно ИИ чат (хочу подчеркнуть - чат) немного помогал в освоении Intellij SDK иногда, но не более того. То есть по факту - весь код написан человеком, документация тоже. Исключение составляют лишь тесты (и то - это условно последний коммит, до этого их вообще не было :))

      По поводу

      не сильно погружаясь во внутренности

      Тут так не получится :) У плагина своя архитектура, свой набор полей для ввода и вывода. Поэтому и было предложено прочитать оригинальный README.md, потому что там всё куда более развёрнуто и даже есть диаграмма как вообще происходит взаимодействие IDE и вашего скрипта. Просто на Хабр тянуть это не посчитал нужным. Мне кажется, тем людям, которым это действительно интересно, проще будет пойти и прочитьать readme на английском, здесь это было добавлено только для того, чтобы посмотреть кому это вообще может понравиться и понравится ли вообще.


      1. funca
        25.03.2026 18:57

        Мой путь был: ссылка examples в статье (понять для чего это может пригодится), но я не понял. Потом README в корне проекта, но он огромный и описывает "how it works" instead of "what problems does it solve out of the box". Я попытался сормить док в LLM, чтобы понять где и как это может упростить мне жизнь, но оно тоже не выдало ни чего вразумительного.

        The intellij-psa plugin enables developers to define custom autocomplete and "GoTo" navigation rules for project-specific patterns, dynamic attributes, and specialized frameworks that standard static analysis cannot resolve. It enhances productivity by mapping custom string literals to class definitions and enabling navigation through complex proxy objects or service locators. For more details, visit GitHub.


  1. sam0delkin Автор
    25.03.2026 18:57

    по поводу

    оформлять сразу в виде скилла для агента

    подумаю об этом, спасибо за идею!


  1. Mausglov
    25.03.2026 18:57

    "рыбные" тексты, типа "My Awesomr template", "Option A", "Option B" - это пережиток нулевых, если не раньше. Нужны реальные, живые значения.
    И было бы лучше, если бы вы на конкретном примере пояснили, чем ваш плагин лучше коробочного функционала. То есть конкретный пример проекта ( в идеале - на гитхабе или подобном сервисе), IDE "из коробки" ведёт себя так-то; File and Code Templates и Live Templates не помогают, потому что ... ( тут объяснение). А дальше показано, как плагин возникшие проблемы решает.
    И тогда, имхо, будет интерес к плагину и желание попробовать.
    А "рыбу" не надо, она протухла двадцать лет назад...