|
| 1 | +# flatconfig |
| 2 | +*A minimal Ghostty-style `key = value` configuration parser for Dart and Flutter.* |
| 3 | + |
| 4 | +[](https://pub.dev/packages/flatconfig) |
| 5 | +[](LICENSE) |
| 6 | +[](https://dart.dev) |
| 7 | + |
| 8 | +flatconfig is a flat, minimal `key = value` configuration format for Dart and Flutter — easy to read, trivial to hand-edit, and simple to round-trip. |
| 9 | +Inspired by 👻 [Ghostty](https://ghostty.org)-style configuration files. |
| 10 | + |
| 11 | +It provides a simple, predictable alternative to verbose formats like YAML or JSON |
| 12 | +for small, human-editable configuration files. Ideal for tools, CLIs, and Flutter apps |
| 13 | +that need structured settings without heavy dependencies. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Highlights |
| 18 | + |
| 19 | +- 🧩 **Tiny syntax:** `key = value` (values may be quoted) |
| 20 | +- 📦 **Pure Dart**, minimal dependencies (only `meta`) |
| 21 | +- 📝 **Supports duplicates**, preserves entry order |
| 22 | +- 🔐 **Strict or lenient parsing**, optional callbacks for invalid lines |
| 23 | +- 📁 **Async/sync file I/O**, handles UTF-8 BOM and any line endings |
| 24 | +- 🧠 **Typed accessors** for durations, bytes, colors, URIs, JSON, enums, ratios, percents, lists, sets, maps, and ranges |
| 25 | +- 🧱 **Collapse helpers** to deduplicate keys (first occurrence or last write) |
| 26 | +- 🧰 **Pretty-print and debug dumps** |
| 27 | +- 🔁 **Round-tripping** with configurable quoting and escaping |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## Usage |
| 32 | + |
| 33 | +Add `flatconfig` as a dependency to your `pubspec.yaml`: |
| 34 | + |
| 35 | +```yaml |
| 36 | +dependencies: |
| 37 | + flatconfig: ^0.1.0 |
| 38 | +``` |
| 39 | +
|
| 40 | +Then import it in your Dart code: |
| 41 | +
|
| 42 | +```dart |
| 43 | +import 'package:flatconfig/flatconfig.dart'; |
| 44 | +``` |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## Quick Start 🚀 |
| 49 | + |
| 50 | +```dart |
| 51 | +import 'package:flatconfig/flatconfig.dart'; |
| 52 | +
|
| 53 | +void main() { |
| 54 | + const raw = ''' |
| 55 | + # Example config |
| 56 | + background = 282c34 |
| 57 | + keybind = ctrl+z=close_surface |
| 58 | + font-family = |
| 59 | + '''; |
| 60 | +
|
| 61 | + final doc = FlatConfig.parse(raw); |
| 62 | +
|
| 63 | + print(doc['background']); // 282c34 |
| 64 | + print(doc.valuesOf('keybind')); // [ctrl+z=close_surface] |
| 65 | + print(doc['font-family']); // null → explicit reset |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +--- |
| 70 | + |
| 71 | +## Data model |
| 72 | + |
| 73 | +```dart |
| 74 | +// A single key/value pair (value may be null for explicit resets: "key =") |
| 75 | +class FlatEntry { |
| 76 | + final String key; |
| 77 | + final String? value; |
| 78 | +} |
| 79 | +
|
| 80 | +// A parsed document that preserves order and duplicates. |
| 81 | +class FlatDocument { |
| 82 | + final List<FlatEntry> entries; |
| 83 | +
|
| 84 | + // Frequently used: |
| 85 | + Map<String, String?> toMap(); // last value per key |
| 86 | + String? operator [](String key); // shorthand for latest[key] |
| 87 | + Iterable<String> get keys; // first occurrence order |
| 88 | + List<String?> valuesOf(String key); |
| 89 | + bool has(String key); |
| 90 | + bool hasNonNull(String key); |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +--- |
| 95 | + |
| 96 | +## Parsing |
| 97 | + |
| 98 | +### Strings |
| 99 | + |
| 100 | +```dart |
| 101 | +final doc = FlatConfig.parse( |
| 102 | + raw, |
| 103 | + options: const FlatParseOptions( |
| 104 | + strict: false, // throw on invalid lines if true |
| 105 | + commentPrefix: '#', // set '' to disable comments |
| 106 | + decodeEscapesInQuoted: false, // decode \" and \\ inside quotes |
| 107 | + ), |
| 108 | +); |
| 109 | +``` |
| 110 | + |
| 111 | +- Lines starting with `commentPrefix` are ignored. |
| 112 | +- Unquoted values are trimmed; quoted values preserve whitespace and `=`. |
| 113 | +- Empty unquoted values → `null` (explicit reset). |
| 114 | +- Duplicate keys are preserved; the last one wins in `toMap()`. |
| 115 | + |
| 116 | +### Files |
| 117 | + |
| 118 | +```dart |
| 119 | +import 'dart:io'; |
| 120 | +import 'package:flatconfig/flatconfig.dart'; |
| 121 | +
|
| 122 | +final fromFile = await parseFlatFile('config.conf'); |
| 123 | +
|
| 124 | +// Sync variant: |
| 125 | +final sync = File('config.conf').parseFlatSync(); |
| 126 | +``` |
| 127 | + |
| 128 | +- Handles UTF-8 BOM |
| 129 | +- Supports `\n`, `\r\n`, and `\r` line endings |
| 130 | +- Works with async and sync file I/O |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## Encoding & Round-Tripping |
| 135 | + |
| 136 | +```dart |
| 137 | +final out = doc.encodeToString( |
| 138 | + options: const FlatEncodeOptions( |
| 139 | + quoteIfWhitespace: true, // quote values with outer spaces |
| 140 | + alwaysQuote: false, // force quotes on all non-null values |
| 141 | + escapeQuoted: false, // escape \" and \\ while encoding |
| 142 | + ), |
| 143 | +); |
| 144 | +``` |
| 145 | + |
| 146 | +### Writing to Files |
| 147 | + |
| 148 | +```dart |
| 149 | +await File('out.conf').writeFlat(doc); |
| 150 | +File('out.conf').writeFlatSync(doc); |
| 151 | +``` |
| 152 | + |
| 153 | +- Lossy by design: comments and blank lines are not preserved |
| 154 | +- `null` values are written as key `=` |
| 155 | + |
| 156 | +--- |
| 157 | + |
| 158 | +## Duplicate Keys → Collapse |
| 159 | + |
| 160 | +```dart |
| 161 | +final collapsedFirst = doc.collapse(); // keep first position, last value wins |
| 162 | +final collapsedLast = doc.collapse(order: CollapseOrder.lastWrite); |
| 163 | +
|
| 164 | +final keepMulti = doc.collapse(multiValueKeys: {'keybind'}); |
| 165 | +final dynamicMulti = doc.collapse(isMultiValueKey: (k) => k.startsWith('mv_')); |
| 166 | +
|
| 167 | +final dropResets = doc.collapse(dropNulls: true); // omit keys with null |
| 168 | +``` |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +## Typed Accessors (Examples) |
| 173 | + |
| 174 | +```dart |
| 175 | +final b = doc.getBytes('size'); // SI (kB/MB/...) and IEC (KiB/MiB/...) |
| 176 | +final cc = doc.getColor('color'); // {a, r, g, b} |
| 177 | +final d = doc.getDuration('timeout'); // "150ms", "2s", "5m", "3h", "1d" |
| 178 | +final e = doc.getEnum('mode', {'prod': 1, 'dev': 2}); // case-insensitive |
| 179 | +final co = doc.getHexColor('color'); // #rgb, #rgba, #rrggbb, #aarrggbb → 0xAARRGGBB |
| 180 | +final j = doc.getJson('payload'); // parsed JSON object |
| 181 | +final p = doc.getPercent('alpha'); // "80%", "0.8", "80" → 0.8 |
| 182 | +final r = doc.getRatio('video'); // "16:9" → 1.777... |
| 183 | +final u = doc.getUri('endpoint'); // relative or absolute URI |
| 184 | +
|
| 185 | +// Collections |
| 186 | +final list = doc.getList('features'); // "A, b , a" → ["A","b","a"] |
| 187 | +final set = doc.getSet('features'); // → {"a","b"} (case-insensitive) |
| 188 | +
|
| 189 | +// Ranges |
| 190 | +final dIn = doc.getDoubleInRange('gamma', min: 0.5, max: 2.0); |
| 191 | +final iIn = doc.getIntInRange('retries', min: 0, max: 10); |
| 192 | +
|
| 193 | +// Require* methods throw FormatException on missing/invalid values |
| 194 | +final sz = doc.requireBytes('size'); |
| 195 | +final ms = doc.requireDuration('timeout'); |
| 196 | +final col = doc.requireHexColor('color'); |
| 197 | +final pct = doc.requirePercent('alpha'); |
| 198 | +``` |
| 199 | + |
| 200 | +### Mini-Documents & Pairs |
| 201 | + |
| 202 | +```dart |
| 203 | +// Single key=value inside a value |
| 204 | +final pair = doc.getKeyValue('keybind'); |
| 205 | +// e.g. "ctrl+z=close_surface" → ('ctrl+z','close_surface') |
| 206 | +
|
| 207 | +// Mini-document in a single value |
| 208 | +final sub = doc.getDocument('db'); // "host=foo, port=5432" |
| 209 | +print(sub.toMap()); // {host: foo, port: 5432} |
| 210 | +
|
| 211 | +// List of mini-documents |
| 212 | +final servers = doc.getListOfDocuments('servers'); |
| 213 | +// "host=foo,port=8080 | host=bar,port=9090" → List<FlatDocument> |
| 214 | +
|
| 215 | +// Host[:port] |
| 216 | +final hp = doc.getHostPort('listen'); // "[::1]:8080" → ('::1', 8080) |
| 217 | +``` |
| 218 | + |
| 219 | +### Other Convenience Methods |
| 220 | + |
| 221 | +```dart |
| 222 | +doc.getTrimmed('name'); // trimmed value |
| 223 | +doc.getStringOr('title', 'Untitled'); // default fallback |
| 224 | +doc.isEnabled('feature_x'); // truthy/falsey strings |
| 225 | +doc.isOneOf('env', {'dev', 'prod'}); // case-insensitive |
| 226 | +doc.requireKeys(['host', 'port']); // throws on first missing key |
| 227 | +``` |
| 228 | + |
| 229 | +All `require*` methods throw a `FormatException` with context on invalid data. |
| 230 | + |
| 231 | +--- |
| 232 | + |
| 233 | +## Debug & Pretty Print |
| 234 | + |
| 235 | +```dart |
| 236 | +print(doc.debugDump()); |
| 237 | +// [0] a = 1 |
| 238 | +// [1] b = null |
| 239 | +// ... |
| 240 | +
|
| 241 | +print(doc.toPrettyString( |
| 242 | + includeIndexes: true, |
| 243 | + sortByKey: true, |
| 244 | + alignColumns: true, |
| 245 | +)); |
| 246 | +``` |
| 247 | + |
| 248 | +--- |
| 249 | + |
| 250 | +## End-to-End Example |
| 251 | + |
| 252 | +```dart |
| 253 | +import 'dart:io'; |
| 254 | +import 'package:flatconfig/flatconfig.dart'; |
| 255 | +
|
| 256 | +Future<void> main() async { |
| 257 | + final result = await parseFlatFile('config.conf'); |
| 258 | +
|
| 259 | + final doc = result; |
| 260 | + final updated = FlatDocument([ |
| 261 | + ...doc.entries, |
| 262 | + const FlatEntry('note', ' keep whitespace '), |
| 263 | + ]); |
| 264 | +
|
| 265 | + await File('out.conf').writeFlat(updated); |
| 266 | +} |
| 267 | +``` |
| 268 | + |
| 269 | +--- |
| 270 | + |
| 271 | +## Format Rules & Limits |
| 272 | + |
| 273 | +- Only full-line comments (default prefix `#`) |
| 274 | +- Inline comments are not supported |
| 275 | +- Lines without `=` are ignored in non-strict mode |
| 276 | +- Unquoted values are trimmed; quoted values preserve whitespace and `=` |
| 277 | +- Empty unquoted values become `null` (explicit reset) |
| 278 | +- Encoding is lossy (comments and blank lines are dropped) |
| 279 | + |
| 280 | +--- |
| 281 | + |
| 282 | +### See also |
| 283 | + |
| 284 | +- 🧠 [Ghostty Configuration Format](https://ghostty.org/docs/config) |
| 285 | +- 🧰 [Dart Configuration File Libraries on pub.dev](https://pub.dev/packages?q=config) |
| 286 | + |
| 287 | +--- |
| 288 | + |
| 289 | +## License |
| 290 | + |
| 291 | +[MIT](LICENSE) |
| 292 | + |
| 293 | +--- |
| 294 | + |
| 295 | +Made with ❤️ in Dart. |
| 296 | +Contributions welcome on [GitHub → grumpypixel/flatconfig](https://github.com/grumpypixel/flatconfig) |
0 commit comments