mirror of
https://github.com/sbrow/envr.git
synced 2026-06-28 02:58:33 -04:00
Compare commits
1 Commits
92faab2706
...
e7da6f60e1
| Author | SHA1 | Date | |
|---|---|---|---|
| e7da6f60e1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -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
268
TABLE_IMPROVEMENT_PLAN.md
Normal 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
|
||||||
23
TODOS.md
23
TODOS.md
@@ -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
|
||||||
|
|||||||
5
cli.odin
5
cli.odin
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#+test
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#+test
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:path/filepath"
|
import "core:path/filepath"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#+test
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#+test
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
6
db.odin
6
db.odin
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#+test
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
44
main.odin
44
main.odin
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
17
prompt.odin
17
prompt.odin
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
#+test
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
133
table.odin
133
table.odin
@@ -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
|
b: strings.Builder
|
||||||
ansi_aware_width :: proc(str: string) -> int {
|
strings.builder_init(&b, context.temp_allocator)
|
||||||
buf: [4096]byte
|
|
||||||
pos := 0
|
hline :: proc(
|
||||||
i := 0
|
w: io.Writer,
|
||||||
for i < len(str) {
|
b: ^strings.Builder,
|
||||||
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
|
left, mid, right: string,
|
||||||
i += 2
|
widths: [dynamic]int,
|
||||||
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
|
) {
|
||||||
|
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 {
|
} else {
|
||||||
buf[pos] = str[i]; pos += 1; i += 1
|
strings.write_string(b, right)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, _, width := utf8.grapheme_count(string(buf[:pos]))
|
fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
|
||||||
return width
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
236
table_test.odin
236
table_test.odin
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
test_cond_import
BIN
test_cond_import
Binary file not shown.
Reference in New Issue
Block a user