Skip to content
Draft
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
113 changes: 77 additions & 36 deletions Sources/SlayNodeMenuBar/CommandParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,44 @@ enum CommandParser {
"-C"
])

private static let portFlags = Set([
"--port",
"-p",
"--inspect",
"--inspect-brk",
"--inspect-wait",
"--inspect-port",
"--http-port",
"--https-port",
"--listen",
"--listen-address",
"--addr",
"--address",
"--bind",
"--socket"
])

private static let defaultInspectFlags = Set([
"--inspect",
"--inspect-brk",
"--inspect-wait"
])

private static let inlineDefaultInspectPrefixes = [
"--inspect=",
"--inspect-brk=",
"--inspect-wait="
]

private static let inlinePortRegexes: [NSRegularExpression] = [
#"^--?(?:port|p)=(.+)$"#,
#"^--?(?:inspect|inspect-brk|inspect-wait|inspect-port)=(.+)$"#,
#"^--?(?:listen|listen-address|addr|address|bind|socket)=(.+)$"#,
#"^-p(\d+)$"#
].compactMap { pattern in
try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}

private static func inlineWorkingDirectoryPath(from token: String) -> String? {
for flag in workingDirectoryValueFlags {
let prefix = "\(flag)="
Expand All @@ -202,34 +240,16 @@ enum CommandParser {
}

private static func isPortFlag(_ token: String) -> Bool {
let normalized = token.lowercased()
return [
"--port",
"-p",
"--inspect",
"--inspect-brk",
"--inspect-wait",
"--inspect-port",
"--http-port",
"--https-port",
"--listen",
"--listen-address",
"--addr",
"--address",
"--bind",
"--socket"
].contains(normalized)
portFlags.contains(token.lowercased())
}

private static func isDefaultInspectFlag(_ token: String) -> Bool {
let normalized = token.lowercased()
return normalized == "--inspect" || normalized == "--inspect-brk" || normalized == "--inspect-wait"
defaultInspectFlags.contains(token.lowercased())
}

private static func isInlineDefaultInspectFlagWithoutPort(_ token: String) -> Bool {
let normalized = token.lowercased()
let prefixes = ["--inspect=", "--inspect-brk=", "--inspect-wait="]
guard let prefix = prefixes.first(where: normalized.hasPrefix) else { return false }
guard let prefix = inlineDefaultInspectPrefixes.first(where: normalized.hasPrefix) else { return false }

let value = String(token.dropFirst(prefix.count))
guard extractPortCandidate(from: value) == nil else { return false }
Expand All @@ -252,16 +272,8 @@ enum CommandParser {
}

private static func extractInlinePort(from token: String) -> Int? {
let patterns = [
#"^--?(?:port|p)=(.+)$"#,
#"^--?(?:inspect|inspect-brk|inspect-wait|inspect-port)=(.+)$"#,
#"^--?(?:listen|listen-address|addr|address|bind|socket)=(.+)$"#,
#"^-p(\d+)$"#
]

for pattern in patterns {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: token, range: NSRange(location: 0, length: token.utf16.count)),
for regex in inlinePortRegexes {
guard let match = regex.firstMatch(in: token, range: NSRange(location: 0, length: token.utf16.count)),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: token) else {
continue
Expand Down Expand Up @@ -293,7 +305,8 @@ enum CommandParser {
return port
}

if let port = extractShellDefaultPort(from: normalizedValue) {
let shellDefaultValue = normalizedValue.trimmingCharacters(in: CharacterSet(charactersIn: ",;"))
if let port = extractShellDefaultPort(from: shellDefaultValue) {
return port
}

Expand All @@ -312,11 +325,13 @@ enum CommandParser {
}

private static func extractPortCandidate(from value: String) -> Int? {
if let directPort = Int(value), isValidPort(directPort) {
let normalized = sanitizePortCandidate(value)

if let directPort = Int(normalized), isValidPort(directPort) {
return directPort
}

return extractTrailingPort(from: value)
return extractTrailingPort(from: normalized)
}

private static func extractTrailingPort(from value: String) -> Int? {
Expand All @@ -325,6 +340,12 @@ enum CommandParser {
return portFromHostPortLiteral(trimmed)
}

private static func sanitizePortCandidate(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: ",;)]"))
}

private static func extractURLPort(from token: String) -> Int? {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
let sanitized = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ",;.)]"))
Expand Down Expand Up @@ -415,7 +436,13 @@ enum CommandParser {
guard let range = expression.range(of: separator) else { continue }
let candidate = String(expression[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
let unwrappedCandidate = unwrappedQuotedValue(candidate)
return extractPortCandidate(from: unwrappedCandidate) ?? parsePortPrefix(unwrappedCandidate)
if let port = extractPortCandidate(from: unwrappedCandidate) {
return port
}
if let port = extractURLPort(from: unwrappedCandidate) {
return port
}
return parsePortPrefix(unwrappedCandidate)
}

return nil
Expand All @@ -431,7 +458,8 @@ enum CommandParser {
lowered.contains("[::") ||
lowered.contains("*:") ||
token.contains("://") ||
looksLikeIPv4HostPort(token)
looksLikeIPv4HostPort(token) ||
looksLikeHostnamePort(token)
}

private static func looksLikeIPv4HostPort(_ token: String) -> Bool {
Expand All @@ -454,6 +482,19 @@ enum CommandParser {
return true
}

private static func looksLikeHostnamePort(_ token: String) -> Bool {
guard let colonIndex = token.lastIndex(of: ":") else { return false }
let hostSlice = token[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !hostSlice.isEmpty else { return false }

let host = hostSlice.split(separator: "/").last.map(String.init) ?? String(hostSlice)
guard host.contains("."), host.contains(where: \.isLetter) else { return false }
guard !host.contains(where: \.isWhitespace) else { return false }

let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-"))
return host.unicodeScalars.allSatisfy { allowed.contains($0) }
}

private static func isValidPort(_ value: Int) -> Bool {
(1...65_535).contains(value)
}
Expand Down
14 changes: 11 additions & 3 deletions Sources/SlayNodeMenuBar/ProcessClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,15 @@ enum ProcessClassifier {
case .next, .vite, .nuxt, .svelteKit, .remix, .astro, .angular:
return { tokens in
let modes = ["dev", "start", "serve", "preview", "build"]
guard let mode = tokens.first(where: { modes.contains($0) }) else { return nil }
let normalized = ProcessClassifier.normalizedLifecycleTokens(from: tokens)
guard let mode = normalized.first(where: { modes.contains($0) }) else { return nil }
return "Mode: \(mode.uppercased())"
}
case .expo:
return { tokens in
if tokens.contains("start") { return "Mode: START" }
if tokens.contains("start:web") { return "Mode: WEB" }
let normalized = ProcessClassifier.normalizedLifecycleTokens(from: tokens)
if normalized.contains("start:web") { return "Mode: WEB" }
if normalized.contains("start") { return "Mode: START" }
return nil
}
default:
Expand Down Expand Up @@ -459,4 +461,10 @@ enum ProcessClassifier {
let stem = (component as NSString).deletingPathExtension
return names.contains(component) || names.contains(stem)
}

private static func normalizedLifecycleTokens(from tokens: [String]) -> [String] {
tokens.map {
$0.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ",;"))
}
}
}
50 changes: 50 additions & 0 deletions Tests/SlayNodeMenuBarTests/CommandParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ final class CommandParserTests: XCTestCase {
XCTAssertEqual(ports, [4173, 9230])
}

func testInferPortsFromInlinePortFlagsWithTrailingPunctuation() {
let tokens = ["node", "server.js", "--port=3000,", "--inspect-port=9230;"]
let ports = CommandParser.inferPorts(from: tokens)

XCTAssertEqual(ports, [3000, 9230])
}

func testInferPortsFromSeparateFlagArgumentsWithTrailingPunctuation() {
let tokens = ["vite", "--port", "4173,", "--inspect", "127.0.0.1:9230;"]
let ports = CommandParser.inferPorts(from: tokens)

XCTAssertEqual(ports, [4173, 9230])
}

func testInferPortsFromDefaultInspectFlags() {
let tokens = ["node", "--inspect", "server.js", "--inspect-brk", "--inspect-wait"]
let ports = CommandParser.inferPorts(from: tokens)
Expand Down Expand Up @@ -164,6 +178,19 @@ final class CommandParserTests: XCTestCase {
XCTAssertEqual(ports, [3000, 4173, 8080, 9229, 9333])
}

func testInferPortsFromEnvironmentAssignmentsWithShellDefaultsContainingURLs() {
let tokens = [
"PORT=${PORT:-http://localhost:3000}",
"DEBUG_PORT=${DEBUG_PORT:-https://127.0.0.1:9229/graphql},",
"npm",
"run",
"dev"
]
let ports = CommandParser.inferPorts(from: tokens)

XCTAssertEqual(ports, [3000, 9229])
}

func testInferPortsFromSocketAddressFlags() {
let tokens = [
"deno",
Expand Down Expand Up @@ -207,6 +234,13 @@ final class CommandParserTests: XCTestCase {
XCTAssertEqual(ports, [3000, 4173])
}

func testInferPortsFromHostnameHostTokens() {
let tokens = ["node", "server.js", "api.local:4173/graphql", "dev.internal:3000,"]
let ports = CommandParser.inferPorts(from: tokens)

XCTAssertEqual(ports, [3000, 4173])
}

func testInferPortsFromURLTokenWithTrailingPunctuation() {
let tokens = ["node", "server.js", "https://localhost:5443/graphql),"]
let ports = CommandParser.inferPorts(from: tokens)
Expand Down Expand Up @@ -240,6 +274,13 @@ final class CommandParserTests: XCTestCase {
XCTAssertTrue(ports.isEmpty)
}

func testInferPortsIgnoresAmbiguousTokenWithoutHostnameSignal() {
let tokens = ["node", "server.js", "build:3000"]
let ports = CommandParser.inferPorts(from: tokens)

XCTAssertTrue(ports.isEmpty)
}

func testInferWorkingDirectoryFromFlag() {
let path = CommandParser.inferWorkingDirectory(from: ["--cwd", "~/Projects/demo"])
XCTAssertTrue(path?.hasSuffix("Projects/demo") ?? false)
Expand Down Expand Up @@ -361,6 +402,15 @@ extension CommandParserTests {
XCTAssertEqual(descriptor.portHints, [5173])
}

func testViteModeDetectionIsCaseInsensitiveAndPunctuationTolerant() {
let tokens = ["vite", "DEV,", "--port", "5173"]
let context = CommandParser.makeContext(executable: tokens[0], tokens: tokens, workingDirectory: nil)
let descriptor = CommandParser.descriptor(from: context)

XCTAssertEqual(descriptor.displayName, "Vite")
XCTAssertEqual(descriptor.details, "Mode: DEV")
}

func testWebpackServeCommandIsDetected() {
let tokens = ["webpack", "serve", "--mode", "development"]
let context = CommandParser.makeContext(executable: tokens[0], tokens: tokens, workingDirectory: nil)
Expand Down
Loading