diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b1ceee1f..4d608fa0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1109,9 +1109,10 @@ class GhosttyApp { "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", ] ) -> Bool { + var visited = Set() for rawPath in configPaths { let path = NSString(string: rawPath).expandingTildeInPath - if Self.configFileContainsCodepointMap(atPath: path) { + if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) { return true } } @@ -1119,11 +1120,21 @@ class GhosttyApp { } /// Scans a single config file (and any files it includes) for - /// `font-codepoint-map` entries. - private static func configFileContainsCodepointMap(atPath path: String) -> Bool { - guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + /// `font-codepoint-map` entries. Tracks visited paths to prevent + /// infinite recursion on cyclic includes. + private static func configFileContainsCodepointMap( + atPath path: String, + visited: inout Set + ) -> Bool { + let resolved = (path as NSString).standardizingPath + guard !visited.contains(resolved) else { return false } + visited.insert(resolved) + + guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else { return false } + let parentDir = (resolved as NSString).deletingLastPathComponent + for line in contents.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("#") { continue } @@ -1133,11 +1144,18 @@ class GhosttyApp { if trimmed.hasPrefix("config-file") { let parts = trimmed.split(separator: "=", maxSplits: 1) if parts.count == 2 { - let includePath = parts[1] + var includePath = parts[1] .trimmingCharacters(in: .whitespaces) .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - let resolved = NSString(string: includePath).expandingTildeInPath - if configFileContainsCodepointMap(atPath: resolved) { + // Ghostty supports optional includes with a trailing '?' + if includePath.hasSuffix("?") { + includePath = String(includePath.dropLast()) + } + let expanded = NSString(string: includePath).expandingTildeInPath + let absolute = (expanded as NSString).isAbsolutePath + ? expanded + : (parentDir as NSString).appendingPathComponent(expanded) + if configFileContainsCodepointMap(atPath: absolute, visited: &visited) { return true } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 6d45da72..9193685e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1431,8 +1431,9 @@ final class GhosttyMouseFocusTests: XCTestCase { } func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { + let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config" XCTAssertFalse( - GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"]) + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]) ) } @@ -1452,4 +1453,55 @@ final class GhosttyMouseFocusTests: XCTestCase { XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) } + + func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = fonts.conf\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = \(included.path)?\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let fileA = dir.appendingPathComponent("a.conf") + let fileB = dir.appendingPathComponent("b.conf") + try "config-file = \(fileB.path)\n" + .write(to: fileA, atomically: true, encoding: .utf8) + try "config-file = \(fileA.path)\n" + .write(to: fileB, atomically: true, encoding: .utf8) + + // Should not hang; should return false since neither file has font-codepoint-map + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path])) + } }