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
7 changes: 5 additions & 2 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,12 @@ public struct Service: Codable, Hashable {
ports = try container.decodeIfPresent([String].self, forKey: .ports)

// Decode 'command' which can be either a single string or an array of strings.
// Per Compose semantics the string form is shell-lexed into argv
// (e.g. `sh -c "npm install"` -> ["sh", "-c", "npm install"]), not passed as one argument.
if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) {
command = cmdArray
} else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) {
command = [cmdString]
command = shellLex(cmdString)
} else {
command = nil
}
Expand All @@ -222,10 +224,11 @@ public struct Service: Codable, Hashable {
hostname = try container.decodeIfPresent(String.self, forKey: .hostname)

// Decode 'entrypoint' which can be either a single string or an array of strings.
// The string form is shell-lexed into argv, matching Compose semantics.
if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) {
entrypoint = entrypointArray
} else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) {
entrypoint = [entrypointString]
entrypoint = shellLex(entrypointString)
} else {
entrypoint = nil
}
Expand Down
51 changes: 51 additions & 0 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,57 @@ public func deriveProjectName(cwd: String) -> String {
return projectName
}

/// Splits a shell-style command string into arguments: whitespace separation,
/// single/double quotes, and backslash escapes. No expansion is performed —
/// this matches how Docker Compose parses string-form `command:` and
/// `entrypoint:` (shellwords), e.g.
/// `sh -c "npm install && npm run build"` -> ["sh", "-c", "npm install && npm run build"].
func shellLex(_ input: String) -> [String] {
var args: [String] = []
var current = ""
var hasToken = false
var inSingleQuotes = false
var inDoubleQuotes = false
var escaped = false

for character in input {
if escaped {
current.append(character)
escaped = false
hasToken = true
continue
}
if character == "\\" && !inSingleQuotes {
escaped = true
continue
}
if character == "'" && !inDoubleQuotes {
inSingleQuotes.toggle()
hasToken = true
continue
}
if character == "\"" && !inSingleQuotes {
inDoubleQuotes.toggle()
hasToken = true
continue
}
if character.isWhitespace && !inSingleQuotes && !inDoubleQuotes {
if hasToken {
args.append(current)
current = ""
hasToken = false
}
continue
}
current.append(character)
hasToken = true
}
if hasToken {
args.append(current)
}
return args
}

/// Converts Docker Compose port specification into a container run -p format.
/// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol.
/// - Parameter portSpec: The port specification string from docker-compose.yml.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ struct DockerComposeParsingTests {
#expect(compose.services["app"]??.command?.first == "sh")
}

@Test("Parse compose with command as string")
@Test("Parse compose with command as string is shell-lexed")
func parseComposeWithCommandString() throws {
let yaml = """
version: '3.8'
Expand All @@ -251,12 +251,31 @@ struct DockerComposeParsingTests {
image: alpine:latest
command: "echo hello"
"""

let decoder = YAMLDecoder()
let compose = try decoder.decode(DockerCompose.self, from: yaml)

#expect(compose.services["app"]??.command?.count == 1)
#expect(compose.services["app"]??.command?.first == "echo hello")

#expect(compose.services["app"]??.command == ["echo", "hello"])
}

@Test("Parse compose with quoted string command preserves quoted argument")
func parseComposeWithQuotedStringCommand() throws {
// Regression: the whole string was passed as a single argument,
// producing 'Cannot find module .../sh -c "npm install && npm run build:watch"'.
let yaml = """
version: '3.8'
services:
app:
image: node:18-alpine
command: sh -c "npm install && npm run build:watch"
entrypoint: /bin/sh -c
"""

let decoder = YAMLDecoder()
let compose = try decoder.decode(DockerCompose.self, from: yaml)

#expect(compose.services["app"]??.command == ["sh", "-c", "npm install && npm run build:watch"])
#expect(compose.services["app"]??.entrypoint == ["/bin/sh", "-c"])
}

@Test("Parse compose with restart policy")
Expand Down
30 changes: 30 additions & 0 deletions Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ struct HelperFunctionsTests {
#expect(result == "0.0.0.0:3000:3000")
}

@Test("Shell lex - plain words")
func testShellLexPlainWords() throws {
#expect(shellLex("echo hello world") == ["echo", "hello", "world"])
}

@Test("Shell lex - double-quoted argument kept as one token")
func testShellLexDoubleQuotes() throws {
#expect(shellLex("sh -c \"npm install && npm run build:watch\"") == ["sh", "-c", "npm install && npm run build:watch"])
}

@Test("Shell lex - single-quoted argument kept as one token")
func testShellLexSingleQuotes() throws {
#expect(shellLex("sh -c 'echo \"nested\" done'") == ["sh", "-c", "echo \"nested\" done"])
}

@Test("Shell lex - escaped space joins token")
func testShellLexEscapedSpace() throws {
#expect(shellLex("cat my\\ file.txt") == ["cat", "my file.txt"])
}

@Test("Shell lex - empty quoted string produces empty token")
func testShellLexEmptyQuotes() throws {
#expect(shellLex("env VAR= \"\"") == ["env", "VAR=", ""])
}

@Test("Shell lex - collapses repeated whitespace")
func testShellLexWhitespaceRuns() throws {
#expect(shellLex(" npm run dev ") == ["npm", "run", "dev"])
}

}

/// Trait that creates a unique temporary directory before a test runs and removes it after.
Expand Down