├── .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 | 62 | 88 | 89 |
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 | 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 |
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 | --------------------------------------------------------------------------------