Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,9 @@ ext-deps:

路径以 YAML 文件所在目录为基准。source 可以是文件或目录;条件 source 支持
`PHP_VERSION`、`PHP_VERSION_ID` 和 `PHP_OS_FAMILY`。命令行参数优先于 YAML
中的同名配置。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成
中的同名配置。扫描源码目录时会进入符号链接指向的目录,因此通过 Composer path
仓库安装的依赖(以符号链接方式安装)会像其他源码一样被编译;如需排除,请在
`ignore` 中按访问该目录所用的路径书写。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成
`ZEND_MOD_REQUIRED`,缺少所需 PHP 扩展时由 Zend 拒绝加载模块。
通用的 `objects` 列表会把已有 `.o`/`.obj` 文件直接加入链接步骤。TypePHP
不会重新编译这些文件;原生编译器、目标架构、编译参数和增量构建均由项目负责。
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,10 @@ ext-deps:

Paths are resolved relative to the YAML file. A source entry may be a file or
directory; conditional entries support `PHP_VERSION`, `PHP_VERSION_ID`, and
`PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Native linker
`PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Scanning a
source directory descends into symlinked directories, so a dependency installed
by a Composer path repository -- which is a symlink -- is compiled like any
other source; `ignore` excludes it, written as the path that reaches it. Native linker
dependencies belong in `link-libs`; `ext-deps` writes `ZEND_MOD_REQUIRED`
entries so Zend can reject loading when a required PHP extension is missing.
The generic `objects` list adds existing `.o`/`.obj` files directly to the
Expand Down
15 changes: 15 additions & 0 deletions docs/en/COMPILER_CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ Corresponding long options:

When a `project.yml` is passed, command-line arguments take precedence over same-named settings in the YAML. For the project file format, see the user documentation and the project configuration parser in the code.

### Symlinked source directories

Scanning a source directory descends into symlinked directories, so a dependency
installed by a Composer path repository -- which is installed as a symlink -- is
compiled like any other source. A link pointing at one of its own ancestors does
not recurse, and a file reached through more than one link is compiled once.

Excluding one is the ordinary `ignore` entry, written as the path that reaches
it rather than the path it points at:

```yaml
ignore:
- vendor/vendor/mylib
```

### Precompiled object files

A project can add object files produced by an external native toolchain as
Expand Down
14 changes: 14 additions & 0 deletions docs/zh-cn/COMPILER_CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ TypePHP 和 PHPX 的最低运行时版本均为 PHP 8.4。`--php-version` 与实

传入 `project.yml` 时,命令行参数优先于 YAML 中的同名配置。项目文件格式参见用户文档及代码中的项目配置解析器。

### 符号链接的源码目录

扫描源码目录时会进入符号链接指向的目录,因此通过 Composer path 仓库安装的依赖
(以符号链接方式安装)会像其他源码一样被编译。指向自身上级目录的链接不会无限
递归;通过多个链接都能访问到的文件只会被编译一次。

如需排除,使用常规的 `ignore` 配置,并按访问该目录所用的路径书写,而不是链接
指向的目标路径:

```yaml
ignore:
- vendor/vendor/mylib
```

### 预编译对象文件

项目可以把外部工具链生成的对象文件作为通用链接输入:
Expand Down
175 changes: 175 additions & 0 deletions phpunit/src/Build/FileScannerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php

namespace TypePhpTest\Build;

use PHPUnit\Framework\TestCase;
use TypePhp\Build\FileScanner;
use TypePhp\CompilerTest;

final class FileScannerTest extends TestCase
{
private string $root;

protected function setUp(): void
{
$this->root = realpath(sys_get_temp_dir()) . '/typephp-file-scanner-' . bin2hex(random_bytes(6));
mkdir($this->root . '/project/src', 0777, true);
mkdir($this->root . '/outside/src', 0777, true);
file_put_contents($this->root . '/project/src/Own.php', "<?php\nclass Own {}\n");
file_put_contents($this->root . '/outside/src/Linked.php', "<?php\nclass Linked {}\n");
}

protected function tearDown(): void
{
$this->removeDirectory($this->root);
}

/**
* A Composer path repository installs a package as a symlink, so the files
* behind one have to be scanned like any other source.
*/
public function testFollowsSymlinkedDirectory(): void
{
symlink($this->root . '/outside', $this->root . '/project/vendor-link');

$files = (new FileScanner($this->root . '/project'))->scan();

$this->assertSame([
$this->root . '/project/src/Own.php',
$this->root . '/project/vendor-link/src/Linked.php',
], $files);
}

public function testFollowsSymlinkedFile(): void
{
symlink($this->root . '/outside/src/Linked.php', $this->root . '/project/src/Linked.php');

$files = (new FileScanner($this->root . '/project'))->scan();

$this->assertSame([
$this->root . '/project/src/Linked.php',
$this->root . '/project/src/Own.php',
], $files);
}

/**
* A link pointing at one of its own ancestors must not recurse forever.
*/
public function testSymlinkCycleIsScannedOnce(): void
{
symlink($this->root . '/project', $this->root . '/project/src/loop');

$files = (new FileScanner($this->root . '/project'))->scan();

$this->assertSame([$this->root . '/project/src/Own.php'], $files);
}

/**
* Two links to one directory are one source file seen twice, and compiling
* it twice would define its symbols twice.
*/
public function testAliasesOfOneFileAreScannedOnce(): void
{
symlink($this->root . '/outside', $this->root . '/project/link-a');
symlink($this->root . '/outside', $this->root . '/project/link-b');

$files = (new FileScanner($this->root . '/project'))->scan();

$this->assertSame([
$this->root . '/project/link-a/src/Linked.php',
$this->root . '/project/src/Own.php',
], $files);
}

/**
* Excluding one alias says nothing about the other. Deduplicating before
* exclusions would drop the allowed path along with the excluded one.
*/
public function testExcludingOneAliasKeepsTheOther(): void
{
symlink($this->root . '/outside', $this->root . '/project/link-a');
symlink($this->root . '/outside', $this->root . '/project/link-b');

foreach (['link-a' => 'link-b', 'link-b' => 'link-a'] as $excluded => $kept) {
$files = (new FileScanner($this->root . '/project'))
->addExcludePattern($this->root . '/project/' . $excluded . '/src/*')
->scan();

$this->assertSame([
$this->root . '/project/' . $kept . '/src/Linked.php',
$this->root . '/project/src/Own.php',
], $files, "excluding {$excluded} must not hide {$kept}");
}
}

/**
* `ignore` names the path that reaches a directory, not the path it points
* at, so the scanned path is what an entry has to be compared against.
*/
public function testYamlIgnoreExcludesLinkedDirectory(): void
{
symlink($this->root . '/outside', $this->root . '/project/vendor-link');
file_put_contents(
$this->root . '/project/project.yml',
"name: demo\nsources:\n - .\nignore:\n - vendor-link\n",
);

$this->assertSame(
[$this->root . '/project/src/Own.php'],
$this->scanProject($this->root . '/project/project.yml'),
);
}

/**
* An entry that names the link target leaves the linked path compiled: it
* describes a directory the scan never reached.
*/
public function testYamlIgnoreOfTheLinkTargetLeavesTheLinkedPath(): void
{
symlink($this->root . '/outside', $this->root . '/project/vendor-link');
file_put_contents(
$this->root . '/project/project.yml',
"name: demo\nsources:\n - .\nignore:\n - ../outside\n",
);

$this->assertSame([
$this->root . '/project/src/Own.php',
$this->root . '/project/vendor-link/src/Linked.php',
], $this->scanProject($this->root . '/project/project.yml'));
}

/** @return list<string> */
private function scanProject(string $projectFile): array
{
$compiler = CompilerTest::create(dirname($projectFile));
$reflection = new \ReflectionClass($compiler);

$parse = $reflection->getMethod('parseProjectYaml');
$filter = $reflection->getMethod('filterIgnoredFiles');

return array_values($filter->invoke($compiler, $parse->invoke($compiler, $projectFile)));
}

private function removeDirectory(string $directory): void
{
if (!is_dir($directory)) {
return;
}

foreach (scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}

$path = $directory . '/' . $entry;
if (is_link($path) || !is_dir($path)) {
unlink($path);
continue;
}

$this->removeDirectory($path);
}

rmdir($directory);
}
}
93 changes: 89 additions & 4 deletions src/Build/FileScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@ public function getDirectory(): string
public function scan(): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS)
);
$iterator = new \RecursiveIteratorIterator($this->createDirectoryIterator());

foreach ($iterator as $file) {
if ($file->isFile()) {
Expand All @@ -106,7 +104,94 @@ public function scan(): array
// keep both generated code and cache classification deterministic.
sort($files, SORT_STRING);

return $files;
return $this->deduplicateByRealPath($files);
}

/**
* Descend into symlinked directories.
*
* RecursiveDirectoryIterator does not follow them unless asked to:
* RecursiveIteratorIterator calls hasChildren(), whose $allowLinks argument
* defaults to false. Composer path repositories install a package as a
* symlink, so without FOLLOW_SYMLINKS a source directory that lives behind
* one contributes no files at all.
*/
private function createDirectoryIterator(): \RecursiveIterator
{
$iterator = new \RecursiveDirectoryIterator(
$this->directory,
\FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS,
);

return new \RecursiveCallbackFilterIterator(
$iterator,
fn(\SplFileInfo $entry): bool => !$this->closesLoop($entry),
);
}

/**
* Whether descending into an entry would repeat the branch that reached it,
* which is what a link pointing at one of its own ancestors does. Only a
* link can: every other directory resolves below the one holding it.
*
* The branch is the whole test, never a set of everything visited so far.
* Two links to one directory are two ordinary source paths, and either of
* them may be the one a project excludes, so which of the two is the same
* file is decided after exclusions instead, by real path.
*/
private function closesLoop(\SplFileInfo $entry): bool
{
if (!$entry->isLink() || !$entry->isDir()) {
return false;
}

$target = $entry->getRealPath();
if ($target === false) {
return false;
}

$ancestor = dirname($entry->getPathname());
while (true) {
if (realpath($ancestor) === $target) {
return true;
}
if ($ancestor === $this->directory) {
return false;
}
$parent = dirname($ancestor);
if ($parent === $ancestor) {
return false;
}
$ancestor = $parent;
}
}

/**
* One file reachable through several links is still one source file, and
* compiling it twice would define its symbols twice.
*
* Exclusions have already run, so a path the project excluded can never be
* the alias that survives here. The list is sorted, so which alias survives
* does not depend on directory-entry order.
*
* @param list<string> $files
* @return list<string>
*/
private function deduplicateByRealPath(array $files): array
{
$unique = [];
$seen = [];

foreach ($files as $file) {
$identity = realpath($file) ?: $file;
if (isset($seen[$identity])) {
continue;
}
$seen[$identity] = true;
$unique[] = $file;
}

return $unique;
}

private function isExcluded(string $filePath): bool
Expand Down
Loading