feat(php): mark source and test roots from composer.json autoload (#1602)

Folders in composer.json's autoload.psr-4 become Sources Roots and those in autoload-dev.psr-4 Test Sources Roots (string or array values; paths into vendor/ or outside the folder are skipped). It runs before the package.json case, so a Laravel app's tests/ is credited to composer.json, and editing composer.json reruns detection.
This commit is contained in:
l0ng-ai
2026-11-06 01:16:32 +08:00
committed by GitHub
parent 2e9a637158
commit 98268106b8
8 changed files with 90 additions and 8 deletions
+82
View File
@@ -32,6 +32,7 @@ const BUILD_FILES: &[&str] = &[
"Pipfile",
"requirements.txt",
"package.json",
"composer.json",
];
/// Folder names that are (or contain) conventional roots.
@@ -214,6 +215,15 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
}
}
// Before `package.json`: a Laravel app's `tests/` is the PHP test root.
if has("composer.json")
&& let Ok(text) = std::fs::read_to_string(dir.join("composer.json"))
{
for (sub, kind) in composer_roots(&text) {
add(&sub, kind, "composer.json");
}
}
if has("package.json") {
// A JVM build's `src` holds source sets, not JavaScript.
if !has("pom.xml") && !GRADLE_FILES.iter().any(|f| has(f)) {
@@ -230,6 +240,37 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
}
}
/// `autoload.psr-4` folders as Source roots and `autoload-dev.psr-4` ones as Test roots, relative to
/// the `composer.json` folder; paths that leave it or point into `vendor/` are left out.
fn composer_roots(text: &str) -> Vec<(String, RootKind)> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
let mut out = Vec::new();
for (section, kind) in [("autoload", RootKind::Source), ("autoload-dev", RootKind::Test)] {
let Some(map) = json.get(section).and_then(|a| a.get("psr-4")).and_then(|m| m.as_object()) else { continue };
for value in map.values() {
let paths: Vec<&str> = match value {
serde_json::Value::String(p) => vec![p.as_str()],
serde_json::Value::Array(a) => a.iter().filter_map(|p| p.as_str()).collect(),
_ => Vec::new(),
};
for path in paths {
let parts: Vec<&str> = path.split(['/', '\\']).filter(|c| !c.is_empty() && *c != ".").collect();
if path.starts_with(['/', '\\'])
|| parts.iter().any(|c| *c == ".." || c.contains(':'))
|| parts.first() == Some(&"vendor")
{
continue;
}
let sub = parts.join("/");
if !out.iter().any(|(p, _)| *p == sub) {
out.push((sub, kind));
}
}
}
}
out
}
/// Gradle's conventional `src/<set>/<lang>` folders that exist: `(set, lang)`.
fn source_sets(dir: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
@@ -307,6 +348,47 @@ mod tests {
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
}
#[test]
fn composer_psr4_folders_are_source_and_test_roots() {
let dir = tree(&[
"app/",
"database/factories/",
"database/seeders/",
"lib/",
"tests/",
"vendor/acme/src/",
"package.json",
]);
fs::write(
dir.path().join("composer.json"),
r#"{
"autoload": { "psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": ["database/seeders/", "lib", "missing/"],
"Vendored\\": "vendor/acme/src/",
"Outside\\": "../elsewhere/"
} },
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
}"#,
)
.unwrap();
let mut roots = summary(&detect(dir.path()));
roots.sort();
assert_eq!(
roots,
[
"app=Source@composer.json",
"database/factories=Source@composer.json",
"database/seeders=Source@composer.json",
"lib=Source@composer.json",
"tests=Test@composer.json",
]
);
assert!(affects_roots(Path::new("composer.json")));
assert!(composer_roots("not json").is_empty());
}
#[test]
fn maven_conventional_roots_and_target() {
let dir = tree(&[
@@ -2,7 +2,7 @@
* Settings › Project Structure (IDEA's module "Sources" tab): the content
* root's Sources / Tests / Resources / Test Resources / Excluded folders.
* Roots detected from build files (`pom.xml`, `build.gradle`, `Cargo.toml`,
* `go.mod`, `pyproject.toml`, `package.json`) apply until the user marks or
* `go.mod`, `pyproject.toml`, `package.json`, `composer.json`) apply until the user marks or
* unmarks the folder (`projectStructure.*`, project scope). Roots drive the
* project tree icons, package inference for new classes, the "Project
* Production / Test Files" scopes, and Excluded folders are skipped by the
@@ -330,7 +330,7 @@ export function ProjectStructurePage() {
<div className={styles.note}>
<T>
{
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)."
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)."
}
</T>
</div>
+1 -1
View File
@@ -3810,7 +3810,7 @@
"JDK Location": "Ubicación del JDK",
"Downloading JDK": "Descargando JDK",
"Use ; to separate name patterns, * for any number of symbols, ? for one.": "Use ; para separar patrones de nombres, * para cualquier cantidad de símbolos, ? para uno.",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "Las carpetas se detectan a partir de archivos de compilación (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marcar o desmarcar una carpeta anula la detección. Las carpetas excluidas se omiten en la vista Proyecto, Buscar en archivos y el índice (vuelva a abrir el proyecto para reindexar).",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "Las carpetas se detectan a partir de archivos de compilación (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marcar o desmarcar una carpeta anula la detección. Las carpetas excluidas se omiten en la vista Proyecto, Buscar en archivos y el índice (vuelva a abrir el proyecto para reindexar).",
"Project virtualenv (auto-detect)": "Virtualenv del proyecto (detección automática)",
"<No interpreter>": "<Sin intérprete>",
"Package name or requirement, e.g. requests or django==5.1": "Nombre del paquete o requisito, p. ej., requests o django==5.1",
+1 -1
View File
@@ -3676,7 +3676,7 @@
"JDK Location": "Emplacement du JDK",
"Downloading JDK": "Téléchargement du JDK",
"Use ; to separate name patterns, * for any number of symbols, ? for one.": "Utilisez ; pour séparer les motifs de noms, * pour un nombre quelconque de caractères, ? pour un seul.",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "Les dossiers sont détectés depuis les fichiers de build (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json) ; marquer ou démarquer un dossier remplace la détection. Les dossiers exclus sont ignorés par la vue Projet, Rechercher dans les fichiers et l'index (rouvrez le projet pour réindexer).",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "Les dossiers sont détectés depuis les fichiers de build (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json) ; marquer ou démarquer un dossier remplace la détection. Les dossiers exclus sont ignorés par la vue Projet, Rechercher dans les fichiers et l'index (rouvrez le projet pour réindexer).",
"Project virtualenv (auto-detect)": "Virtualenv du projet (détection automatique)",
"<No interpreter>": "<Aucun interpréteur>",
"Package name or requirement, e.g. requests or django==5.1": "Nom de paquet ou contrainte, p. ex. requests ou django==5.1",
+1 -1
View File
@@ -3789,7 +3789,7 @@
"JDK Location": "JDK 위치",
"Downloading JDK": "JDK 다운로드 중",
"Use ; to separate name patterns, * for any number of symbols, ? for one.": "이름 패턴은 ;로 구분하고, *는 임의 개수의 문자, ?는 한 문자와 일치합니다.",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "폴더는 빌드 파일(pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json)에서 감지되며, 폴더를 표시하거나 표시 해제하면 감지 결과가 재정의됩니다. 제외된 폴더는 프로젝트 뷰, 파일에서 찾기 및 인덱스에서 건너뜁니다(다시 인덱싱하려면 프로젝트를 다시 여세요).",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "폴더는 빌드 파일(pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json)에서 감지되며, 폴더를 표시하거나 표시 해제하면 감지 결과가 재정의됩니다. 제외된 폴더는 프로젝트 뷰, 파일에서 찾기 및 인덱스에서 건너뜁니다(다시 인덱싱하려면 프로젝트를 다시 여세요).",
"Project virtualenv (auto-detect)": "프로젝트 virtualenv(자동 감지)",
"<No interpreter>": "<인터프리터 없음>",
"Package name or requirement, e.g. requests or django==5.1": "패키지 이름 또는 요구 사항, 예: requests 또는 django==5.1",
+1 -1
View File
@@ -3779,7 +3779,7 @@
"JDK Location": "Lokalizacja JDK",
"Downloading JDK": "Pobieranie JDK",
"Use ; to separate name patterns, * for any number of symbols, ? for one.": "Użyj ; do rozdzielania wzorców nazw, * dla dowolnej liczby znaków, ? dla jednego znaku.",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "Foldery są wykrywane z plików budowania (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); ręczne zaznaczenie lub odznaczenie foldera zastępuje automatyczne wykrywanie. Wykluczone foldery są pomijane w widoku Projektu, wyszukiwaniu w plikach oraz przez indeks (otwórz projekt ponownie, aby reindeksować).",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "Foldery są wykrywane z plików budowania (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); ręczne zaznaczenie lub odznaczenie foldera zastępuje automatyczne wykrywanie. Wykluczone foldery są pomijane w widoku Projektu, wyszukiwaniu w plikach oraz przez indeks (otwórz projekt ponownie, aby reindeksować).",
"Project virtualenv (auto-detect)": "Środowisko wirtualne projektu (automatyczne wykrywanie)",
"<No interpreter>": "<No interpreter>",
"Download JDK…": "Pobierz JDK…",
+1 -1
View File
@@ -3782,7 +3782,7 @@
"JDK Location": "Local do JDK",
"Downloading JDK": "Baixando JDK",
"Use ; to separate name patterns, * for any number of symbols, ? for one.": "Use ; para separar padrões de nomes, * para qualquer quantidade de caracteres, ? para um.",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "As pastas são detectadas a partir de arquivos de build (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marcar ou desmarcar uma pasta substitui a detecção. Pastas excluídas são ignoradas pela visualização do Projeto, Localizar em arquivos e pelo índice (reabra o projeto para reindexar).",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "As pastas são detectadas a partir de arquivos de build (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marcar ou desmarcar uma pasta substitui a detecção. Pastas excluídas são ignoradas pela visualização do Projeto, Localizar em arquivos e pelo índice (reabra o projeto para reindexar).",
"Project virtualenv (auto-detect)": "Virtualenv do projeto (detecção automática)",
"<No interpreter>": "<Nenhum interpretador>",
"Package name or requirement, e.g. requests or django==5.1": "Nome do pacote ou requisito, p. ex. requests ou django==5.1",
+1 -1
View File
@@ -3873,7 +3873,7 @@
"JDK Location": "JDK 位置",
"Downloading JDK": "正在下载 JDK",
"Use ; to separate name patterns, * for any number of symbols, ? for one.": "使用 ; 分隔名称模式,* 匹配任意数量的字符,? 匹配单个字符。",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "文件夹根据构建文件(pom.xml、build.gradle、Cargo.toml、go.mod、pyproject.toml、package.json)检测;标记或取消标记文件夹会覆盖检测结果。项目视图、在文件中查找和索引会跳过已排除的文件夹(重新打开项目以重新索引)。",
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index).": "文件夹根据构建文件(pom.xml、build.gradle、Cargo.toml、go.mod、pyproject.toml、package.json、composer.json)检测;标记或取消标记文件夹会覆盖检测结果。项目视图、在文件中查找和索引会跳过已排除的文件夹(重新打开项目以重新索引)。",
"Project virtualenv (auto-detect)": "项目 virtualenv(自动检测)",
"<No interpreter>": "<无解释器>",
"Package name or requirement, e.g. requests or django==5.1": "软件包名称或需求,例如 requests 或 django==5.1",