From e4809d36a3dbe22cb2f506cc58f896e68d503b0c Mon Sep 17 00:00:00 2001 From: maniramezan Date: Sat, 11 Jul 2026 15:59:30 -0400 Subject: [PATCH 1/2] Add Find command family --- .claude/skills/swiftyshell.md | 30 +- .github/workflows/build.yml | 2 +- .github/workflows/reusable-ci.yml | 2 +- AGENTS.md | 4 +- Package.swift | 5 +- README.md | 3 +- Sources/SwiftyShell/Common/Find.swift | 285 ++++++++++++++++++ .../Articles/SelectingCommandFamilies.md | 5 +- Sources/SwiftyShell/SwiftyShell.docc/Find.md | 67 ++++ .../SwiftyShell.docc/SwiftyShell.md | 5 +- Tests/SwiftyShellTests/Common/FindTests.swift | 81 +++++ 11 files changed, 478 insertions(+), 11 deletions(-) create mode 100644 Sources/SwiftyShell/Common/Find.swift create mode 100644 Sources/SwiftyShell/SwiftyShell.docc/Find.md create mode 100644 Tests/SwiftyShellTests/Common/FindTests.swift diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index d1d9cfb..9e924d3 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -19,7 +19,7 @@ Before writing any code, follow this decision tree: → Use `Git` 2. Is this a git operation NOT covered by the typed `Git` API? → Use `Command("\1", arguments: ...)` -3. Is this a file-system operation covered by a typed wrapper (`Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Rsync`)? +3. Is this a file-system operation covered by a typed wrapper (`Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Rsync`, `Find`)? → Use the typed wrapper 4. Is this an archive operation (`tar`, `zip`, or `unzip`)? → Use `Tar`, `Zip`, or `Unzip` @@ -660,6 +660,32 @@ public struct Pwd: RunnableCommandFamily { public func run() async throws -> ShellOutput } +public struct Find: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func root(_ path: String) -> Self + public func roots(_ paths: [String]) -> Self + public func expression(_ value: FindExpression) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public indirect enum FindExpression: Sendable, Equatable, Hashable { + case name(String) + case path(String) + case type(FindFileType) + case minimumDepth(UInt) + case maximumDepth(UInt) + case not(FindExpression) + case and(FindExpression, FindExpression) + case or(FindExpression, FindExpression) + case print + case print0 +} + +public enum FindFileType: String, Sendable, Equatable, Hashable { + case blockDevice, characterDevice, directory, regularFile, symbolicLink, namedPipe, socket +} + public struct Rsync: RunnableCommandFamily { public init(context: ShellContext = .init()) public func archive(_ enabled: Bool = true) -> Self // -a @@ -1930,7 +1956,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo Declared in `Package.swift`: -- **Per-family** — `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Make`, `Node`, `Npm`, `Yarn`, `Pnpm`, `Bun`, `Terraform`, `Kubectl`, `Python`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. +- **Per-family** — `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Make`, `Node`, `Npm`, `Yarn`, `Pnpm`, `Bun`, `Terraform`, `Kubectl`, `Python`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`, `Find`. One trait per family directory; for `Common/`, one trait per file. - **Umbrellas** — `CommonUtilities` (every `Common/*` family), `All` (every command family). Consumers select families with `traits:` on `.package(...)`: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fda2bac..99e806b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Compute affected traits id: compute run: | - FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' + FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "Rsync", "Tar", "Zip", "Unzip", "Find", "CommonUtilities", "All"]' CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD") echo "Changed files:" diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 2b776b8..c19ce29 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -25,7 +25,7 @@ on: Defaults to the full matrix when omitted. required: false type: string - default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' + default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "Rsync", "Tar", "Zip", "Unzip", "Find", "CommonUtilities", "All"]' jobs: validate-traits: diff --git a/AGENTS.md b/AGENTS.md index 0ed79a9..78331bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Typed wrapper for the Python interpreter CLI: `Python`. ### `Sources/SwiftyShell/Common/` -Typed wrappers for frequently used shell utilities: `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `JqArgument`, `Rsync`, `Tar`, `TarOperation`, `TarCompression`, `Zip`, `ZipCompressionLevel`, `Unzip`, and `UnzipEntry`. Each follows the same fluent builder conventions as all other command families. +Typed wrappers for frequently used shell utilities: `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `JqArgument`, `Rsync`, `Tar`, `TarOperation`, `TarCompression`, `Zip`, `ZipCompressionLevel`, `Unzip`, `UnzipEntry`, `Find`, `FindExpression`, and `FindFileType`. Each follows the same fluent builder conventions as all other command families. ### `Sources/SwiftyShell/Internal/Execution/` @@ -209,7 +209,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo **Trait inventory (declared in `Package.swift`):** -- Per-family: `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Make`, `Node`, `Npm`, `Yarn`, `Pnpm`, `Bun`, `Terraform`, `Kubectl`, `Python`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). +- Per-family: `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Make`, `Node`, `Npm`, `Yarn`, `Pnpm`, `Bun`, `Terraform`, `Kubectl`, `Python`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`, `Find` (one trait per family directory; for `Common/`, one trait per file). - Umbrellas: `CommonUtilities` (all `Common/*`), `All` (every family). **The wiring contract** — enforced by `Scripts/validate-traits.swift` and CI: diff --git a/Package.swift b/Package.swift index a002b75..808882c 100644 --- a/Package.swift +++ b/Package.swift @@ -45,11 +45,14 @@ let package = Package( .trait(name: "Tar", description: "Typed wrapper for tar archives."), .trait(name: "Zip", description: "Typed wrapper for zip (Info-ZIP)."), .trait(name: "Unzip", description: "Typed wrapper for unzip (Info-ZIP)."), + .trait(name: "Find", description: "Typed portable wrapper for find."), // Convenience umbrella that enables every Common/* utility family. .trait( name: "CommonUtilities", description: "Enables all common file/directory utility families.", - enabledTraits: ["Ls", "Cp", "Mkdir", "Chmod", "Rm", "Mv", "Pwd", "Jq", "Rsync", "Tar", "Zip", "Unzip"] + enabledTraits: [ + "Ls", "Cp", "Mkdir", "Chmod", "Rm", "Mv", "Pwd", "Jq", "Rsync", "Tar", "Zip", "Unzip", "Find", + ] ), // Convenience umbrella that enables every command family. .trait( diff --git a/README.md b/README.md index cd77dff..94abc78 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ targets: [ Two umbrella traits cover common cases: -- `CommonUtilities` — enables every `Common/*` family (`Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`). +- `CommonUtilities` — enables every `Common/*` family, including `Find`, archives, and file utilities. - `All` — enables every command family SwiftyShell ships. ```swift @@ -119,6 +119,7 @@ SwiftyShell ships typed wrappers for common tools. Each family is gated behind a | `Tar` | `tar` | `Tar` | Portable tar archive creation, extraction, listing, compression | | `Zip` | `zip` | `Zip` | Info-ZIP archive creation, compression, recursion, exclusions | | `Unzip` | `unzip` | `Unzip` | Info-ZIP archive extraction and structured entry listing | +| `Find` | `find` | `Find` | Portable typed predicates, boolean expressions, and safe path output | When the tool you need isn't listed, `Command("tool", arguments: "arg").run(in: context)` is the fluent escape hatch. If you use the same tool repeatedly, promoting it to a typed family is straightforward — see below. diff --git a/Sources/SwiftyShell/Common/Find.swift b/Sources/SwiftyShell/Common/Find.swift new file mode 100644 index 0000000..e293c7f --- /dev/null +++ b/Sources/SwiftyShell/Common/Find.swift @@ -0,0 +1,285 @@ +#if Find +import Foundation + +/// A portable file type accepted by ``FindExpression/type(_:)``. +public enum FindFileType: String, Sendable, Equatable, Hashable { + /// A block special device. + case blockDevice = "b" + + /// A character special device. + case characterDevice = "c" + + /// A directory. + case directory = "d" + + /// A regular file. + case regularFile = "f" + + /// A symbolic link when links are not being followed. + case symbolicLink = "l" + + /// A named pipe (FIFO). + case namedPipe = "p" + + /// A Unix-domain socket. + case socket = "s" +} + +/// A typed expression evaluated by ``Find`` for each visited path. +/// +/// The model intentionally contains only primaries and operators shared by the current macOS/BSD +/// and GNU implementations. Values become separate argv elements, so patterns and paths are never +/// interpreted by a shell. +/// +/// ```swift +/// let expression = FindExpression.name("*.swift") +/// .and(.type(.regularFile)) +/// .and(.print) +/// +/// let output = try await Find(context: context) +/// .root("Sources") +/// .expression(expression) +/// .run() +/// ``` +public indirect enum FindExpression: Sendable, Equatable, Hashable { + /// Matches the final path component against a shell pattern using `-name`. + case name(String) + + /// Matches the complete traversed path against a shell pattern using `-path`. + case path(String) + + /// Matches entries of the specified file type using `-type`. + case type(FindFileType) + + /// Applies tests and actions only at or below the specified traversal depth. + /// + /// Although spelled as a primary, `-mindepth` affects the complete traversal expression on + /// both supported implementations. + case minimumDepth(UInt) + + /// Prevents descent below the specified traversal depth. + /// + /// Although spelled as a primary, `-maxdepth` affects the complete traversal expression on + /// both supported implementations. + case maximumDepth(UInt) + + /// Negates an expression using the portable `!` operator. + case not(FindExpression) + + /// Combines two expressions with short-circuiting logical AND. + case and(FindExpression, FindExpression) + + /// Combines two expressions with short-circuiting logical OR. + case or(FindExpression, FindExpression) + + /// Prints each matching path followed by a newline. + case print + + /// Prints each matching path followed by an ASCII NUL byte. + case print0 + + /// Returns a parenthesized conjunction of this expression and another expression. + /// + /// - Parameter other: The expression evaluated only when this expression is true. + /// - Returns: A new logical AND expression. + public func and(_ other: Self) -> Self { + .and(self, other) + } + + /// Returns a parenthesized disjunction of this expression and another expression. + /// + /// - Parameter other: The expression evaluated only when this expression is false. + /// - Returns: A new logical OR expression. + public func or(_ other: Self) -> Self { + .or(self, other) + } + + /// Returns this expression wrapped in logical negation. + public func negated() -> Self { + .not(self) + } + + fileprivate var arguments: [String] { + switch self { + case .name(let pattern): + return ["-name", pattern] + case .path(let pattern): + return ["-path", pattern] + case .type(let type): + return ["-type", type.rawValue] + case .minimumDepth(let depth): + return ["-mindepth", String(depth)] + case .maximumDepth(let depth): + return ["-maxdepth", String(depth)] + case .not(let expression): + return ["!", "("] + expression.arguments + [")"] + case .and(let lhs, let rhs): + return ["("] + lhs.arguments + ["-and"] + rhs.arguments + [")"] + case .or(let lhs, let rhs): + return ["("] + lhs.arguments + ["-or"] + rhs.arguments + [")"] + case .print: + return ["-print"] + case .print0: + return ["-print0"] + } + } +} + +/// A portable, typed wrapper for the `find` command. +/// +/// ``Find`` targets the common behavior of the current macOS/BSD and GNU implementations. It +/// supports roots, name/path/type/depth tests, boolean composition, and newline- or NUL-delimited +/// output. It deliberately omits shell execution actions such as `-exec`. +/// +/// ```swift +/// let output = try await Find(context: context) +/// .root("Sources") +/// .expression(.name("*.swift").and(.type(.regularFile)).and(.print0)) +/// .run() +/// ``` +public struct Find: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a `find` command family bound to a shell context. + /// + /// With no configured roots, ``command()`` emits `.` explicitly for consistent BSD/GNU + /// behavior. With no expression, `find` uses its standard implicit newline printing action. + /// + /// - Parameter context: The shell context used to execute the command. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { + self.state = state + } + + /// Returns a copy with updated shared tool configuration. + /// + /// - Parameter update: A pure function that returns the next ``ToolConfiguration``. + /// - Returns: A new ``Find`` value with the updated configuration. + public func updatingConfiguration( + _ update: (ToolConfiguration) -> ToolConfiguration + ) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + /// + /// - Parameter destination: Where the executor should send matching paths. + /// - Returns: A new ``Find`` value with the stdout destination applied. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + /// + /// - Parameter destination: Where the executor should send diagnostics. + /// - Returns: A new ``Find`` value with the stderr destination applied. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy with one additional traversal root. + /// + /// Relative roots beginning with `-`, `!`, or `(` are emitted with a `./` prefix. BSD offers + /// `-f` for such paths while GNU does not; qualification is the portable way to keep the root + /// from being parsed as an expression. Absolute and already-qualified paths are unchanged. + /// + /// - Parameter path: The file or directory at which traversal starts. + /// - Returns: A new ``Find`` value with the root appended. + public func root(_ path: String) -> Self { + copy(roots: state.roots + [Self.portableRoot(path)]) + } + + /// Returns a copy with multiple traversal roots appended in order. + /// + /// Each root receives the same portable qualification described by ``root(_:)``. + /// + /// - Parameter paths: The traversal roots to append. + /// - Returns: A new ``Find`` value with the roots appended. + public func roots(_ paths: [String]) -> Self { + copy(roots: state.roots + paths.map(Self.portableRoot)) + } + + /// Returns a copy with the expression evaluated for every visited path. + /// + /// Calling this method again replaces the previous expression. + /// + /// - Parameter value: The typed tests, operators, and output actions to evaluate. + /// - Returns: A new ``Find`` value with the expression applied. + public func expression(_ value: FindExpression) -> Self { + copy(expression: value) + } + + /// Builds the raw `find` command represented by the current builder state. + /// + /// Every root, operator, primary, pattern, and action is emitted as a separate argv element. + /// No shell parses or expands any supplied value. + /// + /// - Returns: A ``Command`` ready for execution or pipeline composition. + public func command() -> Command { + var arguments = state.roots.isEmpty ? ["."] : state.roots + if let expression = state.expression { + arguments.append(contentsOf: expression.arguments) + } + + let base = Command("find") + .args(arguments) + .stdout(state.stdoutDestination) + .stderr(state.stderrDestination) + + return state.config.apply(to: base) + } + + private static func portableRoot(_ path: String) -> String { + guard let first = path.first, first == "-" || first == "!" || first == "(" else { + return path + } + return "./\(path)" + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + roots: [String]? = nil, + expression: FindExpression? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + roots: roots ?? state.roots, + expression: expression ?? state.expression + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let roots: [String] + let expression: FindExpression? + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + roots: [String] = [], + expression: FindExpression? = nil + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.roots = roots + self.expression = expression + } +} +#endif diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md index e36dab0..aa6faed 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md @@ -9,7 +9,7 @@ pipelines, contexts, errors, executors) and a set of **opt-in** typed command families: ``Git``, ``Brew``, ``Grep``, ``Fzf``, ``Rg``, ``Swift``, ``Gh``, ``Docker``, ``Make``, ``Node``, ``Npm``, ``Yarn``, ``Pnpm``, ``Bun``, ``Terraform``, ``Kubectl``, ``Python``, and a collection of common file/directory utilities (``Ls``, ``Cp``, ``Mv``, ``Mkdir``, ``Chmod``, ``Rm``, ``Pwd``, -``Jq``, ``Rsync``, ``Tar``, ``Zip``, ``Unzip``). +``Jq``, ``Rsync``, ``Tar``, ``Zip``, ``Unzip``, ``Find``). Each family is gated behind a SwiftPM **package trait**. By default no families are enabled, so a fresh `import SwiftyShell` exposes only `Core`. @@ -73,7 +73,8 @@ let status = try await Git() | `Tar` | ``Tar`` portable archive creation, extraction, and listing | | `Zip` | ``Zip`` Info-ZIP archive creation wrapper | | `Unzip` | ``Unzip`` Info-ZIP archive extraction and listing wrapper | -| `CommonUtilities` | All of `Ls`, `Cp`, `Mv`, `Mkdir`, `Chmod`, `Rm`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip` | +| `Find` | ``Find`` portable file traversal and matching wrapper | +| `CommonUtilities` | All common file, directory, archive, data, and search utilities | | `All` | Every per-family trait above (the kitchen-sink umbrella) | ## Common recipes diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Find.md b/Sources/SwiftyShell/SwiftyShell.docc/Find.md new file mode 100644 index 0000000..86e85ba --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Find.md @@ -0,0 +1,67 @@ +# ``Find`` + +Traverse file trees with typed, portable predicates and actions. + +``Find`` models the common subset of the current macOS/BSD and GNU `find` +implementations. Every root, pattern, operator, and action is passed as a separate +argv element, so spaces and shell metacharacters remain literal. The API does not +expose `-exec`, `-execdir`, or raw expression strings. + +```swift +let swiftFiles = try await Find(context: context) + .root("Sources") + .expression( + FindExpression.name("*.swift") + .and(.type(.regularFile)) + .and(.minimumDepth(1)) + .and(.maximumDepth(4)) + .and(.print) + ) + .run() +``` + +Compose alternatives and negation as typed expression nodes. Parentheses and +explicit `-and` or `-or` tokens are generated automatically, preserving the +expression tree without relying on platform-specific precedence shortcuts. + +```swift +let sourceOrManifest = FindExpression.path("*/Sources/*.swift") + .or(.name("Package.swift")) + .and(.path("*/Generated/*").negated()) + .and(.print0) + +let output = try await Find(context: context) + .roots(["Package.swift", "Sources"]) + .expression(sourceOrManifest) + .run() +``` + +Use ``FindExpression/print0`` when names may contain whitespace or newlines. +Relative roots beginning with `-`, `!`, or `(` are automatically prefixed with +`./`. BSD's `-f` root option is not available in GNU `find`; qualification is the +portable approach documented by GNU and accepted by macOS/BSD. + +## Topics + +### Building Searches + +- ``init(context:)`` +- ``root(_:)`` +- ``roots(_:)`` +- ``expression(_:)`` +- ``command()`` + +### Expressions + +- ``FindExpression`` +- ``FindFileType`` +- ``FindExpression/name(_:)`` +- ``FindExpression/path(_:)`` +- ``FindExpression/type(_:)`` +- ``FindExpression/minimumDepth(_:)`` +- ``FindExpression/maximumDepth(_:)`` +- ``FindExpression/and(_:)`` +- ``FindExpression/or(_:)`` +- ``FindExpression/negated()`` +- ``FindExpression/print`` +- ``FindExpression/print0`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md index 449b80e..0ab8a99 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md @@ -8,7 +8,7 @@ SwiftyShell's primary API is a family of typed wrappers — ``Git``, ``Grep``, ` ``Brew``, ``Fzf``, ``Swift``, ``Gh``, ``Docker``, ``Make``, ``Node``, ``Npm``, ``Yarn``, ``Pnpm``, ``Bun``, ``Terraform``, ``Kubectl``, ``Python``, ``Ls``, ``Cp``, ``Mkdir``, ``Chmod``, ``Rm``, ``Mv``, ``Pwd``, ``Jq``, ``Rsync``, ``Tar``, ``Zip``, -``Unzip`` — that model shell tools as Swift values. The compiler enforces which flags exist, +``Unzip``, ``Find`` — that model shell tools as Swift values. The compiler enforces which flags exist, which arguments are required, and what the result looks like. ``Command`` is the fluent escape hatch for tools that don't have a typed wrapper yet; it shares the same builder style so code does not change shape when you fall back @@ -193,6 +193,9 @@ let output = try await Command("echo", arguments: "hello").run(in: context) - ``Rm`` - ``Mv`` - ``Pwd`` +- ``Find`` +- ``FindExpression`` +- ``FindFileType`` ### Archives diff --git a/Tests/SwiftyShellTests/Common/FindTests.swift b/Tests/SwiftyShellTests/Common/FindTests.swift new file mode 100644 index 0000000..5a6a1bc --- /dev/null +++ b/Tests/SwiftyShellTests/Common/FindTests.swift @@ -0,0 +1,81 @@ +#if Find +import Foundation +import Testing +@testable import SwiftyShell + +struct FindCommandTests { + @Test func buildsExactArgvForComposedExpression() { + let expression = FindExpression.name("*.swift") + .and(.type(.regularFile).or(.path("*/Generated/*").negated())) + .and(.minimumDepth(1)) + .and(.maximumDepth(4)) + .and(.print0) + + let command = Find() + .roots(["Sources", "/tmp/special path"]) + .expression(expression) + .command() + + #expect(command.executableName == "find") + #expect( + command.arguments == [ + "Sources", "/tmp/special path", "(", "(", "(", "(", "-name", "*.swift", "-and", "(", + "-type", "f", "-or", "!", "(", "-path", "*/Generated/*", ")", ")", ")", "-and", + "-mindepth", "1", ")", "-and", "-maxdepth", "4", ")", "-and", "-print0", ")", + ] + ) + } + + @Test func defaultsToExplicitCurrentDirectory() { + #expect(Find().command().arguments == ["."]) + } + + @Test func qualifiesAmbiguousRelativeRoots() { + let command = Find() + .roots(["-cache", "!important", "(draft)", "safe/-child", "./-ready", "/-absolute"]) + .expression(.print) + .command() + + #expect( + command.arguments == [ + "./-cache", "./!important", "./(draft)", "safe/-child", "./-ready", "/-absolute", "-print", + ] + ) + } + + @Test func findsSpecialPathsWithoutShellInterpretation() async throws { + let directory = try CommonTestSupport.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let root = directory.appendingPathComponent("-root with spaces", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("literal $HOME [draft].swift") + try "test".write(to: file, atomically: true, encoding: .utf8) + + let context = ShellContext(workingDirectory: directory.path) + let output = try await Find(context: context) + .root("-root with spaces") + .expression(.name("literal $HOME [[]draft].swift").and(.type(.regularFile)).and(.print)) + .run() + + #expect(output.stdout == "./-root with spaces/literal $HOME [draft].swift\n") + #expect(output.exitCode == 0) + } + + @Test func emitsNullDelimitedOutput() async throws { + let directory = try CommonTestSupport.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let file = directory.appendingPathComponent("line\nbreak.txt") + try "test".write(to: file, atomically: true, encoding: .utf8) + + let output = try await Find() + .root(directory.path) + .expression(.name("*.txt").and(.maximumDepth(1)).and(.print0)) + .run() + + #expect(output.stdout.utf8.last == 0) + #expect(output.stdout.contains("line\nbreak.txt")) + } +} +#endif From 0cd62adc9e8b6f8b77f10476dbefd8b98fe1db41 Mon Sep 17 00:00:00 2001 From: maniramezan Date: Sun, 12 Jul 2026 02:58:16 -0400 Subject: [PATCH 2/2] Stabilize pipeline cancellation coverage test --- Tests/SwiftyShellTests/Pipelines/PipelineTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift b/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift index 3961a69..b9d9624 100644 --- a/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift +++ b/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift @@ -133,20 +133,23 @@ struct PipelineTests { @Test func pipelineCancellationPreservesPartialOutput() async throws { let marker = "/tmp/swiftyshell-pipeline-cancel-\(UUID().uuidString)" + let outputMarker = "\(marker)-output" defer { try? FileManager.default.removeItem(atPath: marker) } + defer { try? FileManager.default.removeItem(atPath: outputMarker) } let task = Task { try await Command("/bin/sh", arguments: "-c", "printf 'start'; exec sleep 30") .pipe( to: Command( "/bin/sh", arguments: "-c", - "dd bs=5 count=1 2>/dev/null; touch '\(marker)'; exec sleep 30" + "chunk=$(dd bs=5 count=1 2>/dev/null); printf '%s' \"$chunk\"; touch '\(outputMarker)'; touch '\(marker)'; exec sleep 30" ) ) .run(in: ShellContext()) } try await waitForFile(at: marker) + try await waitForFile(at: outputMarker) task.cancel() do {