Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: SwiftLint
on:
pull_request:
paths:
- '.github/workflows/swiftlint.yml'
- '.swiftlint.yml'
- '**/*.swift'
push:
branches:
- main

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
lint:
# macOS + Homebrew SwiftLint keeps CI aligned with the local
# pre-commit hook (scripts/hooks/pre-commit) — same binary, same
# rule set, no version drift between dev and CI.
runs-on: macos-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install SwiftLint
run: brew install swiftlint
- name: SwiftLint version
run: swiftlint --version
- name: Lint
run: swiftlint lint --strict --quiet
7 changes: 7 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
included:
- Sources
- Tests

excluded:
- .build
- .swiftpm
20 changes: 10 additions & 10 deletions Sources/ShellKit/Command/BinCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ public enum BinCatalog {
/// Map of command name → canonical absolute path. Names not in
/// this map are treated as shell-only built-ins.
public static let knownPaths: [String: String] = {
var m: [String: String] = [:]
var paths: [String: String] = [:]

// /bin — the small set of commands macOS keeps in /bin.
for name in [
"bash", "cat", "chmod", "cp", "dash", "date", "dd", "df",
"echo", "expr", "hostname", "kill", "link", "ln", "ls",
"mkdir", "mv", "ps", "pwd", "realpath", "rm", "rmdir",
"sh", "sleep", "stty", "sync", "test", "[", "unlink",
] { m[name] = "/bin/\(name)" }
"sh", "sleep", "stty", "sync", "test", "[", "unlink"
] { paths[name] = "/bin/\(name)" }

// /usr/bin — everything else we ship that would normally be
// installed there on macOS. The trailing group (`clear`,
Expand All @@ -60,8 +60,8 @@ public enum BinCatalog {
"timeout", "touch", "tr", "tree", "true", "truncate",
"uname", "unexpand", "uniq", "wait", "wc", "which",
"whoami", "xargs", "xattr", "xxd", "yes",
"clear", "open", "pbcopy", "pbpaste", "say",
] { m[name] = "/usr/bin/\(name)" }
"clear", "open", "pbcopy", "pbpaste", "say"
] { paths[name] = "/usr/bin/\(name)" }

// Third-party commonly-installed utilities (Homebrew /
// user-installed). We slot them under /usr/local/bin so
Expand All @@ -85,9 +85,9 @@ public enum BinCatalog {
// backing logic. The catalog entry is here so
// `which swift-js` reports `/usr/local/bin/swift-js`
// rather than dropping back to "not found".
"swift", "swift-script", "swift-js", "node", "bun",
"swift", "swift-script", "swift-js", "node", "bun"
] {
m[name] = "/usr/local/bin/\(name)"
paths[name] = "/usr/local/bin/\(name)"
}

// Embedder-supplied CLIs that ship as primary system tools
Expand All @@ -96,12 +96,12 @@ public enum BinCatalog {
// on whether the host shell registers it; this entry just
// declares where it WOULD live on a hypothetical "real"
// install.
m["coder"] = "/usr/bin/coder"
paths["coder"] = "/usr/bin/coder"

// `curl` ships at /usr/bin/curl on macOS.
m["curl"] = "/usr/bin/curl"
paths["curl"] = "/usr/bin/curl"

return m
return paths
}()

/// All directories that contain at least one entry. Drives the
Expand Down
3 changes: 1 addition & 2 deletions Sources/ShellKit/Command/ClosureCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ public struct ClosureCommand: Command {
private let body: @Sendable ([String]) async throws -> ExitStatus

public init(name: String,
body: @Sendable @escaping ([String]) async throws -> ExitStatus)
{
body: @Sendable @escaping ([String]) async throws -> ExitStatus) {
self.name = name
self.body = body
}
Expand Down
11 changes: 5 additions & 6 deletions Sources/ShellKit/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ public struct Environment: Hashable, Sendable {
public init(variables: [String: String] = [:],
arrays: [String: BashArray] = [:],
associativeArrays: [String: [String: String]] = [:],
workingDirectory: String = FileManager.default.currentDirectoryPath)
{
workingDirectory: String = FileManager.default.currentDirectoryPath) {
self.variables = variables
self.arrays = arrays
self.associativeArrays = associativeArrays
Expand Down Expand Up @@ -75,7 +74,7 @@ public struct Environment: Hashable, Sendable {
"SHELL": "/bin/sh",
"TERM": "dumb",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"LC_ALL": "C.UTF-8"
]
return Environment(variables: vars,
workingDirectory: workingDirectory)
Expand All @@ -98,7 +97,7 @@ public struct BashArray: Hashable, Sendable {
/// Build a dense array from a Swift `[String]` — indices 0..count-1.
public init(dense values: [String]) {
var dict: [Int: String] = [:]
for (i, v) in values.enumerated() { dict[i] = v }
for (index, value) in values.enumerated() { dict[index] = value }
self.entries = dict
}

Expand All @@ -124,8 +123,8 @@ public struct BashArray: Hashable, Sendable {
/// appends starting at 0, matching bash.
public mutating func append(_ values: [String]) {
var next = (entries.keys.max() ?? -1) + 1
for v in values {
entries[next] = v
for value in values {
entries[next] = value
next += 1
}
}
Expand Down
30 changes: 20 additions & 10 deletions Sources/ShellKit/IO/InputSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ public struct InputSource: Sendable {
}()

/// A stream that yields a single UTF-8-encoded chunk then finishes.
public static func string(_ s: String) -> InputSource {
.data(Data(s.utf8))
public static func string(_ text: String) -> InputSource {
.data(Data(text.utf8))
}

/// A stream that yields one `Data` chunk then finishes.
public static func data(_ d: Data) -> InputSource {
public static func data(_ data: Data) -> InputSource {
let (stream, cont) = AsyncStream<Data>.makeStream()
if !d.isEmpty { cont.yield(d) }
if !data.isEmpty { cont.yield(data) }
cont.finish()
return InputSource(bytes: stream)
}
Expand All @@ -81,6 +81,8 @@ public struct InputSource: Sendable {
/// command is fed binary data).
public func readAllString() async -> String {
let data = await readAllData()
// Lossy decode by design — see type doc on permissive UTF-8.
// swiftlint:disable:next optional_data_string_conversion
return String(decoding: data, as: UTF8.self)
}

Expand All @@ -101,13 +103,18 @@ public struct InputSource: Sendable {

func readLine() async -> String? {
while true {
if let nl = pending.firstIndex(of: 0x0A) {
let line = pending[pending.startIndex..<nl]
pending.removeSubrange(pending.startIndex..<(nl + 1))
if let newline = pending.firstIndex(of: 0x0A) {
let line = pending[pending.startIndex..<newline]
pending.removeSubrange(pending.startIndex..<(newline + 1))
// Lossy decode — line may carry non-UTF-8 bytes from
// a binary stream; mirror bash's permissiveness.
// swiftlint:disable:next optional_data_string_conversion
return String(decoding: line, as: UTF8.self)
}
if atEOF {
if pending.isEmpty { return nil }
// Same lossy contract for the final partial line.
// swiftlint:disable:next optional_data_string_conversion
let line = String(decoding: pending, as: UTF8.self)
pending.removeAll()
return line
Expand All @@ -119,9 +126,9 @@ public struct InputSource: Sendable {
if iterator == nil {
iterator = bytes.makeAsyncIterator()
}
var it = iterator!
let chunk = await it.next()
iterator = it
var iter = iterator!
let chunk = await iter.next()
iterator = iter
if let chunk {
pending.append(chunk)
} else {
Expand Down Expand Up @@ -152,6 +159,9 @@ public struct InputSource: Sendable {
var pending = ""
for await chunk in upstream {
if Task.isCancelled { break }
// Chunks may split a multibyte char at the boundary;
// lossy decode by design.
// swiftlint:disable:next optional_data_string_conversion
pending += String(decoding: chunk, as: UTF8.self)
while let nlRange = pending.range(of: "\n") {
let line = String(pending[..<nlRange.lowerBound])
Expand Down
11 changes: 8 additions & 3 deletions Sources/ShellKit/IO/OutputSink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ public final class OutputSink: @unchecked Sendable {
/// UTF-8 decode of the drained stream, lossily replacing invalid
/// sequences.
public func readAllString() async -> String {
// Lossy decode by design — see type doc on permissive UTF-8.
// swiftlint:disable:next optional_data_string_conversion
String(decoding: await readAllData(), as: UTF8.self)
}

Expand All @@ -95,10 +97,13 @@ public final class OutputSink: @unchecked Sendable {
Task {
var pending = ""
for await chunk in bytes {
// Chunks may split a multibyte char at the boundary;
// lossy decode by design.
// swiftlint:disable:next optional_data_string_conversion
pending += String(decoding: chunk, as: UTF8.self)
while let nl = pending.range(of: "\n") {
let line = String(pending[..<nl.lowerBound])
pending.removeSubrange(pending.startIndex..<nl.upperBound)
while let newline = pending.range(of: "\n") {
let line = String(pending[..<newline.lowerBound])
pending.removeSubrange(pending.startIndex..<newline.upperBound)
continuation.yield(line)
}
}
Expand Down
11 changes: 5 additions & 6 deletions Sources/ShellKit/Network/NetworkConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ public struct NetworkResponse: Sendable {

public init(status: Int, statusText: String,
headers: [String: String], body: Data,
finalURL: URL)
{
finalURL: URL) {
self.status = status
self.statusText = statusText
self.headers = headers
Expand Down Expand Up @@ -163,10 +162,10 @@ public enum NetworkError: Error, CustomStringConvertible, Sendable, Equatable {
return "Too many redirects (max: \(max))"
case .privateAddress(let url, let reason):
return "Network access denied: \(reason): \(url)"
case .invalidURL(let s):
return "Could not parse URL: \(s)"
case .transport(let m):
return m
case .invalidURL(let raw):
return "Could not parse URL: \(raw)"
case .transport(let message):
return message
case .timedOut:
return "Request timed out"
}
Expand Down
22 changes: 11 additions & 11 deletions Sources/ShellKit/Network/NetworkFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ public struct NetworkRequest: Sendable {
method: String,
headers: [String: String] = [:],
body: Data? = nil,
timeoutSeconds: TimeInterval = 30)
{
timeoutSeconds: TimeInterval = 30) {
self.url = url
self.method = method
self.headers = headers
Expand Down Expand Up @@ -78,7 +77,9 @@ public struct URLSessionFetcher: NetworkFetcher, @unchecked Sendable {
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: request.timeoutSeconds)
urlReq.httpMethod = request.method
for (k, v) in request.headers { urlReq.setValue(v, forHTTPHeaderField: k) }
for (key, value) in request.headers {
urlReq.setValue(value, forHTTPHeaderField: key)
}
if let body = request.body { urlReq.httpBody = body }

let (data, response): (Data, URLResponse)
Expand All @@ -94,12 +95,12 @@ public struct URLSessionFetcher: NetworkFetcher, @unchecked Sendable {
throw NetworkError.transport(message: "non-HTTP response")
}
var headers: [String: String] = [:]
for (key, value) in http.allHeaderFields {
if let k = key as? String, let v = value as? String {
for (rawKey, rawValue) in http.allHeaderFields {
if let key = rawKey as? String, let value = rawValue as? String {
// Header names are case-insensitive in HTTP. Normalise to
// canonical form (Title-Case-With-Hyphens) so callers can
// look them up without guessing.
headers[canonicalize(headerName: k)] = v
headers[canonicalize(headerName: key)] = value
}
}
return NetworkResponse(
Expand All @@ -115,15 +116,14 @@ public struct URLSessionFetcher: NetworkFetcher, @unchecked Sendable {
// "content-length" → "Content-Length"
let parts = headerName.split(separator: "-",
omittingEmptySubsequences: false)
return parts.map { p -> String in
guard let first = p.first else { return "" }
return String(first).uppercased() + p.dropFirst().lowercased()
return parts.map { part -> String in
guard let first = part.first else { return "" }
return String(first).uppercased() + part.dropFirst().lowercased()
}.joined(separator: "-")
}

private final class NoRedirectDelegate: NSObject,
URLSessionTaskDelegate, @unchecked Sendable
{
URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(
_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
Expand Down
Loading
Loading