导入路径分辨率

为了能够在所有平台上支持可重现的构建,固态编译器必须抽象出源文件所存储的文件系统的细节。导入中使用的路径必须在任何地方都以相同的方式工作,而命令行界面必须能够与平台特定的路径一起工作,以提供良好的用户体验。本节旨在详细解释稳健如何协调这些要求。

虚拟文件系统

编译器维护内部数据库( 虚拟文件系统VFS 简而言之),其中每个源单元被分配唯一的 源设备名称 它是不透明且非结构化的标识符。当您使用 import statement 中,您可以指定一个 导入路径 引用源设备名称的。

导入回调

VFS最初只填充编译器作为输入接收的文件。其他文件可以在编译期间使用 导入回调 ,根据您使用的编译器类型的不同而有所不同(见下文)。如果编译器在VFS中找不到任何与导入路径匹配的源单元名称,它将调用回调,该回调负责获取要放在该名称下的源代码。导入回调可以自由地以任意方式解释源单元名称,而不仅仅是将其解释为路径。如果在需要回调时没有可用的回调,或者如果它找不到源代码,编译就会失败。

命令行编译器提供 主机文件系统加载器 -将源单元名称解释为本地文件系统中的路径的基本回调。这个 JavaScript interface 默认情况下不提供任何内容,但可以由用户提供。该机制可用于从本地文件系统以外的位置获得源代码(例如,当编译器在浏览器中运行时,本地文件系统甚至可能是不可访问的)。例如, Remix IDE 提供多功能回调,使您可以 import files from HTTP, IPFS and Swarm URLs or refer directly to packages in NPM registry

注解

主机文件系统加载器的文件查找与平台相关。例如,源单元名称中的反斜杠可以被解释为目录分隔符,也可以不被解释为目录分隔符,查找可以区分大小写,也可以不区分大小写,具体取决于底层平台。

为便于移植,建议避免使用仅在特定导入回调或仅在一个平台上才能正常工作的导入路径。例如,您应该始终使用正斜杠,因为它们在支持反斜杠的平台上也可以用作路径分隔符。

虚拟文件系统的初始内容

VFS的初始内容取决于您调用编译器的方式:

  1. solc / command-line interface

    使用编译器的命令行界面编译文件时,需要提供一个或多个指向包含固体代码的文件的路径:

    solc contract.sol /usr/local/dapp-bin/token.sol
    

    以这种方式加载的文件的源单元名称是通过将其路径转换为规范形式并在可能的情况下使其相对于基本路径或其中一个包含路径来构造的。看见 CLI Path Normalization and Stripping 有关此过程的详细说明,请参阅。

  2. 标准JSON

    在使用 Standard JSON API(通过 JavaScript interface 或者 --standard-json 命令行选项)提供JSON格式的输入,其中包含所有源文件的内容:

    {
        "language": "Solidity",
        "sources": {
            "contract.sol": {
                "content": "import \"./util.sol\";\ncontract C {}"
            },
            "util.sol": {
                "content": "library Util {}"
            },
            "/usr/local/dapp-bin/token.sol": {
                "content": "contract Token {}"
            }
        },
        "settings": {"outputSelection": {"*": { "*": ["metadata", "evm.bytecode"]}}}
    }
    

    这个 sources 字典成为虚拟文件系统的初始内容,其键用作源单元名称。

  3. 标准JSON(通过导入回调)

    使用标准JSON,还可以告诉编译器使用导入回调来获取源代码:

    {
        "language": "Solidity",
        "sources": {
            "/usr/local/dapp-bin/token.sol": {
                "urls": [
                    "/projects/mytoken.sol",
                    "https://example.com/projects/mytoken.sol"
                ]
            }
        },
        "settings": {"outputSelection": {"*": { "*": ["metadata", "evm.bytecode"]}}}
    }
    

    如果导入回调可用,编译器将为其提供 urls 一个接一个,直到成功加载一个或到达列表末尾。

    源设备名称的确定方式与使用时相同 content -它们是 sources 字典和 urls 不会以任何方式影响他们。

  4. 标准输入

    在命令行上,还可以通过将源代码发送到编译器的标准输入来提供源代码:

    echo 'import "./util.sol"; contract C {}' | solc -
    

    - 用作其中一个参数指示编译器将虚拟文件系统中标准输入的内容放在一个特殊的源单元名称下: <stdin>

初始化VFS后,仍然只能通过导入回调将其他文件添加到其中。

进口商品

IMPORT语句指定一个 导入路径 。根据导入路径的指定方式,我们可以将导入分为两类:

  • Direct imports ,其中您可以直接指定完整的来源单位名称。

  • Relative imports ,其中指定以开头的路径 ./../ 与导入文件的源单位名称组合。

contracts/contract.sol
import "./math/math.sol";
import "contracts/tokens/token.sol";

在上面的 ./math/math.solcontracts/tokens/token.sol 是导入路径,而它们转换为的源设备名称是 contracts/math/math.solcontracts/tokens/token.sol 分别为。

直接进口

不以开头的导入 ./../ 是一种 直接进口

import "/project/lib/util.sol";         // source unit name: /project/lib/util.sol
import "lib/util.sol";                  // source unit name: lib/util.sol
import "@openzeppelin/address.sol";     // source unit name: @openzeppelin/address.sol
import "https://example.com/token.sol"; // source unit name: https://example.com/token.sol

在应用任何 import remappings 导入路径简单地成为源设备名称。

注解

源单元名称只是一个标识符,即使它的值恰好看起来像一个路径,它也不受通常在shell中期望的规范化规则的约束。任何 /.//../ 段或多个斜杠的序列仍然是它的一部分。当源通过标准JSON接口提供时,完全可以将不同的内容与引用磁盘上相同文件的源单元名称相关联。

当源在虚拟文件系统中不可用时,编译器会将源单元名称传递给导入回调。主机文件系统加载器将尝试将其用作路径并在磁盘上查找该文件。此时,特定于平台的规范化规则生效,在VFS中被认为不同的名称实际上可能会导致加载相同的文件。例如 /project/lib/math.sol/project/lib/../lib///math.sol 在VFS中被视为完全不同,即使它们引用的是磁盘上的同一文件。

注解

即使导入回调最终从磁盘上的同一文件加载两个不同源单元名称的源代码,编译器仍会将它们视为单独的源单元。重要的是源单元名称,而不是代码的物理位置。

相对进口

以开头的导入 ./../ 是一种 相对进口 。此类导入指定相对于导入源设备的源设备名称的路径:

/project/lib/math.sol
import "./util.sol" as util;    // source unit name: /project/lib/util.sol
import "../token.sol" as token; // source unit name: /project/token.sol
lib/math.sol
import "./util.sol" as util;    // source unit name: lib/util.sol
import "../token.sol" as token; // source unit name: token.sol

注解

相对进口 始终 开始于 ./../ 所以 import "util.sol" ,不同于 import "./util.sol" ,是直接进口的。虽然这两个路径在主机文件系统中将被认为是相对的, util.sol 在VFS中实际上是绝对的。

让我们定义一个 路径段 作为不包含分隔符并且由两个路径分隔符限定的路径的任何非空部分。分隔符是正向劈开或字符串的开头/结尾。例如,在 ./abc/..// 有三个路径段: .abc..

编译器通过以下方式从导入路径计算源设备名称:

  1. 首先计算前缀

    • 前缀使用导入源单位的源单位名称进行初始化。

    • 带前斜杠的最后一个路径段将从前缀中删除。

    • 然后,规范化导入路径的前导部分仅由 / and . 字符被考虑在内。对于每个 .. 在此部分中找到的段将从前缀中删除具有前面斜杠的最后一个路径段。

  2. 然后,将前缀添加到规范化导入路径的前面。如果前缀非空,则在其与导入路径之间插入单个劈开。

删除带有前面斜杠的最后一条路径段的工作原理如下:

  1. 超过最后一个劈开的所有内容都将被删除(即 a/b//c.sol 变成了 a/b// )。

  2. 删除所有尾部斜杠(即 a/b// 变成了 a/b )。

规范化规则与UNIX路径相同,即:

  • 所有内部 . 将删除线束段。

  • 每个内部 .. 分段回溯层次中的上一级。

  • 多个斜杠被挤压成一个斜杠。

请注意,标准化仅在导入路径上执行。用于前缀的导入模块的源单位名称仍未规范化。这确保了 protocol:// 零件不会变成 protocol:/ 导入文件是否使用URL标识。

如果您的导入路径已经标准化,您可以期待上面的算法产生非常直观的结果。以下是一些示例,说明如果不是这样,您可能会遇到什么情况:

lib/src/../contract.sol
import "./util/./util.sol";         // source unit name: lib/src/../util/util.sol
import "./util//util.sol";          // source unit name: lib/src/../util/util.sol
import "../util/../array/util.sol"; // source unit name: lib/src/array/util.sol
import "../.././../util.sol";       // source unit name: util.sol
import "../../.././../util.sol";    // source unit name: util.sol

注解

含铅的相对进口的使用 .. 不建议使用分段。通过使用直接导入,可以以更可靠的方式实现相同的效果 base path and include paths

基本路径和包含路径

基本路径和包含路径表示主机文件系统加载器将从中加载文件的目录。当将源单元名称传递给加载器时,它会将基本路径添加到该名称前面,并执行文件系统查找。如果查找不成功,则对包含路径列表上的所有目录执行相同的操作。

建议将基本路径设置为项目的根目录,并使用包含路径指定可能包含项目所依赖的库的其他位置。这允许您以统一的方式从这些库中导入,无论它们位于文件系统中相对于您的项目的什么位置。例如,如果您使用NPM安装软件包并导入合同 @openzeppelin/contracts/utils/Strings.sol ,您可以使用这些选项告诉编译器可以在NPM程序包目录之一中找到该库:

solc contract.sol \
    --base-path . \
    --include-path node_modules/ \
    --include-path /usr/local/lib/node_modules/

无论您是将库安装在本地还是全局包目录中,甚至直接安装在项目根目录下,您的约定都将编译(使用相同的元数据)。

默认情况下,基本路径为空,这会使源设备名称保持不变。当源单元名称是相对路径时,这会导致在从中调用编译器的目录中查找文件。它也是导致源设备名称中的绝对路径实际上被解释为磁盘上的绝对路径的唯一值。如果基本路径本身是相对路径,则将其解释为相对于编译器的当前工作目录。

注解

包含路径不能具有空值,并且必须与非空基路径一起使用。

注解

只要不使导入解析模棱两可,包含路径和基本路径就可以重叠。例如,您可以将基本路径内的目录指定为包含目录,或者将包含目录指定为另一个包含目录的子目录。仅当传递到主机文件系统加载器的源单元名称在与多个包含路径或包含路径和基路径组合时表示现有路径时,编译器才会发出错误。

CLI路径标准化和剥离

在命令行上,编译器的行为与您对任何其他程序的期望一样:它接受平台原生格式的路径,并且相对路径是相对于当前工作目录的。但是,分配给其路径在命令行上指定的文件的源单元名称不应仅仅因为项目正在不同的平台上编译或恰好从不同的目录调用编译器而更改。要实现这一点,必须将来自命令行的源文件路径转换为规范形式,并且如果可能,使其相对于基本路径或其中一个包含路径。

规范化规则如下:

  • 如果路径是相对路径,则通过在其前面附加当前工作目录来使其成为绝对路径。

  • 内部 . and .. 线段将折叠。

  • 特定于平台的路径分隔符将替换为正斜杠。

  • 多个连续路径分隔符的序列被压缩到单个分隔符中(除非它们是 UNC path )。

  • 如果路径包含根名称(例如,Windows上的驱动器号),并且根与当前工作目录的根相同,则根将替换为 /

  • 路径中的符号链接是 not 解决了。

    • 唯一的例外是当前工作目录的路径,在将其设置为绝对路径的过程中优先于相对路径。在某些平台上,报告工作目录时总是解析符号链接,因此为了保持一致性,编译器在任何地方都解析它们。

  • 即使文件系统不区分大小写,也会保留路径的原始大小写,但是 case-preserving 而磁盘上的实际情况则有所不同。

注解

在某些情况下,不能使路径与平台无关。例如,在Windows上,编译器可以通过将当前驱动器的根目录引用为 / 但是,对于通向其他驱动器的路径,驱动器号仍然是必需的。您可以通过确保所有文件都在同一驱动器上的单个目录树中可用来避免此类情况。

规范化后,编译器尝试使源文件路径成为相对路径。它首先尝试基本路径,然后按照给定的顺序尝试包含路径。如果基本路径为空或未指定,则将其视为等于当前工作目录的路径(已解析所有符号链接)。只有当规格化目录路径是规格化文件路径的确切前缀时,才接受结果。否则,文件路径保持绝对。这使得转换明确,并确保相对路径不以 ../ 。生成的文件路径成为源设备名称。

注解

剥离生成的相对路径在基本路径和包含路径中必须保持唯一。例如,如果两个命令都存在,则编译器将对以下命令发出错误 /project/contract.sol/lib/contract.sol 存在:

solc /project/contract.sol --base-path /project --include-path /lib

注解

在0.8.8版之前,不执行CLI路径剥离,唯一应用的标准化是路径分隔符的转换。在使用较旧版本的编译器时,建议从基本路径调用编译器,并且仅在命令行上使用相对路径。

允许的路径

作为一种安全措施,主机文件系统加载器将拒绝从默认情况下被认为安全的几个位置之外加载文件:

  • 在标准JSON模式之外:

    • 包含命令行上列出的输入文件的目录。

    • 用作 remapping 目标。如果目标不是目录(即不以 /, /./.. )改为使用包含目标的目录。

    • 基本路径和包含路径。

  • 在标准JSON模式下:

    • 基本路径和包含路径。

可以使用以下命令将其他目录列入白名单 --allow-paths 选项。该选项接受逗号分隔的路径列表:

cd /home/user/project/
solc token/contract.sol \
    lib/util.sol=libs/util.sol \
    --base-path=token/ \
    --include-path=/lib/ \
    --allow-paths=../utils/,/tmp/libraries

当使用上面显示的命令调用编译器时,主机文件系统加载器将允许从以下目录导入文件:

  • /home/user/project/token/ (因为 token/ 包含输入文件并且还因为它是基本路径),

  • /lib/ (因为 /lib/ 是包括路径之一),

  • /home/user/project/libs/ (因为 libs/ 是包含重新映射目标的目录),

  • /home/user/utils/ (因为 ../utils/ 已传递给 --allow-paths ),

  • /tmp/libraries/ (因为 /tmp/libraries 已传递给 --allow-paths ),

注解

编译器的工作目录仅在恰好是基本路径(或未指定基本路径或具有空值)时才是默认允许的路径之一。

注解

编译器不检查允许的路径是否实际存在,以及它们是否为目录。不存在或空的路径将被简单地忽略。如果允许的路径与文件匹配,而不是与目录匹配,则该文件也被视为白名单。

注解

允许的路径区分大小写,即使文件系统不区分大小写也是如此。箱子必须与您进口时使用的箱子完全匹配。例如 --allow-paths tokens 将不匹配 import "Tokens/IERC20.sol"

警告

从允许的目录只能通过符号链接访问的文件和目录不会自动列入白名单。例如,如果 token/contract.sol 在上面的示例中,实际上是指向 /etc/passwd 编译器将拒绝加载它,除非 /etc/ 也是允许的路径之一。

导入重新映射

导入重新映射允许您将导入重定向到虚拟文件系统中的不同位置。该机制通过更改导入路径和源设备名称之间的转换来工作。例如,您可以设置重新映射,以便虚拟目录中的任何导入 github.com/ethereum/dapp-bin/library/ 将被视为从 dapp-bin/library/ 取而代之的是。

您可以通过指定 上下文 。这允许创建仅适用于位于特定库或特定文件中的导入的重新映射。在没有上下文的情况下,会将重新映射应用于虚拟文件系统中所有文件中的每个匹配导入。

导入重新映射的形式为 context:prefix=target

  • context 必须与包含导入的文件的源单位名称的开头匹配。

  • prefix 必须与导入产生的源单位名称的开头匹配。

  • target 前缀替换为的值。

例如,如果您在本地将https://github.com/ethereum/dapp-bin/克隆到 /project/dapp-bin 并使用以下命令运行编译器:

solc github.com/ethereum/dapp-bin/=dapp-bin/ --base-path /project source.sol

您可以在源文件中使用以下内容:

import "github.com/ethereum/dapp-bin/library/math.sol"; // source unit name: dapp-bin/library/math.sol

编译器将在下面的VFS中查找该文件 dapp-bin/library/math.sol 。如果该文件在那里不可用,则源单元名称将被传递到主机文件系统加载器,然后该加载器将在 /project/dapp-bin/library/iterable_mapping.sol

警告

有关重新映射的信息存储在合同元数据中。由于编译器生成的二进制文件中嵌入了元数据的散列,因此对重新映射的任何修改都将导致不同的字节码。

因此,您应该小心,不要在重新映射目标时包含任何本地信息。例如,如果您的库位于 /home/user/packages/mymath/math.sol ,重新映射,如 @math/=/home/user/packages/mymath/ 将导致您的主目录包含在元数据中。为了能够在不同的计算机上通过这样的重新映射来重新生成相同的字节码,您需要在VFS中重新创建部分本地目录结构,并且(如果您依赖主机文件系统加载器)还需要在主机文件系统中重新创建本地目录结构的一部分。

为避免将本地目录结构嵌入到元数据中,建议将包含库的目录指定为 包括路径 取而代之的是。例如,在上面的示例中 --include-path /home/user/packages/ 将允许您使用以 mymath/ 。与重新映射不同,选项本身不会使 mymath 显示为 @math 但这可以通过创建符号链接或重命名包子目录来实现。

作为一个更复杂的示例,假设您依赖一个模块,该模块使用您签出的旧版本的dapp-bin /project/dapp-bin_old ,然后您可以运行:

solc module1:github.com/ethereum/dapp-bin/=dapp-bin/ \
     module2:github.com/ethereum/dapp-bin/=dapp-bin_old/ \
     --base-path /project \
     source.sol

这意味着,中的所有导入 module2 指向旧版本,但在 module1 指向新版本。

以下是管理重新映射行为的详细规则:

  1. 重新映射仅影响导入路径和源设备名称之间的转换。

    不能重新映射以任何其他方式添加到VFS的源设备名称。例如,您在命令行上指定的路径和 sources.urls 在标准JSON中不受影响。

    solc /project/=/contracts/ /project/contract.sol # source unit name: /project/contract.sol
    

    在上面的示例中,编译器将从 /project/contract.sol 并将其放在VFS中完全相同的源单元名称下,而不是放在 /contract/contract.sol

  2. 上下文和前缀必须与源设备名称匹配,而不是导入路径。

    • 这意味着您不能重新映射 ./../ 直接因为它们在转换为源设备名称的过程中被替换,但是您可以重新映射它们被替换为的名称部分:

      solc ./=a/ /project/=b/ /project/contract.sol # source unit name: /project/contract.sol
      
      /project/contract.sol
      import "./util.sol" as util; // source unit name: b/util.sol
      
    • 您不能重新映射基本路径或仅由导入回调在内部添加的路径的任何其他部分:

      solc /project/=/contracts/ /project/contract.sol --base-path /project # source unit name: contract.sol
      
      /project/contract.sol
      import "util.sol" as util; // source unit name: util.sol
      
  3. 目标直接插入到源设备名称中,并且不必是有效路径。

    • 只要导入回调可以处理它,它可以是任何东西。对于主机文件系统加载器,这还包括相对路径。在使用JavaScript接口时,如果您的回调可以处理URL和抽象标识符,您甚至可以使用它们。

    • 重新映射是在相对导入已解析为源设备名称之后进行的。这意味着目标以 ./../ 没有特殊意义,并且相对于基本路径,而不是相对于源文件的位置。

    • 重新映射目标未标准化,因此 @root/=./a/b// 将重新映射 @root/contract.sol./a/b//contract.sol 而不是 a/b/contract.sol

    • 如果目标不是以劈开结尾,编译器不会自动添加一个:

      solc /project/=/contracts /project/contract.sol # source unit name: /project/contract.sol
      
      /project/contract.sol
      import "/project/util.sol" as util; // source unit name: /contractsutil.sol
      
  4. 上下文和前缀是模式,匹配必须精确。

    • a//b=c 将不匹配 a/b

    • 源设备名称未规范化,因此 a/b=c 将不匹配 a//b 也不是。

    • 部分文件名和目录名也可以匹配。 /newProject/con:/new=old 将匹配 /newProject/contract.sol 并将其重新映射到 oldProject/contract.sol

  5. 最多对单个导入应用一个重新映射。

    • 如果多个重新映射与同一源设备名称匹配,则选择前缀最长的一个。

    • 如果前缀相同,则最后指定的前缀获胜。

    • 重新映射在其他重新映射上不起作用。例如 a=b b=c c=d 不会导致 a 被重新映射到 d

  6. 前缀不能为空,但上下文和目标是可选的。

    • 如果 target 是空字符串, prefix 只需从导入路径中删除。

    • 空的 context 表示重新映射适用于所有源单位中的所有导入。

在导入中使用URL

大多数URL前缀,如 https://data:// 在导入路径中没有特殊意义。唯一的例外是 file:// 它由主机文件系统加载器从源单元名称中剥离。

在本地编译时,可以使用导入重新映射将协议和域部分替换为本地路径:

solc :https://github.com/ethereum/dapp-bin=/usr/local/dapp-bin contract.sol

请注意前导 : 当重新映射上下文为空时,这是必需的。否则, https: 部分将由编译器解释为上下文。