Fix wildcard src input

This commit is contained in:
Kevin Stillhammer
2026-04-16 20:28:26 +02:00
parent a9cfed68e4
commit 7ea7640091
16 changed files with 3977 additions and 507 deletions
+30
View File
@@ -395,6 +395,34 @@ jobs:
fi
env:
CHECK_SHOULD_FAIL_OUTCOME: ${{ steps.check-should-fail.outcome }}
test-glob-src:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Use glob pattern in src
uses: ./
with:
src: __tests__/fixtures/glob-project/**/*.py
test-glob-single-star-src:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Use single-star glob pattern in src
uses: ./
with:
src: __tests__/fixtures/glob-single-star-project/foo/bar/*.py
test-multiple-src:
runs-on: ${{ matrix.os }}
strategy:
@@ -479,6 +507,8 @@ jobs:
- test-with-explicit-token
- test-args
- test-failure
- test-glob-src
- test-glob-single-star-src
- test-multiple-src
- test-parent-directory-pyproject
- test-custom-manifest-file
+21 -1
View File
@@ -33,7 +33,7 @@ anything `ruff` can (ex, fix).
| `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None |
| `manifest-file` | URL to a custom Ruff manifest in the `astral-sh/versions` format. | None |
| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` |
| `src` | The directory or single files to run `ruff` on. | [github.workspace] |
| `src` | Source path(s) to run `ruff` on. Supports glob patterns. | [github.workspace] |
| `checksum` | The sha256 checksum of the downloaded artifact. | None |
| `github-token` | The GitHub token to use when downloading Ruff release artifacts from GitHub. | `GITHUB_TOKEN` |
@@ -56,6 +56,8 @@ By default, Ruff version metadata is resolved from the
### Specify multiple files
Separate multiple `src` values with whitespace.
```yaml
- uses: astral-sh/ruff-action@v3
with:
@@ -64,6 +66,24 @@ By default, Ruff version metadata is resolved from the
path/to/file2.py
```
### Use glob patterns
Glob patterns (`*`, `?`, `[...]`, and `**`) are expanded by the action using
`@actions/glob`, so they work consistently across Linux, macOS, and Windows
runners. Hidden files and directories are skipped by default; target them
explicitly, for example with `.venv/**/*.py`, to include them.
```yaml
- uses: astral-sh/ruff-action@v3
with:
src: "src/**/*.py"
```
> [!NOTE]
> When using multiple patterns, only the first is used to search for
> `pyproject.toml` to determine the Ruff version. Use the `version` input
> for explicit control.
### Use to install ruff
This action adds ruff to the PATH, so you can use it in subsequent steps.
@@ -0,0 +1,13 @@
[project]
name = "pyython-project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"ruff==0.6.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -0,0 +1 @@
print("hello")
@@ -0,0 +1 @@
print("deeper")
@@ -0,0 +1 @@
print("check me")
@@ -0,0 +1,2 @@
# This file should be ignored when using foo/bar/*.py glob
print("ignore me")
@@ -0,0 +1,13 @@
[project]
name = "glob-single-star-project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"ruff==0.6.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+396
View File
@@ -0,0 +1,396 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it, jest } from "@jest/globals";
jest.unstable_mockModule("@actions/core", () => ({
debug: jest.fn(),
info: jest.fn(),
}));
const {
expandSourceInput,
getSourceBasePath,
getVersionSourceDirectory,
splitInput,
} = await import("../../src/utils/source-input");
const { findPyprojectToml } = await import("../../src/utils/pyproject-finder");
let tempDir: string | undefined;
afterEach(async () => {
if (tempDir !== undefined) {
await fs.rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
async function createTempProject(): Promise<string> {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ruff-action-source-"));
await fs.mkdir(path.join(tempDir, "src", "package", "nested"), {
recursive: true,
});
await fs.writeFile(path.join(tempDir, "src", "package", "__init__.py"), "");
await fs.writeFile(path.join(tempDir, "src", "package", "main.py"), "");
await fs.writeFile(
path.join(tempDir, "src", "package", "nested", "mod.py"),
"",
);
await fs.writeFile(path.join(tempDir, "src", "package", "notes.txt"), "");
await fs.mkdir(path.join(tempDir, "src", "pkg[legacy]"), {
recursive: true,
});
await fs.writeFile(path.join(tempDir, "src", "pkg[legacy]", "main.py"), "");
await fs.mkdir(path.join(tempDir, "src", "pkgl"), { recursive: true });
await fs.writeFile(path.join(tempDir, "src", "pkgl", "main.py"), "");
return tempDir;
}
describe("splitInput", () => {
it("splits whitespace-delimited inputs", () => {
expect(splitInput(" check --fix\n--diff ")).toEqual([
"check",
"--fix",
"--diff",
]);
});
it("returns an empty array for empty input", () => {
expect(splitInput(" \n\t ")).toEqual([]);
});
});
describe("expandSourceInput", () => {
it("leaves non-glob sources unchanged", async () => {
await expect(expandSourceInput("src file.py")).resolves.toEqual([
"src",
"file.py",
]);
});
it("expands recursive globs without relying on shell behavior", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "**", "*.py");
await expect(expandSourceInput(pattern)).resolves.toEqual([
path.join(projectDir, "src", "package", "__init__.py"),
path.join(projectDir, "src", "package", "main.py"),
path.join(projectDir, "src", "package", "nested", "mod.py"),
path.join(projectDir, "src", "pkg[legacy]", "main.py"),
path.join(projectDir, "src", "pkgl", "main.py"),
]);
});
it("preserves relative source patterns as relative paths", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
try {
process.chdir(projectDir);
await expect(expandSourceInput("src/**/*.py")).resolves.toEqual([
path.join("src", "package", "__init__.py"),
path.join("src", "package", "main.py"),
path.join("src", "package", "nested", "mod.py"),
path.join("src", "pkg[legacy]", "main.py"),
path.join("src", "pkgl", "main.py"),
]);
} finally {
process.chdir(originalCwd);
}
});
it("maps relative root glob matches to . instead of an empty argument", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
try {
process.chdir(projectDir);
for (const pattern of ["**", "**/", "./**"]) {
const sources = await expandSourceInput(pattern);
expect(sources).toContain(".");
expect(sources).not.toContain("");
}
} finally {
process.chdir(originalCwd);
}
});
it("excludes hidden paths by default while allowing explicit hidden patterns", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
await fs.mkdir(path.join(projectDir, ".venv"));
await fs.writeFile(path.join(projectDir, ".venv", "ignored.py"), "");
await fs.mkdir(path.join(projectDir, ".tox"));
await fs.writeFile(path.join(projectDir, ".tox", "ignored.py"), "");
try {
process.chdir(projectDir);
const defaultSources = await expandSourceInput("**/*.py");
expect(defaultSources).not.toContain(path.join(".venv", "ignored.py"));
expect(defaultSources).not.toContain(path.join(".tox", "ignored.py"));
await expect(expandSourceInput(".venv/**/*.py")).resolves.toEqual([
path.join(".venv", "ignored.py"),
]);
} finally {
process.chdir(originalCwd);
}
});
it("preserves unmatched globs for Ruff to report", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "**", "missing-*.py");
await expect(expandSourceInput(pattern)).resolves.toEqual([pattern]);
});
it("expands multiple sources in input order", async () => {
const projectDir = await createTempProject();
const first = path.join(projectDir, "src", "package", "main.py");
const second = path.join(projectDir, "src", "package", "nested", "*.py");
await expect(expandSourceInput(`${first} ${second}`)).resolves.toEqual([
first,
path.join(projectDir, "src", "package", "nested", "mod.py"),
]);
});
it("preserves existing literal paths that contain glob metacharacters", async () => {
const projectDir = await createTempProject();
const source = path.join(projectDir, "src", "pkg[legacy]");
await expect(expandSourceInput(source)).resolves.toEqual([source]);
});
it("escapes existing literal path segments before later glob segments", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "pkg[legacy]", "*.py");
await expect(expandSourceInput(pattern)).resolves.toEqual([
path.join(projectDir, "src", "pkg[legacy]", "main.py"),
]);
});
it("escapes existing literal path segments that contain * or ?", async () => {
if (process.platform === "win32") {
return;
}
const projectDir = await createTempProject();
await fs.mkdir(path.join(projectDir, "src", "pkg*star"));
await fs.writeFile(path.join(projectDir, "src", "pkg*star", "main.py"), "");
await fs.mkdir(path.join(projectDir, "src", "pkg-other-star"));
await fs.writeFile(
path.join(projectDir, "src", "pkg-other-star", "main.py"),
"",
);
await fs.mkdir(path.join(projectDir, "src", "pkg?question"));
await fs.writeFile(
path.join(projectDir, "src", "pkg?question", "main.py"),
"",
);
await fs.mkdir(path.join(projectDir, "src", "pkgxquestion"));
await fs.writeFile(
path.join(projectDir, "src", "pkgxquestion", "main.py"),
"",
);
await expect(
expandSourceInput(path.join(projectDir, "src", "pkg*star", "*.py")),
).resolves.toEqual([path.join(projectDir, "src", "pkg*star", "main.py")]);
await expect(
expandSourceInput(path.join(projectDir, "src", "pkg?question", "*.py")),
).resolves.toEqual([
path.join(projectDir, "src", "pkg?question", "main.py"),
]);
});
it("does not traverse symlinked directories", async () => {
const projectDir = await createTempProject();
const externalDir = path.join(projectDir, "external");
const symlinkPath = path.join(projectDir, "src", "linked");
await fs.mkdir(externalDir);
await fs.writeFile(path.join(externalDir, "external.py"), "");
await fs.symlink(externalDir, symlinkPath, "dir");
const sources = await expandSourceInput(
path.join(projectDir, "src", "**", "*.py"),
);
expect(sources).not.toContain(path.join(symlinkPath, "external.py"));
});
it("still expands missing paths that contain glob metacharacters", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "pkg[l]");
await expect(expandSourceInput(pattern)).resolves.toEqual([
path.join(projectDir, "src", "pkgl"),
]);
});
});
describe("getVersionSourceDirectory", () => {
it("does not scan src globs when version input is explicit", async () => {
await expect(
getVersionSourceDirectory("../**/*.py", "0.1.0", undefined),
).resolves.toBe(".");
});
it("does not scan src globs when version-file input is explicit", async () => {
await expect(
getVersionSourceDirectory("../**/*.py", undefined, "pyproject.toml"),
).resolves.toBe(".");
});
it("uses source discovery when no version input is explicit", async () => {
await expect(
getVersionSourceDirectory("../**/*.py", undefined, undefined),
).rejects.toThrow("Invalid pattern '../**/*.py'");
});
});
describe("getSourceBasePath", () => {
it("returns the directory for a plain path", async () => {
await expect(getSourceBasePath("my-project")).resolves.toBe("my-project");
});
it("returns the directory for a dotted path", async () => {
await expect(getSourceBasePath("./src")).resolves.toBe("./src");
});
it("strips glob pattern and returns parent directory", async () => {
await expect(getSourceBasePath("src/**/*.py")).resolves.toBe("src");
});
it("returns . for a top-level glob", async () => {
await expect(getSourceBasePath("*.py")).resolves.toBe(".");
});
it("returns . for empty string", async () => {
await expect(getSourceBasePath("")).resolves.toBe(".");
});
it("uses only the first whitespace-delimited token", async () => {
await expect(getSourceBasePath("sub1/*.py sub2/")).resolves.toBe("sub1");
});
it("handles trailing slash in prefix", async () => {
await expect(getSourceBasePath("sub/*.py")).resolves.toBe("sub");
});
it("uses the parent directory when a glob appears in a filename", async () => {
await expect(getSourceBasePath("src/file*.py")).resolves.toBe("src");
});
it("handles ? glob", async () => {
await expect(getSourceBasePath("file?.py")).resolves.toBe(".");
});
it("handles bracket glob", async () => {
await expect(getSourceBasePath("file[0-9].py")).resolves.toBe(".");
});
it("preserves existing literal base paths that contain glob metacharacters", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
try {
process.chdir(projectDir);
await expect(getSourceBasePath("src/pkg[legacy]")).resolves.toBe(
"src/pkg[legacy]",
);
await expect(getSourceBasePath("src/pkg[legacy]/*.py")).resolves.toBe(
path.join("src", "pkg[legacy]", "main.py"),
);
} finally {
process.chdir(originalCwd);
}
});
it("keeps version discovery rooted in existing literal paths with glob metacharacters", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
const pyprojectPath = path.join(
projectDir,
"src",
"pkg[legacy]",
"pyproject.toml",
);
await fs.writeFile(
pyprojectPath,
"[project]\ndependencies = ['ruff==0.6.2']\n",
);
try {
process.chdir(projectDir);
const sourceBasePath = await getSourceBasePath("src/pkg[legacy]/main.py");
expect(findPyprojectToml(sourceBasePath, projectDir)).toBe(
await fs.realpath(pyprojectPath),
);
} finally {
process.chdir(originalCwd);
}
});
it("uses the first expanded glob match for version discovery", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
const pyprojectPath = path.join(
projectDir,
"packages",
"foo",
"pyproject.toml",
);
await fs.mkdir(path.join(projectDir, "packages", "foo", "src"), {
recursive: true,
});
await fs.writeFile(
pyprojectPath,
"[project]\ndependencies = ['ruff==0.10.0']\n",
);
await fs.writeFile(
path.join(projectDir, "packages", "foo", "src", "module.py"),
"",
);
try {
process.chdir(projectDir);
const sourceBasePath = await getSourceBasePath("packages/*/src/**/*.py");
expect(sourceBasePath).toBe(
path.join("packages", "foo", "src", "module.py"),
);
const workspaceRoot = await fs.realpath(projectDir);
expect(findPyprojectToml(sourceBasePath, workspaceRoot)).toBe(
await fs.realpath(pyprojectPath),
);
} finally {
process.chdir(originalCwd);
}
});
it("uses the unmatched glob fallback when version-discovery glob has no matches", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
await fs.mkdir(path.join(projectDir, "packages"));
try {
process.chdir(projectDir);
await expect(getSourceBasePath("packages/*/src/**/*.py")).resolves.toBe(
"packages",
);
} finally {
process.chdir(originalCwd);
}
});
it("does not treat brace expansion as glob syntax", async () => {
await expect(getSourceBasePath("src/{a,b}/*.py")).resolves.toBe(
"src/{a,b}",
);
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ inputs:
required: false
default: "check"
src:
description: "Source to run Ruff. Defaults to the current directory."
description: "Source path(s) or glob pattern(s) to run Ruff on. Defaults to the current workspace."
required: false
default: ${{ github.workspace }}
version:
Generated Vendored
+3164 -492
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0",
"@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1",
@@ -51,6 +52,52 @@
"@actions/io": "^3.0.2"
}
},
"node_modules/@actions/glob": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.7.0.tgz",
"integrity": "sha512-+7s3wM+cXapDLmLL1NVWHawqcJOZzXZy2df/VhNn8DnZtS/x83iTCKaUn9F0llur4h3CII0AilvKKH4CMPL8Gw==",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.0",
"minimatch": "^10.2.5"
}
},
"node_modules/@actions/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@actions/glob/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@actions/glob/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@actions/http-client": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0",
"@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1",
+14 -9
View File
@@ -21,6 +21,11 @@ import {
getPlatform,
type Platform,
} from "./utils/platforms";
import {
expandSourceInput,
getVersionSourceDirectory,
splitInput,
} from "./utils/source-input";
import { resolveRuffVersion } from "./version/resolve";
async function run(): Promise<void> {
@@ -42,11 +47,7 @@ async function run(): Promise<void> {
core.setOutput("ruff-version", setupResult.version);
core.info(`Successfully installed ruff version ${setupResult.version}`);
await runRuff(
path.join(setupResult.ruffDir, "ruff"),
args.split(" "),
src.split(" "),
);
await runRuff(path.join(setupResult.ruffDir, "ruff"), args, src);
process.exit(0);
} catch (err) {
@@ -94,7 +95,11 @@ async function setupRuff(
async function determineVersion(): Promise<string> {
return await resolveRuffVersion({
manifestFile: manifestFile || undefined,
sourceDirectory: src,
sourceDirectory: await getVersionSourceDirectory(
src,
version,
versionFileInput,
),
version,
versionFile: versionFileInput,
workspaceRoot: process.env.GITHUB_WORKSPACE || ".",
@@ -125,10 +130,10 @@ function getActionRoot(): string {
async function runRuff(
ruffExecutablePath: string,
args: string[],
src: string[],
args: string,
src: string,
): Promise<void> {
const execArgs = [...args, ...src];
const execArgs = [...splitInput(args), ...(await expandSourceInput(src))];
await exec.exec(ruffExecutablePath, execArgs);
}
+20 -4
View File
@@ -3,19 +3,22 @@ import * as path from "node:path";
import * as core from "@actions/core";
/**
* Search for a pyproject.toml file starting from the given directory
* Search for a pyproject.toml file starting from the given source path
* and traversing upwards through parent directories until reaching
* the GitHub workspace root.
*
* @param startDir The directory to start the search from (e.g., the src input)
* If the source path points to a file, the search begins in that file's
* parent directory.
*
* @param startPath The source path to start the search from (file or directory)
* @param workspaceRoot The GitHub workspace directory (GITHUB_WORKSPACE)
* @returns The path to the found pyproject.toml, or undefined if not found
*/
export function findPyprojectToml(
startDir: string,
startPath: string,
workspaceRoot: string,
): string | undefined {
let currentDir = path.resolve(startDir);
let currentDir = resolveStartDirectory(startPath);
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
while (true) {
@@ -42,6 +45,19 @@ export function findPyprojectToml(
}
}
function resolveStartDirectory(startPath: string): string {
const resolvedStartPath = path.resolve(startPath);
if (!fs.existsSync(resolvedStartPath)) {
return resolvedStartPath;
}
const stats = fs.statSync(resolvedStartPath);
return stats.isDirectory()
? resolvedStartPath
: path.dirname(resolvedStartPath);
}
/**
* Check if a given path is within or equal to the workspace root.
*
+252
View File
@@ -0,0 +1,252 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as glob from "@actions/glob";
const GLOB_PATTERN = /[*?[]/;
/**
* Split an action input into whitespace-delimited tokens.
*
* The action has historically documented both `args` and `src` as plain
* whitespace-delimited inputs. Keep that behavior instead of invoking a shell.
*/
export function splitInput(input: string): string[] {
return input.trim().split(/\s+/).filter(Boolean);
}
/**
* Expand glob patterns from the `src` input in a cross-platform way.
*
* Shell expansion is not portable across GitHub runners (notably Windows), so
* we expand globs with @actions/glob and pass the result to Ruff as argv entries.
* Unmatched patterns are preserved, matching the default behavior of POSIX
* shells and allowing Ruff to report the missing path.
*/
export async function expandSourceInput(srcInput: string): Promise<string[]> {
const sources = splitInput(srcInput);
if (sources.length === 0) {
return [];
}
const expandedSources: string[] = [];
for (const source of sources) {
if (!hasGlobPattern(source) || (await pathExists(source))) {
expandedSources.push(source);
continue;
}
const matches = await globMatches(source);
expandedSources.push(...(matches.length > 0 ? matches : [source]));
}
return expandedSources;
}
/**
* Resolve the starting path for version discovery from the `src` input.
*
* Takes the first whitespace-delimited token. Literal paths are preserved. Glob
* patterns use their first match when available, so pyproject.toml discovery
* starts from the project that Ruff will actually check. Unmatched globs fall
* back to their literal prefix so Ruff can report the unmatched source later.
*/
export async function getVersionSourceDirectory(
srcInput: string,
versionInput: string | undefined,
versionFileInput: string | undefined,
): Promise<string> {
if (hasInput(versionInput) || hasInput(versionFileInput)) {
return ".";
}
return await getSourceBasePath(srcInput);
}
export async function getSourceBasePath(srcInput: string): Promise<string> {
const firstSource = splitInput(srcInput)[0] ?? ".";
if (!hasGlobPattern(firstSource) || (await pathExists(firstSource))) {
return stripTrailingSeparators(firstSource);
}
const matches = await globMatches(firstSource);
if (matches.length > 0) {
return matches[0] ?? ".";
}
return await getUnmatchedSourceBasePath(firstSource);
}
async function getUnmatchedSourceBasePath(source: string): Promise<string> {
const sourcePath = splitSourcePath(source);
const existingSegments = await getExistingLiteralSegments(sourcePath);
const nextSegment = sourcePath.segments[existingSegments.length];
if (existingSegments.length > 0 && hasGlobPattern(nextSegment ?? "")) {
return formatSourcePath(sourcePath.root, existingSegments);
}
const globIndex = source.search(GLOB_PATTERN);
const literalPrefix = source.substring(0, globIndex);
if (literalPrefix === "") {
return ".";
}
if (hasTrailingSeparator(literalPrefix)) {
return stripTrailingSeparators(literalPrefix);
}
return path.dirname(literalPrefix);
}
function hasInput(input: string | undefined): boolean {
return input !== undefined && input.trim() !== "";
}
function hasGlobPattern(source: string): boolean {
return GLOB_PATTERN.test(source);
}
function hasTrailingSeparator(source: string): boolean {
return /[/\\]$/.test(source);
}
function stripTrailingSeparators(source: string): string {
return source.replace(/[/\\]+$/, "") || ".";
}
async function pathExists(source: string): Promise<boolean> {
try {
await fs.stat(source);
return true;
} catch (err) {
if (isPathNotFoundError(err)) {
return false;
}
throw err;
}
}
function isPathNotFoundError(err: unknown): boolean {
const code = (err as { code?: unknown }).code;
return code === "ENOENT" || code === "ENOTDIR";
}
async function getGlobPattern(pattern: string): Promise<string> {
const sourcePath = splitSourcePath(pattern);
const existingSegments = await getExistingLiteralSegments(sourcePath);
if (existingSegments.length === 0) {
return normalizeGlobPattern(pattern);
}
const escapedSegments = sourcePath.segments.map((segment, index) =>
index < existingSegments.length ? globEscape(segment) : segment,
);
return normalizeGlobPattern(
formatSourcePath(sourcePath.root, escapedSegments),
);
}
interface SourcePath {
root: string;
segments: string[];
}
function splitSourcePath(source: string): SourcePath {
const root = path.parse(source).root;
const relativePath = source.slice(root.length);
const separatorPattern = process.platform === "win32" ? /[/\\]+/ : /\/+/;
return {
root,
segments: relativePath.split(separatorPattern).filter(Boolean),
};
}
async function getExistingLiteralSegments(
sourcePath: SourcePath,
): Promise<string[]> {
const existingSegments: string[] = [];
for (const segment of sourcePath.segments) {
const candidateSegments = [...existingSegments, segment];
if (
!(await pathExists(toFileSystemPath(sourcePath.root, candidateSegments)))
) {
break;
}
existingSegments.push(segment);
}
return existingSegments;
}
function toFileSystemPath(root: string, segments: string[]): string {
if (segments.length === 0) {
return root || ".";
}
return root ? path.join(root, ...segments) : path.join(...segments);
}
function formatSourcePath(root: string, segments: string[]): string {
if (segments.length === 0) {
return root || ".";
}
if (!root) {
return segments.join("/");
}
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
return `${normalizedRoot || "/"}/${segments.join("/")}`;
}
function globEscape(source: string): string {
return source
.replace(/(\[)(?=[^/]+\])/g, "[[]")
.replace(/\?/g, "[?]")
.replace(/\*/g, "[*]");
}
async function globMatches(pattern: string): Promise<string[]> {
const globber = await glob.create(await getGlobPattern(pattern), {
excludeHiddenFiles: !explicitlyTargetsHiddenPath(pattern),
followSymbolicLinks: false,
implicitDescendants: false,
});
const matches = await globber.glob();
return await preserveSourcePathStyle(pattern, matches.sort());
}
async function preserveSourcePathStyle(
pattern: string,
matches: string[],
): Promise<string[]> {
if (path.isAbsolute(pattern)) {
return matches;
}
const cwd = await fs.realpath(process.cwd());
return matches.map((match) => path.relative(cwd, match) || ".");
}
function explicitlyTargetsHiddenPath(pattern: string): boolean {
return splitSourcePath(pattern).segments.some(isHiddenPathSegment);
}
function isHiddenPathSegment(segment: string): boolean {
return segment.startsWith(".") && segment !== "." && segment !== "..";
}
function normalizeGlobPattern(pattern: string): string {
if (process.platform !== "win32") {
return pattern;
}
return pattern.replace(/\\/g, "/");
}