krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgApp/UserConfig.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Sources/OrgApp/UserConfig.swift history · blame · raw

441 lines · 28698 bytes

  1import Foundation
  2import OrgCore
  3import OrgPresentation
  4import OrgWorkspace
  5
  6/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and
  7/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a
  8/// dotfiles repository and share. The file is the source of truth: Settings writes its changes
  9/// into it, keeping comments and order, and edits to it apply while the app runs.
 10public enum UserConfig {
 11    /// A folder chosen in the app (on iOS, a synced copy of the Mac's), used over the default.
 12    nonisolated(unsafe) public static var directoryOverride: URL?
 13
 14    public static var directory: URL {
 15        if let directoryOverride { return directoryOverride }
 16        let environment = ProcessInfo.processInfo.environment
 17        if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) }
 18        // Development runs keep everything in their data folder.
 19        if let data = environment["ORGSTAR_DATA_DIR"] { return URL(fileURLWithPath: data) }
 20        if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty {
 21            return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar")
 22        }
 23        return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
 24    }
 25
 26    /// The theme `config.toml` (`text`) sets: its `[theme]` tables' colors over those of
 27    /// `theme-file` (a path relative to `folder`), over the default theme; with problems in
 28    /// both. Nil when `text` isn't TOML.
 29    public static func theme(
 30        config text: String, themeFile name: String?, in folder: URL,
 31        read: (URL) -> String? = { try? String(contentsOf: $0, encoding: .utf8) }
 32    ) -> (theme: ThemeSpec, problems: [String])? {
 33        guard let tables = try? TOML.parse(text) else { return nil }
 34        var spec = ThemeSpec.default
 35        var problems: [String] = []
 36        if let name, !name.isEmpty {
 37            if let themeText = read(folder.appendingPathComponent(name)) {
 38                do {
 39                    (spec, problems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
 40                } catch {
 41                    problems.append("\(name): \(error)")
 42                }
 43            } else {
 44                problems.append("config.toml: theme-file \(name) can't be read")
 45            }
 46        }
 47        let (merged, more) = ThemeSpec.reading(tables, over: spec)
 48        return (merged, problems + more)
 49    }
 50
 51    /// Where earlier versions kept the configuration files.
 52    public static var legacyDirectory: URL {
 53        FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
 54    }
 55
 56    /// A file of the configuration folder; one that exists only where earlier versions kept
 57    /// it (Application Support) is still read from there.
 58    public static func file(_ name: String) -> URL {
 59        let url = directory.appendingPathComponent(name)
 60        let legacy = legacyDirectory.appendingPathComponent(name)
 61        let manager = FileManager.default
 62        if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy }
 63        return url
 64    }
 65
 66    public static var configFile: URL { directory.appendingPathComponent("config.toml") }
 67
 68    /// One setting: where it sits in the file, its user-defaults key, and how values convert.
 69    /// Settings Emacs has a variable for sit at the top of the file under that variable's
 70    /// name; the rest go in `[orgstar]`.
 71    public struct Setting: Sendable {
 72        public enum Kind: Sendable {
 73            case bool, integer, string
 74            /// A string in the file for each stored value.
 75            case choice([(name: String, stored: String)])
 76            case integerChoice([(name: String, stored: Int)])
 77            /// `org-agenda-start-day`: `"-3d"` in the file, -3 stored.
 78            case dayOffset
 79        }
 80
 81        public let section: String
 82        public let key: String
 83        public let defaultsKey: String
 84        public let kind: Kind
 85        public let fallback: TOML.Value
 86        public let comment: String
 87        /// Where earlier versions of the file kept it, `section.key`.
 88        public let legacy: String
 89
 90        public var path: String { section.isEmpty ? key : section + "." + key }
 91    }
 92
 93    public static let settings: [Setting] = [
 94        Setting(section: "", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80),
 95                comment: "M-q fills to this column", legacy: "editor.fill-column"),
 96        Setting(section: "", key: "org-tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77),
 97                comment: "negative: tags end at that column; 0: one space before them", legacy: "editor.tags-column"),
 98        Setting(section: "", key: "org-insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true),
 99                comment: "M-RET adds the new heading after the subtree", legacy: "editor.insert-heading-respect-content"),
100        Setting(section: "", key: "org-M-RET-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false),
101                comment: "M-RET splits the line at the caret", legacy: "editor.meta-return-may-split-line"),
102        Setting(section: "", key: "org-list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true),
103                comment: "lists can use a. b. c.", legacy: "editor.list-allow-alphabetical"),
104        Setting(section: "", key: "org-hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true),
105                comment: "hide *bold* and =code= markers when markup is hidden", legacy: "editor.hide-emphasis-markers"),
106        Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
107                comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
108        Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO(t) PROJ(p) LOOP(r) STRT(s) WAIT(w) HOLD(h) IDEA(i) | DONE(d) KILL(k)"),
109                comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); keys in parentheses turn on fast selection; read at launch", legacy: "editor.todo-keywords"),
110        Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
111                fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
112        Setting(section: "", key: "org-log-reschedule", defaultsKey: "logReschedule", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
113                fallback: .string("nil"), comment: "nil, time or note: log changing or removing a SCHEDULED date", legacy: ""),
114        Setting(section: "", key: "org-log-redeadline", defaultsKey: "logRedeadline", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
115                fallback: .string("nil"), comment: "nil, time or note: log changing or removing a DEADLINE", legacy: ""),
116        Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
117                comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
118        Setting(section: "", key: "org-startup-indented", defaultsKey: "startupIndented", kind: .bool, fallback: .bool(true),
119                comment: "indent bodies under their headings (org-indent-mode); #+STARTUP: (no)indent overrides", legacy: ""),
120        Setting(section: "", key: "org-hide-leading-stars", defaultsKey: "hideLeadingStars", kind: .bool, fallback: .bool(false),
121                comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""),
122        Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false),
123                comment: "align every table when a file opens; #+STARTUP: (no)align overrides", legacy: ""),
124        Setting(section: "", key: "org-startup-truncated", defaultsKey: "startupTruncated", kind: .bool, fallback: .bool(false),
125                comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""),
126        Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false),
127                comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""),
128        Setting(section: "", key: "electric-pair-mode", defaultsKey: "electricPair", kind: .bool, fallback: .bool(true),
129                comment: "type brackets, <> and quotes in pairs as electric-pair-mode does (off in plain Emacs; Doom pairs with smartparens)", legacy: ""),
130        Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false),
131                comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""),
132        Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false),
133                comment: "single keys at the start of a heading line run commands (n, p, t, c, …)", legacy: ""),
134        Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true),
135                comment: "fold drawers when a file opens; #+STARTUP: nohidedrawers overrides", legacy: ""),
136        Setting(section: "", key: "org-cycle-hide-block-startup", defaultsKey: "hideBlockStartup", kind: .bool, fallback: .bool(false),
137                comment: "fold blocks when a file opens; #+STARTUP: hideblocks overrides", legacy: ""),
138        Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
139                comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
140        Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
141                comment: "days the agenda shows", legacy: "agenda.span"),
142        Setting(section: "", key: "org-agenda-start-day", defaultsKey: "agendaStartOffset", kind: .dayOffset, fallback: .string("-3d"),
143                comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
144        Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
145                comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
146        Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
147                comment: "minutes idle with a clock running before asking what to do with the time; 0 never (nil)", legacy: ""),
148        Setting(section: "", key: "org-clock-history-length", defaultsKey: "clockHistoryLength", kind: .integer, fallback: .integer(5),
149                comment: "recently clocked entries to remember", legacy: ""),
150        Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
151                comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
152        Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
153                comment: "the TODO list", legacy: ""),
154        Setting(section: "org-agenda-prefix-format", key: "tags", defaultsKey: "agendaPrefixTags", kind: .string, fallback: .string(PrefixFormat.defaults["tags"]!),
155                comment: "tag and property matches", legacy: ""),
156        Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""),
157                comment: "a monospaced font family; \"\" for the system's", legacy: ""),
158        Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13),
159                comment: "points", legacy: ""),
160        Setting(section: "theme", key: "line-spacing", defaultsKey: "themeLineSpacing", kind: .integer, fallback: .integer(2),
161                comment: "points between lines", legacy: ""),
162        Setting(section: "theme", key: "heading-size-step", defaultsKey: "themeHeadingStep", kind: .integer, fallback: .integer(1),
163                comment: "points a heading is larger than the level below; level 4 and deeper are body size", legacy: ""),
164        Setting(section: "theme", key: "theme-file", defaultsKey: "themeFile", kind: .string, fallback: .string(""),
165                comment: "a theme in its own file in this folder, under the colors set here; colors: see default-theme.toml", legacy: ""),
166        Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
167                fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"),
168        Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
169                fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml", legacy: "general.keymap"),
170        Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
171                kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
172                comment: "left, right, both or none", legacy: "general.option-as-meta"),
173        Setting(section: "orgstar", key: "tab-bar", defaultsKey: "showTabBar", kind: .bool, fallback: .bool(false),
174                comment: "a tab for each open buffer above the editor (View ▸ Show Tab Bar)", legacy: ""),
175        Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
176                comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
177        Setting(section: "orgstar", key: "show-hidden-files", defaultsKey: "showHiddenFiles", kind: .bool, fallback: .bool(true),
178                comment: "list dotfiles and dot folders in your folders", legacy: ""),
179        Setting(section: "orgstar", key: "ignored-folders", defaultsKey: "ignoredFolders", kind: .string,
180                fallback: .string(ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")),
181                comment: "folder names never listed or searched, separated by spaces", legacy: ""),
182        Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
183                comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
184        Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
185                comment: "notify before timed entries", legacy: "agenda.reminders"),
186        Setting(section: "orgstar", key: "global-capture-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
187                comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml", legacy: "capture.global-hotkey"),
188    ]
189
190    /// `"-3d"` as -3; nil for anything else.
191    static func dayOffset(_ s: String) -> Int? {
192        guard s.hasSuffix("d"), let n = Int(s.dropLast().replacingOccurrences(of: "+", with: "")) else { return nil }
193        return n
194    }
195
196    // MARK: - Values
197
198    /// The file value for what `defaults` holds, or the fallback.
199    public static func value(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value {
200        guard defaults.object(forKey: setting.defaultsKey) != nil else { return setting.fallback }
201        switch setting.kind {
202        case .bool: return .bool(defaults.bool(forKey: setting.defaultsKey))
203        case .integer: return .integer(defaults.integer(forKey: setting.defaultsKey))
204        case .string: return .string(defaults.string(forKey: setting.defaultsKey) ?? "")
205        case .choice(let choices):
206            let stored = defaults.string(forKey: setting.defaultsKey)
207            return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
208        case .integerChoice(let choices):
209            let stored = defaults.integer(forKey: setting.defaultsKey)
210            return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
211        case .dayOffset:
212            let days = defaults.integer(forKey: setting.defaultsKey)
213            return .string((days < 0 ? "" : "+") + "\(days)d")
214        }
215    }
216
217    /// Stores a file value; a value of the wrong kind is a problem.
218    static func store(_ value: TOML.Value, _ setting: Setting, in defaults: UserDefaults) -> String? {
219        let key = setting.defaultsKey
220        switch (setting.kind, value) {
221        case (.bool, .bool(let b)): defaults.set(b, forKey: key)
222        case (.integer, .integer(let i)): defaults.set(i, forKey: key)
223        case (.string, .string(let s)): defaults.set(s, forKey: key)
224        case (.choice(let choices), .string(let s)):
225            guard let choice = choices.first(where: { $0.name == s }) else {
226                return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
227            }
228            defaults.set(choice.stored, forKey: key)
229        case (.integerChoice(let choices), .string(let s)):
230            guard let choice = choices.first(where: { $0.name == s }) else {
231                return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
232            }
233            defaults.set(choice.stored, forKey: key)
234        case (.dayOffset, .string(let s)):
235            guard let days = dayOffset(s) else { return "\(setting.key) must look like \"-3d\" or \"+0d\"" }
236            defaults.set(days, forKey: key)
237        default:
238            return "\(setting.path) has the wrong kind of value"
239        }
240        return nil
241    }
242
243    /// Applies a file to `defaults`; settings it leaves out go back to their defaults.
244    /// Returns problems.
245    @discardableResult
246    public static func apply(_ text: String, to defaults: UserDefaults) -> [String] {
247        let tables: [TOML.Table]
248        do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] }
249        var problems: [String] = []
250        let present = Set(tables.flatMap { table in table.values.keys.map { path(table.name, $0) } })
251        for setting in settings where !present.contains(setting.path) && !present.contains(setting.legacy) {
252            if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
253        }
254        for table in tables {
255            // Colors are the theme's to read.
256            if ThemeSpec.isThemeTable(table.name), table.name != "theme" { continue }
257            for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
258                if table.name == "theme", !ThemeSpec.typeKeys.contains(key) { continue }
259                let name = path(table.name, key)
260                guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
261                    problems.append("config.toml: unknown setting \(name)")
262                    continue
263                }
264                if let current = currentValue(setting, in: defaults), current == value { continue }
265                if let problem = store(value, setting, in: defaults) { problems.append("config.toml: \(problem)") }
266            }
267        }
268        return problems
269    }
270
271    static func path(_ section: String, _ key: String) -> String { section.isEmpty ? key : section + "." + key }
272
273    /// Whether the file uses names from before settings took Emacs's names.
274    public static func usesLegacyNames(_ text: String) -> Bool {
275        let names = Set(settings.map(\.legacy))
276        return values(in: text).keys.contains { names.contains($0) }
277    }
278
279    /// `text` with settings under their old names moved to their current names and places,
280    /// keeping comments and everything else. A section the move leaves empty is removed.
281    public static func migratingLegacyNames(_ text: String) -> String {
282        let values = values(in: text)
283        var blocks: [[String]] = [[]]
284        for line in text.components(separatedBy: "\n") {
285            if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { blocks.append([]) }
286            blocks[blocks.count - 1].append(line)
287        }
288        var moves: [(setting: Setting, value: TOML.Value, comment: String)] = []
289        var kept: [String] = []
290        for block in blocks {
291            let header = block.first.map { $0.trimmingCharacters(in: .whitespaces) }.flatMap { $0.hasPrefix("[") ? $0 : nil }
292            let section = header.map { String($0.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) } ?? ""
293            var remaining: [String] = []
294            for (index, line) in block.enumerated() {
295                guard header == nil || index > 0, let equals = line.firstIndex(of: "=") else {
296                    remaining.append(line)
297                    continue
298                }
299                let name = path(section, line[..<equals].trimmingCharacters(in: .whitespaces))
300                guard let setting = settings.first(where: { !$0.legacy.isEmpty && $0.legacy == name }), let value = values[name] else {
301                    remaining.append(line)
302                    continue
303                }
304                // A setting also under its new name keeps that one.
305                if values[setting.path] == nil {
306                    moves.append((setting, value, commentStart(line, after: equals).map { String(line[$0...]) } ?? ""))
307                }
308            }
309            let emptied = header != nil && remaining.count < block.count
310                && remaining.dropFirst().allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty }
311            if !emptied { kept += remaining }
312        }
313        var out = kept.joined(separator: "\n")
314        if text.hasSuffix("\n"), !out.isEmpty, !out.hasSuffix("\n") { out += "\n" }
315        for move in moves {
316            out = setting(out, section: move.setting.section, key: move.setting.key, to: move.value)
317            guard !move.comment.isEmpty else { continue }
318            var lines = out.components(separatedBy: "\n")
319            if let index = lines.firstIndex(of: "\(move.setting.key) = \(format(move.value))") {
320                lines[index] += "  " + move.comment
321                out = lines.joined(separator: "\n")
322            }
323        }
324        return out
325    }
326
327    private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
328        defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
329    }
330
331    /// The values a file sets, by `section.key`.
332    static func values(in text: String) -> [String: TOML.Value] {
333        guard let tables = try? TOML.parse(text) else { return [:] }
334        var result: [String: TOML.Value] = [:]
335        for table in tables { for (key, value) in table.values { result[path(table.name, key)] = value } }
336        return result
337    }
338
339    // MARK: - Text
340
341    static func format(_ value: TOML.Value) -> String {
342        switch value {
343        case .bool(let b): b ? "true" : "false"
344        case .integer(let i): String(i)
345        case .string(let s):
346            "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
347                .replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\t", with: "\\t") + "\""
348        }
349    }
350
351    /// A commented file with every setting at its current value.
352    public static func template(from defaults: UserDefaults) -> String {
353        var out = """
354            # Orgstar settings. The Settings window writes its changes here, and edits made here
355            # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml
356            # and saved agenda views in views.toml, all in this folder.
357
358            # Emacs variables, by their Emacs names.
359
360            """
361        var section = ""
362        for setting in settings {
363            if setting.section != section {
364                section = setting.section
365                out += "\n[\(section)]\n"
366            }
367            out += "\(setting.key) = \(format(value(setting, in: defaults)))  # \(setting.comment)\n"
368        }
369        return out
370    }
371
372    /// `text` with the sections it lacks entirely added at the end, every setting at its
373    /// value in `defaults`: how a file from an earlier version gets settings added since.
374    /// Single lines left out stay out; they mean the default.
375    public static func addingMissingSections(_ text: String, from defaults: UserDefaults) -> String {
376        let tables = (try? TOML.parse(text)) ?? []
377        let present = Set(tables.filter { !$0.values.isEmpty || $0.name != "" }.map(\.name))
378        var out = text
379        var section: String?
380        for setting in settings where !setting.section.isEmpty && !present.contains(setting.section) {
381            if setting.section != section {
382                section = setting.section
383                if !out.hasSuffix("\n") { out += "\n" }
384                out += "\n[\(setting.section)]\n"
385            }
386            out += "\(setting.key) = \(format(value(setting, in: defaults)))  # \(setting.comment)\n"
387        }
388        return out
389    }
390
391    /// `text` with `section.key` set to `value`: the line's value is replaced (its comment
392    /// stays), or a line is added at the end of the section, or the section at the end.
393    public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String {
394        var lines = text.components(separatedBy: "\n")
395        var current = ""
396        var lastInSection: Int?
397        for (index, line) in lines.enumerated() {
398            let trimmed = line.trimmingCharacters(in: .whitespaces)
399            if trimmed.hasPrefix("[") && !trimmed.hasPrefix("[[") {
400                current = String(trimmed.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces)
401                if current == section { lastInSection = index }
402                continue
403            }
404            guard current == section else { continue }
405            if !trimmed.isEmpty { lastInSection = index }
406            guard let equals = line.firstIndex(of: "="),
407                  line[..<equals].trimmingCharacters(in: .whitespaces) == key else { continue }
408            let comment = commentStart(line, after: equals).map { "  " + line[$0...] } ?? ""
409            lines[index] = line[..<equals] + "= " + format(value) + comment
410            return lines.joined(separator: "\n")
411        }
412        if let lastInSection {
413            lines.insert("\(key) = \(format(value))", at: lastInSection + 1)
414        } else if section.isEmpty {
415            let firstHeader = lines.firstIndex { $0.trimmingCharacters(in: .whitespaces).hasPrefix("[") } ?? lines.count
416            lines.insert(contentsOf: ["\(key) = \(format(value))", ""], at: firstHeader)
417        } else {
418            if lines.last == "" { lines.removeLast() }
419            lines += ["", "[\(section)]", "\(key) = \(format(value))", ""]
420        }
421        return lines.joined(separator: "\n")
422    }
423
424    /// Where a `#` comment starts after the value, outside quotes.
425    private static func commentStart(_ line: String, after equals: String.Index) -> String.Index? {
426        var quote: Character?
427        var index = line.index(after: equals)
428        while index < line.endIndex {
429            let c = line[index]
430            if let q = quote {
431                if c == "\\" && q == "\"" { index = line.index(after: index) } else if c == q { quote = nil }
432            } else if c == "\"" || c == "'" {
433                quote = c
434            } else if c == "#" {
435                return index
436            }
437            if index < line.endIndex { index = line.index(after: index) }
438        }
439        return nil
440    }
441}