/
DotEnv.swift
368 lines (339 loc) · 12.6 KB
/
DotEnv.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
#if os(Linux)
import Glibc
#else
import Darwin
#endif
import Logging
import NIOCore
import NIOPosix
/// Reads dotenv (`.env`) files and loads them into the current process.
///
/// let fileio: NonBlockingFileIO
/// let elg: EventLoopGroup
/// let file = try DotEnvFile.read(path: ".env", fileio: fileio, on: elg.next()).wait()
/// for line in file.lines {
/// print("\(line.key)=\(line.value)")
/// }
/// file.load(overwrite: true) // loads all lines into the process
///
/// Dotenv files are formatted using `KEY=VALUE` syntax. They support comments using the `#` symbol.
/// They also support strings, both single and double-quoted.
///
/// FOO=BAR
/// STRING='Single Quote String'
/// # Comment
/// STRING2="Double Quoted\nString"
///
/// Single-quoted strings are parsed literally. Double-quoted strings may contain escaped newlines
/// that will be converted to actual newlines.
public struct DotEnvFile: Sendable {
/// Reads the dotenv files relevant to the environment and loads them into the process.
///
/// let environment: Environment
/// let elgp: EventLoopGroupProvider
/// let fileio: NonBlockingFileIO
/// let logger: Logger
/// try DotEnvFile.load(for: .development, on: elgp, fileio: fileio, logger: logger)
/// print(Environment.process.FOO) // BAR
///
/// - parameters:
/// - environment: current environment, selects which .env file to use.
/// - eventLoopGroupProvider: Either provides an EventLoopGroup or tells the function to create a new one.
/// - fileio: NonBlockingFileIO that is used to read the .env file(s).
/// - logger: Optionally provide an existing logger.
public static func load(
for environment: Environment = .development,
on eventLoopGroupProvider: Application.EventLoopGroupProvider = .singleton,
fileio: NonBlockingFileIO,
logger: Logger = Logger(label: "dot-env-logger")
) {
let eventLoopGroup: EventLoopGroup
switch eventLoopGroupProvider {
case .shared(let group):
eventLoopGroup = group
case .createNew:
eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
}
defer {
switch eventLoopGroupProvider {
case .shared:
logger.trace("Running on shared EventLoopGroup. Not shutting down EventLoopGroup.")
case .createNew:
logger.trace("Shutting down EventLoopGroup")
do {
try eventLoopGroup.syncShutdownGracefully()
} catch {
logger.warning("Shutting down EventLoopGroup failed: \(error)")
}
}
}
// Load specific .env first since values are not overridden.
DotEnvFile.load(path: ".env.\(environment.name)", on: .shared(eventLoopGroup), fileio: fileio, logger: logger)
DotEnvFile.load(path: ".env", on: .shared(eventLoopGroup), fileio: fileio, logger: logger)
}
/// Reads the dotenv files relevant to the environment and loads them into the process.
///
/// let path: String
/// let elgp: EventLoopGroupProvider
/// let fileio: NonBlockingFileIO
/// let logger: Logger
/// try DotEnvFile.load(path: path, on: elgp, fileio: filio, logger: logger)
/// print(Environment.process.FOO) // BAR
///
/// - parameters:
/// - path: Absolute or relative path of the dotenv file.
/// - eventLoopGroupProvider: Either provides an EventLoopGroup or tells the function to create a new one.
/// - fileio: NonBlockingFileIO that is used to read the .env file(s).
/// - logger: Optionally provide an existing logger.
public static func load(
path: String,
on eventLoopGroupProvider: Application.EventLoopGroupProvider = .singleton,
fileio: NonBlockingFileIO,
logger: Logger = Logger(label: "dot-env-logger")
) {
let eventLoopGroup: EventLoopGroup
switch eventLoopGroupProvider {
case .shared(let group):
eventLoopGroup = group
case .createNew:
eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
}
defer {
switch eventLoopGroupProvider {
case .shared:
logger.trace("Running on shared EventLoopGroup. Not shutting down EventLoopGroup.")
case .createNew:
logger.trace("Shutting down EventLoopGroup")
do {
try eventLoopGroup.syncShutdownGracefully()
} catch {
logger.warning("Shutting down EventLoopGroup failed: \(error)")
}
}
}
do {
try load(path: path, fileio: fileio, on: eventLoopGroup.next()).wait()
} catch {
logger.debug("Could not load \(path) file: \(error)")
}
}
/// Reads a dotenv file from the supplied path and loads it into the process.
///
/// let fileio: NonBlockingFileIO
/// let elg: EventLoopGroup
/// try DotEnvFile.load(path: ".env", fileio: fileio, on: elg.next()).wait()
/// print(Environment.process.FOO) // BAR
///
/// Use `DotEnvFile.read` to read the file without loading it.
///
/// - parameters:
/// - path: Absolute or relative path of the dotenv file.
/// - fileio: File loader.
/// - eventLoop: Eventloop to perform async work on.
/// - overwrite: If `true`, values already existing in the process' env
/// will be overwritten. Defaults to `false`.
public static func load(
path: String,
fileio: NonBlockingFileIO,
on eventLoop: EventLoop,
overwrite: Bool = false
) -> EventLoopFuture<Void> {
return self.read(path: path, fileio: fileio, on: eventLoop)
.map { $0.load(overwrite: overwrite) }
}
/// Reads a dotenv file from the supplied path.
///
/// let fileio: NonBlockingFileIO
/// let elg: EventLoopGroup
/// let file = try DotEnvFile.read(path: ".env", fileio: fileio, on: elg.next()).wait()
/// for line in file.lines {
/// print("\(line.key)=\(line.value)")
/// }
/// file.load(overwrite: true) // loads all lines into the process
/// print(Environment.process.FOO) // BAR
///
/// Use `DotEnvFile.load` to read and load with one method.
///
/// - parameters:
/// - path: Absolute or relative path of the dotenv file.
/// - fileio: File loader.
/// - eventLoop: Eventloop to perform async work on.
public static func read(
path: String,
fileio: NonBlockingFileIO,
on eventLoop: EventLoop
) -> EventLoopFuture<DotEnvFile> {
return fileio.openFile(path: path, eventLoop: eventLoop).flatMapWithEventLoop { arg, eventLoop -> EventLoopFuture<ByteBuffer> in
let fileHandleWrapper = NIOLoopBound(arg.0, eventLoop: eventLoop)
return fileio.read(fileRegion: arg.1, allocator: .init(), eventLoop: eventLoop)
.flatMapThrowing
{ buffer in
try fileHandleWrapper.value.close()
return buffer
}
}.map { buffer in
var parser = Parser(source: buffer)
return .init(lines: parser.parse())
}
}
/// Represents a `KEY=VALUE` pair in a dotenv file.
public struct Line: Sendable, CustomStringConvertible, Equatable {
/// The key.
public let key: String
/// The value.
public let value: String
/// `CustomStringConvertible` conformance.
public var description: String {
return "\(self.key)=\(self.value)"
}
}
/// All `KEY=VALUE` pairs found in the file.
public let lines: [Line]
/// Creates a new DotEnvFile
init(lines: [Line]) {
self.lines = lines
}
/// Loads this file's `KEY=VALUE` pairs into the current process.
///
/// let file: DotEnvFile
/// file.load(overwrite: true) // loads all lines into the process
///
/// - parameters:
/// - overwrite: If `true`, values already existing in the process' env
/// will be overwritten. Defaults to `false`.
public func load(overwrite: Bool = false) {
for line in self.lines {
setenv(line.key, line.value, overwrite ? 1 : 0)
}
}
}
// MARK: Parser
extension DotEnvFile {
struct Parser {
var source: ByteBuffer
init(source: ByteBuffer) {
self.source = source
}
mutating func parse() -> [Line] {
var lines: [Line] = []
while let next = self.parseNext() {
lines.append(next)
}
return lines
}
private mutating func parseNext() -> Line? {
self.skipSpaces()
guard let peek = self.peek() else {
return nil
}
switch peek {
case .octothorpe:
// comment following, skip it
self.skipComment()
// then parse next
return self.parseNext()
case .newLine:
// empty line, skip
self.pop() // \n
// then parse next
return self.parseNext()
default:
// this is a valid line, parse it
return self.parseLine()
}
}
private mutating func skipComment() {
let commentLength: Int
if let toNewLine = self.countDistance(to: .newLine) {
commentLength = toNewLine + 1 // include newline
} else {
commentLength = self.source.readableBytes
}
self.source.moveReaderIndex(forwardBy: commentLength)
}
private mutating func parseLine() -> Line? {
guard let keyLength = self.countDistance(to: .equal) else {
return nil
}
guard let key = self.source.readString(length: keyLength) else {
return nil
}
self.pop() // =
guard let value = self.parseLineValue() else {
return nil
}
return Line(key: key, value: value)
}
private mutating func parseLineValue() -> String? {
let valueLength: Int
if let toNewLine = self.countDistance(to: .newLine) {
valueLength = toNewLine
} else {
valueLength = self.source.readableBytes
}
guard let value = self.source.readString(length: valueLength) else {
return nil
}
guard let first = value.first, let last = value.last else {
return value
}
// check for quoted strings
switch (first, last) {
case ("\"", "\""):
// double quoted strings support escaped \n
return value.dropFirst().dropLast()
.replacingOccurrences(of: "\\n", with: "\n")
case ("'", "'"):
// single quoted strings just need quotes removed
return value.dropFirst().dropLast() + ""
default: return value
}
}
private mutating func skipSpaces() {
scan: while let next = self.peek() {
switch next {
case .space: self.pop()
default: break scan
}
}
}
private func peek() -> UInt8? {
return self.source.getInteger(at: self.source.readerIndex)
}
private mutating func pop() {
self.source.moveReaderIndex(forwardBy: 1)
}
private func countDistance(to byte: UInt8) -> Int? {
var copy = self.source
var found = false
scan: while let next = copy.readInteger(as: UInt8.self) {
if next == byte {
found = true
break scan
}
}
guard found else {
return nil
}
let distance = copy.readerIndex - source.readerIndex
guard distance != 0 else {
return nil
}
return distance - 1
}
}
}
private extension UInt8 {
static var newLine: UInt8 {
return 0xA
}
static var space: UInt8 {
return 0x20
}
static var octothorpe: UInt8 {
return 0x23
}
static var equal: UInt8 {
return 0x3D
}
}