Skip to content

Commit 591deae

Browse files
Add Beancount driver plugin
1 parent 5a38125 commit 591deae

16 files changed

Lines changed: 1883 additions & 0 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,14 @@ Libs/*.a
150150
Libs/.downloaded
151151
Libs/dylibs/
152152
Libs/ios/
153+
Libs/rustledger/
153154
fix-1322-plugin-abi-and-registry-overhaul.diff
154155

155156
# Issue analysis blueprints (local only)
156157
.analysis/
158+
159+
# Local planning and assistant history
160+
.specstory/
161+
.planning/
162+
.plans/
163+
planning/
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
//
2+
// BeancountLedgerParser.swift
3+
// BeancountDriverPlugin
4+
//
5+
6+
import Foundation
7+
8+
struct BeancountLedger: Sendable {
9+
let transactions: [BeancountTransaction]
10+
let postings: [BeancountPosting]
11+
let accounts: [BeancountAccount]
12+
let prices: [BeancountPrice]
13+
let balances: [BeancountBalance]
14+
let sourceFiles: [URL]
15+
}
16+
17+
struct BeancountTransaction: Sendable {
18+
let id: Int
19+
let date: String
20+
let flag: String
21+
let payee: String?
22+
let narration: String?
23+
let sourceFile: URL
24+
let line: Int
25+
}
26+
27+
struct BeancountPosting: Sendable {
28+
let id: Int
29+
let transactionId: Int
30+
let date: String
31+
let account: String
32+
let amount: String?
33+
let commodity: String?
34+
let sourceFile: URL
35+
let line: Int
36+
}
37+
38+
struct BeancountAccount: Sendable {
39+
let name: String
40+
let openDate: String
41+
let currencies: String?
42+
let sourceFile: URL
43+
let line: Int
44+
}
45+
46+
struct BeancountPrice: Sendable {
47+
let id: Int
48+
let date: String
49+
let commodity: String
50+
let amount: String
51+
let currency: String
52+
let sourceFile: URL
53+
let line: Int
54+
}
55+
56+
struct BeancountBalance: Sendable {
57+
let id: Int
58+
let date: String
59+
let account: String
60+
let amount: String
61+
let commodity: String
62+
let sourceFile: URL
63+
let line: Int
64+
}
65+
66+
enum BeancountParserError: LocalizedError {
67+
case includeCycle(String)
68+
case unreadable(URL, Error)
69+
70+
var errorDescription: String? {
71+
switch self {
72+
case .includeCycle(let path):
73+
return "Beancount include cycle detected at \(path)"
74+
case .unreadable(let url, let error):
75+
return "Could not read \(url.path): \(error.localizedDescription)"
76+
}
77+
}
78+
}
79+
80+
final class BeancountLedgerParser {
81+
private var visited: Set<URL> = []
82+
private var activeStack: Set<URL> = []
83+
private var sourceFiles: [URL] = []
84+
private var transactions: [BeancountTransaction] = []
85+
private var postings: [BeancountPosting] = []
86+
private var accountsByName: [String: BeancountAccount] = [:]
87+
private var prices: [BeancountPrice] = []
88+
private var balances: [BeancountBalance] = []
89+
90+
func parse(fileURL: URL) throws -> BeancountLedger {
91+
visited.removeAll()
92+
activeStack.removeAll()
93+
sourceFiles.removeAll()
94+
transactions.removeAll()
95+
postings.removeAll()
96+
accountsByName.removeAll()
97+
prices.removeAll()
98+
balances.removeAll()
99+
100+
try parseFile(fileURL.standardizedFileURL)
101+
102+
return BeancountLedger(
103+
transactions: transactions,
104+
postings: postings,
105+
accounts: accountsByName.values.sorted { $0.name < $1.name },
106+
prices: prices,
107+
balances: balances,
108+
sourceFiles: sourceFiles
109+
)
110+
}
111+
112+
private func parseFile(_ url: URL) throws {
113+
let normalized = url.standardizedFileURL
114+
if activeStack.contains(normalized) {
115+
throw BeancountParserError.includeCycle(normalized.path)
116+
}
117+
guard !visited.contains(normalized) else { return }
118+
119+
activeStack.insert(normalized)
120+
defer { activeStack.remove(normalized) }
121+
122+
let contents: String
123+
do {
124+
contents = try String(contentsOf: normalized, encoding: .utf8)
125+
} catch {
126+
throw BeancountParserError.unreadable(normalized, error)
127+
}
128+
129+
visited.insert(normalized)
130+
sourceFiles.append(normalized)
131+
132+
let lines = contents.components(separatedBy: .newlines)
133+
var index = 0
134+
while index < lines.count {
135+
let lineNumber = index + 1
136+
let rawLine = lines[index]
137+
let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces)
138+
defer { index += 1 }
139+
140+
guard !trimmed.isEmpty else { continue }
141+
142+
if let includePath = parseInclude(trimmed) {
143+
let includeURL = normalized.deletingLastPathComponent()
144+
.appendingPathComponent(includePath)
145+
.standardizedFileURL
146+
try parseFile(includeURL)
147+
continue
148+
}
149+
150+
guard let date = parseDatePrefix(trimmed) else { continue }
151+
let remainder = String(trimmed.dropFirst(11))
152+
153+
if remainder.hasPrefix("open ") {
154+
parseOpen(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber)
155+
} else if remainder.hasPrefix("price ") {
156+
parsePrice(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber)
157+
} else if remainder.hasPrefix("balance ") {
158+
parseBalance(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber)
159+
} else if let flag = remainder.first, flag == "*" || flag == "!" {
160+
let transactionId = transactions.count + 1
161+
let transaction = parseTransaction(
162+
id: transactionId,
163+
remainder: remainder,
164+
date: date,
165+
sourceFile: normalized,
166+
line: lineNumber
167+
)
168+
transactions.append(transaction)
169+
170+
var postingIndex = index + 1
171+
while postingIndex < lines.count {
172+
let postingLine = lines[postingIndex]
173+
guard postingLine.first?.isWhitespace == true else { break }
174+
if let posting = parsePosting(
175+
postingLine,
176+
id: postings.count + 1,
177+
transactionId: transactionId,
178+
date: date,
179+
sourceFile: normalized,
180+
line: postingIndex + 1
181+
) {
182+
postings.append(posting)
183+
}
184+
postingIndex += 1
185+
}
186+
index = postingIndex - 1
187+
}
188+
}
189+
}
190+
191+
private func parseInclude(_ line: String) -> String? {
192+
guard line.hasPrefix("include ") else { return nil }
193+
return quotedStrings(in: line).first
194+
}
195+
196+
private func parseOpen(remainder: String, date: String, sourceFile: URL, line: Int) {
197+
let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init)
198+
guard parts.count >= 2 else { return }
199+
let account = parts[1]
200+
let currencies = parts.dropFirst(2).joined(separator: " ")
201+
accountsByName[account] = BeancountAccount(
202+
name: account,
203+
openDate: date,
204+
currencies: currencies.isEmpty ? nil : currencies,
205+
sourceFile: sourceFile,
206+
line: line
207+
)
208+
}
209+
210+
private func parsePrice(remainder: String, date: String, sourceFile: URL, line: Int) {
211+
let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init)
212+
guard parts.count >= 4 else { return }
213+
prices.append(BeancountPrice(
214+
id: prices.count + 1,
215+
date: date,
216+
commodity: parts[1],
217+
amount: parts[2],
218+
currency: parts[3],
219+
sourceFile: sourceFile,
220+
line: line
221+
))
222+
}
223+
224+
private func parseBalance(remainder: String, date: String, sourceFile: URL, line: Int) {
225+
let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init)
226+
guard parts.count >= 4 else { return }
227+
balances.append(BeancountBalance(
228+
id: balances.count + 1,
229+
date: date,
230+
account: parts[1],
231+
amount: parts[2],
232+
commodity: parts[3],
233+
sourceFile: sourceFile,
234+
line: line
235+
))
236+
}
237+
238+
private func parseTransaction(
239+
id: Int,
240+
remainder: String,
241+
date: String,
242+
sourceFile: URL,
243+
line: Int
244+
) -> BeancountTransaction {
245+
let quoted = quotedStrings(in: remainder)
246+
return BeancountTransaction(
247+
id: id,
248+
date: date,
249+
flag: String(remainder.prefix(1)),
250+
payee: quoted.count >= 2 ? quoted[0] : nil,
251+
narration: quoted.count >= 2 ? quoted[1] : quoted.first,
252+
sourceFile: sourceFile,
253+
line: line
254+
)
255+
}
256+
257+
private func parsePosting(
258+
_ rawLine: String,
259+
id: Int,
260+
transactionId: Int,
261+
date: String,
262+
sourceFile: URL,
263+
line: Int
264+
) -> BeancountPosting? {
265+
let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces)
266+
guard !trimmed.isEmpty, !trimmed.hasPrefix(";"), !trimmed.hasPrefix("#") else { return nil }
267+
268+
let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init)
269+
guard let account = parts.first, account.contains(":") else { return nil }
270+
let amount = parts.count >= 2 ? parts[1] : nil
271+
let commodity = parts.count >= 3 ? parts[2] : nil
272+
273+
return BeancountPosting(
274+
id: id,
275+
transactionId: transactionId,
276+
date: date,
277+
account: account,
278+
amount: amount,
279+
commodity: commodity,
280+
sourceFile: sourceFile,
281+
line: line
282+
)
283+
}
284+
285+
private func parseDatePrefix(_ line: String) -> String? {
286+
guard line.count >= 11 else { return nil }
287+
let prefix = String(line.prefix(10))
288+
let pattern = #"^\d{4}-\d{2}-\d{2}$"#
289+
guard prefix.range(of: pattern, options: .regularExpression) != nil,
290+
line.dropFirst(10).first?.isWhitespace == true else {
291+
return nil
292+
}
293+
return prefix
294+
}
295+
296+
private func quotedStrings(in line: String) -> [String] {
297+
var values: [String] = []
298+
var current = ""
299+
var inQuote = false
300+
var isEscaped = false
301+
302+
for character in line {
303+
if isEscaped {
304+
current.append(character)
305+
isEscaped = false
306+
continue
307+
}
308+
if character == "\\" {
309+
isEscaped = true
310+
continue
311+
}
312+
if character == "\"" {
313+
if inQuote {
314+
values.append(current)
315+
current = ""
316+
}
317+
inQuote.toggle()
318+
continue
319+
}
320+
if inQuote {
321+
current.append(character)
322+
}
323+
}
324+
325+
return values
326+
}
327+
328+
private func stripComment(_ line: String) -> String {
329+
var inQuote = false
330+
var isEscaped = false
331+
var result = ""
332+
for character in line {
333+
if isEscaped {
334+
result.append(character)
335+
isEscaped = false
336+
continue
337+
}
338+
if character == "\\" {
339+
result.append(character)
340+
isEscaped = true
341+
continue
342+
}
343+
if character == "\"" {
344+
inQuote.toggle()
345+
}
346+
if character == ";" && !inQuote {
347+
break
348+
}
349+
result.append(character)
350+
}
351+
return result
352+
}
353+
}

0 commit comments

Comments
 (0)