diff --git a/.gitignore b/.gitignore index 406cdd6..3ed4fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Xcode build/ DerivedData/ +*.profraw *.xcuserdata *.xcworkspace/xcuserdata/ xcuserdata/ diff --git a/Pique.xcodeproj/project.pbxproj b/Pique.xcodeproj/project.pbxproj index cc9dae0..4b3720c 100644 --- a/Pique.xcodeproj/project.pbxproj +++ b/Pique.xcodeproj/project.pbxproj @@ -521,7 +521,7 @@ CF100000000000AR /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconRelease; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Pique/Pique.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; diff --git a/Pique/ContentView.swift b/Pique/ContentView.swift index 191ad5c..4f4ce89 100644 --- a/Pique/ContentView.swift +++ b/Pique/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { ("TOML", "doc.text", Color.blue), ("XML", "doc.text", Color.green), ("mobileconfig", "lock.doc", Color.red), + ("VPP token", "key.fill", Color.yellow), ("Shell", "terminal", Color.mint), ("Python", "chevron.left.forwardslash.chevron.right", Color.cyan), ("HCL", "doc.text", Color.indigo), @@ -31,15 +32,9 @@ struct ContentView: View { Text("QuickLook previews for config files") .foregroundStyle(.secondary) - HStack(spacing: 16) { - ForEach(formats, id: \.0) { name, icon, color in - Label(name, systemImage: icon) - .font(.caption.bold()) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 6)) - .foregroundStyle(color) - } + VStack(spacing: 10) { + formatChipsRow(formats.prefix(5)) + formatChipsRow(formats.dropFirst(5)) } Text("Select a supported file in Finder and press Space to preview.") @@ -65,6 +60,23 @@ struct ContentView: View { SettingsView() } } + + @ViewBuilder + private func formatChipsRow(_ entries: S) -> some View + where S.Element == (String, String, Color) { + HStack(spacing: 12) { + ForEach(Array(entries), id: \.0) { name, icon, color in + Label(name, systemImage: icon) + .font(.caption.bold()) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 6)) + .foregroundStyle(color) + } + } + } } #Preview { diff --git a/PiquePreview/Info.plist b/PiquePreview/Info.plist index 41fb62c..ff35c73 100644 --- a/PiquePreview/Info.plist +++ b/PiquePreview/Info.plist @@ -114,6 +114,23 @@ + + UTTypeIdentifier + io.macadmins.pique.vpptoken + UTTypeDescription + Apple VPP / Apps and Books Token + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + vpptoken + + + NSExtension @@ -145,6 +162,7 @@ net.daringfireball.markdown io.macadmins.pique.hcl io.macadmins.pique.recipe + io.macadmins.pique.vpptoken com.apple.terminal.shell-script QLFileExtensions @@ -184,6 +202,7 @@ tfvars hcl recipe + vpptoken QLPreviewWidth 1024 diff --git a/PiquePreview/PreviewProvider.swift b/PiquePreview/PreviewProvider.swift index bd6744f..f18e963 100644 --- a/PiquePreview/PreviewProvider.swift +++ b/PiquePreview/PreviewProvider.swift @@ -34,7 +34,17 @@ class PreviewProvider: NSViewController, QLPreviewingController { func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { do { var data = try FileReader.readData(url: url) - let format = FileFormat(pathExtension: url.pathExtension) ?? .json + let extLower = url.pathExtension.lowercased() + let nameLower = url.lastPathComponent.lowercased() + // .recipe (XML), .recipe.plist (override), and .recipe.yaml (YAML recipe) + // all flow through the structured renderer. Pathological extensions like + // .yaml/.plist alone are NOT treated as recipes — the suffix matters. + let isAutoPkgRecipe = extLower == "recipe" + || nameLower.hasSuffix(".recipe.plist") + || nameLower.hasSuffix(".recipe.yaml") + let format: FileFormat = isAutoPkgRecipe + ? .recipe + : (FileFormat(pathExtension: url.pathExtension) ?? .json) // Step 1: Strip CMS signature from signed mobileconfig files if format == .mobileconfig, FileReader.isCMSEnvelope(data), @@ -42,16 +52,21 @@ class PreviewProvider: NSViewController, QLPreviewingController { data = inner } - // Step 2: Convert binary plist to XML text + // Step 2: Convert source bytes to displayable text let text: String - if FileReader.isBinaryPlist(data), - let xml = FileReader.convertBinaryPlistToXMLString(data) { + if extLower == "vpptoken", + let json = FileReader.decodeVPPToken(data) { + text = json + } else if FileReader.isBinaryPlist(data), + let xml = FileReader.convertBinaryPlistToXMLString(data) { text = xml } else { text = FileReader.decodeToString(data) } - let formatName = PreviewProvider.formatName(for: url.pathExtension) + let formatName = isAutoPkgRecipe + ? "AutoPkg" + : PreviewProvider.formatName(for: url.pathExtension) let isDark: Bool switch AppearanceSettings.override(forFormat: formatName) { case .system: @@ -61,7 +76,12 @@ class PreviewProvider: NSViewController, QLPreviewingController { case .dark: isDark = true } - let html = SyntaxHighlighter.highlight(text, format: format, darkMode: isDark) + var html = SyntaxHighlighter.highlight(text, format: format, darkMode: isDark) + if url.pathExtension.lowercased() == "vpptoken", + let info = FileReader.vppTokenInfo(data) { + let banner = PreviewProvider.vppTokenBanner(info: info, dark: isDark) + html = html.replacingOccurrences(of: "", with: "\(banner)") + } logger.info("Preview for \(url.lastPathComponent, privacy: .public)") @@ -89,13 +109,53 @@ class PreviewProvider: NSViewController, QLPreviewingController { } } + /// Builds an HTML expiration banner for a VPP token, colour-coded by remaining days. + /// Expired → red, ≤30 days → amber, otherwise green. Org name is included when present. + private static func vppTokenBanner(info: FileReader.VPPTokenInfo, dark: Bool) -> String { + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyy-MM-dd" + dateFmt.timeZone = TimeZone(secondsFromGMT: 0) + + let title: String + let bg: String + let fg: String + if let exp = info.expDate { + let cal = Calendar(identifier: .gregorian) + let today = cal.startOfDay(for: Date()) + let expDay = cal.startOfDay(for: exp) + let days = cal.dateComponents([.day], from: today, to: expDay).day ?? 0 + let dateStr = dateFmt.string(from: exp) + if days < 0 { + title = "EXPIRED \(-days) day\(-days == 1 ? "" : "s") ago — \(dateStr)" + bg = dark ? "#5a1a1a" : "#fde2e2"; fg = dark ? "#ffb4b4" : "#8a1a1a" + } else if days == 0 { + title = "Expires today — \(dateStr)" + bg = dark ? "#5a1a1a" : "#fde2e2"; fg = dark ? "#ffb4b4" : "#8a1a1a" + } else if days <= 30 { + title = "Expires in \(days) day\(days == 1 ? "" : "s") — \(dateStr)" + bg = dark ? "#5a4a1a" : "#fff4d6"; fg = dark ? "#ffd98a" : "#8a5a00" + } else { + title = "Expires in \(days) days — \(dateStr)" + bg = dark ? "#1a4a2a" : "#dff5e1"; fg = dark ? "#a8e6b8" : "#1a5a2a" + } + } else { + title = "Expiration date not readable" + bg = dark ? "#3a3a3c" : "#eeeeee"; fg = dark ? "#d0d0d0" : "#555555" + } + + let org = (info.orgName?.isEmpty == false) ? " · \(info.orgName!)" : "" + return """ +
\(title)\(org)
+ """ + } + /// Maps a file extension to a format group name matching AppearanceSettings keys. private static func formatName(for ext: String) -> String { switch ext.lowercased() { - case "json", "ndjson", "jsonl": return "JSON" - case "yaml", "yml": return "YAML" + case "json", "ndjson", "jsonl", "vpptoken": return "JSON" + case "yaml", "yml", "recipe": return "YAML" case "toml", "lock": return "TOML" - case "xml", "recipe": return "XML" + case "xml": return "XML" case "mobileconfig", "plist": return "mobileconfig" case "sh", "bash", "zsh", "ksh", "dash", "rc", "command": return "Shell" case "ps1", "psm1", "psd1": return "PowerShell" diff --git a/PiqueTests/FileFormatTests.swift b/PiqueTests/FileFormatTests.swift index 144746f..19af558 100644 --- a/PiqueTests/FileFormatTests.swift +++ b/PiqueTests/FileFormatTests.swift @@ -9,6 +9,7 @@ final class FileFormatTests: XCTestCase { XCTAssertEqual(FileFormat(pathExtension: "json"), .json) XCTAssertEqual(FileFormat(pathExtension: "ndjson"), .json) XCTAssertEqual(FileFormat(pathExtension: "jsonl"), .json) + XCTAssertEqual(FileFormat(pathExtension: "vpptoken"), .json) } func testYAML() { @@ -23,7 +24,11 @@ final class FileFormatTests: XCTestCase { func testXML() { XCTAssertEqual(FileFormat(pathExtension: "xml"), .xml) - XCTAssertEqual(FileFormat(pathExtension: "recipe"), .xml) + } + + func testRecipeIsItsOwnFormat() { + // .recipe files are XML plists but get a custom structured renderer + XCTAssertEqual(FileFormat(pathExtension: "recipe"), .recipe) } func testMobileconfig() { diff --git a/PiqueTests/FileReaderTests.swift b/PiqueTests/FileReaderTests.swift index 6787fe0..c164d91 100644 --- a/PiqueTests/FileReaderTests.swift +++ b/PiqueTests/FileReaderTests.swift @@ -199,4 +199,81 @@ final class FileReaderTests: XCTestCase { XCTAssertTrue(xmlString!.contains("PayloadType")) XCTAssertTrue(xmlString!.contains("Configuration")) } + + // MARK: - Recipe YAML parser + + func testRecipeYAMLParsesScalarsAndNestedMapping() { + let yaml = """ + Identifier: com.example.foo + Description: Hello world + Input: + NAME: Foo + """ + let dict = RecipeYAMLParser.parse(yaml) + XCTAssertEqual(dict?["Identifier"] as? String, "com.example.foo") + XCTAssertEqual(dict?["Description"] as? String, "Hello world") + XCTAssertEqual((dict?["Input"] as? [String: Any])?["NAME"] as? String, "Foo") + } + + func testRecipeYAMLParsesSequenceAtSameIndentAsParent() { + // recipe-robot emits Process: with `- ` at column 0; this is the bug + // that existed in v1 of the parser. + let yaml = """ + Process: + - Processor: Alpha + - Processor: Beta + """ + let dict = RecipeYAMLParser.parse(yaml) + let process = dict?["Process"] as? [Any] + XCTAssertEqual(process?.count, 2) + XCTAssertEqual((process?[0] as? [String: Any])?["Processor"] as? String, "Alpha") + XCTAssertEqual((process?[1] as? [String: Any])?["Processor"] as? String, "Beta") + } + + func testRecipeYAMLParsesProcessStepWithArguments() { + let yaml = """ + Process: + - Processor: AppPkgCreator + Arguments: + app_path: '%RECIPE_CACHE_DIR%/%NAME%.app' + """ + let dict = RecipeYAMLParser.parse(yaml) + let step = (dict?["Process"] as? [Any])?.first as? [String: Any] + XCTAssertEqual(step?["Processor"] as? String, "AppPkgCreator") + let args = step?["Arguments"] as? [String: Any] + XCTAssertEqual(args?["app_path"] as? String, "%RECIPE_CACHE_DIR%/%NAME%.app") + } + + func testRecipeYAMLSingleQuotedNumberStaysString() { + let dict = RecipeYAMLParser.parse("MinimumVersion: '2.3'") + // Single-quoted '2.3' must be preserved as a string, not converted to 2.3 + XCTAssertEqual(dict?["MinimumVersion"] as? String, "2.3") + } + + func testRecipeYAMLBareNumberBecomesNumber() { + let dict = RecipeYAMLParser.parse("MinimumVersion: 2.3") + XCTAssertEqual(dict?["MinimumVersion"] as? Double, 2.3) + } + + func testRecipeYAMLIgnoresLineComments() { + let yaml = """ + # heading comment + Identifier: com.example.foo # trailing comment + """ + let dict = RecipeYAMLParser.parse(yaml) + XCTAssertEqual(dict?["Identifier"] as? String, "com.example.foo") + } + + func testRecipeYAMLProcessStepWithoutArguments() { + // Raycast.pkg.recipe.yaml has `- Processor: AppPkgCreator` with no Arguments + let yaml = """ + Process: + - Processor: AppPkgCreator + """ + let dict = RecipeYAMLParser.parse(yaml) + let step = (dict?["Process"] as? [Any])?.first as? [String: Any] + XCTAssertEqual(step?["Processor"] as? String, "AppPkgCreator") + XCTAssertNil(step?["Arguments"]) + } + } diff --git a/Shared/FileReader.swift b/Shared/FileReader.swift index fe3fba6..9935d5d 100644 --- a/Shared/FileReader.swift +++ b/Shared/FileReader.swift @@ -81,6 +81,44 @@ enum FileReader { return String(data: xmlData, encoding: .utf8) } + // MARK: - VPP / Apps & Books service token + + /// Metadata extracted from a `.vpptoken` file for use in the preview banner. + struct VPPTokenInfo { + let expDate: Date? + let orgName: String? + } + + /// Returns the parsed expiration date and org name from a base64-wrapped VPP token. + /// Returns nil if the file is not a valid token envelope. + static func vppTokenInfo(_ data: Data) -> VPPTokenInfo? { + let trimmed = decodeToString(data).trimmingCharacters(in: .whitespacesAndNewlines) + guard let decoded = Data(base64Encoded: trimmed, options: .ignoreUnknownCharacters), + let object = try? JSONSerialization.jsonObject(with: decoded), + let dict = object as? [String: Any] + else { return nil } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let exp = (dict["expDate"] as? String).flatMap(formatter.date(from:)) + let org = dict["orgName"] as? String + return VPPTokenInfo(expDate: exp, orgName: org) + } + + /// Decodes a base64-wrapped VPP service token to pretty-printed JSON. + static func decodeVPPToken(_ data: Data) -> String? { + let trimmed = decodeToString(data).trimmingCharacters(in: .whitespacesAndNewlines) + guard let decoded = Data(base64Encoded: trimmed, options: .ignoreUnknownCharacters), + let object = try? JSONSerialization.jsonObject(with: decoded) + else { return nil } + + guard let pretty = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) else { return nil } + return String(data: pretty, encoding: .utf8) + } + enum FileReaderError: LocalizedError { case fileTooLarge(UInt64) @@ -93,3 +131,4 @@ enum FileReader { } } } + diff --git a/Shared/SyntaxHighlighter.swift b/Shared/SyntaxHighlighter.swift index 84839d9..425f315 100644 --- a/Shared/SyntaxHighlighter.swift +++ b/Shared/SyntaxHighlighter.swift @@ -6,15 +6,16 @@ import Foundation enum FileFormat { - case json, yaml, toml, xml, mobileconfig, shell, powershell, python, ruby, go, rust, javascript, - markdown, hcl + case json, yaml, toml, xml, mobileconfig, recipe, shell, powershell, python, ruby, go, rust, + javascript, markdown, hcl init?(pathExtension: String) { switch pathExtension.lowercased() { - case "json", "ndjson", "jsonl": self = .json + case "json", "ndjson", "jsonl", "vpptoken": self = .json case "yaml", "yml": self = .yaml + case "recipe": self = .recipe case "toml", "lock": self = .toml - case "xml", "recipe": self = .xml + case "xml": self = .xml case "mobileconfig", "plist": self = .mobileconfig case "sh", "bash", "zsh", "ksh", "dash", "rc", "command": self = .shell case "ps1", "psm1", "psd1": self = .powershell @@ -37,6 +38,11 @@ enum SyntaxHighlighter { return html } } + if format == .recipe, let data = source.data(using: .utf8) { + if let html = renderRecipe(data, dark: darkMode) { + return html + } + } if format == .json, let data = source.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], isAppleConfigProfile(json) @@ -50,7 +56,7 @@ enum SyntaxHighlighter { case .json: tokens = tokenizeJSON(source) case .yaml: tokens = tokenizeYAML(source) case .toml: tokens = tokenizeTOML(source) - case .xml, .mobileconfig: tokens = tokenizeXML(source) + case .xml, .mobileconfig, .recipe: tokens = tokenizeXML(source) case .shell: tokens = tokenizeShell(source) case .powershell: tokens = tokenizePowerShell(source) case .python: tokens = tokenizePython(source) @@ -293,6 +299,173 @@ enum SyntaxHighlighter { return wrapMobileconfigHTML(h, t: t) } + // MARK: - AutoPkg Recipe Renderer + + /// Dispatches recipe rendering based on whether the file is XML plist or YAML. + private static func renderRecipe(_ data: Data, dark: Bool) -> String? { + guard let rawSource = String(data: data, encoding: .utf8) else { return nil } + let head = rawSource.prefix(256).lowercased() + let isYAML = !head.contains(" String { + let t = dark ? Theme.dark : Theme.light + var h = "" + + let identifier = plist["Identifier"] as? String ?? "Untitled Recipe" + let description = plist["Description"] as? String + let parentRecipe = plist["ParentRecipe"] as? String + let minVersion = plist["MinimumVersion"] as? String + let input = plist["Input"] as? [String: Any] ?? [:] + let process = plist["Process"] as? [[String: Any]] ?? [] + let trustInfo = plist["ParentRecipeTrustInfo"] as? [String: Any] + let isOverride = trustInfo != nil + + let (typeText, typeColor) = recipeTypeBadge(identifier: identifier, isOverride: isOverride, t: t) + + // Display name: prefer Input.NAME, fallback to last segment of Identifier. + let displayName = (input["NAME"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? identifier.split(separator: ".").last.map(String.init) + ?? identifier + + // ── Header card ── + h += "" + h += "
" + h += "\(typeText.uppercased())
" + h += "\(esc(displayName))" + h += "
\(esc(identifier))" + if let desc = description, !desc.isEmpty { + let descHTML = esc(desc).replacingOccurrences(of: "\n", with: "
") + h += "

\(descHTML)" + } + h += "
" + + // ── Recipe metadata (Parent + MinimumVersion) ── + var metaRows: [(String, String)] = [] + if let p = parentRecipe { + metaRows.append(("Parent Recipe", + "\(esc(p))")) + } + if let m = minVersion { + metaRows.append(("Minimum Version", + "\(esc(m))")) + } + if !metaRows.isEmpty { + h += sectionHeader("Recipe", t: t) + h += groupStart(t) + for (i, row) in metaRows.enumerated() { + h += cellRow(row.0, row.1, t: t, last: i == metaRows.count - 1) + } + h += groupEnd() + } + + // ── Input variables ── + if !input.isEmpty { + h += sectionHeader("Input", t: t) + renderRecipeDictGroup(input, t: t, into: &h) + } + + // ── Process steps ── + if !process.isEmpty { + h += sectionHeader("Process", t: t) + for (idx, step) in process.enumerated() { + let processor = step["Processor"] as? String ?? "Unknown Processor" + let arguments = step["Arguments"] as? [String: Any] ?? [:] + + h += "" + h += "
" + h += "STEP \(idx + 1)
" + h += "\(esc(processor))" + h += "
" + + if !arguments.isEmpty { + renderRecipeDictGroup(arguments, t: t, into: &h) + } + } + } + + // ── Trust info (overrides only) ── + if let trust = trustInfo, !trust.isEmpty { + h += sectionHeader("Parent Recipe Trust Info", t: t) + h += renderComplexValue(trust, t: t) + } + + // ── Source panel (XML or YAML) ── + let sourceLabel = isYAML ? "YAML SOURCE" : "XML SOURCE" + h += "" + h += "" + h += "
\(thinLine(t))
" + h += "\(sourceLabel)" + h += "
" + h += "" + h += "
" + let sourceTokens = isYAML ? tokenizeYAML(rawSource) : tokenizeXML(rawSource) + h += "
\(renderTokens(sourceTokens))
" + h += "
" + + return wrapMobileconfigHTML(h, t: t) + } + + /// Renders a dict as a table of rows, splitting simple/complex values like + /// the mobileconfig settings group does. + private static func renderRecipeDictGroup(_ dict: [String: Any], t: Theme, into h: inout String) { + let keys = dict.keys.sorted() + let simple = keys.filter { isSimple(dict[$0]!) && !isLongString(dict[$0]!) } + let complex = keys.filter { !isSimple(dict[$0]!) || isLongString(dict[$0]!) } + + if !simple.isEmpty { + h += groupStart(t) + for (i, k) in simple.enumerated() { + h += cellRow(k, inlineValue(dict[k]!, key: k, t: t), + t: t, last: i == simple.count - 1) + } + h += groupEnd() + } + for k in complex { + h += sectionHeader(k, t: t) + if let str = dict[k] as? String, str.contains("\n") { + // Multi-line scripts/strings get a Menlo block treatment + h += "" + h += "
" + h += "
\(esc(str))
" + h += "
" + } else { + h += renderComplexValue(dict[k]!, t: t) + } + } + } + + /// Type badge based on the recipe identifier (e.g. `com.x.download.Foo` → "Download Recipe"). + /// Overrides are detected by the presence of `ParentRecipeTrustInfo`. + private static func recipeTypeBadge(identifier: String, isOverride: Bool, t: Theme) -> (String, String) { + if isOverride { + return ("Recipe Override", t.scopeUser) + } + let parts = identifier.lowercased().split(separator: ".").map(String.init) + if parts.contains("download") { return ("Download Recipe", t.scopeDevice) } + if parts.contains("pkg") { return ("Pkg Recipe", t.scopeUser) } + if parts.contains("munki") { return ("Munki Recipe", "#10b981") } + if parts.contains("install") { return ("Install Recipe", "#a855f7") } + if parts.contains("jamf") || parts.contains("jss") { + return ("Jamf Recipe", "#ef4444") + } + if parts.contains("intune") { return ("Intune Recipe", "#0ea5e9") } + return ("AutoPkg Recipe", t.muted) + } + // MARK: - JSON Profile Detection & Renderer /// Check if a JSON dictionary looks like an Apple configuration profile @@ -1513,3 +1686,228 @@ enum SyntaxHighlighter { """ } } + +// MARK: - Recipe YAML parser + +/// Parses a constrained YAML subset suitable for AutoPkg recipe files. +/// Supports: block-style mappings, block-style sequences of mappings or scalars, +/// plain scalars, single/double-quoted strings, and `#` line comments. +/// NOT supported: flow style (`{}`/`[]`), anchors, aliases, tags, multi-document streams, +/// or block scalars (`|`/`>`). Returns nil when the input doesn't look like a mapping. +enum RecipeYAMLParser { + fileprivate struct Token { + let indent: Int + let content: String + } + + static func parse(_ source: String) -> [String: Any]? { + let tokens = tokenize(source) + guard !tokens.isEmpty else { return nil } + var index = 0 + let result = parseMapping(tokens: tokens, index: &index, indent: tokens[0].indent) + return result.isEmpty ? nil : result + } + + fileprivate static func tokenize(_ source: String) -> [Token] { + var s = source + if s.hasPrefix("\u{FEFF}") { s.removeFirst() } + var tokens: [Token] = [] + for line in s.components(separatedBy: "\n") { + var indent = 0 + var i = line.startIndex + while i < line.endIndex, line[i] == " " { + indent += 1 + i = line.index(after: i) + } + let rest = String(line[i...]) + let trimmed = rest.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + tokens.append(Token(indent: indent, content: stripInlineComment(rest))) + } + return tokens + } + + fileprivate static func parseMapping(tokens: [Token], index: inout Int, indent: Int) -> [String: Any] { + var dict: [String: Any] = [:] + while index < tokens.count { + let tok = tokens[index] + if tok.indent < indent { break } + if tok.indent > indent { index += 1; continue } + guard let colon = findUnquotedColon(in: tok.content) else { + index += 1 + continue + } + let key = unquote(String(tok.content[..= indent { + dict[key] = parseSequence(tokens: tokens, index: &index, indent: next.indent) + } else if next.indent > indent { + dict[key] = parseMapping(tokens: tokens, index: &index, indent: next.indent) + } else { + dict[key] = NSNull() + } + } else { + dict[key] = NSNull() + } + } else { + dict[key] = parseScalar(valuePart) + } + } + return dict + } + + fileprivate static func parseSequence(tokens: [Token], index: inout Int, indent: Int) -> [Any] { + var arr: [Any] = [] + while index < tokens.count { + let tok = tokens[index] + if tok.indent < indent { break } + if tok.indent > indent { index += 1; continue } + guard tok.content.hasPrefix("- ") || tok.content == "-" else { break } + + let after = tok.content == "-" + ? "" + : String(tok.content.dropFirst(2)).trimmingCharacters(in: .whitespaces) + + if after.isEmpty { + index += 1 + if index < tokens.count, tokens[index].indent > indent { + let next = tokens[index] + if next.content.hasPrefix("- ") { + arr.append(parseSequence(tokens: tokens, index: &index, indent: next.indent)) + } else { + arr.append(parseMapping(tokens: tokens, index: &index, indent: next.indent)) + } + } else { + arr.append(NSNull()) + } + } else if let colon = findUnquotedColon(in: after) { + // Inline mapping: "- Key: value" — continuation keys live at indent + 2 + let firstKey = unquote(String(after[.. indent { + sub[firstKey] = parseMapping( + tokens: tokens, index: &index, indent: tokens[index].indent) + } else { + sub[firstKey] = NSNull() + } + } else { + sub[firstKey] = parseScalar(firstVal) + } + let continueIndent = indent + 2 + while index < tokens.count, tokens[index].indent >= continueIndent { + let cont = tokens[index] + if cont.indent != continueIndent { index += 1; continue } + if cont.content.hasPrefix("- ") || cont.content == "-" { break } + guard let c2 = findUnquotedColon(in: cont.content) else { + index += 1 + continue + } + let k = unquote(String(cont.content[.. continueIndent { + sub[k] = parseMapping( + tokens: tokens, index: &index, indent: tokens[index].indent) + } else { + sub[k] = NSNull() + } + } else { + sub[k] = parseScalar(v) + } + } + arr.append(sub) + } else { + arr.append(parseScalar(after)) + index += 1 + } + } + return arr + } + + fileprivate static func parseScalar(_ s: String) -> Any { + if s.isEmpty || s == "null" || s == "Null" || s == "NULL" || s == "~" { return NSNull() } + if let q = unwrapQuoted(s) { return q } + if s == "true" || s == "True" || s == "TRUE" { return true } + if s == "false" || s == "False" || s == "FALSE" { return false } + if let i = Int(s) { return i } + if let d = Double(s) { return d } + return s + } + + fileprivate static func unquote(_ s: String) -> String { + unwrapQuoted(s) ?? s + } + + fileprivate static func unwrapQuoted(_ s: String) -> String? { + guard s.count >= 2 else { return nil } + if s.first == "'" && s.last == "'" { + return String(s.dropFirst().dropLast()) + .replacingOccurrences(of: "''", with: "'") + } + if s.first == "\"" && s.last == "\"" { + let inner = String(s.dropFirst().dropLast()) + return inner + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + } + return nil + } + + /// Finds a `:` that's outside any quoted span and followed by EOL or whitespace + /// (the YAML rule for a key/value separator). + fileprivate static func findUnquotedColon(in s: String) -> String.Index? { + var inSingle = false + var inDouble = false + var i = s.startIndex + while i < s.endIndex { + let ch = s[i] + if ch == "'" && !inDouble { inSingle.toggle() } + else if ch == "\"" && !inSingle { inDouble.toggle() } + else if ch == ":" && !inSingle && !inDouble { + let next = s.index(after: i) + if next == s.endIndex || s[next] == " " || s[next] == "\t" { + return i + } + } + i = s.index(after: i) + } + return nil + } + + fileprivate static func stripInlineComment(_ s: String) -> String { + var inSingle = false + var inDouble = false + var prev: Character = " " + var i = s.startIndex + while i < s.endIndex { + let ch = s[i] + if ch == "'" && !inDouble { inSingle.toggle() } + else if ch == "\"" && !inSingle { inDouble.toggle() } + else if ch == "#" && !inSingle && !inDouble && prev == " " { + return String(s[..