1 Commits

Author SHA1 Message Date
e7da6f60e1 fix: Fixed leaks. 2026-06-19 15:12:12 -04:00
26 changed files with 661 additions and 180 deletions

1
.gitignore vendored
View File

@@ -11,7 +11,6 @@ man
builds builds
envr envr
envr-go envr-go
envr-prof
findr/findr findr/findr
findr/findr-prof findr/findr-prof
findr/bench-*.md findr/bench-*.md

View File

@@ -10,7 +10,7 @@ LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64 LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64 DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release profile help .PHONY: all clean cleanall build-linux build-darwin compress release help
# Default target # Default target
all: release clean all: release clean
@@ -66,12 +66,6 @@ release: build-linux compress
@echo "Release artifacts created:" @echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found" @ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"
# Build with spall profiling instrumentation
profile:
@echo "Building with spall profiling..."
odin build . -define:SPALL=true -o:speed -out:envr-prof
@echo "Built envr-prof (run it to generate envr.spall)"
# Clean binary files only # Clean binary files only
clean: clean:
@echo "Cleaning binary files..." @echo "Cleaning binary files..."
@@ -90,7 +84,6 @@ help:
@echo " build-linux - Build Linux binaries only" @echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only" @echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries" @echo " compress - Compress all built binaries"
@echo " profile - Build with spall profiling instrumentation"
@echo " clean - Remove binary files only" @echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory" @echo " cleanall - Remove entire build directory"
@echo " help - Show this help message" @echo " help - Show this help message"

268
TABLE_IMPROVEMENT_PLAN.md Normal file
View File

@@ -0,0 +1,268 @@
# Table Rendering Memory Optimization Plan
## Executive Summary
This plan outlines improvements to eliminate excessive memory allocations and copies in the Odin table rendering system. The current implementation makes 10+ allocations per row, while the Zig equivalent makes zero allocations for rendering. This optimization will reduce memory usage, improve performance, and align with the project's efficiency goals.
## Current State Analysis
### Zig Version (Reference Implementation)
- **Allocations**: 1 (data only)
- **Data copies**: 0
- **String allocation**: 0
- **Column widths**: Stack array
- **Output**: Direct to writer
### Odin Version (Current Implementation)
- **Allocations**: 10+ per row
- **Data copies**: Multiple per row
- **String allocation**: 2+ per row (concatenate + slice)
- **Column widths**: Heap allocated
- **Output**: Builder → stdout
### Current Issues Identified
1. **Table Infrastructure** (`table.odin`)
- Uses `strings.Builder` which allocates per-line memory
- Heap-allocated `[dynamic]int` for column widths
- Multiple `strings.concatenate()` calls creating new strings
2. **Command Implementations**
- `cmd_list`: Creates intermediate `[]string` slices per row, allocates new strings via `strings.concatenate()`
- `cmd_sync`: Creates `SyncEntry` structs with cloned strings, allocates dynamic arrays
- `cmd_deps`: Allocates dynamic rows array unnecessarily
3. **Memory Pattern**
- Each command allocates `[][]string` for table data
- Manual struct-to-row transformation creates copies
- Duplicate code across all table-using commands
## Proposed Solutions
### Phase 1: Core Table Infrastructure Overhaul
#### 1.1 Direct Writer-Based Rendering
**Current:**
```odin
b: strings.Builder
strings.builder_init(&b)
// ... build table in builder
fmt.println(strings.to_string(b))
```
**Proposed:**
```odin
render_table :: proc(writer: io.Writer, headers: []string, rows: [][]string)
```
- Replace `strings.Builder` with `io.Writer` output
- Eliminate intermediate string allocations
- Write table components directly to output stream
#### 1.2 Stack-Based Column Widths
**Current:**
```odin
col_widths := make([dynamic]int, 0, len(headers))
```
**Proposed:**
- Use fixed stack arrays for reasonable column counts
- Implement small buffer optimization (SBO) for variable column counts
- Only allocate for tables exceeding threshold (e.g., 16 columns)
#### 1.3 Zero-Copy String Handling
**Current:**
```odin
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
```
**Proposed:**
- Replace `strings.concatenate()` with string slicing
- Work directly with `EnvFile.Path` and `EnvFile.Dir` fields
- Use `filepath.base()` and `filepath.dir()` without allocation where possible
### Phase 2: Generic Table Interface
#### 2.1 Field-Based Table Renderer
```odin
Table_Field :: struct {
name: string,
value: string, // String view, no allocation
alignment: Alignment,
}
Table_Config :: struct {
writer: io.Writer,
fields: []Table_Field,
col_widths: []int,
}
render_row :: proc(cfg: Table_Config, row_data: any)
```
- Accept struct fields directly without intermediate arrays
- Support field selection (show only specific fields)
- Alignment options (left/center/right)
#### 2.2 Field Extraction Procs
- Generate field extraction helpers for each struct type
- Avoid string allocation by returning string views
- Cache computed values (like formatted status strings)
#### 2.3 Streaming Table Processing
- Process rows one at a time without collecting all rows
- Reduce peak memory usage from O(N × strings) to O(table_structure)
- Enable early termination if needed
### Phase 3: Command-Specific Optimizations
#### 3.1 Eliminate Intermediate Structs
**Current (cmd_sync):**
```odin
for &file in files {
// ... processing
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
```
**Proposed:**
```odin
for &file in files {
result, err_msg := db_sync(&db, &file)
// Direct rendering with zero-copy
render_sync_row(writer, file, result, err_msg)
}
```
- `cmd_sync`: Work directly with `EnvFile` + `SyncFlagEnum`
- `cmd_list`: Use `EnvFile` fields directly, no `ListEntry`
- Generate table content on-the-fly
#### 3.2 In-Place Status Computation
```odin
get_sync_status :: proc(result: SyncFlag, err_msg: string) -> string {
switch {
case .Error in result: return if len(err_msg) > 0 then err_msg else "error"
case .BackedUp in result: return "Backed Up"
case .Restored in result: return "Restored"
case .DirUpdated in result: return "Moved"
case: return "OK"
}
}
```
- Compute status strings without allocation (use static lookup)
- Cache formatted status values if needed
- Reduce allocation count from N to 0 or 1
#### 3.3 Batch Processing
- Reduce allocation count by pooling small allocations
- Use `context.temp_allocator` more effectively
- Pre-allocate buffers for expected sizes
### Phase 4: JSON Output Separation
#### 4.1 Unified JSON Rendering
```odin
render_json_rows :: proc(writer: io.Writer, rows: any, field_names: []string)
```
- Create centralized JSON rendering helper
- Work with same structs as table rendering
- Use reflection or explicit field marshaling
#### 4.2 Format-Agnostic Interface
- Commands generate data → renderers handle format
- Table renderer focuses only on ASCII/Unicode output
- Keep terminal detection in command layer
## Expected Improvements
| Metric | Current | Target | Improvement |
|--------|---------|--------|-------------|
| **Allocations** | 10+ per row | 0-1 per table | 10x+ reduction |
| **Memory copies** | 2-3 per row | 0 | 100% reduction |
| **Peak memory** | O(N × strings) | O(table_structure) | Constant factor |
| **Throughput** | Baseline | 2-3x faster | Performance boost |
## Implementation Strategy
### High-Priority Changes
1. Replace `strings.Builder` with direct `io.Writer` output
2. Convert column widths to stack-based allocation
3. Eliminate intermediate struct allocations in commands
### Medium-Priority Changes
1. Create generic field-based table interface
2. Implement streaming table processing
3. Centralize JSON rendering logic
### Low-Priority Changes
1. Add alignment options beyond left-aligned
2. Implement comprehensive field introspection
3. Add advanced table formatting features
## Tradeoff Questions
Before implementation begins, we need to resolve these architectural questions:
### 1. Generality vs. Performance
**Question:** Should we create a fully generic table renderer (similar to Zig's `Table(T)`) or focus on optimizing the current 3 use cases first?
**Options:**
- **Generic approach**: Higher development cost, future-proof, may have some overhead
- **Specific optimization**: Faster implementation, maximum performance for current use cases, less flexible
**Recommendation:** Start with specific optimizations for current use cases, then generalize patterns that emerge.
### 2. Alignment Support
**Question:** Does the project need left/center/right alignment support, or is left-alignment sufficient?
**Context:** Zig supports alignment options, but current Odin implementation only left-aligns. Most CLI tables work fine with left alignment.
**Recommendation:** Start with left-alignment only, add alignment if specific use cases demand it.
### 3. API Compatibility
**Question:** Should we maintain the current `render_table()` API signature, or are breaking changes acceptable?
**Current API:**
```odin
render_table :: proc(headers: []string, rows: [][]string)
```
**Options:**
- **Maintain API**: Slower to implement, backward compatible, may need adapter layers
- **Break API**: Faster implementation, cleaner code, requires updates to all callers
**Recommendation:** Breaking changes are acceptable since this is an optimization-focused effort and callers are limited to 3 commands.
### 4. Odin Capabilities
**Question:** What runtime reflection or field introspection capabilities does Odin provide?
**Context:** Zig uses `@typeInfo()` and comptime field iteration. We need to understand Odin's equivalent capabilities to design the optimal solution.
**Recommendation:** Investigate Odin's runtime type information capabilities before finalizing the generic table interface design.
### 5. Testing Strategy
**Question:** Should we add comprehensive tests for new table rendering before optimizing commands, or optimize incrementally with tests added afterwards?
**Options:**
- **Test-first**: More robust, catches regressions early, slower initial development
- **Optimize-first**: Faster development, may miss edge cases, requires retroactive testing
**Recommendation:** Hybrid approach - add basic tests for core infrastructure, then optimize incrementally with additional tests for each command.
## Next Steps
1. **Research Phase**: Investigate Odin's type system and reflection capabilities
2. **Prototype Phase**: Create minimal working prototype of zero-allocation table renderer
3. **Refactor Phase**: Incrementally update commands to use new infrastructure
4. **Test Phase**: Add comprehensive tests and verify memory improvements
5. **Benchmark Phase**: Measure performance improvements and memory usage
## Success Criteria
- [ ] Zero allocations for table rendering (excluding initial data)
- [ ] Zero string copies in the happy path
- [ ] All 3 commands (`list`, `sync`, `deps`) use new infrastructure
- [ ] Performance improvement of 2x or more
- [ ] Memory usage reduction of 50% or more
- [ ] No regression in table formatting quality
- [ ] Backward compatibility with JSON output format

View File

@@ -1,13 +1,13 @@
# TODOs # TODOs
1. Commands are still leaking. 1. envr scan crashes when there are zero results.
27. Commands are still leaking.
28. **db.odin** — Inconsistencies in how struct vs sqlite are named. 28. **db.odin** — Inconsistencies in how struct vs sqlite are named.
29. Add color flag and support non colored output. 29. Add color flag and support non colored output.
30. Use text/tables for command output
2. Generate md and man pages again. 2. Generate md and man pages again.
3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing. 3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.
@@ -26,19 +26,21 @@
12. **cmd_sync.odin:80, cmd_list.odin:33**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass. 12. **cmd_sync.odin:80, cmd_list.odin:33**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
13. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 13. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
14. Add a text filter to the multi_select. 14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
16. Add tests for untested commands. 15. Add a text filter to the multi_select.
17. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path. 17. Add tests for untested commands.
19. add --format -f flag to commands that draw tables. 18. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
20. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate. 20. add --format -f flag to commands that draw tables.
21. Change struct field names from PascalCase to snake_case. 21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
22. Change struct field names from PascalCase to snake_case.
23. procedures should be ordered by use, main at the top, then in the order they are called from main. 23. procedures should be ordered by use, main at the top, then in the order they are called from main.
@@ -52,7 +54,6 @@
- [ ] cli.odin - [ ] cli.odin
- [ ] cli_test.odin - [ ] cli_test.odin
- [x] colors.odin
- [x] cmd_backup.odin - [x] cmd_backup.odin
- [x] cmd_check.odin - [x] cmd_check.odin
- [ ] cmd_check_test.odin - [ ] cmd_check_test.odin

View File

@@ -103,10 +103,9 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
} }
} }
val: string = --- if val, ok := cmd.flags["config-file"]; ok {
if val, ok = cmd.flags["config-file"]; ok {
cmd.config_path = val cmd.config_path = val
} else if val, ok = cmd.flags["c"]; ok { } else if val, ok := cmd.flags["c"]; ok {
cmd.config_path = val cmd.config_path = val
} else { } else {
// FIXME: Handle err // FIXME: Handle err

View File

@@ -1,6 +1,5 @@
#+feature dynamic-literals #+feature dynamic-literals
package main package main
package main
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"

View File

@@ -1,7 +1,6 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:terminal/ansi"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := has_flag(cmd, "force") || has_flag(cmd, "f") force := has_flag(cmd, "force") || has_flag(cmd, "f")
@@ -33,7 +32,7 @@ Generate one with: ssh-keygen -t ed25519`, flush = false)
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET, flush = false) fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return return
} }

View File

@@ -6,7 +6,6 @@ import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:terminal" import "core:terminal"
import "core:text/table"
ListEntry :: struct { ListEntry :: struct {
Directory: string `json:"directory"`, Directory: string `json:"directory"`,
@@ -28,27 +27,19 @@ cmd_list :: proc(cmd: ^Command) {
} }
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
t: table.Table headers := []string{"Directory", "Path"}
table.init(&t, context.temp_allocator, context.temp_allocator) table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
COLOR_TABLE_HEADING + "Directory" + ANSI_RESET,
COLOR_TABLE_HEADING + "Path" + ANSI_RESET,
)
for row in rows { for row in rows {
dir_str := strings.concatenate( dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
{row.Dir, os.Path_Separator_String},
context.temp_allocator,
)
filename := filepath.base(row.Path) filename := filepath.base(row.Path)
row_slice := make([]string, 2, context.temp_allocator)
table.row(&t, dir_str, filename) row_slice[0] = dir_str
row_slice[1] = filename
append(&table_rows, row_slice)
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) render_table(cmd.out, headers, table_rows[:])
} else { } else {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries: [dynamic]ListEntry

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:path/filepath" import "core:path/filepath"

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"

View File

@@ -4,7 +4,6 @@ import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:terminal" import "core:terminal"
import "core:terminal/ansi"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
@@ -72,7 +71,7 @@ cmd_scan :: proc(cmd: ^Command) {
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET, flush = false) fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return return
} }
@@ -96,12 +95,12 @@ cmd_scan :: proc(cmd: ^Command) {
if added_count > 0 { if added_count > 0 {
fmt.wprintf( fmt.wprintf(
cmd.out, cmd.out,
ansi.CSI + ansi.BOLD + ";" + ansi.FG_GREEN + ansi.SGR + "Successfully added %d file(s) to backup." + ANSI_RESET + "\n", "\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n",
added_count, added_count,
flush = false, flush = false,
) )
} else { } else {
fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET, flush = false) fmt.wprintln(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false)
} }
} }

View File

@@ -3,8 +3,8 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:strings"
import "core:terminal" import "core:terminal"
import "core:text/table"
SyncEntry :: struct { SyncEntry :: struct {
Path: string `json:"path"`, Path: string `json:"path"`,
@@ -25,7 +25,15 @@ cmd_sync :: proc(cmd: ^Command) {
return return
} }
results := make([]SyncEntry, len(files), context.temp_allocator) // TODO: Can't use temp allocator becuase strings inside are copied to context.allocator
results := make([]SyncEntry, len(files))
defer {
for &e in results {
delete(e.Path)
delete(e.Status)
}
delete(results)
}
for &file, i in files { for &file, i in files {
result, err := db_sync(&db, &file) result, err := db_sync(&db, &file)
@@ -43,29 +51,26 @@ cmd_sync :: proc(cmd: ^Command) {
status = "OK" status = "OK"
} }
// TODO: Handle errors
path_str, _ := strings.clone(file.Path, context.temp_allocator)
status_str, _ := strings.clone(status, context.temp_allocator)
results[i] = SyncEntry { results[i] = SyncEntry {
Path = file.Path, Path = path_str,
Status = status, Status = status_str,
} }
} }
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
t: table.Table headers := []string{"File", "Status"}
table.init(&t, context.temp_allocator, context.temp_allocator) // TODO: Use [2]string instead of slice here
table.padding(&t, 1, 1) table_rows := make([dynamic][]string, 0, len(results), context.temp_allocator)
table.aligned_header_of_values(
&t,
.Center,
COLOR_TABLE_HEADING + "File" + ANSI_RESET,
COLOR_TABLE_HEADING + "Status" + ANSI_RESET,
)
for res in results { for res in results {
table.row(&t, res.Path, res.Status) row_slice := [2]string{res.Path, res.Status}
append(&table_rows, row_slice[:])
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) render_table(cmd.out, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator) data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {

View File

@@ -11,7 +11,5 @@ COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
COLOR_FLAGS :: ansi.CSI + ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE + ansi.SGR COLOR_FLAGS :: ansi.CSI + ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE + ansi.SGR
COLOR_TABLE_HEADING :: ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"

View File

@@ -1,11 +1,9 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:testing" import "core:testing"
CRYPTO_TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys" CRYPTO_TEST_KEY_DIR :: "fixtures/keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair { make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name) priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)

View File

@@ -407,7 +407,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error // TODO: Handle error
hex_bytes, _ := hex.encode(digest) hex_bytes, _ := hex.encode(digest, context.temp_allocator)
return EnvFile { return EnvFile {
Path = abs_path, Path = abs_path,
@@ -535,8 +535,8 @@ shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string { get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string {
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator) config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
// TODO: Handle error // TODO: Handle error
m, _, read_ok := ini.load_map_from_path(config_path, context.temp_allocator) m, _, ok := ini.load_map_from_path(config_path, context.temp_allocator)
if !read_ok { if !ok {
return nil return nil
} }

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:crypto/hash" import "core:crypto/hash"
@@ -514,8 +513,7 @@ test_db_sync_dir_missing :: proc(t: ^testing.T) {
db_insert(&d, f) db_insert(&d, f)
result, err := db_sync(&d, &f) result, err := db_sync(&d, &f)
testing.expect_value(t, err, SyncError.DirMissing) testing.expect(t, err == .DirMissing, "should return DirMissing error")
testing.expect_value(t, result, nil)
} }
@(test) @(test)

View File

@@ -1,36 +1,10 @@
package main package main
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:mem" import "core:mem"
import "core:os" import "core:os"
import "core:prof/spall"
import "core:sync"
SPALL :: #config(SPALL, false)
when SPALL {
spall_ctx: spall.Context
@(thread_local)
spall_buffer: spall.Buffer
}
main :: proc() { main :: proc() {
when SPALL {
ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0)
if !spall_ok {
fmt.eprintln("Failed to create spall trace file")
os.exit(1)
}
spall_ctx = ctx
defer spall.context_destroy(&spall_ctx)
spall_backing := make([]u8, spall.BUFFER_DEFAULT_SIZE)
defer delete(spall_backing)
spall_buffer = spall.buffer_create(spall_backing, u32(sync.current_thread_id()))
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
}
when ODIN_DEBUG { when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator) mem.tracking_allocator_init(&heap_track, context.allocator)
@@ -86,21 +60,3 @@ main :: proc() {
} }
} }
when SPALL {
@(instrumentation_enter)
spall_enter :: proc "contextless" (
proc_address, call_site_return_address: rawptr,
loc: runtime.Source_Code_Location,
) {
spall._buffer_begin(&spall_ctx, &spall_buffer, "", "", loc)
}
@(instrumentation_exit)
spall_exit :: proc "contextless" (
proc_address, call_site_return_address: rawptr,
loc: runtime.Source_Code_Location,
) {
spall._buffer_end(&spall_ctx, &spall_buffer)
}
}

View File

@@ -2,7 +2,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:sys/posix" import "core:sys/posix"
import "core:terminal/ansi"
MultiSelect_Result :: enum { MultiSelect_Result :: enum {
Confirm, Confirm,
@@ -41,12 +40,12 @@ multi_select :: proc(
cursor: int = 0 cursor: int = 0
scroll_offset: int = 0 scroll_offset: int = 0
fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE) fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset) visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO) raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok { if !ok {
fmt.printf(ansi.CSI + ansi.DECTCEM_SHOW) fmt.printf("\x1b[?25h")
return return
} }
defer disable_raw_mode(&raw) defer disable_raw_mode(&raw)
@@ -66,18 +65,18 @@ multi_select :: proc(
case .Space: case .Space:
selected[cursor] = !selected[cursor] selected[cursor] = !selected[cursor]
case .Enter: case .Enter:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Confirm result = .Confirm
return return
case .Escape: case .Escape:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Cancel result = .Cancel
return return
case .Unknown: case .Unknown:
} }
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE)) scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1) fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset) visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
} }
} }
@@ -89,7 +88,7 @@ render_options :: proc(
cursor: int, cursor: int,
scroll_offset: int, scroll_offset: int,
) -> int { ) -> int {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_CYAN + ansi.SGR + "%s" + ANSI_RESET + " (↑/↓ move, space select, enter confirm)\r\n", prompt) fmt.printf("\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", prompt)
end := scroll_offset + MAX_VISIBLE end := scroll_offset + MAX_VISIBLE
if end > len(options) { if end > len(options) {
@@ -102,9 +101,9 @@ render_options :: proc(
checkbox = "x" checkbox = "x"
} }
if i == cursor { if i == cursor {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_GREEN + ansi.SGR + "> " + ANSI_RESET + "[" + ansi.CSI + ansi.FG_GREEN + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i]) fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
} else { } else {
fmt.printf(" [" + ansi.CSI + ansi.FAINT + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i]) fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
} }
} }

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"

View File

@@ -1,11 +1,9 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:testing" import "core:testing"
TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys" TEST_KEY_DIR :: "fixtures/keys"
@(test) @(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) { test_parse_ed25519_public_key :: proc(t: ^testing.T) {

View File

@@ -1,36 +1,117 @@
package main package main
import "core:text/table" import "core:encoding/json"
import "core:unicode/utf8" import "core:fmt"
import "core:io"
import "core:strings"
import "core:terminal/ansi"
decorations := table.Decorations { render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
"┌", col_widths := make([dynamic]int, 0, len(headers), context.temp_allocator)
"┬", for i in 0 ..< len(headers) {
"┐", append(&col_widths, strings.rune_count(headers[i]))
"├", }
"┼", for r in rows {
"┤", for i in 0 ..< len(r) {
"└", rw := strings.rune_count(r[i])
"┴", if i < len(col_widths) && rw > col_widths[i] {
"┘", col_widths[i] = rw
"│", }
"─",
}
// TODO: Optimize ansi_aware_width
ansi_aware_width :: proc(str: string) -> int {
buf: [4096]byte
pos := 0
i := 0
for i < len(str) {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else {
buf[pos] = str[i]; pos += 1; i += 1
} }
} }
_, _, width := utf8.grapheme_count(string(buf[:pos]))
return width b: strings.Builder
strings.builder_init(&b, context.temp_allocator)
hline :: proc(
w: io.Writer,
b: ^strings.Builder,
left, mid, right: string,
widths: [dynamic]int,
) {
strings.write_string(b, left)
for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
strings.write_string(b, "\u2500")
}
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else {
strings.write_string(b, right)
}
}
fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b)
}
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int, color: string = "", center := false) {
before: int
after: int
total_pad := width - strings.rune_count(s)
if center {
before = total_pad / 2
after = total_pad - before
} else {
before = 0
after = total_pad
}
fmt.sbprintf(
b,
" %s%s%s%*s%s%*s%s \u2502",
ansi.CSI,
color,
ansi.SGR,
before,
"",
s,
after,
"",
ansi.CSI + ansi.RESET + ansi.SGR,
)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i], ansi.FG_BRIGHT_GREEN, true)
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
}
hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
}
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator)
for row in rows {
entry := make(map[string]string, len(headers), context.temp_allocator)
for i in 0 ..< len(headers) {
entry[headers[i]] = row[i]
}
append(&entries, entry)
}
data, err := json.marshal(entries[:], allocator = context.temp_allocator)
if err != nil {
fmt.eprintf("Error marshaling JSON: %v\n", err)
return
}
fmt.wprintf(w, "%s", data, flush = false)
} }

View File

@@ -1,33 +1,239 @@
#+test
package main package main
import "core:encoding/json"
import "core:fmt"
import "core:strings"
import "core:terminal/ansi"
import "core:testing" import "core:testing"
@(test) @(test)
test_ansi_aware_width_plain_ascii :: proc(t: ^testing.T) { test_render_json_rows_normal :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("hello"), 5) b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name", "path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 rows, got %d", len(result)))
testing.expect(
t,
result[0]["name"] == "foo",
fmt.tprintf("expected name=foo, got %q", result[0]["name"]),
)
testing.expect(t, result[0]["path"] == "/home/user/.env")
testing.expect(t, result[1]["name"] == "bar")
testing.expect(t, result[1]["path"] == "/home/user/project/.env")
} }
@(test) @(test)
test_ansi_aware_width_empty :: proc(t: ^testing.T) { test_render_json_rows_special_chars :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width(""), 0) b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"key", "value"}
rows := [][]string {
{"quote", `has "double quotes"`},
{"backslash", `path\to\file`},
{"newline", "line1\nline2"},
{"mixed", `a "b" c\nd`},
}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal(
transmute([]byte)output,
&result,
allocator = context.temp_allocator,
)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 4)
testing.expect(
t,
result[0]["value"] == `has "double quotes"`,
fmt.tprintf("got %q", result[0]["value"]),
)
testing.expect(t, result[1]["value"] == `path\to\file`)
testing.expect(t, result[2]["value"] == "line1\nline2")
testing.expect(t, result[3]["value"] == `a "b" c\nd`)
} }
@(test) @(test)
test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) { test_render_json_rows_empty :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "Directory" + ANSI_RESET b: strings.Builder
testing.expect_value(t, ansi_aware_width(colored), 9) strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name"}
rows: [][]string
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 0)
} }
@(test) @(test)
test_ansi_aware_width_unicode :: proc(t: ^testing.T) { test_render_table_normal :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("\u2713 Available"), 11) b: strings.Builder
testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 9) strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name", "Path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
r := ANSI_RESET
n := ansi.CSI + ansi.SGR
expected := fmt.tprintf(
"┌──────┬─────────────────────────┐\n" +
"│ %sName%s │ %s Path %s │\n" +
"├──────┼─────────────────────────┤\n" +
"│ %sfoo %s │ %s/home/user/.env %s │\n" +
"│ %sbar %s │ %s/home/user/project/.env%s │\n" +
"└──────┴─────────────────────────┘\n",
g,
r,
g,
r,
n,
r,
n,
r,
n,
r,
n,
r,
)
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }
@(test) @(test)
test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) { test_render_table_empty :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "a" + ANSI_RESET + "b" + COLOR_TABLE_HEADING + "c" + ANSI_RESET b: strings.Builder
testing.expect_value(t, ansi_aware_width(colored), 3) strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name"}
rows: [][]string
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
r := ANSI_RESET
expected := fmt.tprintf(
"┌──────┐\n" +
"│ %sName%s │\n" +
"├──────┤\n" +
"└──────┘\n",
g,
r,
)
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }
@(test)
test_render_table_unicode :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Status", "Detail"}
rows := [][]string{{"\u2713 Available", "ok"}, {"\u2717 Missing", "fail"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
r := ANSI_RESET
n := ansi.CSI + ansi.SGR
expected := fmt.tprintf(
"┌─────────────┬────────┐\n" +
"│ %s Status %s │ %sDetail%s │\n" +
"├─────────────┼────────┤\n" +
"│ %s✓ Available%s │ %sok %s │\n" +
"│ %s✗ Missing %s │ %sfail %s │\n" +
"└─────────────┴────────┘\n",
g,
r,
g,
r,
n,
r,
n,
r,
n,
r,
n,
r,
)
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}

Binary file not shown.