Идея

Основная идея по сути в том, чтобы реализовать некое подобие 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 файл (при написании оригинала ИИ не использовались).