├── .gitignore
├── README.md
├── executable.js
├── fixtures
├── basic.input
├── basic.output
├── computing.input
├── computing.output
├── dedupe.input
├── dedupe.output
├── double.input
├── double.output
├── empty.input
├── empty.output
├── incomplete.input
├── incomplete.output
├── missing.input
├── missing.output
├── multiplier.input
├── multiplier.output
├── negative.input
├── negative.output
├── padding.input
├── padding.output
├── references.input
├── references.output
├── result.input
├── result.output
├── single.input
└── single.output
├── index.js
├── index.test.js
├── package.json
└── pnpm-lock.yaml
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # PlainBudget
5 |
6 | Minimalist plain text budgeting.
7 |
8 | [**Read the blog post.**](https://hire.jonasgalvez.com.br/2025/may/8/plainbudget)
9 |
10 | [**Get the app.**](https://plainbudget.com/)
11 |
12 | ```
13 | % npm i pbudget -g
14 | % pbudget -s Budget.txt
15 | % pbudget --stats Budget.txt
16 | % cat Budget.txt | pbudget > Budget.txt
17 | ```
18 |
19 | ## Supported Syntax
20 |
21 | - **Groups** start with `=` and are used to group values.
22 |
23 | - **Flows** start with `+` and are used to express cash flow.
24 |
25 | - **Groups** can be referenced in other groups or flows.
26 |
27 | - **Multipliers** can added to any referenced group or value.
28 |
29 | - Blocks of text with invalid syntax will be ignored and remain intact in the source.
30 |
31 | - Circular dependencies (group references) will cause both groups to be ignored.
32 |
33 | - Padding is automatically added to the value column.
34 |
35 |
36 |
37 |
38 |
39 | **Input**
40 |
41 | ```
42 | = Main
43 | - 2000 Rent
44 | - 1000 Utilities
45 | - 500 Leisure
46 |
47 | = Groceries
48 | - 10 Coffee x 12
49 | - 10 Milk x 12
50 | - 20 Cereal x 6
51 |
52 | = Income
53 | - 5000 Salary
54 | - 1000 Side hustle
55 |
56 | + Income
57 | - Main
58 | - Groceries
59 | ```
60 |
61 | |
62 |
63 |
64 | **Output**
65 |
66 | ```
67 | = 3500 Main
68 | - 2000 Rent
69 | - 1000 Utilities
70 | - 500 Leisure
71 |
72 | = 360 Groceries
73 | - 10 Coffee x 12
74 | - 10 Milk x 12
75 | - 20 Cereal x 6
76 |
77 | = 6000 Income
78 | - 5000 Salary
79 | - 1000 Side hustle
80 |
81 | + 6000 Income
82 | - 3500 Main
83 | - 360 Groceries
84 | = 2140
85 | ```
86 |
87 | |
88 |
89 |
90 |
91 | ## Programmatic Usage
92 |
93 | ```js
94 | import { readFileSync } from 'node:fs'
95 | import { PlainBudget } from 'pbudget'
96 |
97 | const budget = readFileSync('Budget.txt', 'utf8')
98 |
99 | const pbudget = new PlainBudget(budget)
100 |
101 | pbudget.process()
102 |
103 | console.log(pbudget.renderWithPadding())
104 |
105 | pbudget.computeStats()
106 |
107 | console.log(pbudget.stats)
108 | ```
109 |
--------------------------------------------------------------------------------
/executable.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import fs from 'node:fs'
4 | import { fileURLToPath } from 'node:url'
5 | import { join, dirname } from 'node:path'
6 | import { Command } from 'commander'
7 | import { PlainBudget } from './index.js'
8 |
9 | // For Node v20 compatibility
10 | const __dirname = dirname(fileURLToPath(import.meta.url))
11 | const pbudget = new Command('pbudget')
12 | const pkg = JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8'))
13 |
14 | pbudget
15 | .version(pkg.version)
16 | .option('--save, -s', 'modifies source file with result.')
17 | .option('--stats', 'outputs JSON with projections and distribution.')
18 |
19 | function compute (src, text, options = {}) {
20 | try {
21 | if (!text) {
22 | console.log('No source input provided.')
23 | process.exit(1)
24 | }
25 | const pb = new PlainBudget(text)
26 | pb.process()
27 | const computed = pb.renderWithPadding()
28 | if (src && options.save) {
29 | fs.writeFileSync(src, computed, 'utf8')
30 | process.exit(0)
31 | } else {
32 | process.stdout.write(`${computed}\n`)
33 | process.exit(0)
34 | }
35 | } catch {
36 | console.log(!src
37 | ? 'Error computing source'
38 | : `Error computing source file: ${src}`
39 | )
40 | process.exit(1)
41 | }
42 | }
43 |
44 | function computeStats (src, text) {
45 | try {
46 | if (!text) {
47 | console.log('No source input provided.')
48 | process.exit(1)
49 | }
50 | const pb = new PlainBudget(text)
51 | pb.process()
52 | pb.computeStats()
53 | process.stdout.write(`${JSON.stringify(pb.stats, null, 2)}\n`)
54 | process.exit(0)
55 | } catch {
56 | console.log(!src
57 | ? 'Error computing source'
58 | : `Error computing source file: ${src}`
59 | )
60 | process.exit(1)
61 | }
62 | }
63 |
64 | async function readStream(stream) {
65 | const chunks = []
66 | for await (const chunk of stream) {
67 | chunks.push(chunk)
68 | }
69 | return Buffer.concat(chunks).toString('utf8')
70 | }
71 |
72 | pbudget
73 | .arguments('[src]')
74 | .description('Computes a PlainBudget sheet file.')
75 | .action(async (src, options) => {
76 | if (options.stats) {
77 | if (!src) {
78 | if (process.stdin.isTTY) {
79 | pbudget.help()
80 | } else {
81 | computeStats(null, await readStream(process.stdin))
82 | return
83 | }
84 | } else {
85 | computeStats(src, fs.readFileSync(src, 'utf8'))
86 | }
87 | return
88 | }
89 | if (!src) {
90 | if (process.stdin.isTTY) {
91 | pbudget.help()
92 | } else {
93 | compute(null, await readStream(process.stdin), options)
94 | }
95 | } else {
96 | compute(src, fs.readFileSync(src, 'utf8'), options)
97 | }
98 | })
99 |
100 | pbudget.parse(process.argv)
101 |
--------------------------------------------------------------------------------
/fixtures/basic.input:
--------------------------------------------------------------------------------
1 |
2 | = Group 1
3 | - 100 Something one
4 | - 200 Something two
5 | -400Invalid entry (missing spaces) (makes the whole block non-computable)
6 | - 300 Something three
7 |
8 | = 500 Group 2
9 | - 100 Something one
10 | - 200 Something two x 2
11 | Invalid entry (makes the whole block non-computable)
12 | - 300 Something three
13 | - Group 1
14 |
15 | Random text.
16 |
17 | Computable block:
18 | + Income A
19 | + Income B
20 | + Extra Income Group
21 | - Group 3
22 | - 500 Random
23 |
24 | More random text preceeded by two blank lines.
25 |
26 | Another computable block:
27 | = Group 3
28 | - Something
29 | - 1000 Other
30 |
31 | Another final non-computable line.
32 |
33 | = 500 Group 4
34 | - 100 Something one
35 | - 200 Something two
--------------------------------------------------------------------------------
/fixtures/basic.output:
--------------------------------------------------------------------------------
1 |
2 | = Group 1
3 | - 100 Something one
4 | - 200 Something two
5 | -400Invalid entry (missing spaces) (makes the whole block non-computable)
6 | - 300 Something three
7 |
8 | = 500 Group 2
9 | - 100 Something one
10 | - 200 Something two x 2
11 | Invalid entry (makes the whole block non-computable)
12 | - 300 Something three
13 | - Group 1
14 |
15 | Random text.
16 |
17 | Computable block:
18 | + Income A
19 | + Income B
20 | + Extra Income Group
21 | - Group 3
22 | - 500 Random
23 |
24 | More random text preceeded by two blank lines.
25 |
26 | Another computable block:
27 | = Group 3
28 | - Something
29 | - 1000 Other
30 |
31 | Another final non-computable line.
32 |
33 | = 500 Group 4
34 | - 100 Something one
35 | - 200 Something two
--------------------------------------------------------------------------------
/fixtures/computing.input:
--------------------------------------------------------------------------------
1 |
2 | First a few groups:
3 |
4 | = 1700 Housing
5 | - 1000 Rent
6 | - 500 Utilities
7 | - 200 Internet
8 |
9 | = 5000 Debt
10 | - 3000 Car payment
11 | - 2000 House payment
12 |
13 | = Extra income
14 | - 500 Side hustle
15 | - 20 Bitcoin mining
16 | - 30 Uber driving x 10
17 |
18 | Then a couple of flow entries:
19 |
20 | + 1000 Foo
21 | - 500 Bar
22 |
23 | + 7000 Income
24 | + Extra income
25 | - 1700 Housing
26 | - 5000 Debt
27 |
--------------------------------------------------------------------------------
/fixtures/computing.output:
--------------------------------------------------------------------------------
1 |
2 | First a few groups:
3 |
4 | = 1700 Housing
5 | - 1000 Rent
6 | - 500 Utilities
7 | - 200 Internet
8 |
9 | = 5000 Debt
10 | - 3000 Car payment
11 | - 2000 House payment
12 |
13 | = 820 Extra income
14 | - 500 Side hustle
15 | - 20 Bitcoin mining
16 | - 30 Uber driving x 10
17 |
18 | Then a couple of flow entries:
19 |
20 | + 1000 Foo
21 | - 500 Bar
22 | = 500
23 |
24 | + 7000 Income
25 | + 820 Extra income
26 | - 1700 Housing
27 | - 5000 Debt
28 | = 1120
29 |
--------------------------------------------------------------------------------
/fixtures/dedupe.input:
--------------------------------------------------------------------------------
1 |
2 | + 16000 Random 0
3 | - 6000 Expense 1
4 | - 3000 Expense 2
5 | = 2841
6 |
7 | + 200 Random 1
8 |
9 | + 16000 Random 2
10 |
11 | + 32000 Random 3
12 | - 10000 Expenses
13 | = 24000
14 |
15 | + 32000 Random 4
16 | - 10000 Expenses
17 | = 24000
18 |
19 | + 32000 Random 5
20 | - 10000 Expenses
21 | = 22000
22 |
23 | = 8783 Debt
24 | - 4000 House
25 | - 2000 Car
26 |
--------------------------------------------------------------------------------
/fixtures/dedupe.output:
--------------------------------------------------------------------------------
1 |
2 | + 16000 Random 0
3 | - 6000 Expense 1
4 | - 3000 Expense 2
5 | = 7000
6 |
7 | + 200 Random 1
8 |
9 | + 16000 Random 2
10 |
11 | + 32000 Random 3
12 | - 10000 Expenses
13 | = 22000
14 |
15 | + 32000 Random 4
16 | - 10000 Expenses
17 | = 22000
18 |
19 | + 32000 Random 5
20 | - 10000 Expenses
21 | = 22000
22 |
23 | = 6000 Debt
24 | - 4000 House
25 | - 2000 Car
26 |
--------------------------------------------------------------------------------
/fixtures/double.input:
--------------------------------------------------------------------------------
1 | + 1000 Foo
2 | - 500 Bar
--------------------------------------------------------------------------------
/fixtures/double.output:
--------------------------------------------------------------------------------
1 | + 1000 Foo
2 | - 500 Bar
3 | = 500
--------------------------------------------------------------------------------
/fixtures/empty.input:
--------------------------------------------------------------------------------
1 | +
2 |
3 | + 1
--------------------------------------------------------------------------------
/fixtures/empty.output:
--------------------------------------------------------------------------------
1 | +
2 |
3 | + 1
--------------------------------------------------------------------------------
/fixtures/incomplete.input:
--------------------------------------------------------------------------------
1 | + 1000 Income
2 | - 500
--------------------------------------------------------------------------------
/fixtures/incomplete.output:
--------------------------------------------------------------------------------
1 | + 1000 Income
2 | - 500
--------------------------------------------------------------------------------
/fixtures/missing.input:
--------------------------------------------------------------------------------
1 |
2 | = Existent
3 | - 1000 Expense
4 |
5 | + 5000 Income
6 | - Inexistent
7 | - Existent
--------------------------------------------------------------------------------
/fixtures/missing.output:
--------------------------------------------------------------------------------
1 |
2 | = 1000 Existent
3 | - 1000 Expense
4 |
5 | + 5000 Income
6 | - Inexistent
7 | - Existent
--------------------------------------------------------------------------------
/fixtures/multiplier.input:
--------------------------------------------------------------------------------
1 | = 3500 Housing
2 | - 2000 Rent
3 | - 1000 Utilities
4 | - 500 Leisure
5 |
6 | = 360 Groceries
7 | - 10 Coffee x 12
8 | - 10 Milk x 12
9 | - 20 Cereal x 6
10 |
11 | = 6000 Income
12 | - 5000 Salary
13 | - 1000 Side hustle
14 |
15 | + 6000 Income
16 | - 3500 Housing
17 | - 360 Groceries
18 | - 50 Foobar x 2
19 | = 2140
20 |
--------------------------------------------------------------------------------
/fixtures/multiplier.output:
--------------------------------------------------------------------------------
1 | = 3500 Housing
2 | - 2000 Rent
3 | - 1000 Utilities
4 | - 500 Leisure
5 |
6 | = 360 Groceries
7 | - 10 Coffee x 12
8 | - 10 Milk x 12
9 | - 20 Cereal x 6
10 |
11 | = 6000 Income
12 | - 5000 Salary
13 | - 1000 Side hustle
14 |
15 | + 6000 Income
16 | - 3500 Housing
17 | - 360 Groceries
18 | - 50 Foobar x 2
19 | = 2040
20 |
--------------------------------------------------------------------------------
/fixtures/negative.input:
--------------------------------------------------------------------------------
1 |
2 | Test to ensure negative flows are detected.
3 |
4 | + 5000 Income
5 | - 4000 Expense
6 | - 2000 Expense
7 | + 2000 Income
8 |
9 | On the third line, balance dips to -1000.
10 |
11 | The group must be marked non-computable in this case.
12 |
13 | This other group should compute successfully:
14 |
15 | + 5000 Income
16 | - 4000 Expenses
17 |
--------------------------------------------------------------------------------
/fixtures/negative.output:
--------------------------------------------------------------------------------
1 |
2 | Test to ensure negative flows are detected.
3 |
4 | + 5000 Income
5 | - 4000 Expense
6 | - 2000 Expense
7 | + 2000 Income
8 |
9 | On the third line, balance dips to -1000.
10 |
11 | The group must be marked non-computable in this case.
12 |
13 | This other group should compute successfully:
14 |
15 | + 5000 Income
16 | - 4000 Expenses
17 | = 1000
18 |
--------------------------------------------------------------------------------
/fixtures/padding.input:
--------------------------------------------------------------------------------
1 | = 3500 Housing
2 | - 2000 Rent
3 | - 1000 Utilities
4 | - 500 Security
5 |
6 | = 1500 Office
7 | - 1000 Rent
8 | - 500 Utilities
9 |
10 | + 10000 Income
11 | - 3500 Housing
12 | - 1500 Office
13 | - 1000 Groceries
14 | = 4000
15 |
--------------------------------------------------------------------------------
/fixtures/padding.output:
--------------------------------------------------------------------------------
1 | = 3500 Housing
2 | - 2000 Rent
3 | - 1000 Utilities
4 | - 500 Security
5 |
6 | = 1500 Office
7 | - 1000 Rent
8 | - 500 Utilities
9 |
10 | + 10000 Income
11 | - 3500 Housing
12 | - 1500 Office
13 | - 1000 Groceries
14 | = 4000
15 |
--------------------------------------------------------------------------------
/fixtures/references.input:
--------------------------------------------------------------------------------
1 |
2 | = Expenses Group 1
3 | - 100 Something one
4 | - 200 Something two
5 | - 300 Something three
6 |
7 | = Expenses Group 2
8 | - Expenses Group 1
9 | - 400 Something four
10 |
11 | = Expenses Group 3
12 | - Expenses Group 1
13 | - Expenses Group 2
14 | - Expenses Group 4
15 | - 500 Something
16 |
17 | = Expenses Group 4
18 | - Expenses Group 3
19 | - Expenses Group 1
20 |
21 | = Expenses Group 5
22 | - 100 Something one
23 | - Expenses Group 6
24 | - 200 Something two
25 |
26 | = Expenses Group 6
27 | - 300 Something three
28 |
29 | = Income group
30 | - 2000 Client A
31 | - 5000 Client B
32 | - 500 SaaS Subscriptions
33 |
34 | + Income group
35 | - Expenses Group 1
36 |
37 | = Codependent 1
38 | - 10 Foo
39 | - Codependent 2
40 |
41 | = Codependent 2
42 | - 20 Bar
43 | - Codependent 1
44 |
--------------------------------------------------------------------------------
/fixtures/references.output:
--------------------------------------------------------------------------------
1 |
2 | = Expenses Group 1
3 | - 100 Something one
4 | - 200 Something two
5 | - 300 Something three
6 |
7 | = Expenses Group 2
8 | - Expenses Group 1
9 | - 400 Something four
10 |
11 | = Expenses Group 3
12 | - Expenses Group 1
13 | - Expenses Group 2
14 | - Expenses Group 4
15 | - 500 Something
16 |
17 | = Expenses Group 4
18 | - Expenses Group 3
19 | - Expenses Group 1
20 |
21 | = Expenses Group 5
22 | - 100 Something one
23 | - Expenses Group 6
24 | - 200 Something two
25 |
26 | = Expenses Group 6
27 | - 300 Something three
28 |
29 | = Income group
30 | - 2000 Client A
31 | - 5000 Client B
32 | - 500 SaaS Subscriptions
33 |
34 | + Income group
35 | - Expenses Group 1
36 |
37 | = Codependent 1
38 | - 10 Foo
39 | - Codependent 2
40 |
41 | = Codependent 2
42 | - 20 Bar
43 | - Codependent 1
44 |
--------------------------------------------------------------------------------
/fixtures/result.input:
--------------------------------------------------------------------------------
1 | + 1000 Income
2 | - 400 Expenses
3 | - 400 Expenses2
4 | = 100
5 |
--------------------------------------------------------------------------------
/fixtures/result.output:
--------------------------------------------------------------------------------
1 | + 1000 Income
2 | - 400 Expenses
3 | - 400 Expenses2
4 | = 200
5 |
--------------------------------------------------------------------------------
/fixtures/single.input:
--------------------------------------------------------------------------------
1 | + 1000 Income
2 |
--------------------------------------------------------------------------------
/fixtures/single.output:
--------------------------------------------------------------------------------
1 | + 1000 Income
2 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 |
2 | export class PlainBudget {
3 | MULTIPLIER_REGEX = /^(.*?)\s+x\s+(\d+)$/
4 | COMPUTABLE_LINE_REGEX = /^[=\-~+x]\s+/
5 | RESULT_LINE_REGEX = /^=\s+(\d+)\s*$/
6 | VALUE_REGEX = /^\s+(\d+)/
7 | LABEL_REGEX = /\s+\d+\s+(.+)/
8 |
9 | constructor (source, padding) {
10 | this.source = source
11 | this.padding = padding ?? 3
12 | this.blocks = null
13 | this.raw = null
14 | this.ids = null
15 | this.multipliers = null
16 | this.computed = null
17 | this.index = null
18 | this.blocks = null
19 | }
20 |
21 | process () {
22 | this.parse()
23 | const { order } = this.validate()
24 | for (const id of order) {
25 | const group = this.index.get(id)
26 | const i = this.blocks.findIndex(_ => _ === group)
27 | if (i !== -1) {
28 | this.compute(i)
29 | }
30 | }
31 | const flows = this.blocks.filter(_ => Array.isArray(_) && _[0][0] === '+')
32 | for (const flow of flows) {
33 | const i = this.blocks.findIndex(_ => _ === flow)
34 | this.compute(i)
35 | }
36 | return {
37 | blocks: this.blocks,
38 | output: this.render(),
39 | }
40 | }
41 |
42 | parse () {
43 | this.raw = new WeakMap
44 | this.ids = new WeakMap
45 | this.multipliers = new WeakMap
46 | this.computed = new Map
47 | this.index = new Map
48 | this.blocks = []
49 |
50 | const lines = this.source.split(/\r?\n/g)
51 |
52 | let group = null
53 |
54 | let op
55 | let line
56 |
57 | let lineCount = lines.length
58 | let maxIter = lineCount - 1
59 | for (let i = 0; i < lineCount; i++) {
60 | line = lines[i].trim()
61 | op = line[0]
62 | if ('=+'.includes(op) && group === null) {
63 | const parsed = this.#parseLine(line, lines[i])
64 | if (parsed) {
65 | group = [parsed]
66 | } else {
67 | this.blocks.push(lines[i])
68 | }
69 | } else if (group) {
70 | if (line.match(/^\s*$/) && group.length > 1) {
71 | this.blocks.push(group)
72 | this.blocks.push('')
73 | group = null
74 | } else {
75 | const parsed = this.#parseLine(line, lines[i])
76 | if (!parsed) {
77 | this.blocks.push(...group.map(l => this.raw.get(l)))
78 | this.blocks.push(lines[i])
79 | group = null
80 | } else {
81 | group.push(parsed)
82 | }
83 | }
84 | } else {
85 | this.blocks.push(lines[i])
86 | }
87 | if (i === maxIter) {
88 | if (group) {
89 | if (group.length > 1) {
90 | this.blocks.push(group)
91 | } else {
92 | this.blocks.push(lines[i])
93 | }
94 | group = null
95 | }
96 | }
97 | }
98 | }
99 |
100 | validate () {
101 | const dupes = []
102 | for (const group of this.blocks) {
103 | if (!Array.isArray(group)) {
104 | continue
105 | }
106 | for (const entry of group) {
107 | if (entry[0] === '=') {
108 | const id = this.ids.get(entry)
109 | if (id) {
110 | if (this.index.has(id)) {
111 | dupes.push(this.index.get(id))
112 | }
113 | this.index.set(id, group)
114 | }
115 | } else {
116 | continue
117 | }
118 | }
119 | }
120 |
121 | for (const group of dupes) {
122 | const i = this.blocks.findIndex(_ => _ === group)
123 | this.ids.delete(group)
124 | this.blocks.splice(i, 1, ...group.map(l => this.raw.get(l)))
125 | }
126 |
127 | const graph = new Map
128 | for (const group of this.index.values()) {
129 | let deps = null
130 | for (const entry of group) {
131 | if (entry[0] === '=') {
132 | deps = new Map
133 | graph.set(entry[2], deps)
134 | continue
135 | }
136 | if (entry[1] === null) {
137 | if (deps) {
138 | deps.set(entry[2], 1)
139 | }
140 | }
141 | }
142 | }
143 |
144 | const { order, invalid } = this.#resolveDependencies(graph)
145 |
146 | for (const id of invalid) {
147 | const group = this.index.get(id)
148 | const i = this.blocks.findIndex(_ => _ === group)
149 | this.ids.delete(group)
150 | this.blocks.splice(i, 1, ...group.map(l => this.raw.get(l)))
151 | }
152 |
153 | for (const group of this.blocks.filter(_ => Array.isArray(_))) {
154 | for (const line of group) {
155 | if (line[0] === '-' || line[0] === '+') {
156 | const id = this.ids.get(line)
157 | if (line[1] === null && !this.index.get(id)) {
158 | const i = this.blocks.findIndex(_ => _ === group)
159 | this.ids.delete(group)
160 | this.blocks.splice(i, 1, ...group.map(l => this.raw.get(l)))
161 | }
162 | }
163 | }
164 | }
165 |
166 | return { order, invalid }
167 | }
168 |
169 | compute (index) {
170 | if (!Array.isArray(this.blocks[index])) {
171 | return
172 | }
173 |
174 | const group = this.blocks[index]
175 |
176 | let value
177 |
178 | if ('='.includes(group[0][0])) {
179 | if (group[0][2] === '') {
180 | group[0][2] = '\n'
181 | }
182 | value = 0
183 | for (const topOp of group.slice(1)) {
184 | if (topOp[0] === 'x') {
185 | continue
186 | }
187 | value += this.#processFlowEntry(topOp)
188 | }
189 | } else if (group[0][0] === '+') {
190 | if (typeof value === 'undefined') {
191 | value = this.#processFlowEntry(group[0])
192 | } else {
193 | value += this.#processFlowEntry(group[0])
194 | }
195 | for (const op of group.slice(1)) {
196 | if (op[0] === '+') {
197 | value += this.#processFlowEntry(op)
198 | } else if (op[0] === '-') {
199 | value -= this.#processFlowEntry(op)
200 | }
201 | if (value < 0) {
202 | this.blocks.splice(index, 1, ...group.map((l) => {
203 | return this.raw.get(l)
204 | }))
205 | return
206 | }
207 | }
208 | }
209 | if (group[0][0] === '=') {
210 | group[0][1] = value
211 | const id = this.ids.get(group[0])
212 | this.computed.set(id, value)
213 | } else {
214 | if (group.at(-1)[0] !== '=') {
215 | group.push(['=', value, ''])
216 | } else {
217 | group[group.length - 1][1] = value
218 | }
219 | }
220 | }
221 |
222 | computeStats () {
223 | this.stats = {}
224 | let credits = 0
225 | let debits = 0
226 | let balance
227 | const expenses = new Map()
228 | for (const block of this.blocks) {
229 | if (!Array.isArray(block) || block[0][0] !== '+') {
230 | continue
231 | }
232 | for (const group of block) {
233 | let [op, value] = group
234 | if (op === '+') {
235 | credits += value
236 | } else if(op === '-') {
237 | const id = this.ids.get(group)
238 | if (this.computed.has(id)) {
239 | const subgroup = this.index.get(id)
240 | for (const line of subgroup.slice(1)) {
241 | const subid = this.ids.get(line)
242 | const multiplier = this.multipliers.get(line)
243 | let value = line[1]
244 | if (multiplier) {
245 | value = value * multiplier
246 | }
247 | if (expenses.has(subid)) {
248 | expenses.set(subid, expenses.get(subid) + value)
249 | } else {
250 | expenses.set(subid, value)
251 | }
252 | debits += value
253 | }
254 | } else {
255 | const multiplier = this.multipliers.get(group)
256 | if (multiplier) {
257 | value = value * multiplier
258 | }
259 | if (expenses.has(id)) {
260 | expenses.set(id, expenses.get(id) + value)
261 | } else {
262 | expenses.set(id, value)
263 | }
264 | debits += value
265 | }
266 | }
267 | }
268 | }
269 |
270 | this.stats.distribution = []
271 | for (const [expense, amount] of expenses.entries()) {
272 | this.stats.distribution.push([
273 | expense,
274 | parseFloat((amount / credits).toFixed(5))
275 | ])
276 | }
277 | this.stats.distribution.sort(this.#sortDescending)
278 |
279 | balance = credits - debits
280 | this.stats.projections = {
281 | savings: balance,
282 | sixmonths: balance + (balance * 6),
283 | oneyear: balance + (balance * 12),
284 | threeyears: balance + (balance * 36),
285 | fiveyears: balance + (balance * 60),
286 | tenyears: balance + (balance * 120)
287 | }
288 | }
289 |
290 | render (blocksInput) {
291 | let output = ''
292 | const blocks = blocksInput ?? this.blocks
293 | for (const block of blocks.slice(0, -1)) {
294 | if (typeof block === 'string') {
295 | output += `${block}\n`
296 | } else {
297 | for (const line of block) {
298 | output += `${line.filter(Boolean).join(' ')}\n`
299 | }
300 | }
301 | }
302 | const lastBlock = blocks.at(-1)
303 | if (typeof lastBlock === 'string') {
304 | output += `${lastBlock}`
305 | } else {
306 | for (const line of lastBlock.slice(0, -1)) {
307 | output += `${line.filter(Boolean).join(' ')}\n`
308 | }
309 | output += `${lastBlock.at(-1).filter(Boolean).join(' ')}`
310 | }
311 | return output
312 | }
313 |
314 | renderWithPadding (blocksInput) {
315 | const padding = this.padding !== null
316 | ? Math.max(this.padding, this.#getPadding())
317 | : 0
318 |
319 | let output = ''
320 | const blocks = blocksInput ?? this.blocks
321 | for (const block of blocks.slice(0, -1)) {
322 | if (typeof block === 'string') {
323 | output += `${block}\n`
324 | } else {
325 | for (const line of block) {
326 | output += `${line[0]} ${line[1].toString().padStart(padding)} ${line[2] ?? ''}\n`
327 | }
328 | }
329 | }
330 | const lastBlock = blocks.at(-1)
331 | if (typeof lastBlock === 'string') {
332 | output += `${lastBlock}`
333 | } else {
334 | for (const line of lastBlock.slice(0, -1)) {
335 | output += `${line[0]} ${line[1].toString().padStart(padding)} ${line[2] ?? ''}\n`
336 | }
337 | const lastLine = lastBlock.at(-1)
338 | output += `${lastLine[0]} ${lastLine[1].toString().padStart(padding)} ${lastLine[2] ?? ''}\n`
339 | }
340 | return output
341 | }
342 |
343 | #processFlowEntry (op) {
344 | const multiplier = this.multipliers.get(op)
345 | const id = this.ids.get(op)
346 | const computed = this.computed.get(id)
347 | if (multiplier) {
348 | if (computed) {
349 | op[1] = computed * multiplier
350 | return op[1]
351 | } else {
352 | return op[1] * multiplier
353 | }
354 | }
355 | if (computed) {
356 | op[1] = computed
357 | }
358 | return op[1]
359 | }
360 |
361 | #parseValue (line) {
362 | let value = line.slice(1).match(this.VALUE_REGEX) ?? null
363 | if (value) {
364 | value = parseInt(value[1])
365 | if (isNaN(value)) {
366 | value = null
367 | }
368 | }
369 | return value
370 | }
371 |
372 | #parseLine (line, raw) {
373 | if (!line.match(this.COMPUTABLE_LINE_REGEX)) {
374 | return
375 | }
376 | if (line.match(this.RESULT_LINE_REGEX)) {
377 | const value = this.#parseValue(line)
378 | const parsed = ['=', value]
379 | this.raw.set(parsed, raw)
380 | return parsed
381 | }
382 | const value = this.#parseValue(line)
383 | let label = line.slice(1).match(this.LABEL_REGEX) ?? null
384 | if (label === null && value !== null) {
385 | return
386 | }
387 | label = label ? label[1] : line.slice(1).trim()
388 | const parsed = [line[0], value, label]
389 | const multiplier = this.#parseMultiplier(parsed[2])
390 | if (multiplier) {
391 | this.ids.set(parsed, multiplier[0])
392 | this.multipliers.set(parsed, parseInt(multiplier[1]))
393 | } else {
394 | this.ids.set(parsed, label)
395 | }
396 | this.raw.set(parsed, raw)
397 | return parsed
398 | }
399 |
400 | #parseMultiplier (label) {
401 | const m = label.trim().match(this.MULTIPLIER_REGEX)
402 | if (m) {
403 | return [m[1], m[2]]
404 | }
405 | }
406 |
407 | #resolveDependencies(dependencies) {
408 | const order = []
409 | const invalid = new Set()
410 | const states = new Map() // [unvisited, visiting, visited]
411 |
412 | for (const node of dependencies.keys()) {
413 | if (!states.has(node)) {
414 | this.#visitDependencyNode(node, dependencies, order, invalid, states)
415 | }
416 | }
417 |
418 | return { order, invalid: [...invalid] }
419 | }
420 |
421 | #visitDependencyNode(node, dependencies, order, invalid, states) {
422 | const state = states.get(node) ?? 0
423 |
424 | if (state === 1) {
425 | return true
426 | }
427 |
428 | if (state === 2) {
429 | return invalid.has(node)
430 | }
431 |
432 | states.set(node, 1)
433 |
434 | let isInvalid = false
435 | const deps = dependencies.get(node) || new Map()
436 |
437 | for (const dep of deps.keys()) {
438 | if (this.#visitDependencyNode(dep, dependencies, order, invalid, states)) {
439 | isInvalid = true
440 | }
441 | }
442 |
443 | states.set(node, 2)
444 |
445 | if (isInvalid) {
446 | invalid.add(node)
447 | } else {
448 | order.push(node)
449 | }
450 |
451 | return isInvalid
452 | }
453 |
454 |
455 | #getPadding () {
456 | let p = 3
457 | for (const block of this.blocks) {
458 | if (!Array.isArray(block)) {
459 | continue
460 | }
461 | let nlen
462 | for (const group of block) {
463 | if (group[1] !== null) {
464 | nlen = group[1].toString().length
465 | if (nlen > (p + 1)) {
466 | p = nlen + 1
467 | }
468 | }
469 | }
470 | }
471 | return p + 1
472 | }
473 |
474 | #sortDescending (a, b) {
475 | return b[1] - a[1]
476 | }
477 | }
478 |
--------------------------------------------------------------------------------
/index.test.js:
--------------------------------------------------------------------------------
1 | import { ok, deepEqual } from 'node:assert'
2 | import { test } from 'node:test'
3 | import { readFileSync } from 'node:fs'
4 | import { join } from 'node:path'
5 | import { PlainBudget } from './index.js'
6 |
7 | const root = import.meta.dirname
8 |
9 | test('basic', () => {
10 | const { input, output } = loadFixture('basic')
11 | const pb = new PlainBudget(input)
12 | pb.parse()
13 | ok(output === pb.render())
14 | })
15 |
16 | test('computing', () => {
17 | const { input, output } = loadFixture('computing')
18 | const pb = new PlainBudget(input)
19 | pb.process()
20 | pb.computeStats()
21 | deepEqual(pb.stats.distribution, [
22 | [ 'Car payment', 0.34014 ],
23 | [ 'House payment', 0.22676 ],
24 | [ 'Rent', 0.11338 ],
25 | [ 'Bar', 0.05669 ],
26 | [ 'Utilities', 0.05669 ],
27 | [ 'Internet', 0.02268 ]
28 | ])
29 | deepEqual(pb.stats.projections, {
30 | savings: 1620,
31 | sixmonths: 11340,
32 | oneyear: 21060,
33 | threeyears: 59940,
34 | fiveyears: 98820,
35 | tenyears: 196020
36 | })
37 | ok(output === pb.renderWithPadding())
38 | })
39 |
40 | test('double', () => {
41 | const { input, output } = loadFixture('double')
42 | const pb = new PlainBudget(input)
43 | pb.process()
44 | ok(output === pb.render())
45 | })
46 |
47 | test('empty', () => {
48 | const { input, output } = loadFixture('empty')
49 | const pb = new PlainBudget(input)
50 | pb.process()
51 | ok(output === pb.render())
52 | })
53 |
54 | test('incomplete', () => {
55 | const { input, output } = loadFixture('incomplete')
56 | const pb = new PlainBudget(input)
57 | pb.process()
58 | ok(output === pb.render())
59 | })
60 |
61 | test('missing', () => {
62 | processingTest('missing')
63 | })
64 |
65 | test('negative', () => {
66 | const { input, output } = loadFixture('negative')
67 | const pb = new PlainBudget(input)
68 | pb.process()
69 | ok(output === pb.render())
70 | })
71 |
72 | test('multiplier', () => {
73 | const { input, output } = loadFixture('multiplier')
74 | const pb = new PlainBudget(input)
75 | pb.process()
76 | pb.computeStats()
77 | deepEqual(pb.stats.distribution, [
78 | [ 'Rent', 0.33333 ],
79 | [ 'Utilities', 0.16667 ],
80 | [ 'Leisure', 0.08333 ],
81 | [ 'Coffee', 0.02 ],
82 | [ 'Milk', 0.02 ],
83 | [ 'Cereal', 0.02 ],
84 | [ 'Foobar', 0.01667 ]
85 | ])
86 | deepEqual(pb.stats.projections, {
87 | savings: 2040,
88 | sixmonths: 14280,
89 | oneyear: 26520,
90 | threeyears: 75480,
91 | fiveyears: 124440,
92 | tenyears: 246840
93 | })
94 | ok(output === pb.renderWithPadding())
95 | })
96 |
97 | test('padding', () => {
98 | processingTest('padding', true)
99 | })
100 |
101 | test('references', () => {
102 | const { input } = loadFixture('references')
103 | const pb = new PlainBudget(input)
104 | pb.parse()
105 | const { order, invalid } = pb.validate()
106 | deepEqual(order, [
107 | 'Expenses Group 1',
108 | 'Expenses Group 2',
109 | 'Expenses Group 6',
110 | 'Expenses Group 5',
111 | 'Income group'
112 | ])
113 | deepEqual(invalid, [
114 | 'Expenses Group 4',
115 | 'Expenses Group 3',
116 | 'Codependent 2',
117 | 'Codependent 1',
118 | ])
119 | })
120 |
121 | test('result', () => {
122 | const { input, output } = loadFixture('result')
123 | const pb = new PlainBudget(input)
124 | pb.process()
125 | ok(output === pb.render())
126 | })
127 |
128 | test('single', () => {
129 | const { input, output } = loadFixture('single')
130 | const pb = new PlainBudget(input)
131 | pb.process()
132 | ok(output === pb.render())
133 | })
134 |
135 | test('dedupe', () => {
136 | processingTest('dedupe', true)
137 | })
138 |
139 | function loadFixture (...fixtureInput) {
140 | const fixtureRoot = join(root, 'fixtures')
141 | const fixture = join(fixtureRoot, ...fixtureInput)
142 | const input = readFileSync(`${fixture}.input`, 'utf8')
143 | const output = readFileSync(`${fixture}.output`, 'utf8')
144 | return { input, output }
145 | }
146 |
147 | function processingTest(fixture, padding = false) {
148 | const { input, output } = loadFixture(fixture)
149 | const pb = new PlainBudget(input)
150 | pb.process()
151 | const rendered = padding
152 | ? pb.renderWithPadding()
153 | : pb.render()
154 | ok(output === rendered)
155 | }
156 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pbudget",
3 | "version": "1.0.5",
4 | "type": "module",
5 | "bin": {
6 | "pbudget": "./executable.js"
7 | },
8 | "scripts": {
9 | "test": "node --test",
10 | "lint": "oxlint ."
11 | },
12 | "files": [
13 | "index.js",
14 | "executable.js"
15 | ],
16 | "main": "./index.js",
17 | "devDependencies": {
18 | "oxlint": "^0.16.9"
19 | },
20 | "dependencies": {
21 | "commander": "^13.1.0"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/galvez/plainbudget.git"
26 | },
27 | "license": "MIT"
28 | }
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | commander:
12 | specifier: ^13.1.0
13 | version: 13.1.0
14 | devDependencies:
15 | oxlint:
16 | specifier: ^0.16.9
17 | version: 0.16.9
18 |
19 | packages:
20 |
21 | '@oxlint/darwin-arm64@0.16.9':
22 | resolution: {integrity: sha512-s8gPacumFNuDQcl0dCRLI0+efbiQrOF984brGrW1i2buJtaMzjYiunaU5TSrbHgnb/omvpFnG4l9g+YHOK/s0A==}
23 | cpu: [arm64]
24 | os: [darwin]
25 |
26 | '@oxlint/darwin-x64@0.16.9':
27 | resolution: {integrity: sha512-QFue9yhfRU+fkbsOmDzCCwDafPR6fnaP/M+Rocp/MMDQ7qvjbB2sj0/xxp/CcvL7aLtFklMeXPR0hCfW3LyrSw==}
28 | cpu: [x64]
29 | os: [darwin]
30 |
31 | '@oxlint/linux-arm64-gnu@0.16.9':
32 | resolution: {integrity: sha512-qP/wdlgqLuiW9WDkAsyMN85wQ3nqAQynjRD+1II1QO0yI9N1ZHD6LF9P5fXAqY0eJwcf3emluQMoaeveewtiCg==}
33 | cpu: [arm64]
34 | os: [linux]
35 |
36 | '@oxlint/linux-arm64-musl@0.16.9':
37 | resolution: {integrity: sha512-486wn1MIqP4hkHTnuWedTb16X6Zs3ImmmMxqzfYlcemf9kODM6yNlxal6wGvkm7SGRPYrsB/P9S5wgpzmLzKrw==}
38 | cpu: [arm64]
39 | os: [linux]
40 |
41 | '@oxlint/linux-x64-gnu@0.16.9':
42 | resolution: {integrity: sha512-d20zqy4Mimz9CxjIEJdGd6jtyyhpSryff95gNJSTvh/EA4NqvjjlbjxuHt3clNjglRwJWE3kgmUCw9U9oWFcWA==}
43 | cpu: [x64]
44 | os: [linux]
45 |
46 | '@oxlint/linux-x64-musl@0.16.9':
47 | resolution: {integrity: sha512-mZLshz99773RMa77YhtsgWbqE7JY4xnYSDecDy+ZkafRb0acqz1Ujiq2l4cE+HnJOGVOMaOpzG0UoQ3ZNkXNAA==}
48 | cpu: [x64]
49 | os: [linux]
50 |
51 | '@oxlint/win32-arm64@0.16.9':
52 | resolution: {integrity: sha512-Zp9+0CfTb7ebgvRwDO2F6NVgRtRmxWMdBnrbMRdVbKY6CCT2vjLAIILwBf5AsNLdLQC7FbXAEivSKbRX0UPyJA==}
53 | cpu: [arm64]
54 | os: [win32]
55 |
56 | '@oxlint/win32-x64@0.16.9':
57 | resolution: {integrity: sha512-6a507bALmDNFdvEbJzu9ybajryBHo+6nMWPNyu/mBguCqmconoBbQXftELd2VG/0ecxBmBcMKAQW6aONGUVc3w==}
58 | cpu: [x64]
59 | os: [win32]
60 |
61 | commander@13.1.0:
62 | resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
63 | engines: {node: '>=18'}
64 |
65 | oxlint@0.16.9:
66 | resolution: {integrity: sha512-YMGu177AURJxdCq45/Yw6Q+uDh9ZfU++GuLYhUz+DfIGdHpAqVlBI9lCqm2HkLc6qO8ySYZ+8ljsWHLQA8F+EQ==}
67 | engines: {node: '>=8.*'}
68 | hasBin: true
69 |
70 | snapshots:
71 |
72 | '@oxlint/darwin-arm64@0.16.9':
73 | optional: true
74 |
75 | '@oxlint/darwin-x64@0.16.9':
76 | optional: true
77 |
78 | '@oxlint/linux-arm64-gnu@0.16.9':
79 | optional: true
80 |
81 | '@oxlint/linux-arm64-musl@0.16.9':
82 | optional: true
83 |
84 | '@oxlint/linux-x64-gnu@0.16.9':
85 | optional: true
86 |
87 | '@oxlint/linux-x64-musl@0.16.9':
88 | optional: true
89 |
90 | '@oxlint/win32-arm64@0.16.9':
91 | optional: true
92 |
93 | '@oxlint/win32-x64@0.16.9':
94 | optional: true
95 |
96 | commander@13.1.0: {}
97 |
98 | oxlint@0.16.9:
99 | optionalDependencies:
100 | '@oxlint/darwin-arm64': 0.16.9
101 | '@oxlint/darwin-x64': 0.16.9
102 | '@oxlint/linux-arm64-gnu': 0.16.9
103 | '@oxlint/linux-arm64-musl': 0.16.9
104 | '@oxlint/linux-x64-gnu': 0.16.9
105 | '@oxlint/linux-x64-musl': 0.16.9
106 | '@oxlint/win32-arm64': 0.16.9
107 | '@oxlint/win32-x64': 0.16.9
108 |
--------------------------------------------------------------------------------