hershel-theodore-layton/portable-hack-ast

轻量级且便携地查询 Hack AST。

v0.0.3 2024-07-25 18:22 UTC

This package is auto-updated.

Last update: 2024-09-26 18:41:16 UTC


README

轻量级且便携地查询 Hack AST。

快速开始

只想用 linters?请看这里 :)

想在 AST 上构建自己的工具?

  • composer require hershel-theodore-layton/portable-hack-ast hershel-theodore-layton/portable-hack-ast-extras
  • 阅读此 README
  • 熟悉以下内容:节点类型成员
  • 通过 API 完成自动补全
  • ???
  • 盈利

什么是 AST?

AST 是 Hack 和 HHVM 如何查看和推理你的源代码的方式。如果你看到这段代码,你可以立即识别并命名部分。

$var = Math\minva(1, 2 + 3 * 4);

这是一个语句,让我们识别一些部分

  • minva是一个名称,但它与Math一起使用。
  • Math\minva是一个限定名称,它可能解析为HH\Lib\Math\minva
  • Math\minva(1, 2 + 3 * 4)是一个函数调用,它有参数。
  • 1是一个简单的文字数字表达式。
  • 2 + 3 * 4是一个复杂表达式,其中3 * 4的绑定更紧密。
  • 3 * 4是一个二元表达式,操作数是KIND_STAR*)标记
  • 2 + 3 * 4也是,其中整个部分3 * 4是右侧。
  • $var = Math\minva(1, 2 + 3 * 4)是一个赋值表达式。

如果你编程几年(或几十年),你几乎不思考地进行这种分类1。计算机也是以这种方式推理代码,然后在它们将其转换为你可以运行的东西之前。

为什么我会使用 AST?

假设这个函数是大型代码库中广泛使用的函数。

namespace MyNamespace\Rendering;

function to_html(
  Renderable $render,
  bool $be_unsafe = false, 
  bool $use_cache = false,
): string {
  // Code...
}

// In a completely different file...
$rendered = Rendering\to_html($something_untrusted, true);

哦不,你以为你启用了缓存,但你关闭了安全检查!幸运的是,这在代码审查中被捕捉到了,但结果可能会很糟糕。让我们使用grep来查看是否有其他 api 混淆的实例。

你很快会遇到一个障碍,Rendering\to_html(...)从成千上万的地方被调用,其中许多地方只传递一个Renderable。哦,让我们使正则表达式更复杂以找到具有多个参数的结果。同时,让我们也排除to_html(..., false, ...)。祝你好运!如果第一个参数很复杂,你就会陷入困境。你将花费大部分时间在正则表达式上尝试跳过它,但这几乎是不可能的。

退一步,使用正确的工具来完成这项工作。应该有一个工具来做这件事,但pfff在2017年被存档了。让我们自己构建一个,这不可能是一项太多工作,对吧?这个例子有点大,但非常具有说明性。

function check_for_unsafe_render_calls(
  Pha\Script $script,
  Pha\SyntaxIndex $syntax_index,
  Pha\Resolver $resolver,
)[]: vec<shape('code' => string, 'line' => int)> {
  $get_thing_being_called =
    Pha\create_member_accessor($script, Pha\MEMBER_FUNCTION_CALL_RECEIVER);
  $get_arguments =
    Pha\create_member_accessor($script, Pha\MEMBER_FUNCTION_CALL_ARGUMENT_LIST);

  $to_txt = $n ==> Pha\node_get_code($script, $n);
  $to_short_txt = $n ==> Pha\node_get_code_compressed($script, $n);
  $to_function_name = $n ==> Pha\resolve_name($resolver, $script, $n);
  $to_line = $n ==>
    Pha\node_get_line_and_column_numbers($script, $n)->getStartLineOneBased();

  $is_calling_to_html = $call ==> $get_thing_being_called($call)
    |> $to_function_name($$) === 'MyNamespace\Rendering\to_html';

  $is_unsafe = $call ==> $get_arguments($call)
    |> Pha\as_syntax($$)
    |> Pha\list_get_items_of_children($script, $$)
    |> C\count($$) > 1 && $to_short_txt($$[1]) !== 'false';

  return Pha\index_get_nodes_by_kind(
    $syntax_index,
    Pha\KIND_FUNCTION_CALL_EXPRESSION,
  )
    |> Vec\filter($$, $call ==> $is_calling_to_html($call) && $is_unsafe($call))
    |> Vec\map(
      $$,
      $call ==> shape('code' => $to_txt($call), 'line' => $to_line($call)),
    );
}

我们用ScriptSyntaxIndexResolver开始这个函数

  • Script 表示您的源代码。我们可以查询它以获取见解。
  • SyntaxIndex 用于获取 Script 中的函数调用列表。
  • Resolver 将确定您调用的是哪个函数,因为命名空间。

让我们定义一些访问所需数据的函数。

  • $get_thing_being_calledRendering\to_html(...) 获取 Rendering\to_html
  • $get_argumentsRendering\to_html($r, true) 获取 $r, true
  • $to_txt 获取包含注释和空格的完整文本,作为最终结果。
  • $to_short_txt 获取不带空格或注释的 false,以便进行比较。
  • $to_function_name 获取包括整个命名空间的函数名。
  • $to_line 获取代码开始的行号。

逐步组合这些部分以获取 $is_calling_to_html$is_unsafe

Pha\index_get_nodes_by_kind(...) 从文件中选取所有函数调用。我们仅过滤出我们感兴趣的函数。转换输出以方便查看,您就有了一个代码库宽范围搜索的工具。

我们刚刚编写了我们自己的超级专业化的代码检查器。有关使用 ast 可以做什么的更多示例,请参阅 HTL\PhaLinters

入门

$code = ''; // your source code goes here...
// You want to reuse $ctx if you can.
$ctx = Pha\create_context();
// The $script is what you are after, the $ctx is the updated $ctx.
// The object in $ctx is never changed, so use this `list()` assignment to get the new one.
list($script, $ctx) = Pha\parse($code, $ctx);

// Some tools...
// These indexes allow you to use Pha\index_get_nodes_by_kind()
$syntax_index = Pha\create_syntax_kind_index($script);
$token_index = Pha\create_token_kind_index($script);
$trivium_index = Pha\create_trivium_kind_index($script);

// Store your work in a cache (sqlite, apc, files on disk).
$ready_to_serialize = Pha\dematerialize_script($script);
// And get them back.
$deserialized = $ready_to_serialize;
$ctx = Pha\materialize_context($deserialized['context']);
Pha\materialize_script($deserialized['script'], $ctx);

// resolver, and pragma_map require you install portable-hack-ast-extras
// This allows you to resolve names to the namespace they belong in.
$resolver = Pha\create_name_resolver($script, $syntax_index, $token_index);
// This gives you all the `pragma()` declarations and `<<Pragma()>>` annotations.
$pragma_map = Pha\create_pragma_map($script, $syntax_index);

与所有这些值交互的完整 API 可在 node_functions.hack 中找到。撰写时大约有 50 个函数,所以通过一些自动完成,您应该能够很快上手。

有关种类和成员的定义,请参阅 Kind.hackMember.hack。如果定义不完整,您可以在运行时创建它们。

  • Pha\syntax_kind_from_string(...)
  • Pha\token_kind_from_string(...)
  • Pha\trivium_kind_from_string(...)
  • Pha\member_from_tuple(...)

性能

这个库在性能方面无所不用其极。您可以解析非常大的代码库,并将所有 Script 保留在内存中,毫不费力2。HHAST 是这个基准的目标,因为它包含大量的代码生成定义,这足以代表具有大量类的代码库。

Parsing: ../vendor/hhvm/hhast with hhast
1063.71 megabytes used.

Parsing: ../vendor/hhvm/hhast with pha
29.2615 megabytes used.

运行时间很难给出一个具体的数字,但 Pha 非常快。我能在 400 毫秒内检查 HTL\ 命名空间中的所有内容。当将其添加到解析中(不缓存任何内容)时,即使启用了 HHAST 的 .var/cache/hhvm/hhast/parser-cache 机制,结果仍然优于 HHAST。

名字背后的含义

"可移植" Hack AST,"可移植"是什么意思?

此代码库在 Hack AST 版本(即 hhvm 版本)之间可移植。在 HTL\ 命名空间中的一切都支持广泛的 hhvm 版本。为了使用 AST 库做到这一点,您不能硬编码定义。AST 的结构和布局在运行时动态学习。

解析的结构在不同程序调用之间是可移植的。它表示为两个 vec<Node ~ int> 和一个 dict<Kind ~ string, int>。当您将 Script 解析并序列化时,您编码值类型的数组。它们可以被反序列化和物化而不会丢失信息。此操作非常快速,甚至可以在内存压力过大时用于将大型 Script "交换"到磁盘。

此代码足够简单,可以移植到另一种完全不同的语言。95% 的代码执行简单的操作,这些操作可以一比一地翻译到任何其他编程语言,其性能将优于 HHVM 上的 Hack。Pha 在 HHVM 上的性能足以满足我目前使用的代码库(目前如此)。当我要处理的代码量再增长 20 倍时,我知道我可以采取一条前进的道路,在几天内实现更高的性能。

转换命名

x_from_y:(也可称为 xs_from_ys)

  • X 是一个新类型,Y 是底层类型。
  • 值不会以任何方式进行校验。
  • 这是一个未校验的下转型。

x_to_y:(也可称为 xs_to_ys)

  • X 是一个新类型,Y 是底层类型。
  • 此函数移除了新类型。

as_x(其中 x 不是数组)

  • X 是一个新类型。
  • 无名的参数是一个更不具体的新类型。
  • 如果运行时值不匹配,则抛出异常。
  • 这是一个校验的下转型。

as_x(其中 x 是数组)

  • 在序列化后恢复运行时值。
  • 如果需要,将执行数组类型转换。

x_hide:

  • X 是一个对象类型。
  • 此函数“隐藏”(移除)了方法。
  • 不需要执行任何检查。

x_reveal:

  • X 是一个对象类型。
  • 参数是 x_hide 创建的新类型。
  • 此函数再次“揭示”方法。

cast_away_nil:

  • 从 Nillable 到 T 执行未校验的转换。

为什么选择 PHA 而不是 HHAST 或类似的东西?

  1. PHA 使用更少的内存,比 HHAST 更快。
    • 这意味着您可以编写大型、复杂的完整代码库分析工具。
  2. PHA 可以表示无效的 Hack 文件。
    • 这使得它特别适合作为您输入工具。
    • HHAST 会在您的代码在语法上不正确时破坏类型不变性。
  3. PHA 可以在不同的 HHVM 版本/构建之间移植。
    • 这使您从运行的 HHVM 版本中获得的 lint 工具摆脱了束缚。
  4. PHA 不会抑制错误或执行不安全的转换。
    • 好类型才是王道!!!
  5. PHA 在纯上下文中运行,读作 []
    • 这使得代码更容易推理和预测。
    • 它使审计代码更容易,因为纯代码不能做任何奇怪的事情3,而不会破坏类型系统,如 HH_FIXME 和 Coeffects\backdoor

小心

此库包含许多函数,这些函数接受一个 Script 和一个或多个 Node 参数。每个函数都期望 NodeScript 的组成部分或 NIL。如果您将一个相关的 ScriptNode 传递给一个函数,操作的结果是未定义的4

序列化

缓存机制非常快且体积小。这很重要,因为大多数解析实际上是重新解析。未缓存的性能仅在以下情况下才重要:

  • 首次检出新的存储库。
  • 切换到您以前从未见过的分支。
  • 拉取更改/与 HEAD 同步。

如性能部分所述性能,未缓存的解析性能仍然很好,但缓存的性能要好得多。为了有效地缓存 Script,您必须首先对其进行解体。您可能不会观察到解体后的表示形式,它可能发生变化。以下表格没有考虑序列化开销。给定脚本的序列化大小通常是字节大小的约 10 倍5

上下文也需要进行序列化,但上下文很少是唯一的。如果您通过 context_hash 进行去重,存储需求就会降低。