diff --git a/Sources/SlayNodeMenuBar/CommandParsing.swift b/Sources/SlayNodeMenuBar/CommandParsing.swift index 52853c4..b376c2c 100644 --- a/Sources/SlayNodeMenuBar/CommandParsing.swift +++ b/Sources/SlayNodeMenuBar/CommandParsing.swift @@ -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)=" @@ -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 } @@ -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 @@ -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 } @@ -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? { @@ -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: ",;.)]")) @@ -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 @@ -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 { @@ -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[.. Bool { (1...65_535).contains(value) } diff --git a/Sources/SlayNodeMenuBar/ProcessClassifier.swift b/Sources/SlayNodeMenuBar/ProcessClassifier.swift index 030bf30..4e9cdff 100644 --- a/Sources/SlayNodeMenuBar/ProcessClassifier.swift +++ b/Sources/SlayNodeMenuBar/ProcessClassifier.swift @@ -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: @@ -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: ",;")) + } + } } diff --git a/Tests/SlayNodeMenuBarTests/CommandParserTests.swift b/Tests/SlayNodeMenuBarTests/CommandParserTests.swift index 87d7c8b..d85e606 100644 --- a/Tests/SlayNodeMenuBarTests/CommandParserTests.swift +++ b/Tests/SlayNodeMenuBarTests/CommandParserTests.swift @@ -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) @@ -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", @@ -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) @@ -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) @@ -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)