diff --git a/.gitignore b/.gitignore index 7066c52..cdde39f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ .venv/ venv/ .env +.uv-cache/ # Testing .pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a4eb210 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# v0.5.0 + +First release after v0.2.0. This is a large jump — the reports system has been replaced by a proper plugin architecture, parsing has grown in several directions, and the CLI, LSP, and docs have all been reworked. The notes below group changes by theme rather than by commit. + +## Breaking changes + +- **`@exercise` blocks are now `@movement` blocks.** The block type, the parsed dataclass (`MovementDefinition`), and the tree-sitter grammar all use the new name. Update any `.ox` logs that use the old directive. +- **The `run` REPL command has been removed.** Use `plugins` to list available plugins, and invoke a plugin directly by name (e.g. `volume -m squat`). Avoid naming a custom plugin the same as a built-in command (`query`, `tables`, `reload`, `lint`, `plugins`, `help`, `exit`, `quit`) — built-ins win the name lookup. +- **Reports have been replaced by plugins.** The previous `report` / `generate` CLI commands are gone. The `stats` and `history` plugins were removed; other analyses moved to the new plugin system. +- **Unit normalization.** `lbs` has been unified to `lb`. The grammar now accepts any pint-compatible mass unit (`g`, `oz`, `stone`, `grain`, `kg`, `lb`, …). +- **Time tokens are ISO 8601.** Durations are written `PT30M`, `PT1H30M15S`, etc. + +## New plugins + +Four built-in plugins ship with this release: + +- **`e1rm`** — Estimated 1RM progression from sets tagged with `^rm`, with Brzycki and Epley formulas and table/plot output. +- **`weighin`** — Body-weight tracking with rolling average, trend, multi-scale breakdown, and plot. +- **`srpe`** — Session RPE training load, AU totals per time bin, and ACWR / monotony / strain output modes. +- **`wendler531`** — Generates a 4-week Wendler 5/3/1 cycle as planned sessions (`!` flag), with optional `^rm` tagging and configurable start date and unit. + +The existing `volume` plugin remains. + +## Plugin system + +- Plugins are first-class. A plugin exports `register()` and its functions receive `PluginContext(db, log)`, returning `TableResult`, `TextResult`, or `PlotResult`. +- Load user plugins from your log with `@plugin "path/to/plugin.py"`. Paths resolve relative to the `.ox` file. +- User plugins loaded via `@plugin` override built-ins with the same name. +- On startup, the CLI prints the list of user plugins that were loaded. +- The REPL lists plugins via `plugins` and invokes them by name. + +## Parser and language features + +- **Movement definitions** — `@movement name … @end` blocks with `equipment`, `tags`, `note`, and `url` fields. Parsed into `MovementDefinition` and exposed on `TrainingLog.movement_definitions`. +- **Notes are first-class objects** — both single-line `note "…"` entries and in-session `note:` lines flow through `Note` / session notes and into the database. +- **Stored queries** — `2025-01-10 query "name" "SELECT …"` lines are parsed and surfaced via the `query` command by name. +- **Weigh-ins** — `date W weight [time] [scale]` lines parse into `WeighIn` dataclasses and populate a `weigh_ins` table. +- **Implied units in progressive weights** — `160/185/210lb` now parses correctly; each segment inherits the nearest following unit. +- **`BW` inside progressions** — `BW/24kg/32kg` and similar forms work without lint errors. +- **Parse diagnostics / linter** — parse errors are collected on load and surfaced via the `lint` command and through the LSP. +- **SQLite `REGEXP`** — available in `query` expressions. +- **Short flags** on plugin parameters (e.g. `-m`, `-b`). + +## CLI + +- `plugins` — new command to list available plugins. +- Plugins are invoked directly by name. Running a plugin with no args prints its usage. +- `reload` — re-parse the current log from disk without leaving the REPL. Reprints parse diagnostics and re-announces loaded user plugins. +- `tables -h` — show column details alongside the table/view list. +- `query name` — recall a stored query by name, or run inline SQL with `query SELECT …`. +- `--version` reads from `pyproject.toml` so there is a single source of truth. + +## Plots + +All built-in plots now route through a small `plot` facade over `plotext`, giving consistent axes, markers, and legends across `e1rm`, `weighin`, and `srpe`. The earlier hand-rolled ASCII plots are gone. + +## Editor support + +- **VSCode** — syntax highlighting updated to cover `@movement`, `@session`, `@template`, `@plugin`, `@include`, `note` entries, `query` entries, and the `equipment`/`tags`/`note`/`url` fields inside movement definitions. Comment folding is fixed. +- **LSP** — movement-name autocomplete is populated from `@movement` blocks in the parsed log; diagnostics surface parse errors and invalid `@include` paths; comment folding is supported. + +## Documentation + +- `docs/getting-started.md` covers movement definitions, session-level notes, stored queries, and `@plugin` loading. +- `docs/plugins.md` documents every built-in plugin, loading rules, the plugin API, and reserved names. +- `docs/api-reference.md` now lists every dataclass (including `MovementDefinition`), the full `TrainingLog` surface, and the plugin result types. +- `docs/cli-reference.md` reflects the current command set. +- `docs/editor-support.md` clarifies that VSCode is the only shipped extension; Neovim and Helix users can wire up the grammar and `ox-lsp` directly. + +## Fixes + +- `e1rm` only considers completed sessions. +- Progressive weights with `BW` no longer trigger lint errors. +- Weekly time bins default to Sunday dates. +- Single-line sessions now use the movement name as the session name. diff --git a/CLAUDE.md b/CLAUDE.md index d337d38..21bfe97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,39 +20,44 @@ uv run ruff format src/ tests/ # format ``` src/ox/ - parse.py - Tree-sitter node → data structures (the core parser) - data.py - Dataclasses: TrainingSet, Movement, TrainingSession, TrainingLog, Note, WeighIn, StoredQuery, Diagnostic - db.py - In-memory SQLite layer: create_db(log) → Connection - reports.py - Reports: volume, matrix; get_all_reports() merges builtin + plugin reports - plugins.py - Plugin discovery and registry (report + generator types) - units.py - Pint unit registry (shared instance) - cli.py - Click CLI with interactive REPL (stats, history, report, generate, query, tables, lint, reload) - lsp.py - LSP server: diagnostics, movement completion, comment folding - lint.py - Parse error collection for CLI lint command and LSP + parse.py - Tree-sitter node → data structures (the core parser) + data.py - Dataclasses: TrainingSet, Movement, TrainingSession, TrainingLog, Note, WeighIn, StoredQuery, Diagnostic + db.py - In-memory SQLite layer: create_db(log) → Connection + plugins.py - Plugin discovery, registry, PluginContext, result types (TableResult, TextResult, PlotResult) + sql_utils.py - SQL helper utilities for plugins (parse_plugin_args, plugin_usage, _weight_sql_expr, _time_bin_expr) + units.py - Pint unit registry (shared instance) + cli.py - Click CLI with interactive REPL (run, query, tables, lint, reload) + lsp.py - LSP server: diagnostics, movement completion, comment folding + lint.py - Parse error collection for CLI lint command and LSP builtins/ - e1rm.py - Estimated 1RM report (Brzycki/Epley) - weighin.py - Weigh-in stats/plot report (rolling average, trend, multi-scale) - wendler531.py - Wendler 5/3/1 cycle generator + volume.py - Volume over time plugin + e1rm.py - Estimated 1RM plugin (Brzycki/Epley) + weighin.py - Weigh-in stats/plot plugin (rolling average, trend, multi-scale) + srpe.py - Session RPE training load plugin (ACWR, monotony, strain) + wendler531.py - Wendler 5/3/1 cycle generator plugin tests/ conftest.py - Shared fixtures (simple_log_*, weight_edge_cases, log_with_query_*, log_with_weigh_ins_*, weigh_in_multi_scale_*, simple_db, example_db) test_parse.py - Weight/rep parsing test_data.py - Data structures test_db.py - SQLite schema, loading, views, queries - test_reports.py - Reports, arg parsing, registry + test_reports.py - SQL utils, volume plugin, arg parsing, plugin registry test_plugins.py - Plugin registration, loading, builtins test_integration.py - End-to-end parsing - test_weighin.py - Weigh-in report (rolling avg, trend, table/plot/stats) + test_weighin.py - Weigh-in plugin (rolling avg, trend, table/plot/stats) test_notes.py - Note parsing, session notes, DB population + test_srpe.py - sRPE plugin (training load, ACWR, monotony, strain) test_lint.py - Diagnostic collection tree-sitter-ox/ grammar.js - Tree-sitter grammar definition for .ox format editors/ vscode/ - VSCode extension for .ox syntax highlighting examples/ - plugins/ - Example plugin scripts (wendler531.py) + plugins/ - Example plugin scripts (wendler531.py) + plugin_template.py - Template for writing user plugins docs/ - MkDocs documentation source -example/ - example.ox - Reference training log with all supported formats +examples/ + example.ox - Reference training log with all supported formats + advanced.ox - sRPE tracking example with 8 weeks of training data ``` ## .ox File Format @@ -60,7 +65,7 @@ example/ ``` # Comments start with # -# Single-line entry: date flag exercise: weight reps "note" +# Single-line entry: date flag movement: weight reps "note" 2025-01-10 * pullups: BW 5x10 # Session block @@ -82,9 +87,24 @@ kb-oh-press: 24kg 5/5/5 # Include another file @include "other.ox" +# Movement definition +@movement squat +equipment: barbell +tags: squat, lower +note: back squat +@end + +# Template block +@template "my-template" +movement: details +@end + +# Load a plugin +@plugin "my_plugin.py" + # Flags: * = completed, ! = planned, W = weigh-in # Weight units: kg, lb, g, oz, stone, grain, and more (any pint-compatible mass unit) -# Weight formats: 24kg, BW, 24kg+32kg (combined), 24kg/32kg/48kg (progressive) +# Weight formats: 24kg, BW, 24kg+32kg (combined), 24kg/32kg/48kg (progressive), 160/185/210lb (implied unit) # Rep formats: 5x5 (sets x reps), 5/5/5 (per-set reps) # Duration: ISO 8601 (PT30M, PT1H30M15S) # Distance: numeric + unit (m, km, ft, mi, etc.) @@ -95,9 +115,9 @@ kb-oh-press: 24kg 5/5/5 - Python 3.12, dependencies managed with uv - Frozen dataclasses with `slots=True` for data structures - `pint.Quantity` for all weight values (never raw numbers) -- Exercise names are hyphenated lowercase (e.g. `kb-oh-press`, `bench-press`) +- Movement names are hyphenated lowercase (e.g. `kb-oh-press`, `bench-press`) - `to_ox()` methods serialize back to .ox format (round-trip support) - Tree-sitter nodes are processed in `parse.py`; data structures live in `data.py` — keep this separation +- All analysis features are plugins (builtins or user-defined). Plugins receive `PluginContext(db, log)` and return `TableResult`, `TextResult`, or `PlotResult` +- CLI commands: `plugins` to list available plugins, `query` for raw SQL. Plugins are invoked by name directly (e.g. `volume -m squat`) -## Known Issues -- Progressive weights for the same movement require explicit units, this is a known bug and applicable tests are skipped. diff --git a/README.md b/README.md index 95ea03e..63b7e41 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ox -Plain text training log format and toolchain. Write workouts in `.ox` files, parse into structured data, analyze progress over time. +Plain text training log format and toolchain. Record training in `.ox` files, parse into structured data, analyze progress over time. Inspired by [Beancount](https://github.com/beancount/beancount) (plain text accounting, but for training). Named after [Milo of Croton](https://en.wikipedia.org/wiki/Milo_of_Croton). @@ -35,7 +35,7 @@ Full docs at [konnerhorton.github.io/ox](https://konnerhorton.github.io/ox): - [CLI Reference](https://konnerhorton.github.io/ox/cli-reference/) — commands and usage - [Reports & Plugins](https://konnerhorton.github.io/ox/plugins/) — built-in reports, plugin system - [API Reference](https://konnerhorton.github.io/ox/api-reference/) — Python library -- [Editor Support](https://konnerhorton.github.io/ox/editor-support/) — VSCode, Neovim, Helix +- [Editor Support](https://konnerhorton.github.io/ox/editor-support/) — VSCode extension, LSP, tree-sitter grammar ## Syntax Overview @@ -48,7 +48,7 @@ Full docs at [konnerhorton.github.io/ox](https://konnerhorton.github.io/ox): 2025-01-15 * Lower Body squat: 135lb 5x5 deadlift: 185lb 3x5 -note: easy day +note: "easy day" @end # Weigh-in @@ -59,11 +59,21 @@ note: easy day # Include another file @include "other.ox" + +# Movement definition +@movement squat +equipment: barbell +tags: squat, lower +note: back squat +@end + +# Load a plugin +@plugin "plugins/my_plugin.py" ``` **Flags:** `*` completed, `!` planned, `W` weigh-in -**Weights:** `24kg`, `135lb`, `BW`, `24kg+32kg` (combined), `24kg/32kg/48kg` (progressive) +**Weights:** `24kg`, `135lb`, `BW`, `24kg+32kg` (combined), `24/32/48kg` (progressive, with implied units) **Reps:** `5x5` (sets x reps), `5/3/1` (per-set) @@ -71,7 +81,7 @@ note: easy day **Distance:** `5km`, `3mi`, `400m` -**Exercise names:** no spaces, hyphenated lowercase (`kb-oh-press`, `bb-back-squat`) +**Movement names:** no spaces (`kb-oh-press`, `bb-back-squat`) ## Installation diff --git a/SPEC.md b/SPEC.md index bc62dc8..d032ec4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -27,10 +27,10 @@ Developers and power users comfortable with text files and CLIs. - **Python parser** (`src/ox/parse.py`) — tree-sitter nodes → dataclasses - **Data model** (`src/ox/data.py`) — `TrainingSet`, `Movement`, `TrainingSession`, `TrainingLog`, `Note`, `WeighIn`, `StoredQuery`, `Diagnostic` - **SQLite query layer** (`src/ox/db.py`) — in-memory DB with `sessions`, `movements`, `sets`, `notes`, `session_notes`, `weigh_ins`, `queries` tables and `training` view -- **Plugin system** (`src/ox/plugins.py`) — discovery from `~/.ox/plugins/`, entry points, and builtins; report and generator types +- **Plugin system** (`src/ox/plugins.py`) — built-in plugins plus user plugins loaded via `@plugin` directives in `.ox` files - **Built-in reports** (`src/ox/reports.py`) — `volume` (volume over time) and `matrix` (session count per movement) - **Built-in plugins** — `e1rm` (estimated 1RM via Brzycki/Epley), `weighin` (weight tracking with stats/plot/rolling average), `wendler531` (5/3/1 cycle generator) -- **CLI** (`src/ox/cli.py`) — interactive REPL with `stats`, `history`, `report`, `generate`, `query`, `tables`, `lint`, `reload` commands and tab completion +- **CLI** (`src/ox/cli.py`) — interactive REPL with `report`, `generate`, `query`, `tables`, `lint`, `reload` commands and tab completion - **LSP** (`src/ox/lsp.py`) — diagnostics (syntax errors + include validation), movement name completion, comment folding ranges - **Weigh-in tracking** — full pipeline: parse → `WeighIn` dataclass → DB → builtin report with table/plot/stats output - **Notes** — standalone and session-level notes, parse → `Note` dataclass → DB, `to_ox()` round-trip @@ -45,22 +45,21 @@ Developers and power users comfortable with text files and CLIs. ### What's incomplete - Planned sessions (`!` flag) — parsed but ignored in analysis -- Exercise definitions (`@exercise` blocks) — parsed but not used in analysis - Template blocks (`@template`) — grammar exists, no processing - Progressive implied weights (e.g. `160/185/210lbs`) — known parsing bug -- CLI exercise autocompletion (tab-complete exercise names, not just commands) +- CLI movement autocompletion (tab-complete movement names, not just commands) ## Direction ### Richer analysis - Cycle tracking — micro/meso/macro periodization -- Exercise definitions feeding into analysis (e.g. grouping by movement pattern) +- Movement definitions feeding into analysis (e.g. grouping by movement tag) - `pint.Quantity` for time/distance — enables derived units like pace and speed ### Better editor experience -- LSP hover info (exercise definitions, recent history for a movement) +- LSP hover info (movement definitions, recent history for a movement) - LSP completions for session templates - Snippets for common entry patterns diff --git a/docs/api-reference.md b/docs/api-reference.md index a928711..849aa9a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -28,6 +28,9 @@ All are frozen dataclasses with `slots=True`. |---|---| | `sessions` | `tuple[TrainingSession, ...]` | | `notes` | `tuple[Note, ...]` | +| `weigh_ins` | `tuple[WeighIn, ...]` | +| `queries` | `tuple[StoredQuery, ...]` | +| `movement_definitions` | `tuple[MovementDefinition, ...]` | | `diagnostics` | `tuple[Diagnostic, ...]` | **Properties:** `completed_sessions`, `planned_sessions` @@ -65,6 +68,18 @@ All are frozen dataclasses with `slots=True`. **Properties:** `volume` (`reps × weight`, or `None` for BW) +### MovementDefinition + +| Attribute | Type | +|---|---| +| `name` | `str` | +| `equipment` | `str \| None` | +| `tags` | `tuple[str, ...]` | +| `note` | `str \| None` | +| `url` | `str \| None` | + +Parsed from `@movement` blocks. Used by the LSP for name completion; queryable directly off the log. + ### WeighIn | Attribute | Type | @@ -144,3 +159,35 @@ session.to_ox() # serialize session to .ox format movement.to_ox() # serialize movement note.to_ox() # serialize note ``` + +## Plugin API + +Plugins receive a `PluginContext` and return one of three result types. All are frozen dataclasses in `ox.plugins`. + +### PluginContext + +| Attribute | Type | +|---|---| +| `db` | `sqlite3.Connection` | +| `log` | `TrainingLog` | + +### TableResult + +| Attribute | Type | +|---|---| +| `columns` | `list[str]` | +| `rows` | `list[tuple]` | + +### TextResult + +| Attribute | Type | +|---|---| +| `text` | `str` | + +### PlotResult + +| Attribute | Type | +|---|---| +| `lines` | `list[str]` | + +See [Plugins](plugins.md) for a walkthrough of writing a plugin and registering it via `register()`. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 59e6d57..9ec10ce 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -14,42 +14,25 @@ Opens an interactive REPL. Parse errors are summarized on load — run `lint` fo ## Commands -### `stats` +### `plugins` -Summary table of all exercises: session count, total reps, last session date. +List available plugins with their descriptions and usage strings. ``` -ox> stats +ox> plugins ``` -### `history EXERCISE` +### Running a plugin -Per-exercise history: date, sets/reps, top weight, volume. +Invoke a plugin by name. Plugins receive the parsed log plus a SQLite connection and return a table, text, or plot. ``` -ox> history squat +ox> volume -m squat --bin monthly +ox> e1rm -m deadlift +ox> wendler531 -m squat:315,bench:225 ``` -### `report [NAME [OPTIONS]]` - -List or run reports. Reports query the SQLite database and return tables. - -``` -ox> report # list available reports -ox> report volume -m squat --bin monthly -ox> report e1rm -m deadlift -``` - -See [Reports & Plugins](plugins.md) for details. - -### `generate [NAME [OPTIONS]]` - -List or run generators. Generators produce `.ox` text for planning. - -``` -ox> generate # list available generators -ox> generate wendler531 -m squat:315,bench:225 -``` +Typing a plugin's name with no args prints its usage. See [Plugins](plugins.md) for details. ### `query SQL` diff --git a/docs/editor-support.md b/docs/editor-support.md index d0dcdc5..6f94857 100644 --- a/docs/editor-support.md +++ b/docs/editor-support.md @@ -18,7 +18,9 @@ Reload VSCode (`Ctrl+Shift+P` → "Developer: Reload Window"). ### Features -- Syntax highlighting (dates, flags, weights, reps, comments, strings) +- Syntax highlighting for dates, flags (`*`, `!`, `W`), weights, reps, strings, and comments +- Block directives (`@session`, `@movement`, `@template`, `@end`) and top-level directives (`@include`, `@plugin`) +- `note` and `query` entry highlighting; `equipment`/`tags`/`note`/`url` fields inside `@movement` blocks - Comment toggling (`#`) - Comment section folding - Auto-closing quotes @@ -30,7 +32,7 @@ Reload VSCode (`Ctrl+Shift+P` → "Developer: Reload Window"). ### Features - **Diagnostics** — syntax errors and invalid `@include` paths -- **Completions** — movement name autocomplete +- **Completions** — movement name autocomplete, populated from `@movement` blocks in the parsed log - **Folding** — collapse comment sections ### Editor Configuration @@ -39,4 +41,4 @@ Reload VSCode (`Ctrl+Shift+P` → "Developer: Reload Window"). ## Tree-sitter Grammar -Available in `tree-sitter-ox/` for editors with tree-sitter support. +Available in `tree-sitter-ox/` for editors with tree-sitter support. The VSCode extension is the only editor integration shipped today; Neovim and Helix users can wire up the grammar and `ox-lsp` themselves. diff --git a/docs/future.md b/docs/future.md new file mode 100644 index 0000000..0049c3c --- /dev/null +++ b/docs/future.md @@ -0,0 +1,77 @@ +# Updates + +## Movement definitions + +Unilateral exercises should implicitly mean both sides when reps are stated. +For example, `pistol-squat: 3x4` means I did 3 sets of 4 reps on _each_ leg. +For now, this will be captured with the word 'unilateral' in a `tag` in the definition + +I'd also like a way to query these movements meaningfully, maybe a plugin to pull up exercises with certain tags. +For now, `query` can be used to pull. + +Plugin features: + +- Show movements with specific tags +- show most used movements +- show least used +- show movements that have not been done in a while, but were at one time popular + +## Named sessions + +I'd like to be able to track progression within a session (and provide the tooling for that tracking). +If I have a specific circuit, I want to see how my total volume or top weights have changed over time for that session. +To do that, we need named sessions, which we have. +And, some way to track exertion, which we can use [sRPE](#session-rate-of-perceived-exertion). +It would also be good to be able to categorize sessions based on their specific protocol ([protocol metadata](#protocol-metadata)), like emom, tababta, amrap, etc. +sRPE is now available via a plugin, so I'll probaly do a similar string/note based plugin for protocol first, them promote it to first-class later once I work out the kinks. + +Eventually, I need to build a plugin that allows me to compare across a single named session. +For a given alt-emom, I want to see how I have progressed over time, mainly based on total volume within the session and resultant srpe. + +## Session rate of perceived exertion + +The goals of ox include simplicity and self awareness. +So, it is based around not using devices like heart rate monitors etc. +All that you should need, if you need a device during your session at all, is a watch to keep the time. +Perhaps the most well established method of tracking training load through time in the literature is the session rate of perceived exertion. +With this tool, the individual rates their exertion over the session on some scale at some point after then session. +The most typical scale is 0-10 (foster modified borg scale, where the borg scale was originally developed for RPE). +And ratings are typically performed 30 minutes after the session. +The rating and total duration are multiplied to get some value that then can be compared across sessions. + +For example: + +A light run might take 30 minutes and feel like a 2 (easy): $2 \times 30min = 60 AU$ +A 10-minute amrap crossfit session might by quick but feel like an 8 (two steps below maximal): $8 \times 10min = 80 AU$ + +Using these arbitrary units, I can then track total volume over whatever time period I am interested in. +Typically that will probably be weeks and months. + +Syntax is the big question though, should I track these using first class citizens in `ox` or just use something like `spre: "4, PT30M"` +I'll do the above for now, until I figure out the path forward. +The srpe builtin provides this in the short term. + +## Cardio zones + +Similar to sRPE above, I'd like to track my cardio zones as well for specific runs (and maybe other modalities later on). +It would be the 1-5 system (don't know what it is call) but mostly using 2-5. +Currently I have a `zone-2-run` movement, I could do that or have a note in the session, I'll stick with this for now. + +For other levels, I'd like to do the norwegian 4x4 method for more vo2 max training, so that'd be zone 4 or 5. +Measures will be subjective since I do not have a heart rate monitor (and do not want to get one), but I think this will be sufficient. +I will also use the sRPE scores on these sessions because they will certainly need to be included in the calcs. +Zone 3 is not as important as the literature suggests its not that useful if you are doing a lot of zone 2 and sufficient amounts of 4/5. + +## Protocol metadata + +I'd like to add metadata to a session, both ad-hoc and standard session so I can know the full layout 3 years from now. +Items would include EMOM, AMRAP, Alt-EMOM, Tabata, RFT, Ladder, Complex, etc. +I would need some standard nomenclature to describe the scheme fully, though some would be implicit in the session movements. +And certain protocols will require certain metadata. +Alt-EMOM would require a list of movements and duration for set (if not a minute but say 30 seconds), duration would be derived later from the interval and total sets. + +For now, since my sessions are quite simple, I will use `meta: "alt-emom, PT1M"` + +There may need to be some provision for specifying target work/rest ratios, but that might get too verbose and could probably be handled with some text metadata instead of a proper data structure. + +However this is defined, it should be once for the named session, in some sort of session ID data structure so that its not repeated everywhere the session is used. diff --git a/docs/getting-started.md b/docs/getting-started.md index 3877d3e..f992a58 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,8 +22,7 @@ Run it: ```bash ox training.ox -ox> stats -ox> history squat +ox> query SELECT * FROM training LIMIT 10 ``` ## Syntax @@ -64,18 +63,20 @@ note: "felt strong today" 24kg kilograms BW bodyweight 24kg+32kg combined (two bells) -135lb/155lb/175lb progressive (per-set) +135/155/175lb progressive (per-set) ``` +Any [pint](https://pint.readthedocs.io/)-compatible mass unit works (`g`, `oz`, `stone`, `grain`, …); `lb` and `kg` are just the common cases. + ### Reps ``` -5x5 5 sets of 5 reps +5x3 5 sets of 3 reps 5/3/1 3 sets with different reps 10/8/6/4/2 pyramid ``` -### Exercise names +### Movement names No spaces — hyphens are common but any non-space format works: @@ -84,13 +85,56 @@ squat kb-swing bb-deadlift bench-press kb-oh-press bb-back-squat ``` +### Movement definitions + +Declare a movement once with `@movement` to give it equipment, tags, a description, and a reference URL. Definitions are stored on the parsed log (`TrainingLog.movement_definitions`) and feed LSP name completion. + +``` +@movement squat +equipment: barbell +tags: squat, lower +note: back squat +url: https://example.com/squat-form +@end +``` + +### Notes inside sessions + +Inside a `@session` block, a `note:` line attaches to the session (not to any one movement) and lands in the `session_notes` table: + +``` +@session +2025-01-16 * Upper Body +bench-press: 135lb 5x5 +note: "felt strong today" +@end +``` + +### Stored queries + +Save a SQL query with a name so it can be recalled later: + +``` +2025-01-10 query "recent-squats" "SELECT * FROM training WHERE movement_name='squat' ORDER BY date DESC LIMIT 10" +``` + +### Loading plugins + +Plugins extend ox with custom analysis or generation. Reference a Python file from your log with `@plugin` (path is relative to the `.ox` file): + +``` +@plugin "plugins/my_plugin.py" +``` + +See [Plugins](plugins.md) for the built-ins and for writing your own. + ### Includes Split logs across files: ``` -@include "upper.ox" -@include "lower.ox" +@include "2022.ox" +@include "2023.ox" ``` ## Example @@ -122,4 +166,5 @@ pullup: BW 4x10 - [Reports & Plugins](plugins.md) — built-in analysis and extending ox - [API Reference](api-reference.md) — Python library - [Editor Support](editor-support.md) — syntax highlighting and LSP -- [example.ox](https://github.com/konnerhorton/ox/blob/main/example/example.ox) — full reference log +- [example.ox](https://github.com/konnerhorton/ox/blob/main/examples/example.ox) — full reference log +- [advanced.ox](https://github.com/konnerhorton/ox/blob/main/examples/advanced.ox) — 8 weeks of training with sRPE tracking diff --git a/docs/plugins.md b/docs/plugins.md index 0e63550..c72bee4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,52 +2,39 @@ icon: material/puzzle-edit --- -# Reports & Plugins +# Plugins -Two plugin types: **reports** (query DB, return tables) and **generators** (produce `.ox` text). +Plugins extend ox with custom analysis and generation. Each plugin receives a `PluginContext` (with `db` and `log`) and returns a `TableResult`, `TextResult`, or `PlotResult`. -## Built-in Reports +## Built-in Plugins ### `volume` Volume over time for a movement. ``` -ox> report volume -m squat -ox> report volume -m deadlift --bin monthly --unit kg +ox> volume -m squat +ox> volume -m deadlift --bin monthly --unit kg ``` | Param | Default | Options | |---|---|---| -| `-m/--movement` | *required* | exercise name | +| `-m/--movement` | *required* | movement name | | `-b/--bin` | `weekly` | `daily`, `weekly`, `weekly-num`, `monthly` | | `-u/--unit` | `lb` | any mass unit | -### `matrix` - -Session count per movement per time period. - -``` -ox> report matrix -ox> report matrix --bin monthly -``` - -| Param | Default | Options | -|---|---|---| -| `-b/--bin` | `weekly` | `daily`, `weekly`, `weekly-num`, `monthly` | - ### `e1rm` Estimated 1RM progression. Only uses sets with `^rm` in the note. ``` -ox> report e1rm -m deadlift -ox> report e1rm -m squat --formula epley --output plot +ox> e1rm -m deadlift +ox> e1rm -m squat --formula epley --output plot ``` | Param | Default | Options | |---|---|---| -| `-m/--movement` | *required* | exercise name | +| `-m/--movement` | *required* | movement name | | `-f/--formula` | `brzycki` | `brzycki`, `epley` | | `-u/--unit` | `lb` | any mass unit | | `-o/--output` | `table` | `table`, `plot` | @@ -63,9 +50,9 @@ deadlift: 315lb 1x3 "^rm top set" Body weight tracking with statistics and trend analysis. ``` -ox> report weighin -ox> report weighin --output plot --window 14 -ox> report weighin --output stats +ox> weighin +ox> weighin --output plot --window 14 +ox> weighin --output stats ``` | Param | Default | Options | @@ -76,15 +63,53 @@ ox> report weighin --output stats Supports multiple scales — `stats` output shows per-scale breakdowns. -## Built-in Generator +### `srpe` + +Training load analysis from session RPE (sRPE). Computes arbitrary units (AU = rating × duration in minutes) from sRPE entries recorded as session metadata or movement notes. + +``` +ox> srpe +ox> srpe -b monthly +ox> srpe -o plot +ox> srpe -o acwr +ox> srpe -o monotony +ox> srpe -o strain +``` + +| Param | Default | Options | +|---|---|---| +| `-b/--bin` | `weekly` | `daily`, `weekly`, `monthly` | +| `-o/--output` | `table` | `table`, `plot`, `acwr`, `monotony`, `strain` | + +**Output modes:** + +- **table** — AU totals per time bin (sessions, total/avg/max AU) +- **plot** — ASCII chart of AU over time +- **acwr** — Acute:Chronic Workload Ratio (7-day acute / 28-day chronic). Zones: undertraining (<0.8), sweet spot (0.8–1.3), caution (1.3–1.5), danger (>1.5) +- **monotony** — Weekly training monotony (mean daily AU / SD). High monotony (>2.0) with high load predicts overtraining +- **strain** — Weekly strain (AU × monotony). Risk levels: low, moderate, HIGH + +**Recording sRPE in your log:** + +``` +# As session metadata (movement named "srpe") +@session +2025-01-06 * Lower Strength +srpe: "5; PT45M" +squat: 155lb 4x5 +@end + +# Embedded in a movement note +2025-01-08 * run: PT30M "easy pace, srpe: 3; PT30M" +``` ### `wendler531` Generates a 4-week Wendler 5/3/1 cycle as planned sessions. ``` -ox> generate wendler531 -m squat:315,bench:225 -ox> generate wendler531 -m deadlift:405 --unit kg --start-date 2026-03-01 +ox> wendler531 -m squat:315,bench:225 +ox> wendler531 -m deadlift:405 --unit kg --start-date 2026-03-01 ``` | Param | Default | Options | @@ -94,69 +119,52 @@ ox> generate wendler531 -m deadlift:405 --unit kg --start-date 2026-03-01 | `-d/--start-date` | today | `YYYY-MM-DD` | | `-r/--rm` | `true` | `true`, `false` — tag sets with `^rm` | -## Installing Plugins - -### Personal scripts +## Loading Plugins -Place `.py` files in `~/.ox/plugins/` — loaded automatically. +Plugins come from two sources: -### Entry points +1. **Built-ins** — shipped with ox (`volume`, `e1rm`, `weighin`, `wendler531`, `srpe`) +2. **`@plugin` directives** — Python files referenced from your `.ox` log -Distribute as a Python package with an `ox.plugins` entry point: +To load a custom plugin, add an `@plugin` directive to your log file. The path is resolved relative to the `.ox` file that contains it: -```toml -[project.entry-points."ox.plugins"] -my_plugin = "my_package.my_module" +``` +@plugin "plugins/my_plugin.py" +@plugin "../shared/team_plugin.py" ``` -## Writing a Plugin +Plugins loaded via `@plugin` override built-ins with the same name. -Export a `register()` function returning a list of descriptors. +### Reserved names -### Report plugin +Avoid naming a plugin the same as a built-in REPL command (`query`, `tables`, `reload`, `lint`, `plugins`, `help`, `exit`, `quit`) — built-ins win the name lookup and the plugin will be unreachable. + +## Writing a Plugin + +Export a `register()` function returning a list of descriptors. Each plugin function receives a `PluginContext` as its first argument and returns a `TableResult`, `TextResult`, or `PlotResult`. ```python -import sqlite3 +from ox.plugins import PluginContext, TableResult, TextResult, PlotResult -def my_report(conn: sqlite3.Connection, movement: str) -> tuple[list[str], list[tuple]]: - rows = conn.execute( +def my_plugin(ctx: PluginContext, movement: str, unit: str = "lb"): + """ctx.db is a sqlite3.Connection; ctx.log is the parsed TrainingLog.""" + rows = ctx.db.execute( "SELECT date, SUM(reps) FROM training WHERE movement_name = ? GROUP BY date", (movement,), ).fetchall() - return ["date", "total_reps"], rows - -def register(): - return [{ - "type": "report", - "name": "my-report", - "fn": my_report, - "description": "Total reps per day", - "params": [ - {"name": "movement", "type": str, "required": True, "short": "m"}, - ], - }] -``` + return TableResult(["date", "total_reps"], rows) -### Generator plugin - -```python -def my_generator(movement: str, sets: int = 5) -> str: - from datetime import date - today = date.today().strftime("%Y-%m-%d") - lines = ["@session", f"{today} ! Generated Session"] - lines += [f"{movement}: BW 1x10" for _ in range(sets)] - lines.append("@end") - return "\n".join(lines) + # Or return TextResult("generated .ox content") + # Or return PlotResult(["line1", "line2", ...]) def register(): return [{ - "type": "generator", - "name": "my-generator", - "fn": my_generator, - "description": "Bodyweight session generator", + "name": "my-plugin", + "fn": my_plugin, + "description": "Total reps per day", "params": [ {"name": "movement", "type": str, "required": True, "short": "m"}, - {"name": "sets", "type": int, "required": False, "default": 5, "short": "s"}, + {"name": "unit", "type": str, "required": False, "default": "lb", "short": "u"}, ], }] ``` @@ -167,12 +175,10 @@ def register(): | Field | Required | Description | |---|---|---| -| `type` | yes | `"report"` or `"generator"` | | `name` | yes | CLI name | -| `fn` | yes | Callable | +| `fn` | yes | Callable (receives `PluginContext` + params) | | `description` | yes | Short description | | `params` | yes | Parameter descriptors | -| `needs_db` | no | If `True`, generator receives `conn` as first arg | **Parameter:** diff --git a/editors/vscode/syntaxes/ox.tmLanguage.json b/editors/vscode/syntaxes/ox.tmLanguage.json index 5912fd0..e42cfaf 100644 --- a/editors/vscode/syntaxes/ox.tmLanguage.json +++ b/editors/vscode/syntaxes/ox.tmLanguage.json @@ -6,6 +6,7 @@ { "include": "#comment" }, { "include": "#block-keyword" }, { "include": "#include-directive" }, + { "include": "#plugin-directive" }, { "include": "#query-entry" }, { "include": "#singleline-entry" }, { "include": "#note-entry" }, @@ -20,7 +21,7 @@ "name": "comment.line.number-sign.ox" }, "block-keyword": { - "match": "^(@session|@exercise|@template|@end)\\b", + "match": "^(@session|@movement|@template|@end)\\b", "name": "keyword.control.block.ox" }, "include-directive": { @@ -30,6 +31,13 @@ "2": { "name": "string.quoted.double.ox" } } }, + "plugin-directive": { + "match": "^(@plugin)\\s+(\"[^\"]+\")", + "captures": { + "1": { "name": "keyword.control.plugin.ox" }, + "2": { "name": "string.quoted.double.ox" } + } + }, "query-entry": { "match": "^(\\d{4}-\\d{2}-\\d{2})\\s+(query)\\s+(\"[^\"]*\")\\s+(\"[^\"]*\")$", "captures": { @@ -80,7 +88,7 @@ } }, "metadata-line": { - "match": "^\\s*(equipment|pattern|url|note):\\s*(.*)$", + "match": "^\\s*(equipment|tag|tags|url|note):\\s*(.*)$", "captures": { "1": { "name": "support.type.property-name.ox" }, "2": { "name": "string.unquoted.ox" } @@ -100,7 +108,7 @@ "name": "string.quoted.double.ox" }, "weight": { - "match": "\\b(BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)([+/]\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))*)\\b", + "match": "\\b(((BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)?)/)+(BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)(\\+\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))*|BW)\\b", "name": "constant.numeric.weight.ox" }, "rep-scheme": { diff --git a/examples/advanced.ox b/examples/advanced.ox new file mode 100644 index 0000000..6c60987 --- /dev/null +++ b/examples/advanced.ox @@ -0,0 +1,402 @@ +# Advanced Training Log Example +# Demonstrates session RPE tracking with ACWR, monotony, and strain analysis +# ~8 weeks of realistic training data with sRPE, weigh-ins, notes, and queries + +# Exercise Definitions +@movement squat +equipment: barbell +tags: squat, lower +note: back squat, full depth +@end + +@movement deadlift +equipment: barbell +tags: hinge, lower +note: conventional barbell deadlift +@end + +@movement bench-press +equipment: barbell +tags: push, upper +note: flat barbell bench press +@end + +@movement kb-snatch +equipment: kettlebell +tags: power, full-body, unilateral +note: single-arm kettlebell snatch +@end + +@movement kb-oh-press +equipment: kettlebell +tags: push, upper, unilateral +note: single-arm overhead press +@end + +@movement kb-tgu +equipment: kettlebell +tags: full-body, unilateral +note: Turkish get-up +@end + +@movement kb-dh-swing +equipment: kettlebell +tags: hinge, lower, conditioning +note: double-handed kettlebell swing +@end + +@movement pullup +equipment: pull-up bar +tags: pull, upper +note: pronated grip pull-up +@end + +@movement goblet-squat +equipment: kettlebell +tags: squat, lower +note: weight held at chest +@end + +@movement burpee +equipment: none +tags: conditioning, full-body +note: chest to floor, explosive jump +@end + +@movement run +equipment: none +tags: cardio, conditioning +note: running for distance or time +@end + +# Weigh-ins (morning, pre-training) +2025-01-06 W 165.2lb T07:00 "home" +2025-01-08 W 164.8lb T06:45 "home" +2025-01-13 W 165.0lb T07:00 "home" +2025-01-15 W 164.4lb T06:50 "home" +2025-01-20 W 164.6lb T07:10 "home" +2025-01-22 W 163.8lb T06:55 "home" +2025-01-27 W 164.2lb T07:00 "home" +2025-01-29 W 163.6lb T06:45 "home" +2025-02-03 W 163.4lb T07:00 "home" +2025-02-05 W 164.0lb T06:50 "home" +2025-02-10 W 163.8lb T07:05 "home" +2025-02-12 W 163.2lb T06:55 "home" +2025-02-17 W 163.0lb T07:00 "home" +2025-02-19 W 163.6lb T06:50 "home" +2025-02-24 W 164.4lb T07:00 "home" +2025-02-26 W 163.8lb T06:45 "home" + +# Notes +2025-01-06 note "Starting 8-week block. Goal: build base, introduce sRPE tracking." +2025-01-27 note "Feeling good, ramping up intensity this week." +2025-02-03 note "Peak week incoming. Pushing volume and intensity." +2025-02-17 note "Deload week. Active recovery focus." +2025-02-24 note "Second peak block. Controlled spike for adaptation." + +# Stored queries +2025-01-06 query "weekly-volume" "SELECT date(date, '-' || strftime('%w', date) || ' days') AS week, movement_name, ROUND(SUM(reps * weight_magnitude), 1) AS volume FROM training GROUP BY week, movement_name ORDER BY week, volume DESC" +2025-01-06 query "squat-progress" "SELECT date, weight_magnitude, weight_unit, reps, sets FROM training WHERE movement_name = 'squat' ORDER BY date" +2025-01-06 query "srpe-daily" "SELECT s.date, m.note FROM movements m JOIN sessions s ON m.session_id = s.id WHERE LOWER(m.name) = 'srpe' ORDER BY s.date" + +# ============================================================================ +# Week 1 - Base Building (low-moderate intensity) +# ============================================================================ + +@session +2025-01-06 * Lower Strength +srpe: "5; PT45M" +squat: 155lb 4x5 +deadlift: 185lb 3x5 +goblet-squat: 32kg 3x8 +@end + +@session +2025-01-07 * Upper KB +srpe: "4; PT35M" +kb-oh-press: 24kg 5x5 +kb-snatch: 24kg 5x5 +pullup: BW 4x8 +@end + +2025-01-08 * run: PT30M "easy pace, srpe: 3; PT30M" + +@session +2025-01-09 * Lower Volume +srpe: "5; PT40M" +squat: 135lb 4x8 +kb-dh-swing: 32kg 5x15 +goblet-squat: 24kg 4x10 +@end + +@session +2025-01-10 * Upper Strength +srpe: "4; PT35M" +bench-press: 135lb 4x5 +pullup: BW 5x5 +kb-oh-press: 24kg 4x6 +@end + +# ============================================================================ +# Week 2 - Base Building (slight progression) +# ============================================================================ + +@session +2025-01-13 * Lower Strength +srpe: "5; PT50M" +squat: 165lb 4x5 +deadlift: 195lb 3x5 +goblet-squat: 32kg 3x10 +@end + +@session +2025-01-14 * Upper KB +srpe: "5; PT40M" +kb-oh-press: 24kg 5x6 +kb-snatch: 24kg 5x6 +pullup: BW 4x8 +kb-tgu: 24kg 3x1 +@end + +2025-01-15 * run: PT35M "moderate pace, srpe: 4; PT35M" + +@session +2025-01-16 * Lower Volume +srpe: "5; PT45M" +squat: 145lb 4x8 +kb-dh-swing: 32kg 5x15 +goblet-squat: 32kg 4x10 +@end + +@session +2025-01-17 * Upper Strength +srpe: "5; PT40M" +bench-press: 140lb 4x5 +pullup: BW 5x6 +kb-oh-press: 24kg 5x6 +@end + +# ============================================================================ +# Week 3 - Building (moderate intensity) +# ============================================================================ + +@session +2025-01-20 * Lower Strength +srpe: "6; PT50M" +squat: 175lb 4x5 +deadlift: 205lb 3x5 +goblet-squat: 32kg 4x10 +@end + +@session +2025-01-21 * Upper KB +srpe: "6; PT45M" +kb-oh-press: 32kg 5x4 +kb-snatch: 24kg 6x6 +pullup: BW 5x8 +kb-tgu: 24kg 4x1 +@end + +2025-01-22 * run: PT40M "tempo intervals, srpe: 5; PT40M" + +@session +2025-01-23 * Lower Volume +srpe: "6; PT50M" +squat: 155lb 5x8 +kb-dh-swing: 32kg 6x15 +goblet-squat: 32kg 5x10 +@end + +@session +2025-01-24 * Upper Strength + Conditioning +srpe: "6; PT50M" +bench-press: 150lb 4x5 +pullup: 10lb 4x5 +burpee: BW 5x10 +@end + +# ============================================================================ +# Week 4 - Ramping Up (moderate-high) +# ============================================================================ + +@session +2025-01-27 * Lower Heavy +srpe: "7; PT55M" +squat: 185lb 5x5 +deadlift: 225lb 3x5 +goblet-squat: 32kg 4x10 +@end + +@session +2025-01-28 * Upper KB Heavy +srpe: "7; PT50M" +kb-oh-press: 32kg 5x5 +kb-snatch: 32kg 5x4 +pullup: 15lb 5x5 +kb-tgu: 32kg 4x1 +@end + +2025-01-29 * run: PT35M "tempo, srpe: 5; PT35M" + +@session +2025-01-30 * Lower Volume +srpe: "6; PT50M" +squat: 165lb 5x8 +kb-dh-swing: 32kg 8x15 +goblet-squat: 32kg 5x10 +@end + +@session +2025-01-31 * Full Body Power +srpe: "7; PT55M" +bench-press: 155lb 5x3 +squat: 175lb 5x3 +pullup: 20lb 4x3 +@end + +# ============================================================================ +# Week 5 - Peak Week 1 (high intensity) +# ============================================================================ + +@session +2025-02-03 * Lower Max Effort +srpe: "8; PT60M" +squat: 205lb 5x3 +deadlift: 245lb 3x3 +goblet-squat: 32kg 3x8 +@end + +@session +2025-02-04 * Upper KB Volume +srpe: "7; PT55M" +kb-oh-press: 32kg 6x5 +kb-snatch: 32kg 6x4 +pullup: 20lb 5x5 +kb-tgu: 32kg 5x1 +@end + +2025-02-05 * run: PT45M "long run with hills, srpe: 6; PT45M" + +@session +2025-02-06 * Lower Hypertrophy +srpe: "7; PT55M" +squat: 175lb 5x8 +kb-dh-swing: 32kg 8x20 +burpee: BW 5x12 +@end + +@session +2025-02-07 * Upper Heavy +srpe: "8; PT60M" +bench-press: 165lb 5x5 +pullup: 25lb 5x3 +kb-oh-press: 32kg 5x5 +@end + +# ============================================================================ +# Week 6 - Peak Week 2 (highest intensity) +# ============================================================================ + +@session +2025-02-10 * Lower Max Effort +srpe: "9; PT65M" +squat: 215lb 5x3 +deadlift: 255lb 3x3 +goblet-squat: 32kg 3x8 +@end + +@session +2025-02-11 * Upper KB Max +srpe: "8; PT55M" +kb-oh-press: 32kg 7x5 +kb-snatch: 32kg 6x5 +pullup: 25lb 5x4 +kb-tgu: 32kg 5x1 +@end + +2025-02-12 * run: PT40M "tempo, srpe: 6; PT40M" + +@session +2025-02-13 * Lower + Conditioning +srpe: "8; PT60M" +squat: 185lb 5x8 +kb-dh-swing: 32kg 10x20 +burpee: BW 8x10 +@end + +@session +2025-02-14 * Upper Heavy + Test +srpe: "9; PT60M" +bench-press: 175lb 5x3 +pullup: 30lb 4x3 +kb-oh-press: 32kg 6x5 +@end + +# ============================================================================ +# Week 7 - Deload (low intensity, active recovery) +# ============================================================================ + +@session +2025-02-17 * Light Lower +srpe: "3; PT30M" +squat: 115lb 3x5 +goblet-squat: 24kg 3x8 +kb-dh-swing: 24kg 3x10 +@end + +@session +2025-02-18 * Light Upper +srpe: "3; PT25M" +bench-press: 95lb 3x5 +pullup: BW 3x5 +kb-oh-press: 24kg 3x5 +@end + +2025-02-19 * run: PT20M "easy, srpe: 2; PT20M" + +@session +2025-02-20 * Light KB +srpe: "2; PT25M" +kb-tgu: 24kg 5x1 +kb-snatch: 24kg 3x5 +kb-dh-swing: 24kg 5x10 +@end + +# ============================================================================ +# Week 8 - Rebuild (controlled spike for adaptation testing) +# ============================================================================ + +@session +2025-02-24 * Lower Strength +srpe: "7; PT55M" +squat: 185lb 5x5 +deadlift: 225lb 4x3 +goblet-squat: 32kg 4x10 +@end + +@session +2025-02-25 * Upper KB +srpe: "7; PT50M" +kb-oh-press: 32kg 6x5 +kb-snatch: 32kg 5x5 +pullup: 15lb 5x5 +kb-tgu: 32kg 4x1 +@end + +2025-02-26 * run: PT40M "moderate, srpe: 5; PT40M" + +@session +2025-02-27 * Lower Volume +srpe: "6; PT50M" +squat: 165lb 5x8 +kb-dh-swing: 32kg 8x15 +burpee: BW 5x10 +@end + +@session +2025-02-28 * Upper Heavy +srpe: "8; PT55M" +bench-press: 165lb 5x5 +pullup: 25lb 4x4 +kb-oh-press: 32kg 5x5 +@end diff --git a/example/example.ox b/examples/example.ox similarity index 94% rename from example/example.ox rename to examples/example.ox index 56ff55d..23f7d5a 100644 --- a/example/example.ox +++ b/examples/example.ox @@ -3,86 +3,86 @@ # Using a focused set of movements for better statistics # Exercise Definitions -@exercise squat +@movement squat equipment: barbell -pattern: squat +tag: squat url: https://www.strongerbyscience.com/how-to-squat/ note: keep chest up, knees track over toes, full depth @end -@exercise deadlift +@movement deadlift equipment: barbell -pattern: hinge +tag: hinge url: https://www.strongerbyscience.com/how-to-deadlift/ note: neutral spine, drive through heels, hinge at hips @end -@exercise bench-press +@movement bench-press equipment: barbell -pattern: press +tag: press url: https://www.strongerbyscience.com/how-to-bench/ note: retract scapula, feet planted, bar path to mid-chest @end -@exercise overhead-press +@movement overhead-press equipment: barbell -pattern: press +tag: press url: https://www.strongerbyscience.com/how-to-press/ note: brace core, vertical bar path, squeeze glutes @end -@exercise pullup +@movement pullup equipment: bodyweight -pattern: pull +tag: pull url: https://www.strongerbyscience.com/how-to-pull-up/ note: full hang to chin over bar, control descent @end -@exercise kb-swing +@movement kb-swing equipment: kettlebell -pattern: hinge +tag: hinge url: https://www.strongfirst.com/the-swing/ note: explosive hip drive, park the bell, tight lats @end -@exercise kb-snatch +@movement kb-snatch equipment: kettlebell -pattern: ballistic +tag: ballistic url: https://www.strongfirst.com/the-snatch/ note: punch through at top, smooth arc, tight shoulder @end -@exercise kb-clean-and-press +@movement kb-clean-and-press equipment: kettlebell -pattern: combination +tag: combination url: https://www.strongfirst.com/the-clean/ note: clean to rack position, press with full lockout @end -@exercise kb-turkish-getup +@movement kb-turkish-getup equipment: kettlebell -pattern: getup +tag: getup url: https://www.strongfirst.com/the-turkish-get-up/ note: eyes on bell, stable shoulder, controlled movement @end -@exercise box-jump +@movement box-jump equipment: plyometric -pattern: jump +tag: jump url: https://www.bodybuilding.com/exercises/box-jump note: soft landing, full hip extension, step down @end -@exercise burpee +@movement burpee equipment: bodyweight -pattern: full-body +tag: full-body url: https://wodwell.com/exercise/burpee/ note: chest to floor, explosive jump, full extension @end -@exercise run +@movement run equipment: cardio -pattern: endurance +tag: endurance url: https://www.runnersworld.com/training/ note: comfortable pace unless noted, focus on form @end @@ -166,7 +166,7 @@ pullup: BW 4x8 2024-01-30 * Upper Heavy bench-press: 165lb 5x5 overhead-press: 105lb 3x8 -pullup: BW+25lb 4x5 +pullup: 25lb 4x5 @end @session @@ -185,7 +185,7 @@ box-jump: BW 5x5 squat: 185lb 5x3 bench-press: 155lb 5x3 deadlift: 225lb 5x3 -pullup: BW+20lb 4x5 +pullup: 20lb 4x5 @end @session @@ -475,7 +475,7 @@ box-jump: BW 5x5 @session 2024-04-08 * Bodyweight Upper pullup: BW 8x5 -pullup: BW+25lb 5x3 +pullup: 25lb 5x3 bench-press: 135lb 3x8 overhead-press: 85lb 3x8 @end @@ -507,15 +507,15 @@ box-jump: BW 6x3 # Week 15 - Mixed Training @session 2024-04-15 * Upper Power -bench-press: 175lb 5x3 +bench-press: 120/130/140/150/175lb 5x3 overhead-press: 105lb 5x3 -pullup: BW+30lb 5x3 +pullup: 30lb 5x3 @end @session 2024-04-16 * Lower Power squat: 205lb 5x3 -deadlift: 245lb 5x3 +deadlift: 45/245lb 5x3 box-jump: BW 5x3 @end @@ -601,4 +601,4 @@ burpee: BW 5x10 2024-05-03 note "started daily creatine ~5g" -2024-05-03 W 120lb "gym" \ No newline at end of file +2024-05-03 W 120lb "gym" diff --git a/examples/plugin_template.py b/examples/plugin_template.py new file mode 100644 index 0000000..908adf6 --- /dev/null +++ b/examples/plugin_template.py @@ -0,0 +1,66 @@ +"""Template for writing an ox plugin. + +Copy this file and modify it to create your own plugin. +Reference it in your .ox file with: @plugin "path/to/your_plugin.py" + +A plugin is a Python module with a register() function that returns a list +of plugin descriptors. Each plugin receives a PluginContext and returns one +of: TableResult, TextResult, or PlotResult. +""" + +from ox.plugins import PluginContext, PlotResult, TableResult, TextResult + + +def my_plugin(ctx: PluginContext, exercise: str, unit: str = "lb"): + """Example plugin that queries training data. + + Args: + ctx.db: sqlite3.Connection with tables: sessions, movements, sets, + weigh_ins, notes, queries, and the `training` view + ctx.log: TrainingLog with in-memory parsed data + """ + # Option A: query the database + rows = ctx.db.execute( + """ + SELECT date, movement_name, SUM(reps) as total_reps + FROM training + WHERE movement_name = ? + GROUP BY date + ORDER BY date + """, + (exercise,), + ).fetchall() + + columns = ["date", "movement", "total_reps"] + return TableResult(columns, rows) + + # Option B: use in-memory log data + # data = ctx.log.movement_history(exercise) + # ... + # return TableResult(columns, rows) + + # Option C: return plain text (e.g. generated .ox content) + # return TextResult("2025-01-01 * squat: 135lb 5x5") + + # Option D: return pre-rendered lines (e.g. from a plotting library) + # return PlotResult(["line 1", "line 2", ...]) + + +def register(): + return [ + { + "name": "my-plugin", + "fn": my_plugin, + "description": "Short description shown in plugin list", + "params": [ + {"name": "exercise", "type": str, "required": True, "short": "e"}, + { + "name": "unit", + "type": str, + "default": "lb", + "required": False, + "short": "u", + }, + ], + } + ] diff --git a/mkdocs.yml b/mkdocs.yml index 820b2a4..fce38c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ nav: - Reports & Plugins: plugins.md - API Reference: api-reference.md - Editor Support: editor-support.md + - Future Improvements: future.md markdown_extensions: - pymdownx.highlight: diff --git a/pyproject.toml b/pyproject.toml index 4d45a96..02f501e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ox" -version = "0.2.0" +version = "0.5.0" description = "Plain text training log parser and analyzer" readme = "README.md" requires-python = ">=3.12" @@ -27,6 +27,7 @@ dependencies = [ "click>=8.3.1", "numpy>=2.3.5", "pint>=0.25.2", + "plotext>=5.3.2", "prompt-toolkit>=3.0.52", "pygls>=1.3.0", "rich>=14.2.0", diff --git a/src/ox/builtins/e1rm.py b/src/ox/builtins/e1rm.py index a657894..f1d8270 100644 --- a/src/ox/builtins/e1rm.py +++ b/src/ox/builtins/e1rm.py @@ -5,21 +5,18 @@ (convention for marking max-effort sets). Usage: - report e1rm -m deadlift - report e1rm -m squat -f epley - report e1rm -m deadlift -o plot + e1rm -m deadlift + e1rm -m squat -f epley + e1rm -m deadlift -o plot Example .ox line: deadlift: 315lbs 1x3 "^rm top set felt good" """ -from datetime import date as _date - +from ox import plot +from ox.plugins import PlotResult, PluginContext, TableResult from ox.units import Q_ -_PLOT_WIDTH = 60 -_PLOT_HEIGHT = 15 - def _brzycki(weight, reps): """Brzycki formula: weight * 36 / (37 - reps).""" @@ -39,94 +36,21 @@ def _epley(weight, reps): } -def _render_plot(data, unit): - """Render data as a transparent ASCII line plot. - - Args: - data: List of (date, e1rm, weight, reps) tuples sorted by date - unit: Weight unit string for the Y axis label - - Returns: - List of strings, one per plot row - """ - dates = [row[0] for row in data] - values = [row[1] for row in data] - - min_v = min(values) - max_v = max(values) - v_range = max_v - min_v or 1.0 - - width = _PLOT_WIDTH - height = _PLOT_HEIGHT - - grid = [[" "] * width for _ in range(height)] - - parsed = [_date.fromisoformat(d) for d in dates] - day_offsets = [(d - parsed[0]).days for d in parsed] - total_days = day_offsets[-1] or 1 - - def to_y(v): - return int((max_v - v) / v_range * (height - 1) + 0.5) - - def to_x(i): - if total_days == 0: - return width // 2 - return int(day_offsets[i] / total_days * (width - 1)) - - coords = [(to_x(i), to_y(v)) for i, v in enumerate(values)] - - for x, y in coords: - if 0 <= y < height and 0 <= x < width: - grid[y][x] = "●" - - tick_interval = max(1, height // 4) - lines = [] - for row_idx in range(height): - v = max_v - v_range * row_idx / (height - 1) if height > 1 else max_v - if row_idx % tick_interval == 0 or row_idx == height - 1: - label = f"{v:6.1f} │" - else: - label = " │" - lines.append(label + "".join(grid[row_idx])) - - lines.append(" └" + "─" * width) - - n = len(dates) - num_labels = min(5, n) - if num_labels > 1: - label_indices = [int(i * (n - 1) / (num_labels - 1)) for i in range(num_labels)] - else: - label_indices = [0] - - x_label_chars = [" "] * (8 + width) - for idx in label_indices: - x = 8 + to_x(idx) - label = dates[idx][-5:] if len(dates[idx]) >= 5 else dates[idx] - start = x - len(label) // 2 - for j, ch in enumerate(label): - pos = start + j - if 0 <= pos < len(x_label_chars): - x_label_chars[pos] = ch - - lines.append("".join(x_label_chars)) - return lines - - -def estimated_1rm(conn, movement, formula="brzycki", unit="lb", output="table"): +def estimated_1rm( + ctx: PluginContext, + movement, + formula="brzycki", + unit="lb", + output="table", + width=None, + height=None, + y_step=None, + x_scale=None, +): """Estimated 1RM progression for a movement. Finds sets where the movement note contains "^rm", takes the heaviest set per movement line, and calculates estimated 1RM. - - Args: - conn: SQLite connection - movement: Movement name to filter by - formula: 1RM formula to use ("brzycki" or "epley") - unit: Weight unit for output values (default "lb") - output: Output format ("table" or "plot") - - Returns: - (columns, rows) tuple """ if formula not in FORMULAS: raise ValueError( @@ -137,7 +61,7 @@ def estimated_1rm(conn, movement, formula="brzycki", unit="lb", output="table"): calc = FORMULAS[formula] - rows = conn.execute( + rows = ctx.db.execute( """ SELECT t.date, @@ -155,11 +79,10 @@ def estimated_1rm(conn, movement, formula="brzycki", unit="lb", output="table"): ).fetchall() if not rows: - return ( - [f"estimated_1rm ({unit})"] - if output == "plot" - else ["date", f"estimated_1rm ({unit})", f"weight ({unit})", "reps"], - [], + if output == "plot": + return PlotResult([]) + return TableResult( + ["date", f"estimated_1rm ({unit})", f"weight ({unit})", "reps"], [] ) seen_dates = {} @@ -175,17 +98,28 @@ def estimated_1rm(conn, movement, formula="brzycki", unit="lb", output="table"): result.append((date, e1rm, converted, reps)) if output == "plot": - plot_lines = _render_plot(result, unit) - return ([f"e1rm ({unit})"], [(line,) for line in plot_lines]) + dates = [row[0] for row in result] + values = [row[1] for row in result] + kwargs = {"y_label": f"e1rm ({unit})"} + if width is not None: + kwargs["width"] = int(width) + if height is not None: + kwargs["height"] = int(height) + if y_step is not None: + kwargs["y_step"] = float(y_step) + if x_scale is not None: + if x_scale not in ("week", "month", "quarter", "year"): + raise ValueError("x_scale must be one of: week, month, quarter, year") + kwargs["x_scale"] = x_scale + return PlotResult(plot.scatter(dates, values, **kwargs)) columns = ["date", f"estimated_1rm ({unit})", f"weight ({unit})", "reps"] - return columns, result + return TableResult(columns, result) def register(): return [ { - "type": "report", "name": "e1rm", "fn": estimated_1rm, "description": "Estimated 1RM progression for a movement", @@ -212,6 +146,34 @@ def register(): "required": False, "short": "o", }, + { + "name": "width", + "type": int, + "default": None, + "required": False, + "short": "W", + }, + { + "name": "height", + "type": int, + "default": None, + "required": False, + "short": "H", + }, + { + "name": "y_step", + "type": float, + "default": None, + "required": False, + "short": "y", + }, + { + "name": "x_scale", + "type": str, + "default": None, + "required": False, + "short": "x", + }, ], } ] diff --git a/src/ox/builtins/srpe.py b/src/ox/builtins/srpe.py new file mode 100644 index 0000000..a5a987e --- /dev/null +++ b/src/ox/builtins/srpe.py @@ -0,0 +1,419 @@ +"""Session Rate of Perceived Exertion (sRPE) plugin for ox. + +Computes training load in arbitrary units (AU) from sRPE entries. +AU = rating × duration_minutes. + +sRPE data is extracted from: +- Session metadata movements: `srpe: "4; PT30M"` (parsed as a movement named "srpe") +- Single-line entry notes: `"srpe: 4; PT50M"` (embedded in the movement note) + +Usage: + srpe + srpe -b monthly + srpe -o plot + srpe -o acwr + srpe -o monotony + srpe -o strain +""" + +import math +import re +from collections import defaultdict +from datetime import date as _date, timedelta as _timedelta + +from ox import plot +from ox.plugins import PlotResult, PluginContext, TableResult + +_SRPE_PATTERN = re.compile( + r"srpe:\s*(\d+(?:\.\d+)?)\s*[;,]\s*(PT[\dHMShms]+)", re.IGNORECASE +) + + +def _parse_iso_duration_minutes(duration_str: str) -> float: + """Parse an ISO 8601 duration string into total minutes. + + Supports PT#H#M#S format (e.g., PT30M, PT1H30M, PT1H, PT90S). + """ + m = re.match( + r"PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$", + duration_str, + re.IGNORECASE, + ) + if not m: + raise ValueError(f"Invalid ISO 8601 duration: {duration_str}") + hours = float(m.group(1) or 0) + minutes = float(m.group(2) or 0) + seconds = float(m.group(3) or 0) + return hours * 60 + minutes + seconds / 60 + + +def _parse_srpe(text: str) -> tuple[float, float, float] | None: + """Parse an sRPE string and return (rating, duration_minutes, AU). + + Returns None if the string doesn't contain a valid sRPE entry. + """ + m = _SRPE_PATTERN.search(text) + if not m: + return None + rating = float(m.group(1)) + duration_min = _parse_iso_duration_minutes(m.group(2)) + return rating, duration_min, rating * duration_min + + +def _extract_srpe_data(ctx: PluginContext) -> list[tuple[str, float, float, float]]: + """Extract all sRPE entries from the database. + + Returns list of (date, rating, duration_minutes, AU). + """ + results = [] + + # Case 1: srpe as a movement name in a session (srpe: "4; PT30M") + # The note field contains the value like "4; PT30M" + rows = ctx.db.execute( + """ + SELECT s.date, m.note + FROM movements m + JOIN sessions s ON m.session_id = s.id + WHERE LOWER(m.name) = 'srpe' AND m.note IS NOT NULL + ORDER BY s.date + """ + ).fetchall() + + for date_str, note in rows: + parsed = _parse_srpe(f"srpe: {note}") + if parsed: + results.append((date_str, *parsed)) + + # Case 2: srpe embedded in a movement note (e.g., "srpe: 4; PT50M") + rows = ctx.db.execute( + """ + SELECT s.date, m.note + FROM movements m + JOIN sessions s ON m.session_id = s.id + WHERE LOWER(m.name) != 'srpe' + AND m.note IS NOT NULL + AND LOWER(m.note) LIKE '%srpe:%' + ORDER BY s.date + """ + ).fetchall() + + for date_str, note in rows: + parsed = _parse_srpe(note) + if parsed: + results.append((date_str, *parsed)) + + results.sort(key=lambda r: r[0]) + return results + + +def _daily_au(data: list[tuple[str, float, float, float]]) -> dict[_date, float]: + """Aggregate sRPE data into total AU per calendar day.""" + daily: dict[_date, float] = defaultdict(float) + for date_str, _rating, _dur, au in data: + daily[_date.fromisoformat(date_str)] += au + return dict(daily) + + +def _weekly_daily_buckets( + daily: dict[_date, float], +) -> dict[_date, list[float]]: + """Bucket daily AU into ISO weeks (Mon-Sun), filling missing days with 0. + + Returns {monday_date: [au_per_day, ...]} spanning first to last observed day. + """ + if not daily: + return {} + all_dates = sorted(daily.keys()) + first, last = all_dates[0], all_dates[-1] + weeks: dict[_date, list[float]] = defaultdict(list) + d = first + while d <= last: + monday = d - _timedelta(days=d.weekday()) + weeks[monday].append(daily.get(d, 0.0)) + d += _timedelta(days=1) + return weeks + + +def _acwr_report( + data: list[tuple[str, float, float, float]], + acute_days: int = 7, + chronic_days: int = 28, +) -> TableResult: + """Acute:Chronic Workload Ratio rolling report. + + For each day that has sRPE data, compute: + - acute = sum of AU in the last *acute_days* days + - chronic = average weekly AU over the last *chronic_days* days + - ACWR = acute / chronic + """ + daily = _daily_au(data) + if not daily: + return TableResult(["date", "acute_AU", "chronic_AU", "ACWR", "zone"], []) + + all_dates = sorted(daily.keys()) + first, last = all_dates[0], all_dates[-1] + + # Build a complete date range so rest days count as 0 + date_range = [] + d = first + while d <= last: + date_range.append(d) + d += _timedelta(days=1) + + full_daily = {d: daily.get(d, 0.0) for d in date_range} + dates_list = sorted(full_daily.keys()) + + rows = [] + for i, d in enumerate(dates_list): + # Only report rows for dates that actually have training data + if d not in daily: + continue + + acute_start = d - _timedelta(days=acute_days - 1) + acute = sum(full_daily[dd] for dd in dates_list if acute_start <= dd <= d) + + chronic_start = d - _timedelta(days=chronic_days - 1) + chronic_days_list = [ + full_daily[dd] for dd in dates_list if chronic_start <= dd <= d + ] + # Chronic = rolling average expressed as weekly rate + if chronic_days_list: + chronic_total = sum(chronic_days_list) + chronic_weeks = len(chronic_days_list) / 7.0 + chronic = chronic_total / chronic_weeks if chronic_weeks > 0 else 0.0 + else: + chronic = 0.0 + + acwr = round(acute / chronic, 2) if chronic > 0 else None + zone = _acwr_zone(acwr) + + rows.append( + ( + d.isoformat(), + round(acute, 1), + round(chronic, 1), + acwr, + zone, + ) + ) + + return TableResult(["date", "acute_AU", "chronic_AU", "ACWR", "zone"], rows) + + +def _acwr_zone(acwr: float | None) -> str: + """Classify ACWR into a training zone.""" + if acwr is None: + return "N/A" + if acwr < 0.8: + return "undertraining" + if acwr <= 1.3: + return "sweet spot" + if acwr <= 1.5: + return "caution" + return "danger" + + +def _monotony_report( + data: list[tuple[str, float, float, float]], +) -> TableResult: + """Weekly training monotony report. + + Monotony = mean daily TL / SD of daily TL (over a 7-day window). + High monotony (>2.0) with high load predicts overtraining. + """ + weeks = _weekly_daily_buckets(_daily_au(data)) + if not weeks: + return TableResult( + ["week", "weekly_AU", "mean_daily_AU", "sd_daily_AU", "monotony"], + [], + ) + + rows = [] + for monday in sorted(weeks.keys()): + vals = weeks[monday] + weekly_au = sum(vals) + mean = weekly_au / len(vals) + if len(vals) > 1: + sd = math.sqrt(sum((v - mean) ** 2 for v in vals) / len(vals)) + else: + sd = 0.0 + monotony = round(mean / sd, 2) if sd > 0 else None + + rows.append( + ( + monday.isoformat(), + round(weekly_au, 1), + round(mean, 1), + round(sd, 1), + monotony, + ) + ) + + return TableResult( + ["week", "weekly_AU", "mean_daily_AU", "sd_daily_AU", "monotony"], + rows, + ) + + +def _strain_report( + data: list[tuple[str, float, float, float]], +) -> TableResult: + """Weekly training strain report. + + Strain = weekly TL × monotony. + High strain combined with high monotony predicts illness/overtraining. + """ + weeks = _weekly_daily_buckets(_daily_au(data)) + if not weeks: + return TableResult( + ["week", "weekly_AU", "monotony", "strain", "risk"], + [], + ) + + rows = [] + for monday in sorted(weeks.keys()): + vals = weeks[monday] + weekly_au = sum(vals) + mean = weekly_au / len(vals) + if len(vals) > 1: + sd = math.sqrt(sum((v - mean) ** 2 for v in vals) / len(vals)) + else: + sd = 0.0 + monotony = mean / sd if sd > 0 else None + strain = round(weekly_au * monotony, 1) if monotony is not None else None + risk = _strain_risk(monotony, strain) + + rows.append( + ( + monday.isoformat(), + round(weekly_au, 1), + round(monotony, 2) if monotony is not None else None, + strain, + risk, + ) + ) + + return TableResult( + ["week", "weekly_AU", "monotony", "strain", "risk"], + rows, + ) + + +def _strain_risk(monotony: float | None, strain: float | None) -> str: + """Classify weekly strain risk level.""" + if monotony is None or strain is None: + return "N/A" + if monotony > 2.0 and strain > 6000: + return "HIGH" + if monotony > 2.0 or strain > 4000: + return "moderate" + return "low" + + +def srpe_report(ctx: PluginContext, bin: str = "weekly", output: str = "table"): + """Training load from session RPE over time. + + Args: + ctx: Plugin context with db and log + bin: Time bin size ("daily", "weekly", "monthly") + output: Output format ("table", "plot", "acwr", "monotony", "strain") + """ + _valid_outputs = ("table", "plot", "acwr", "monotony", "strain") + if output not in _valid_outputs: + raise ValueError(f"output must be one of: {', '.join(_valid_outputs)}") + + data = _extract_srpe_data(ctx) + if not data: + if output == "plot": + return PlotResult(["No sRPE data found."]) + if output == "acwr": + return _acwr_report([]) + if output == "monotony": + return _monotony_report([]) + if output == "strain": + return _strain_report([]) + return TableResult(["period", "sessions", "total_AU", "avg_AU", "max_AU"], []) + + # Dispatch to specialized reports + if output == "acwr": + return _acwr_report(data) + if output == "monotony": + return _monotony_report(data) + if output == "strain": + return _strain_report(data) + + # Group by time bin + grouped = defaultdict(list) + for date_str, rating, duration_min, au in data: + # Compute the bin key using the same SQL logic, but in Python + period = _compute_period(date_str, bin) + grouped[period].append((rating, duration_min, au)) + + periods = sorted(grouped.keys()) + + if output == "plot": + labels = [p for p in periods] + values = [sum(e[2] for e in grouped[p]) for p in periods] + return PlotResult(plot.bar(labels, values, y_label=f"total AU ({bin})")) + + # table output + rows = [] + for period in periods: + entries = grouped[period] + count = len(entries) + total_au = round(sum(e[2] for e in entries), 1) + avg_au = round(total_au / count, 1) + max_au = round(max(e[2] for e in entries), 1) + rows.append((period, count, total_au, avg_au, max_au)) + + return TableResult( + ["period", "sessions", "total_AU", "avg_AU", "max_AU"], + rows, + ) + + +def _compute_period(date_str: str, bin: str) -> str: + """Compute the period string for a date given a bin size.""" + d = _date.fromisoformat(date_str) + if bin == "daily": + return d.isoformat() + elif bin == "weekly": + # Monday of the week + monday = ( + d.isoformat() + if d.weekday() == 0 + else (d - _timedelta(days=d.weekday())).isoformat() + ) + return monday + elif bin == "weekly-num": + return d.strftime("%Y-W%W") + elif bin == "monthly": + return d.strftime("%Y-%m") + else: + raise ValueError(f"Unknown bin: {bin}") + + +def register(): + return [ + { + "name": "srpe", + "fn": srpe_report, + "description": "Training load from session RPE (AU = rating × duration)", + "params": [ + { + "name": "bin", + "type": str, + "default": "weekly", + "required": False, + "short": "b", + }, + { + "name": "output", + "type": str, + "default": "table", + "required": False, + "short": "o", + }, + ], + } + ] diff --git a/src/ox/builtins/volume.py b/src/ox/builtins/volume.py new file mode 100644 index 0000000..fd290a2 --- /dev/null +++ b/src/ox/builtins/volume.py @@ -0,0 +1,70 @@ +"""Volume over time plugin for ox. + +Usage: + volume -m squat + volume -m squat -b monthly -u kg +""" + +from ox.plugins import PluginContext, TableResult +from ox.sql_utils import _time_bin_expr, _weight_sql_expr + + +def volume(ctx: PluginContext, movement: str, bin: str = "weekly", unit: str = "lb"): + """Volume over time for a single movement. + + Args: + ctx: Plugin context with db and log + movement: Movement name to filter by + bin: Time bin size ("daily", "weekly", "monthly") + unit: Weight unit for output values (default "lb") + """ + expr = _time_bin_expr(bin, "date") + w = _weight_sql_expr("weight_magnitude", "weight_unit", unit) + rows = ctx.db.execute( + f""" + SELECT + {expr} AS period, + ROUND(SUM(reps * {w}), 1) AS total_volume, + SUM(reps) AS total_reps, + ROUND(SUM(reps * {w}) * 1.0 / SUM(reps), 1) AS avg_weight_per_rep + FROM training + WHERE movement_name = ? + GROUP BY period + ORDER BY period + """, + (movement,), + ).fetchall() + columns = [ + "period", + f"total_volume ({unit})", + "total_reps", + f"avg_weight_per_rep ({unit})", + ] + return TableResult(columns, rows) + + +def register(): + return [ + { + "name": "volume", + "fn": volume, + "description": "Volume over time for a movement", + "params": [ + {"name": "movement", "type": str, "required": True, "short": "m"}, + { + "name": "bin", + "type": str, + "default": "weekly", + "required": False, + "short": "b", + }, + { + "name": "unit", + "type": str, + "default": "lb", + "required": False, + "short": "u", + }, + ], + } + ] diff --git a/src/ox/builtins/weighin.py b/src/ox/builtins/weighin.py index c86af1c..7893d93 100644 --- a/src/ox/builtins/weighin.py +++ b/src/ox/builtins/weighin.py @@ -3,11 +3,11 @@ Tracks body weight over time, with support for multiple scales. Usage: - report weighin - report weighin -o plot - report weighin -o stats - report weighin -u kg - report weighin -o plot -w 14 + weighin + weighin -o plot + weighin -o stats + weighin -u kg + weighin -o plot -w 14 Example .ox lines: 2025-01-10 W 185lb @@ -18,13 +18,10 @@ from collections import defaultdict from datetime import date as _date, timedelta as _timedelta +from ox import plot +from ox.plugins import PlotResult, PluginContext, TableResult from ox.units import Q_ -_PLOT_WIDTH = 60 -_PLOT_HEIGHT = 15 -_MARKERS = ["●", "○", "▲", "△", "■", "□"] -_AVG_MARKER = "·" - def _rolling_avg(data, window_days): """Compute rolling average for each data point. @@ -75,109 +72,12 @@ def _linear_trend(pairs): return num / den -def _render_plot(data, avg_data, scale_markers, unit, window_days): - """Render weigh-in data as ASCII plot. - - Raw data points use per-scale markers; rolling average uses _AVG_MARKER. - Data points render on top of average markers when they share a cell. - - Args: - data: List of (date_str, weight, scale) sorted by date - avg_data: List of (date_str, avg_weight) aligned with data - scale_markers: Dict mapping scale -> marker char - unit: Unit string for y-axis label - window_days: Window size for legend - - Returns: - List of strings, one per plot row (including x-axis and legend) - """ - all_weights = [w for _, w, _ in data] + [w for _, w in avg_data] - min_v = min(all_weights) - max_v = max(all_weights) - v_range = max_v - min_v or 1.0 - - width = _PLOT_WIDTH - height = _PLOT_HEIGHT - grid = [[" "] * width for _ in range(height)] - - dates = [d for d, _, _ in data] - parsed = [_date.fromisoformat(d) for d in dates] - first_day = parsed[0] - total_days = (parsed[-1] - first_day).days or 1 - - def to_y(v): - return int((max_v - v) / v_range * (height - 1) + 0.5) - - def to_x(date_str): - offset = (_date.fromisoformat(date_str) - first_day).days - return int(offset / total_days * (width - 1)) - - # Draw rolling average first so data points render on top - for date_str, avg_w in avg_data: - x, y = to_x(date_str), to_y(avg_w) - if 0 <= y < height and 0 <= x < width and grid[y][x] == " ": - grid[y][x] = _AVG_MARKER - - for date_str, weight, scale in data: - x, y = to_x(date_str), to_y(weight) - if 0 <= y < height and 0 <= x < width: - grid[y][x] = scale_markers[scale] - - tick_interval = max(1, height // 4) - lines = [] - for row_idx in range(height): - v = max_v - v_range * row_idx / (height - 1) if height > 1 else max_v - if row_idx % tick_interval == 0 or row_idx == height - 1: - label = f"{v:6.1f} │" - else: - label = " │" - lines.append(label + "".join(grid[row_idx])) - - lines.append(" └" + "─" * width) - - n = len(dates) - num_labels = min(5, n) - label_indices = ( - [int(i * (n - 1) / (num_labels - 1)) for i in range(num_labels)] - if num_labels > 1 - else [0] - ) - x_label_chars = [" "] * (8 + width) - for idx in label_indices: - x = 8 + to_x(dates[idx]) - label = dates[idx][-5:] - start = x - len(label) // 2 - for j, ch in enumerate(label): - pos = start + j - if 0 <= pos < len(x_label_chars): - x_label_chars[pos] = ch - lines.append("".join(x_label_chars)) - - lines.append("") - for scale, marker in scale_markers.items(): - display = scale if scale is not None else "(no scale)" - lines.append(f" {marker} {display}") - lines.append(f" {_AVG_MARKER} {window_days}-day rolling avg") - - return lines - - -def weigh_in_report(conn, unit="lb", output="table", window=7): - """Weigh-in statistics over time. - - Args: - conn: SQLite connection - unit: Weight unit for output values (default "lb") - output: Output format ("table", "plot", or "stats") - window: Rolling average window in calendar days (default 7) - - Returns: - (columns, rows) tuple - """ +def weigh_in_report(ctx: PluginContext, unit="lb", output="table", window=0): + """Weigh-in statistics over time.""" if output not in ("table", "plot", "stats"): raise ValueError("output must be 'table', 'plot', or 'stats'") - rows = conn.execute( + rows = ctx.db.execute( """ SELECT date, weight_magnitude, weight_unit, scale FROM weigh_ins @@ -187,18 +87,21 @@ def weigh_in_report(conn, unit="lb", output="table", window=7): if not rows: if output == "plot": - return ["plot"], [("No weigh-in data found.",)] + return PlotResult(["No weigh-in data found."]) if output == "stats": - return [ - "scale", - "count", - f"current ({unit})", - f"min ({unit})", - f"max ({unit})", - f"avg ({unit})", - f"trend ({unit}/wk)", - ], [] - return ["date", f"weight ({unit})", "scale"], [] + return TableResult( + [ + "scale", + "count", + f"current ({unit})", + f"min ({unit})", + f"max ({unit})", + f"avg ({unit})", + f"trend ({unit}/wk)", + ], + [], + ) + return TableResult(["date", f"weight ({unit})", "scale"], []) data = [] for date_str, mag, raw_unit, scale in rows: @@ -206,19 +109,37 @@ def weigh_in_report(conn, unit="lb", output="table", window=7): data.append((date_str, converted, scale)) if output == "table": - return ( + return TableResult( ["date", f"weight ({unit})", "scale"], [(d, w, s or "") for d, w, s in data], ) if output == "plot": if len(data) < 2: - return (["plot"], [("Not enough data to plot.",)]) + return PlotResult(["Not enough data to plot."]) scales = list(dict.fromkeys(s for _, _, s in data)) - scale_markers = {s: _MARKERS[i % len(_MARKERS)] for i, s in enumerate(scales)} - avg_data = _rolling_avg(data, window) - plot_lines = _render_plot(data, avg_data, scale_markers, unit, window) - return (["plot"], [(line,) for line in plot_lines]) + series: list[plot.Series] = [] + for s in scales: + scale_data = [(d, w) for d, w, sc in data if sc == s] + series.append( + plot.Series( + label=s if s is not None else "(no scale)", + dates=[d for d, _ in scale_data], + values=[w for _, w in scale_data], + style="scatter", + ) + ) + if window > 0: + avg_data = _rolling_avg(data, window) + series.append( + plot.Series( + label=f"{window}-day rolling avg", + dates=[d for d, _ in avg_data], + values=[v for _, v in avg_data], + style="line", + ) + ) + return PlotResult(plot.multi_series(series, y_label=f"weight ({unit})")) # stats by_scale = defaultdict(list) @@ -257,13 +178,12 @@ def make_row(label, pairs): f"avg ({unit})", f"trend ({unit}/wk)", ] - return columns, stats_rows + return TableResult(columns, stats_rows) def register(): return [ { - "type": "report", "name": "weighin", "fn": weigh_in_report, "description": "Weigh-in statistics over time", @@ -285,7 +205,7 @@ def register(): { "name": "window", "type": int, - "default": 7, + "default": 0, "required": False, "short": "w", }, diff --git a/src/ox/builtins/wendler531.py b/src/ox/builtins/wendler531.py index b678805..5ec2fac 100644 --- a/src/ox/builtins/wendler531.py +++ b/src/ox/builtins/wendler531.py @@ -4,15 +4,16 @@ Outputs valid .ox text with planned (!) flag. Usage: - generate wendler531 -m squat:315 - generate wendler531 -m squat:315,bench-press:200 - generate wendler531 -m squat:315 -u kg - generate wendler531 -m squat:315,deadlift:405 -d 2026-03-01 + wendler531 -m squat:315 + wendler531 -m squat:315,bench-press:200 + wendler531 -m squat:315 -u kg + wendler531 -m squat:315,deadlift:405 -d 2026-03-01 """ from datetime import datetime, timedelta from ox.data import Movement, TrainingSession, TrainingSet +from ox.plugins import PluginContext, TextResult from ox.units import Q_ # Percentages of training max for each week. @@ -57,19 +58,8 @@ def _pint_unit(unit): return {"lb": "pound", "lbs": "pound", "kg": "kilogram"}.get(unit, unit) -def wendler531(movements, unit="lb", start_date=None, rm="true"): - """Generate a 4-week Wendler 5/3/1 cycle. - - Args: - movements: Comma-separated name:training_max pairs - (e.g. "squat:315,deadlift:405") - unit: Weight unit ("lb" or "kg") - start_date: Start date as YYYY-MM-DD string (defaults to today) - rm: Tag working weeks (1-3) with ^rm for e1rm tracking ("true"/"false") - - Returns: - Valid .ox formatted text - """ +def wendler531(ctx: PluginContext, movements, unit="lb", start_date=None, rm="true"): + """Generate a 4-week Wendler 5/3/1 cycle.""" parsed = _parse_movements(movements) pint_unit = _pint_unit(unit) tag_rm = rm.lower() == "true" @@ -99,18 +89,17 @@ def wendler531(movements, unit="lb", start_date=None, rm="true"): TrainingSession( date=session_date, flag="!", - name=f"5/3/1 Week {week_num}", + name=f"531-week-{week_num}", movements=tuple(week_movements), ) ) - return "\n\n".join(s.to_ox() for s in sessions) + "\n" + return TextResult("\n\n".join(s.to_ox() for s in sessions) + "\n") def register(): return [ { - "type": "generator", "name": "wendler531", "fn": wendler531, "description": "Generate a Wendler 5/3/1 cycle", diff --git a/src/ox/cli.py b/src/ox/cli.py index 2166e5f..cdd8786 100644 --- a/src/ox/cli.py +++ b/src/ox/cli.py @@ -1,6 +1,7 @@ """Command-line interface for ox.""" import sqlite3 +from importlib.metadata import version as _pkg_version import click from pathlib import Path @@ -12,12 +13,28 @@ from prompt_toolkit import PromptSession from prompt_toolkit.completion import WordCompleter -from ox.parse import process_include_directive, process_node -from ox.data import Diagnostic, Note, StoredQuery, TrainingLog, TrainingSession, WeighIn +from ox.parse import process_include_directive, process_plugin_directive, process_node +from ox.data import ( + Diagnostic, + MovementDefinition, + Note, + StoredQuery, + TrainingLog, + TrainingSession, + WeighIn, +) from ox.db import create_db from ox.lint import collect_diagnostics -from ox.plugins import GENERATOR_PLUGINS, load_plugins -from ox.reports import get_all_reports, parse_report_args, report_usage +from ox.plugins import ( + PLUGINS, + USER_PLUGINS, + PlotResult, + PluginContext, + TableResult, + TextResult, + load_plugins, +) +from ox.sql_utils import parse_plugin_args, plugin_usage console = Console() @@ -26,11 +43,11 @@ def _parse_single_file( file_path: Path, parser: Parser -) -> tuple[list, list, list, list, list, list[str]]: +) -> tuple[list, list, list, list, list, list[str], list[str], list]: """Parse a single .ox file without resolving includes. Returns: - Tuple of (sessions, notes, queries, weigh_ins, diagnostics, include_paths) + Tuple of (sessions, notes, queries, weigh_ins, diagnostics, include_paths, plugin_paths, movement_definitions) """ with open(file_path, "r") as f: data = bytes(f.read(), encoding="utf-8") @@ -43,10 +60,15 @@ def _parse_single_file( log_queries = [] log_weigh_ins = [] include_paths = [] + plugin_paths = [] + movement_definitions = [] for child in root_node.children: if child.type == "include_directive": include_paths.append(process_include_directive(child)) continue + if child.type == "plugin_directive": + plugin_paths.append(process_plugin_directive(child)) + continue result = process_node(child) if isinstance(result, TrainingSession): entries.append(result) @@ -56,20 +78,31 @@ def _parse_single_file( log_queries.append(result) elif isinstance(result, WeighIn): log_weigh_ins.append(result) + elif isinstance(result, MovementDefinition): + movement_definitions.append(result) diagnostics = list(collect_diagnostics(tree)) - return entries, log_notes, log_queries, log_weigh_ins, diagnostics, include_paths + return ( + entries, + log_notes, + log_queries, + log_weigh_ins, + diagnostics, + include_paths, + plugin_paths, + movement_definitions, + ) def _load_recursive( file_path: Path, parser: Parser, visited: set[Path], -) -> tuple[list, list, list, list, list]: +) -> tuple[list, list, list, list, list, list, list]: """Recursively load a file and its includes with cycle detection. Returns: - Tuple of (sessions, notes, queries, weigh_ins, diagnostics) + Tuple of (sessions, notes, queries, weigh_ins, diagnostics, plugin_paths, movement_definitions) """ abs_path = file_path.resolve() @@ -82,7 +115,7 @@ def _load_recursive( message=f"Circular include detected: {file_path}", severity="warning", ) - return [], [], [], [], [diag] + return [], [], [], [], [diag], [], [] visited.add(abs_path) @@ -95,24 +128,47 @@ def _load_recursive( message=f"Included file not found: {file_path}", severity="warning", ) - return [], [], [], [], [diag] - - entries, notes, queries, weigh_ins, diagnostics, include_paths = _parse_single_file( - abs_path, parser - ) + return [], [], [], [], [diag], [], [] + + ( + entries, + notes, + queries, + weigh_ins, + diagnostics, + include_paths, + plugin_paths, + movement_definitions, + ) = _parse_single_file(abs_path, parser) for inc_path in include_paths: resolved = (abs_path.parent / inc_path).resolve() - inc_entries, inc_notes, inc_queries, inc_weigh_ins, inc_diagnostics = ( - _load_recursive(Path(resolved), parser, visited) - ) + ( + inc_entries, + inc_notes, + inc_queries, + inc_weigh_ins, + inc_diagnostics, + inc_plugins, + inc_defs, + ) = _load_recursive(Path(resolved), parser, visited) entries.extend(inc_entries) notes.extend(inc_notes) queries.extend(inc_queries) weigh_ins.extend(inc_weigh_ins) diagnostics.extend(inc_diagnostics) - - return entries, notes, queries, weigh_ins, diagnostics + plugin_paths.extend(inc_plugins) + movement_definitions.extend(inc_defs) + + return ( + entries, + notes, + queries, + weigh_ins, + diagnostics, + plugin_paths, + movement_definitions, + ) def parse_file(file_path: Path) -> TrainingLog: @@ -129,9 +185,15 @@ def parse_file(file_path: Path) -> TrainingLog: language = Language(tree_sitter_ox.language()) parser = Parser(language) - entries, notes, queries, weigh_ins, diagnostics = _load_recursive( - file_path, parser, visited=set() - ) + ( + entries, + notes, + queries, + weigh_ins, + diagnostics, + plugin_paths, + movement_definitions, + ) = _load_recursive(file_path, parser, visited=set()) return TrainingLog( tuple(entries), @@ -139,23 +201,17 @@ def parse_file(file_path: Path) -> TrainingLog: tuple(diagnostics), tuple(queries), tuple(weigh_ins), + tuple(plugin_paths), + tuple(movement_definitions), ) def show_help(): """Display help message with available commands.""" console.print("\n[bold cyan]Available Commands:[/bold cyan]") + console.print(" [green]plugins[/green] - List available plugins") console.print( - " [green]stats[/green] - Show summary statistics for all exercises" - ) - console.print( - " [green]history[/green] EXERCISE - Show training history for an exercise" - ) - console.print( - " [green]report[/green] - List available reports (or run one)" - ) - console.print( - " [green]generate[/green] - List available generators (or run one)" + " [green][/green] [ARGS] - Run a plugin by name (e.g. [green]volume -m squat[/green])" ) console.print( " [green]query[/green] SQL - Run a SQL query or a stored query by name" @@ -172,61 +228,60 @@ def show_help(): console.print() -def show_stats(log: TrainingLog): - """Show summary statistics for completed exercises. - - Only includes completed sessions (flag="*"), not planned sessions. - """ - # Collect all unique exercises - exercises = {} - for date, movement in log.movements(): - if movement.name not in exercises: - exercises[movement.name] = [] - exercises[movement.name].append((date, movement)) - - table = Table(title="Training Statistics", box=DEFAULT_TABLE_BOX) - table.add_column("Exercise", style="cyan") - table.add_column("Sessions", style="magenta") - table.add_column("Total Reps", style="green") - table.add_column("Last Session", style="yellow") - - for exercise_name, sessions in sorted(exercises.items()): - total_reps = sum(m.total_reps for _, m in sessions) - last_date = max(d for d, _ in sessions) - - table.add_row( - exercise_name, str(len(sessions)), str(total_reps), str(last_date) - ) - - console.print(table) - console.print(f"\n[bold]Completed sessions:[/bold] {len(log.completed_sessions)}") - console.print(f"[bold]Planned sessions:[/bold] {len(log.planned_sessions)}") - console.print(f"[bold]Unique exercises:[/bold] {len(exercises)}\n") - +def show_plugin_list(): + """Show available plugins with descriptions and usage.""" + if not PLUGINS: + console.print("[yellow]No plugins installed.[/yellow]\n") + return + console.print("\n[bold cyan]Available Plugins:[/bold cyan]") + for name, entry in PLUGINS.items(): + usage = plugin_usage(name, entry) + console.print(f" [green]{name}[/green] - {entry['description']}") + console.print(f" Usage: {usage}") + console.print() -def show_history(log: TrainingLog, exercise: str): - """Show training history for a specific exercise.""" - history = log.movement_history(exercise) - if not history: - console.print(f"[yellow]No history found for '{exercise}'[/yellow]\n") +def render_result(result): + """Render a plugin result to the console.""" + if isinstance(result, TableResult): + if not result.rows: + console.print("[yellow]No results.[/yellow]\n") + return + table = Table(box=DEFAULT_TABLE_BOX) + for col in result.columns: + table.add_column(col, style="cyan") + for row in result.rows: + table.add_row(*(str(v) for v in row)) + console.print(table) + console.print(f"\n[dim]{len(result.rows)} row(s)[/dim]\n") + elif isinstance(result, TextResult): + console.print(result.text) + elif isinstance(result, PlotResult): + for line in result.lines: + console.print(line) + console.print() + + +def run_plugin(ctx: PluginContext, plugin_name: str, arg_string: str): + """Look up and execute a plugin by name.""" + if plugin_name not in PLUGINS: + console.print(f"[red]Unknown plugin: {plugin_name}[/red]") + show_plugin_list() return - table = Table(title=f"History: {exercise}", box=DEFAULT_TABLE_BOX) - table.add_column("Date", style="cyan") - table.add_column("Sets × Reps", style="magenta") - table.add_column("Top Weight", style="green") - table.add_column("Volume", style="yellow") - - for date, movement in history: - sets_reps = " + ".join([str(s.reps) for s in movement.sets]) - top_weight = str(movement.top_set_weight) if movement.top_set_weight else "BW" - volume = str(movement.total_volume()) if movement.total_volume() else "—" + entry = PLUGINS[plugin_name] - table.add_row(str(date), sets_reps, top_weight, volume) + if not arg_string.strip() and any(p.get("required") for p in entry["params"]): + usage = plugin_usage(plugin_name, entry) + console.print(f"[yellow]Usage: {usage}[/yellow]\n") + return - console.print(table) - console.print() # Blank line after table + try: + kwargs = parse_plugin_args(entry["params"], arg_string) + result = entry["fn"](ctx, **kwargs) + render_result(result) + except ValueError as e: + console.print(f"[red]{e}[/red]\n") def show_query(conn: sqlite3.Connection, sql: str): @@ -268,97 +323,9 @@ def show_tables(conn: sqlite3.Connection, headers: bool = False): console.print() -def show_report_list(): - """Show available reports with descriptions and usage.""" - console.print("\n[bold cyan]Available Reports:[/bold cyan]") - for name, entry in get_all_reports().items(): - usage = report_usage(name, entry) - console.print(f" [green]{name}[/green] - {entry['description']}") - console.print(f" Usage: {usage}") - console.print() - - -def render_report(columns: list[str], rows: list[tuple]): - """Render (columns, rows) as a rich table.""" - if not rows: - console.print("[yellow]No results.[/yellow]\n") - return - - table = Table(box=DEFAULT_TABLE_BOX) - for col in columns: - table.add_column(col, style="cyan") - - for row in rows: - table.add_row(*(str(v) for v in row)) - - console.print(table) - console.print(f"\n[dim]{len(rows)} row(s)[/dim]\n") - - -def run_report(conn: sqlite3.Connection, report_name: str, arg_string: str): - """Look up and execute a report by name.""" - all_reports = get_all_reports() - if report_name not in all_reports: - console.print(f"[red]Unknown report: {report_name}[/red]") - show_report_list() - return - - entry = all_reports[report_name] - - if not arg_string.strip() and any(p.get("required") for p in entry["params"]): - usage = report_usage(report_name, entry) - console.print(f"[yellow]Usage: {usage}[/yellow]\n") - return - - try: - kwargs = parse_report_args(entry["params"], arg_string) - columns, rows = entry["fn"](conn, **kwargs) - render_report(columns, rows) - except ValueError as e: - console.print(f"[red]{e}[/red]\n") - - -def show_generator_list(): - """Show available generators with descriptions and usage.""" - if not GENERATOR_PLUGINS: - console.print("[yellow]No generator plugins installed.[/yellow]\n") - return - console.print("\n[bold cyan]Available Generators:[/bold cyan]") - for name, entry in GENERATOR_PLUGINS.items(): - usage = report_usage(name, entry, command="generate") - console.print(f" [green]{name}[/green] - {entry['description']}") - console.print(f" Usage: {usage}") - console.print() - - -def run_generator(conn: sqlite3.Connection, gen_name: str, arg_string: str): - """Look up and execute a generator by name.""" - if gen_name not in GENERATOR_PLUGINS: - console.print(f"[red]Unknown generator: {gen_name}[/red]") - show_generator_list() - return - - entry = GENERATOR_PLUGINS[gen_name] - - if not arg_string.strip() and any(p.get("required") for p in entry["params"]): - usage = report_usage(gen_name, entry, command="generate") - console.print(f"[yellow]Usage: {usage}[/yellow]\n") - return - - try: - kwargs = parse_report_args(entry["params"], arg_string) - if entry.get("needs_db"): - output = entry["fn"](conn, **kwargs) - else: - output = entry["fn"](**kwargs) - console.print(output) - except ValueError as e: - console.print(f"[red]{e}[/red]\n") - - @click.command() @click.argument("file", type=click.Path(exists=True, path_type=Path)) -@click.version_option(version="0.2.0") +@click.version_option(version=_pkg_version("ox")) def cli(file): """Interactive training log analyzer. @@ -369,12 +336,19 @@ def cli(file): console.print(f"[cyan]Loading {file}...[/cyan]") log = parse_file(file) db = create_db(log) - load_plugins() + load_plugins(log, file) + ctx = PluginContext(db=db, log=log) console.print( f"[green]✓[/green] Loaded {len(log.completed_sessions)} completed, " f"{len(log.planned_sessions)} planned sessions, " - f"{len(log.weigh_ins)} weigh-in(s)\n" + f"{len(log.weigh_ins)} weigh-in(s)" ) + if USER_PLUGINS: + console.print( + f"[green]✓[/green] Loaded user plugins: {', '.join(sorted(USER_PLUGINS))}\n" + ) + else: + console.print() if log.diagnostics: console.print( f"[yellow]Warning: {len(log.diagnostics)} parse error(s). " @@ -384,20 +358,17 @@ def cli(file): console.print(f"[red]✗[/red] Error loading file: {e}", style="red") raise click.Abort() - # Setup tab completion for commands + # Setup tab completion for commands + plugin names commands = [ - "history", - "stats", - "report", - "reload", - "generate", + "plugins", "query", "tables", + "reload", "lint", "help", "exit", "quit", - ] + ] + list(PLUGINS.keys()) completer = WordCompleter(commands, ignore_case=True) # Create prompt session @@ -426,32 +397,8 @@ def cli(file): elif command == "help": show_help() - elif command == "stats": - show_stats(log) - - elif command == "history": - if not args: - console.print("[yellow]Usage: history EXERCISE[/yellow]") - else: - show_history(log, args) - - elif command == "report": - if not args: - show_report_list() - else: - parts2 = args.split(maxsplit=1) - report_name = parts2[0] - report_args = parts2[1] if len(parts2) > 1 else "" - run_report(db, report_name, report_args) - - elif command == "generate": - if not args: - show_generator_list() - else: - parts2 = args.split(maxsplit=1) - gen_name = parts2[0] - gen_args = parts2[1] if len(parts2) > 1 else "" - run_generator(db, gen_name, gen_args) + elif command == "plugins": + show_plugin_list() elif command == "query": if not args: @@ -491,16 +438,36 @@ def cli(file): log = parse_file(file) db.close() db = create_db(log) + load_plugins(log, file) + ctx = PluginContext(db=db, log=log) console.print( f"[green]✓[/green] Loaded {len(log.completed_sessions)} completed, " f"{len(log.planned_sessions)} planned sessions, " - f"{len(log.weigh_ins)} weigh-in(s)\n" + f"{len(log.weigh_ins)} weigh-in(s)" ) + if USER_PLUGINS: + console.print( + f"[green]✓[/green] Loaded user plugins: {', '.join(sorted(USER_PLUGINS))}" + ) + else: + console.print() if log.diagnostics: console.print( f"[yellow]Warning: {len(log.diagnostics)} parse error(s). " "Run 'lint' for details.[/yellow]\n" ) + # Update completer with any new plugins + commands = [ + "plugins", + "query", + "tables", + "reload", + "lint", + "help", + "exit", + "quit", + ] + list(PLUGINS.keys()) + session.completer = WordCompleter(commands, ignore_case=True) except Exception as e: console.print(f"[red]✗[/red] Error reloading file: {e}\n") @@ -512,6 +479,9 @@ def cli(file): console.print(f"Line {d.line}, col {d.col}: {d.message}") console.print() + elif command in PLUGINS: + run_plugin(ctx, command, args) + else: console.print(f"[red]Unknown command: {command}[/red]") console.print("Type 'help' for available commands") diff --git a/src/ox/data.py b/src/ox/data.py index 2ec6974..82dcf19 100644 --- a/src/ox/data.py +++ b/src/ox/data.py @@ -6,7 +6,6 @@ from pint import Quantity DATE_FORMAT = "%Y-%m-%d" -ITEM_FIELDS = ["weight", "rep_scheme", "time", "distance", "note"] @dataclass(frozen=True, slots=True) @@ -168,6 +167,25 @@ def to_ox(self, compact_reps: bool = False) -> str: return f"{self.name}: {detail_str}" if detail_str else f"{self.name}:" +@dataclass(frozen=True, slots=True) +class MovementDefinition: + """A movement definition from an @movement block. + + Attributes: + name: Movement name (e.g., "kb-oh-press") + equipment: Equipment used (e.g., "kettlebell") + tags: Movement tags (e.g., ("press", "upper")) + note: Freeform description + url: Reference URL + """ + + name: str + equipment: Optional[str] = None + tags: tuple[str, ...] = () + note: Optional[str] = None + url: Optional[str] = None + + @dataclass(frozen=True, slots=True) class TrainingSession(Entry): """A training session containing one or more movements. @@ -208,11 +226,14 @@ class TrainingLog: diagnostics: Tuple of parse diagnostics (errors/warnings) """ + # TODO: Add attributes to docstring and improve description sessions: tuple[TrainingSession, ...] notes: tuple[Note, ...] = field(default_factory=tuple) diagnostics: tuple[Diagnostic, ...] = field(default_factory=tuple) queries: tuple[StoredQuery, ...] = field(default_factory=tuple) weigh_ins: tuple[WeighIn, ...] = field(default_factory=tuple) + plugin_paths: tuple[str, ...] = field(default_factory=tuple) + movement_definitions: tuple[MovementDefinition, ...] = field(default_factory=tuple) @property def completed_sessions(self) -> tuple[TrainingSession, ...]: diff --git a/src/ox/db.py b/src/ox/db.py index f1fe9ae..f690740 100644 --- a/src/ox/db.py +++ b/src/ox/db.py @@ -52,6 +52,20 @@ sql TEXT NOT NULL ); +CREATE TABLE movement_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + equipment TEXT, + note TEXT, + url TEXT +); + +CREATE TABLE movement_tags ( + movement_definition_id INTEGER NOT NULL REFERENCES movement_definitions(id), + tag TEXT NOT NULL, + PRIMARY KEY (movement_definition_id, tag) +); + CREATE TABLE weigh_ins ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, @@ -145,6 +159,18 @@ def create_db(log: TrainingLog) -> sqlite3.Connection: (q.date.isoformat(), q.name, q.sql), ) + for mdef in log.movement_definitions: + cursor = conn.execute( + "INSERT INTO movement_definitions (name, equipment, note, url) VALUES (?, ?, ?, ?)", + (mdef.name, mdef.equipment, mdef.note, mdef.url), + ) + mdef_id = cursor.lastrowid + for tag in mdef.tags: + conn.execute( + "INSERT INTO movement_tags (movement_definition_id, tag) VALUES (?, ?)", + (mdef_id, tag), + ) + for w in log.weigh_ins: mag, unit = _decompose_weight(w.weight) conn.execute( diff --git a/src/ox/lsp.py b/src/ox/lsp.py index 2ab82f3..bb9d45f 100644 --- a/src/ox/lsp.py +++ b/src/ox/lsp.py @@ -119,6 +119,10 @@ def _collect_movement_names(tree) -> set[str]: item = child.child_by_field_name("item") if item: names.add(item.text.decode("utf-8")) + elif node.type == "movement_block": + name = node.child_by_field_name("name") + if name: + names.add(name.text.decode("utf-8")) return names diff --git a/src/ox/parse.py b/src/ox/parse.py index 0e00d97..ffc58f6 100644 --- a/src/ox/parse.py +++ b/src/ox/parse.py @@ -5,6 +5,7 @@ from ox.data import ( DATE_FORMAT, Movement, + MovementDefinition, Note, StoredQuery, TrainingSession, @@ -78,10 +79,36 @@ def process_weights(weight_str: str) -> list[Quantity]: """Parse weight string into list of Quantity objects. Handles formats like "24kg", "24kg+32kg", "24kg/32kg/48kg". + + In progressive sequences, a segment may omit its unit; it inherits the + nearest succeeding unit. E.g. "160/185/210lb" → three lb weights; + "60/70kg/160/180lb" → [60kg, 70kg, 160lb, 180lb]. """ weight_str_split = weight_str.split("/") + # Right-to-left pass to resolve implied units. + carried_unit = None + resolved = [None] * len(weight_str_split) + for i in range(len(weight_str_split) - 1, -1, -1): + w = weight_str_split[i] + if w == "BW" or "+" in w: + resolved[i] = w + continue + m = re.match(r"^(\d+(?:\.\d+)?)(\w+)?$", w) + if not m: + resolved[i] = w + continue + num, unit = m.group(1), m.group(2) + if unit is None: + if carried_unit is None: + resolved[i] = w # will fail to parse downstream + else: + resolved[i] = f"{num}{carried_unit}" + else: + carried_unit = unit + resolved[i] = w + weight_objs = [] - for w in weight_str_split: + for w in resolved: if "+" in w: result = sum([weight_text_to_quantity(i) for i in w.split("+")]) weight_objs.append(result) @@ -179,17 +206,19 @@ def process_singleline_entry(raw_entry: Node) -> TrainingSession | None: if flag in ["*", "!"]: date, movement = process_singleline_completed_session(raw_entry) - return TrainingSession(name=None, date=date, flag=flag, movements=movement) + return TrainingSession( + name=movement[0].name, date=date, flag=flag, movements=movement + ) return None def process_session_block_pending(raw_entry: Node) -> TrainingSession | None: """Process a pending session block (flag='!'). - Not yet implemented. + Deferred: planned sessions are parsed but not materialized for analysis. + See SPEC.md "What's incomplete". """ - # TODO: implement pending session processing - pass + return None def process_session_block(raw_entry: Node) -> TrainingSession | None: @@ -206,7 +235,6 @@ def process_session_block(raw_entry: Node) -> TrainingSession | None: name=name, flag=flag, date=date, movements=tuple(movements), notes=notes ) else: - # TODO: handle pending sessions return process_session_block_pending(raw_entry) @@ -239,12 +267,47 @@ def process_query_entry(node: Node) -> StoredQuery: return StoredQuery(name=name, sql=sql, date=date) +def process_movement_block(node: Node) -> MovementDefinition: + """Process a movement_block node into a MovementDefinition.""" + name = node.child_by_field_name("name").text.decode("utf-8") + metadata: dict[str, str] = {} + for child in node.children: + if child.type != "metadata_line": + continue + key_node = child.child_by_field_name("key") + value_node = child.child_by_field_name("value") + if key_node is None or value_node is None: + continue + metadata[key_node.text.decode("utf-8")] = value_node.text.decode( + "utf-8" + ).strip() + + tags_raw = metadata.get("tags") or metadata.get("tag") + tags: tuple[str, ...] = () + if tags_raw: + tags = tuple(t.strip() for t in tags_raw.split(",") if t.strip()) + + return MovementDefinition( + name=name, + equipment=metadata.get("equipment"), + tags=tags, + note=metadata.get("note"), + url=metadata.get("url"), + ) + + def process_include_directive(node: Node) -> str: """Extract file path from an include_directive node.""" raw = node.child_by_field_name("path").text.decode("utf-8") return raw.strip('"') +def process_plugin_directive(node: Node) -> str: + """Extract file path from a plugin_directive node.""" + raw = node.child_by_field_name("path").text.decode("utf-8") + return raw.strip('"') + + def process_node(node: Node) -> TrainingSession | Note | StoredQuery | None: """Process any node type and return appropriate data structure. @@ -264,5 +327,7 @@ def process_node(node: Node) -> TrainingSession | Note | StoredQuery | None: return process_query_entry(node) if node.type == "weigh_in_entry": return process_weigh_in_entry(node) - # Skip comments, exercise_block, template_block for now + if node.type == "movement_block": + return process_movement_block(node) + # Skip comments, template_block for now return None diff --git a/src/ox/plot.py b/src/ox/plot.py new file mode 100644 index 0000000..e9e5cbf --- /dev/null +++ b/src/ox/plot.py @@ -0,0 +1,430 @@ +"""Plot facade for ox plugins. + +Thin wrapper over the `plotext` library so plugins don't each reimplement +tick spacing, datetime axes, and label clipping. Returns `list[str]` to +match the `PlotResult.lines` contract consumed by the CLI. +""" + +import calendar +import math +import re +from dataclasses import dataclass +from datetime import date as _date +from typing import Literal + +import plotext as plt + +Scale = Literal["week", "month", "quarter", "year"] +_SCALE_MONTHS = {"month": 1, "quarter": 3, "year": 12} + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + +_DEFAULT_WIDTH = 60 +_DEFAULT_HEIGHT = 22 # empirical: even 3-row tick spacing for 6–7 y-ticks + +_EMPTY_MESSAGE = "Not enough data to plot." + +_SCATTER_MARKERS = ["dot", "cross", "star", "heart", "at"] +_LINE_MARKER = "braille" + + +@dataclass(frozen=True, slots=True) +class Series: + """A named data series for :func:`multi_series` plotting. + + Attributes: + label: Legend label rendered by plotext. + dates: ISO-formatted date strings ("YYYY-MM-DD"), one per value. + values: Numeric y-values, aligned with ``dates``. + style: "scatter" renders as discrete markers, "line" connects points. + """ + + label: str + dates: list[str] + values: list[float] + style: Literal["scatter", "line"] = "scatter" + + +def _finalize() -> list[str]: + """Render the current plotext figure to a list of plain-text rows. + + Calls ``plt.build()`` and strips ANSI escape sequences left by the + "clear" theme so plugin output is monochrome. + + Returns: + One string per rendered row, with trailing blank lines removed. + """ + out = plt.build() + out = _ANSI_RE.sub("", out) + return out.rstrip("\n").split("\n") + + +def _nice_step( + span: float, target_intervals: int = 5, max_step: float | None = None +) -> float: + """Pick a whole-number step (1/2/5 × 10ⁿ) producing ~target_intervals. + + Args: + span: Total range to be divided into ticks. + target_intervals: Preferred number of intervals between ticks. + max_step: Optional upper bound on the returned step. + + Returns: + The chosen step size. Falls back to ``10 × 10^floor(log10(span/n))`` + (clamped to ``max_step`` when provided) if no candidate in + ``{1, 2, 5, 10}`` satisfies the target. + """ + if span <= 0: + return 1.0 + raw = span / target_intervals + mag = 10 ** math.floor(math.log10(raw)) + for mult in (1, 2, 5, 10): + step = mult * mag + if max_step is not None and step > max_step: + continue + if span / step <= target_intervals + 1: + return step + fallback = 10 * mag + return fallback if max_step is None else min(fallback, max_step) + + +def _whole_number_yticks( + values: list[float], step: float | None = None +) -> tuple[list[float], float, float]: + """Compute evenly-spaced y-ticks on whole-number boundaries. + + Args: + values: Data values used to derive the tick range. + step: Override the auto-picked tick increment. If None, a nice + step is chosen via :func:`_nice_step` (max 25). + + Returns: + A tuple ``(ticks, bottom, top)`` where ``ticks`` is the ascending + list of tick values, ``bottom`` is ``floor(min / step) * step``, + and ``top`` is ``ceil(max / step) * step``. + """ + lo, hi = min(values), max(values) + if step is None: + step = _nice_step(hi - lo, max_step=25) + top = math.ceil(hi / step) * step + bottom = math.floor(lo / step) * step + n = int(round((top - bottom) / step)) + 1 + ticks = [bottom + i * step for i in range(n)] + if step >= 1: + ticks = [float(round(t)) for t in ticks] + return ticks, float(bottom), float(top) + + +def _step_back(d: _date, scale: Scale) -> _date: + """Return ``d`` moved back by one unit of ``scale``. + + Args: + d: The reference date. + scale: "week" steps back 7 days; "month", "quarter", "year" step + back 1, 3, or 12 calendar months. Day-of-month is clamped + when the target month is shorter. + + Returns: + The new date. + """ + if scale == "week": + return _date.fromordinal(d.toordinal() - 7) + months = _SCALE_MONTHS[scale] + total = d.year * 12 + (d.month - 1) - months + y, m = divmod(total, 12) + m += 1 + day = min(d.day, calendar.monthrange(y, m)[1]) + return _date(y, m, day) + + +def _anchored_date_xticks( + dates: list[str], + target_ticks: int = 4, + scale: Scale | None = None, +) -> tuple[list[str], list[str], list[int]]: + """Compute x-ticks anchored on the most recent date, stepping back regularly. + + Args: + dates: ISO-formatted date strings; the min/max define the range. + target_ticks: Preferred number of ticks; also drives decimation + when ``scale`` produces too many ticks to fit. + scale: If given, steps back by a calendar unit (see + :func:`_step_back`) and caps at ``target_ticks * 2`` by + striding the results. If None, a nice day-based step is + chosen from the data span. + + Returns: + A tuple ``(positions_iso, mm_dd_labels, years_per_tick)``, each + of equal length, ordered oldest → newest. + """ + parsed = sorted({_date.fromisoformat(d) for d in dates}) + if len(parsed) < 2: + if parsed: + return ( + [parsed[0].isoformat()], + [parsed[0].strftime("%m-%d")], + [parsed[0].year], + ) + return [], [], [] + positions: list[_date] = [] + cur = parsed[-1] + first = parsed[0] + if scale is None: + total_days = (parsed[-1] - parsed[0]).days + step_days = max(1, int(round(_nice_step(total_days, target_ticks)))) + while cur >= first: + positions.append(cur) + cur = _date.fromordinal(cur.toordinal() - step_days) + else: + while cur >= first: + positions.append(cur) + cur = _step_back(cur, scale) + max_ticks = target_ticks * 2 + if len(positions) > max_ticks: + stride = math.ceil(len(positions) / max_ticks) + positions = positions[::stride] + positions.reverse() + return ( + [p.isoformat() for p in positions], + [p.strftime("%m-%d") for p in positions], + [p.year for p in positions], + ) + + +def _inject_year_row( + lines: list[str], labels: list[str], years: list[int] +) -> list[str]: + """Insert a hierarchical year row below the mm-dd tick labels. + + Locates the rendered tick row by picking the line containing the + most ``labels`` substrings, finds each label's column, groups + contiguous same-year runs, and centers the year string under each + run. Labels that plotext drops at the edges are skipped. + + Args: + lines: The rendered plot rows returned by :func:`_finalize`. + labels: The mm-dd tick labels in left-to-right order. + years: The year for each label in ``labels``, same order. + + Returns: + ``lines`` with a new year row inserted immediately below the + tick-label row, or ``lines`` unchanged if no tick row can be + identified or no labels were rendered. + """ + tick_row_idx = None + best_match_count = 0 + for i, row in enumerate(lines): + found = sum(1 for lbl in labels if lbl in row) + if found > best_match_count and found >= 2: + best_match_count = found + tick_row_idx = i + if tick_row_idx is None: + return lines + + tick_row = lines[tick_row_idx] + centers: list[int | None] = [] + cursor = 0 + for lbl in labels: + pos = tick_row.find(lbl, cursor) + if pos < 0: + centers.append(None) + else: + centers.append(pos + len(lbl) // 2) + cursor = pos + len(lbl) + + found_years = [(y, c) for y, c in zip(years, centers, strict=True) if c is not None] + if not found_years: + return lines + + runs: list[tuple[int, int, int]] = [] + start = 0 + for i in range(1, len(found_years) + 1): + if i == len(found_years) or found_years[i][0] != found_years[start][0]: + runs.append( + (found_years[start][0], found_years[start][1], found_years[i - 1][1]) + ) + start = i + + width = max(len(r) for r in lines) + year_row = [" "] * width + for year, left_c, right_c in runs: + label = str(year) + mid = (left_c + right_c) // 2 + start_col = max(0, mid - len(label) // 2) + for j, ch in enumerate(label): + if start_col + j < width: + year_row[start_col + j] = ch + + return ( + lines[: tick_row_idx + 1] + + ["".join(year_row).rstrip()] + + lines[tick_row_idx + 1 :] + ) + + +def scatter( + dates: list[str], + values: list[float], + *, + y_label: str, + title: str | None = None, + width: int = _DEFAULT_WIDTH, + height: int = _DEFAULT_HEIGHT, + y_step: float | None = None, + x_scale: Scale | None = None, +) -> list[str]: + """Scatter plot of values over an ISO-date x-axis. + + Y ticks anchor at the next step-boundary above max, descending at + regular whole-number intervals. X ticks anchor on the most recent + date, stepping back at regular intervals; year is shown in a + hierarchical row beneath the mm-dd labels. + + Args: + dates: ISO-formatted date strings ("YYYY-MM-DD"), one per value. + values: Numeric y-values, aligned with ``dates``. + y_label: Label rendered beneath the y-axis. + title: Optional plot title. + width: Plot width in characters. + height: Plot height in rows. Affects tick visual spacing. + y_step: Override the auto-picked y-tick increment. If None, a + nice step (1/2/5 × 10ⁿ, max 25) is chosen from the data span. + x_scale: One of "week", "month", "quarter", "year" to force + calendar-aligned x-tick stepping. If None, a nice day-based + step is chosen from the data span. + + Returns: + One string per row of the rendered plot, including axes, + tick labels, hierarchical year row, and y-label. Returns + ``["Not enough data to plot."]`` when fewer than 2 values. + """ + if len(values) < 2: + return [_EMPTY_MESSAGE] + plt.clf() + plt.theme("clear") + plt.plot_size(width, height) + plt.date_form("Y-m-d") + plt.scatter(dates, values) + yticks, y_bottom, y_top = _whole_number_yticks(values, step=y_step) + plt.yticks(yticks) + plt.ylim(y_bottom, y_top) + tick_positions, tick_labels, tick_years = _anchored_date_xticks( + dates, scale=x_scale + ) + plt.xticks(tick_positions, tick_labels) + plt.ylabel(y_label) + if title: + plt.title(title) + lines = _finalize() + return _inject_year_row(lines, tick_labels, tick_years) + + +def multi_series( + series: list[Series], + *, + y_label: str, + title: str | None = None, + width: int = _DEFAULT_WIDTH, + height: int = _DEFAULT_HEIGHT, + y_step: float | None = None, + x_scale: Scale | None = None, +) -> list[str]: + """Plot multiple named series on a shared ISO-date x-axis. + + Scatter series cycle through distinct markers so they remain + distinguishable under the monochrome "clear" theme; line series + use a braille marker for continuous connection. + + Args: + series: Data series to overlay. Empty series are skipped. + y_label: Label rendered beneath the y-axis. + title: Optional plot title. + width: Plot width in characters. + height: Plot height in rows. + y_step: Override the auto-picked y-tick increment. + x_scale: One of "week", "month", "quarter", "year" to force + calendar-aligned x-tick stepping. + + Returns: + Rendered rows. Returns ``["Not enough data to plot."]`` when + fewer than 2 total points are supplied. + """ + non_empty = [s for s in series if s.values] + total = sum(len(s.values) for s in non_empty) + if total < 2: + return [_EMPTY_MESSAGE] + + plt.clf() + plt.theme("clear") + plt.plot_size(width, height) + plt.date_form("Y-m-d") + + scatter_idx = 0 + for s in non_empty: + if s.style == "line": + plt.plot(s.dates, s.values, label=s.label, marker=_LINE_MARKER) + else: + marker = _SCATTER_MARKERS[scatter_idx % len(_SCATTER_MARKERS)] + scatter_idx += 1 + plt.scatter(s.dates, s.values, label=s.label, marker=marker) + + all_values = [v for s in non_empty for v in s.values] + yticks, y_bottom, y_top = _whole_number_yticks(all_values, step=y_step) + plt.yticks(yticks) + plt.ylim(y_bottom, y_top) + + all_dates = [d for s in non_empty for d in s.dates] + tick_positions, tick_labels, tick_years = _anchored_date_xticks( + all_dates, scale=x_scale + ) + plt.xticks(tick_positions, tick_labels) + plt.ylabel(y_label) + if title: + plt.title(title) + lines = _finalize() + return _inject_year_row(lines, tick_labels, tick_years) + + +def bar( + labels: list[str], + values: list[float], + *, + y_label: str, + title: str | None = None, + width: int = _DEFAULT_WIDTH, + height: int = _DEFAULT_HEIGHT, + y_step: float | None = None, +) -> list[str]: + """Vertical bar chart with categorical string labels. + + Plotext decimates x-labels automatically when too many bars are + supplied; no year-row injection is done since bar categories are + not assumed to be dates. + + Args: + labels: Category labels, one per bar. + values: Bar heights, aligned with ``labels``. + y_label: Label rendered beneath the y-axis. + title: Optional plot title. + width: Plot width in characters. + height: Plot height in rows. + y_step: Override the auto-picked y-tick increment. + + Returns: + Rendered rows. Returns ``["Not enough data to plot."]`` when + fewer than 2 values are supplied. + """ + if len(values) < 2: + return [_EMPTY_MESSAGE] + plt.clf() + plt.theme("clear") + plt.plot_size(width, height) + plt.bar(labels, values, width=0.5) + if y_step is not None: + yticks, y_bottom, y_top = _whole_number_yticks([0.0, *values], step=y_step) + plt.yticks(yticks) + plt.ylim(y_bottom, y_top) + plt.ylabel(y_label) + if title: + plt.title(title) + return _finalize() diff --git a/src/ox/plugins.py b/src/ox/plugins.py index 0383454..60949de 100644 --- a/src/ox/plugins.py +++ b/src/ox/plugins.py @@ -1,29 +1,49 @@ """Plugin discovery and loading for ox. Plugins are Python modules that export a register() function returning -a list of plugin descriptors (dicts). Two plugin types are supported: - -- "report": query SQLite, return (columns, rows) -- "generator": accept parameters, return .ox formatted text +a list of plugin descriptors (dicts). Each plugin receives a PluginContext +and returns a TableResult, TextResult, or PlotResult. Discovery sources (loaded in order): -1. ~/.ox/plugins/*.py (personal scripts) -2. Entry points in the "ox.plugins" group (installable packages) +1. Built-in plugins (volume, e1rm, weighin, wendler531, srpe) +2. @plugin directives in .ox files """ import importlib.util import logging -from importlib.metadata import entry_points +import sqlite3 +from dataclasses import dataclass from pathlib import Path from types import ModuleType +from ox.data import TrainingLog + logger = logging.getLogger(__name__) -PLUGIN_DIR = Path.home() / ".ox" / "plugins" -ENTRY_POINT_GROUP = "ox.plugins" +PLUGINS: dict[str, dict] = {} +USER_PLUGINS: set[str] = set() + + +@dataclass(frozen=True, slots=True) +class PluginContext: + db: sqlite3.Connection + log: TrainingLog + + +@dataclass(frozen=True, slots=True) +class TableResult: + columns: list[str] + rows: list[tuple] + -REPORT_PLUGINS: dict[str, dict] = {} -GENERATOR_PLUGINS: dict[str, dict] = {} +@dataclass(frozen=True, slots=True) +class TextResult: + text: str + + +@dataclass(frozen=True, slots=True) +class PlotResult: + lines: list[str] def _load_module_from_path(path: Path) -> ModuleType | None: @@ -41,69 +61,53 @@ def _load_module_from_path(path: Path) -> ModuleType | None: return module -def _register_descriptors(descriptors: list[dict], source: str) -> None: - """Register plugin descriptors into the appropriate registry.""" +def _register_descriptors( + descriptors: list[dict], source: str, is_user: bool = False +) -> None: + """Register plugin descriptors into the unified registry.""" for desc in descriptors: - plugin_type = desc.get("type") name = desc.get("name") - if not plugin_type or not name or "fn" not in desc: + if not name or "fn" not in desc: logger.warning( "Skipping malformed plugin descriptor from %s: %s", source, desc ) continue - if plugin_type == "report": - if name in REPORT_PLUGINS: - logger.warning("Report plugin '%s' redefined by %s", name, source) - REPORT_PLUGINS[name] = desc - elif plugin_type == "generator": - if name in GENERATOR_PLUGINS: - logger.warning("Generator plugin '%s' redefined by %s", name, source) - GENERATOR_PLUGINS[name] = desc - else: - logger.warning("Unknown plugin type '%s' from %s", plugin_type, source) - - -def _load_from_directory() -> None: - """Load all .py files from ~/.ox/plugins/.""" - if not PLUGIN_DIR.is_dir(): - return - for path in sorted(PLUGIN_DIR.glob("*.py")): - module = _load_module_from_path(path) + if name in PLUGINS: + logger.warning("Plugin '%s' redefined by %s", name, source) + PLUGINS[name] = desc + if is_user: + USER_PLUGINS.add(name) + + +def _load_from_log_directives(log: TrainingLog, base_path: Path) -> None: + """Load plugins declared via @plugin directives in the .ox file.""" + for rel_path in log.plugin_paths: + resolved = (base_path.parent / rel_path).resolve() + module = _load_module_from_path(resolved) if module and hasattr(module, "register"): try: descriptors = module.register() - _register_descriptors(descriptors, str(path)) + _register_descriptors(descriptors, str(resolved), is_user=True) except Exception: - logger.warning("Error calling register() in %s", path, exc_info=True) - - -def _load_from_entry_points() -> None: - """Load plugins registered via entry points.""" - for ep in entry_points(group=ENTRY_POINT_GROUP): - try: - module = ep.load() - if hasattr(module, "register"): - descriptors = module.register() - _register_descriptors(descriptors, f"entry_point:{ep.name}") - except Exception: - logger.warning("Error loading entry point '%s'", ep.name, exc_info=True) + logger.warning( + "Error calling register() in %s", resolved, exc_info=True + ) def _load_builtins() -> None: """Load plugins that ship with ox.""" - from ox.builtins import e1rm, weighin, wendler531 + from ox.builtins import e1rm, srpe, volume, weighin, wendler531 - _register_descriptors(e1rm.register(), "builtin:e1rm") - _register_descriptors(weighin.register(), "builtin:weighin") - _register_descriptors(wendler531.register(), "builtin:wendler531") + for mod in (volume, e1rm, weighin, wendler531, srpe): + _register_descriptors(mod.register(), f"builtin:{mod.__name__}") -def load_plugins() -> None: +def load_plugins(log: TrainingLog | None = None, base_path: Path | None = None) -> None: """Discover and load all plugins. Call once at startup.""" - REPORT_PLUGINS.clear() - GENERATOR_PLUGINS.clear() + PLUGINS.clear() + USER_PLUGINS.clear() _load_builtins() - _load_from_directory() - _load_from_entry_points() + if log is not None and base_path is not None: + _load_from_log_directives(log, base_path) diff --git a/src/ox/reports.py b/src/ox/reports.py deleted file mode 100644 index 86f8549..0000000 --- a/src/ox/reports.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Standard reports for training log analysis. - -Each report function takes a sqlite3.Connection and keyword arguments, -and returns (columns, rows) — a list of column names and a list of tuples. -This keeps reports independent of any rendering layer. -""" - -import shlex -import sqlite3 -from collections import defaultdict - -from ox.units import Q_, ureg - -# Unit strings as stored in the DB (Pint internal names) -_DB_UNITS = ["kilogram", "pound"] - -TIME_BINS = { - "daily": "strftime('%Y-%m-%d', {col})", - "weekly": "date({col}, '-' || strftime('%w', {col}) || ' days')", - "weekly-num": "strftime('%Y-W%W', {col})", - "monthly": "strftime('%Y-%m', {col})", -} - - -def _weight_sql_expr(magnitude_col: str, unit_col: str, target_unit: str) -> str: - """SQL CASE expression converting weight_magnitude to target_unit. - - Uses Pint to derive conversion factors, so any valid mass unit string is accepted. - - Raises: - ValueError: If target_unit is not a recognized Pint unit - """ - try: - target = ureg.parse_units(target_unit) - except Exception: - raise ValueError(f"Unknown unit: '{target_unit}'") - cases = [] - for db_unit in _DB_UNITS: - factor = float(Q_(1, db_unit).to(target).magnitude) - cases.append(f"WHEN '{db_unit}' THEN {magnitude_col} * {factor}") - return f"CASE {unit_col} {' '.join(cases)} ELSE {magnitude_col} END" - - -def _time_bin_expr(bin: str, col: str = "date") -> str: - """Return a SQL expression for a time bin name. - - Args: - bin: One of "daily", "weekly", "weekly-num", "monthly" - col: The date column name to use in the expression - - Raises: - ValueError: If bin is not a recognized time bin - """ - if bin not in TIME_BINS: - raise ValueError( - f"Unknown time bin '{bin}'. Choose from: {', '.join(TIME_BINS)}" - ) - return TIME_BINS[bin].format(col=col) - - -def volume_over_time( - conn: sqlite3.Connection, movement: str, bin: str = "weekly", unit: str = "lb" -) -> tuple[list[str], list[tuple]]: - """Volume over time for a single movement. - - Args: - conn: SQLite connection with training data - movement: Movement name to filter by - bin: Time bin size ("daily", "weekly", "monthly") - unit: Weight unit for output values (default "lb") - - Returns: - (columns, rows) where columns are - ["period", "total_volume ()", "total_reps", "avg_weight_per_rep ()"] - """ - expr = _time_bin_expr(bin, "date") - w = _weight_sql_expr("weight_magnitude", "weight_unit", unit) - rows = conn.execute( - f""" - SELECT - {expr} AS period, - ROUND(SUM(reps * {w}), 1) AS total_volume, - SUM(reps) AS total_reps, - ROUND(SUM(reps * {w}) * 1.0 / SUM(reps), 1) AS avg_weight_per_rep - FROM training - WHERE movement_name = ? - GROUP BY period - ORDER BY period - """, - (movement,), - ).fetchall() - columns = [ - "period", - f"total_volume ({unit})", - "total_reps", - f"avg_weight_per_rep ({unit})", - ] - return columns, rows - - -def session_matrix( - conn: sqlite3.Connection, bin: str = "weekly" -) -> tuple[list[str], list[tuple]]: - """Session count per movement per time period. - - Rows are time periods, columns are movement names (sorted by frequency, - most common first). - - Args: - conn: SQLite connection with training data - bin: Time bin size ("daily", "weekly", "monthly") - - Returns: - (columns, rows) where columns are ["period", movement1, movement2, ...] - """ - expr = _time_bin_expr(bin, "s.date") - - # Get movement names sorted by total frequency (most common first) - movement_names = [ - r[0] - for r in conn.execute( - """ - SELECT name, COUNT(DISTINCT session_id) AS freq - FROM movements - GROUP BY name - ORDER BY freq DESC, name - """ - ).fetchall() - ] - - # Get per-period, per-movement session counts - raw = conn.execute( - f""" - SELECT - {expr} AS period, - m.name AS movement_name, - COUNT(DISTINCT s.id) AS session_count - FROM sessions s - JOIN movements m ON m.session_id = s.id - GROUP BY period, movement_name - ORDER BY period - """ - ).fetchall() - - # Pivot into {period: {movement: count}} - pivot = defaultdict(lambda: defaultdict(int)) - periods = [] - for period, movement_name, count in raw: - if period not in pivot: - periods.append(period) - pivot[period][movement_name] = count - - # Flatten to rows - columns = ["period"] + movement_names - rows = [] - for period in periods: - row = [period] + [pivot[period].get(m, 0) for m in movement_names] - rows.append(tuple(row)) - - return columns, rows - - -def parse_report_args(params: list[dict], arg_string: str) -> dict: - """Parse --flag value pairs from a string against a param spec. - - Args: - params: List of param dicts with keys: name, type, required, default (optional) - arg_string: Raw argument string (e.g. "--movement kb-swing --bin weekly") - - Returns: - Dict of parsed keyword arguments - - Raises: - ValueError: If required params are missing or unknown flags are given - """ - tokens = shlex.split(arg_string) if arg_string.strip() else [] - parsed = {} - i = 0 - while i < len(tokens): - token = tokens[i] - if token.startswith("--"): - key = token[2:] - param = next((p for p in params if p["name"] == key), None) - if param is None: - raise ValueError(f"Unknown flag: --{key}") - flag = f"--{key}" - elif token.startswith("-") and len(token) == 2: - key = token[1:] - param = next((p for p in params if p.get("short") == key), None) - if param is None: - raise ValueError(f"Unknown flag: -{key}") - flag = f"-{key}" - else: - raise ValueError(f"Unexpected argument: {token}") - if i + 1 >= len(tokens): - raise ValueError(f"{flag} requires a value") - parsed[param["name"]] = param["type"](tokens[i + 1]) - i += 2 - - # Apply defaults and check required - for param in params: - name = param["name"] - if name not in parsed: - if param.get("required", False): - required_names = [ - f"--{p['name']}" for p in params if p.get("required", False) - ] - raise ValueError( - f"Missing required flag(s): {', '.join(required_names)}" - ) - parsed[name] = param.get("default") - - return parsed - - -def report_usage(name: str, entry: dict, command: str = "report") -> str: - """Generate a usage string for a report or generator. - - Args: - name: Report/generator name - entry: Registry entry with params list - command: CLI command prefix ("report" or "generate") - - Returns: - Formatted usage string - """ - parts = [f"{command} {name}"] - for p in entry["params"]: - short = f"-{p['short']}/" if p.get("short") else "" - flag = f"{short}--{p['name']} <{p['name']}>" - if p.get("required", False): - parts.append(flag) - else: - parts.append(f"[{flag}]") - return " ".join(parts) - - -REPORTS = { - "volume": { - "fn": volume_over_time, - "description": "Volume over time for a movement", - "params": [ - {"name": "movement", "type": str, "required": True, "short": "m"}, - { - "name": "bin", - "type": str, - "default": "weekly", - "required": False, - "short": "b", - }, - { - "name": "unit", - "type": str, - "default": "lb", - "required": False, - "short": "u", - }, - ], - }, - "matrix": { - "fn": session_matrix, - "description": "Session count per movement per time period", - "params": [ - { - "name": "bin", - "type": str, - "default": "weekly", - "required": False, - "short": "b", - }, - ], - }, -} - - -def get_all_reports() -> dict[str, dict]: - """Return built-in reports merged with plugin reports.""" - from ox.plugins import REPORT_PLUGINS - - merged = dict(REPORTS) - for name, desc in REPORT_PLUGINS.items(): - merged[name] = { - "fn": desc["fn"], - "description": desc["description"], - "params": desc["params"], - } - return merged diff --git a/src/ox/sql_utils.py b/src/ox/sql_utils.py new file mode 100644 index 0000000..9116a09 --- /dev/null +++ b/src/ox/sql_utils.py @@ -0,0 +1,130 @@ +"""SQL helper utilities for ox plugins. + +Shared functions used by plugins that query the SQLite database. +""" + +import shlex + +from ox.units import Q_, ureg + +# Unit strings as stored in the DB (Pint internal names) +# TODO: Expand this as required in the future +_DB_UNITS = ["kilogram", "pound"] + +# TODO: Add annual +TIME_BINS = { + "daily": "strftime('%Y-%m-%d', {col})", + "weekly": "date({col}, '-' || strftime('%w', {col}) || ' days')", + "weekly-num": "strftime('%Y-W%W', {col})", + "monthly": "strftime('%Y-%m', {col})", +} + + +def _weight_sql_expr(magnitude_col: str, unit_col: str, target_unit: str) -> str: + """SQL CASE expression converting weight_magnitude to target_unit. + + Uses Pint to derive conversion factors, so any valid mass unit string is accepted. + + Raises: + ValueError: If target_unit is not a recognized Pint unit + """ + try: + target = ureg.parse_units(target_unit) + except Exception: + raise ValueError(f"Unknown unit: '{target_unit}'") + cases = [] + for db_unit in _DB_UNITS: + factor = float(Q_(1, db_unit).to(target).magnitude) + cases.append(f"WHEN '{db_unit}' THEN {magnitude_col} * {factor}") + return f"CASE {unit_col} {' '.join(cases)} ELSE {magnitude_col} END" + + +def _time_bin_expr(bin: str, col: str = "date") -> str: + """Return a SQL expression for a time bin name. + + Args: + bin: One of "daily", "weekly", "weekly-num", "monthly" + col: The date column name to use in the expression + + Raises: + ValueError: If bin is not a recognized time bin + """ + if bin not in TIME_BINS: + raise ValueError( + f"Unknown time bin '{bin}'. Choose from: {', '.join(TIME_BINS)}" + ) + return TIME_BINS[bin].format(col=col) + + +def parse_plugin_args(params: list[dict], arg_string: str) -> dict: + """Parse --flag value pairs from a string against a param spec. + + Args: + params: List of param dicts with keys: name, type, required, default (optional) + arg_string: Raw argument string (e.g. "--movement kb-swing --bin weekly") + + Returns: + Dict of parsed keyword arguments + + Raises: + ValueError: If required params are missing or unknown flags are given + """ + tokens = shlex.split(arg_string) if arg_string.strip() else [] + parsed = {} + i = 0 + while i < len(tokens): + token = tokens[i] + if token.startswith("--"): + key = token[2:] + param = next((p for p in params if p["name"] == key), None) + if param is None: + raise ValueError(f"Unknown flag: --{key}") + flag = f"--{key}" + elif token.startswith("-") and len(token) == 2: + key = token[1:] + param = next((p for p in params if p.get("short") == key), None) + if param is None: + raise ValueError(f"Unknown flag: -{key}") + flag = f"-{key}" + else: + raise ValueError(f"Unexpected argument: {token}") + if i + 1 >= len(tokens): + raise ValueError(f"{flag} requires a value") + parsed[param["name"]] = param["type"](tokens[i + 1]) + i += 2 + + # Apply defaults and check required + for param in params: + name = param["name"] + if name not in parsed: + if param.get("required", False): + required_names = [ + f"--{p['name']}" for p in params if p.get("required", False) + ] + raise ValueError( + f"Missing required flag(s): {', '.join(required_names)}" + ) + parsed[name] = param.get("default") + + return parsed + + +def plugin_usage(name: str, entry: dict) -> str: + """Generate a usage string for a plugin. + + Args: + name: Plugin name + entry: Registry entry with params list + + Returns: + Formatted usage string (e.g. "volume --movement ") + """ + parts = [name] + for p in entry["params"]: + short = f"-{p['short']}/" if p.get("short") else "" + flag = f"{short}--{p['name']} <{p['name']}>" + if p.get("required", False): + parts.append(flag) + else: + parts.append(f"[{flag}]") + return " ".join(parts) diff --git a/tests/conftest.py b/tests/conftest.py index c866356..25ab84a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -140,7 +140,7 @@ def simple_db(simple_log_file): @pytest.fixture def example_db(): """In-memory SQLite database loaded from the example training log.""" - log = parse_file(Path(__file__).parent.parent / "example" / "example.ox") + log = parse_file(Path(__file__).parent.parent / "examples" / "example.ox") conn = create_db(log) yield conn conn.close() diff --git a/tests/test_data.py b/tests/test_data.py index bf5bcb4..11a73a9 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -6,6 +6,8 @@ - Focus on edge cases and business logic """ +from pathlib import Path + import pytest from datetime import date, time from ox.data import TrainingSet, Movement, TrainingSession, TrainingLog, WeighIn @@ -121,6 +123,83 @@ def test_top_set_weight_bodyweight(self): assert movement.top_set_weight is None +class TestToOxRoundTrip: + """Movement and TrainingSession to_ox() should emit a form that re-parses to an equal object.""" + + def _reparse_movement(self, line: str) -> Movement: + from ox.cli import parse_file + import tempfile + + p = Path(tempfile.mktemp(suffix=".ox")) + p.write_text(f"2025-01-10 * {line}\n") + return parse_file(p).sessions[0].movements[0] + + def test_movement_uniform_weight(self): + m = Movement( + name="bench-press", + sets=[TrainingSet(reps=5, weight=135 * ureg.pound) for _ in range(3)], + note=None, + ) + assert m.to_ox() == "bench-press: 135lb 3x5" + round_tripped = self._reparse_movement(m.to_ox()) + assert round_tripped.name == m.name + assert round_tripped.total_reps == m.total_reps + assert round_tripped.top_set_weight == m.top_set_weight + + def test_movement_bodyweight(self): + m = Movement( + name="pullups", + sets=[TrainingSet(reps=10, weight=None) for _ in range(5)], + note=None, + ) + assert m.to_ox() == "pullups: BW 5x10" + + def test_movement_progressive_weight(self): + m = Movement( + name="squat", + sets=[TrainingSet(reps=5, weight=w * ureg.pound) for w in (135, 185, 225)], + note=None, + ) + # varied weights → progressive form + assert "/" in m.to_ox() + round_tripped = self._reparse_movement(m.to_ox()) + assert [s.weight for s in round_tripped.sets] == [ + 135 * ureg.pound, + 185 * ureg.pound, + 225 * ureg.pound, + ] + + def test_movement_with_note_round_trip(self): + m = Movement( + name="bench-press", + sets=[TrainingSet(reps=5, weight=135 * ureg.pound)], + note="paused", + ) + round_tripped = self._reparse_movement(m.to_ox()) + assert round_tripped.note == "paused" + + def test_session_single_line(self): + m = Movement( + name="pullups", sets=[TrainingSet(reps=10, weight=None)], note=None + ) + s = TrainingSession(date=date(2025, 1, 10), flag="*", name=None, movements=(m,)) + assert s.to_ox() == "2025-01-10 * pullups: BW 1x10" + + def test_session_block(self): + m1 = Movement( + name="bench-press", + sets=[TrainingSet(reps=5, weight=135 * ureg.pound) for _ in range(5)], + note=None, + ) + s = TrainingSession( + date=date(2025, 1, 11), flag="*", name="Upper Day", movements=(m1,) + ) + out = s.to_ox() + assert out.startswith("@session\n2025-01-11 * Upper Day") + assert out.endswith("@end") + assert "bench-press: 135lb 5x5" in out + + class TestTrainingLog: """Test TrainingLog query methods.""" diff --git a/tests/test_db.py b/tests/test_db.py index 0319954..b28f370 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -12,27 +12,22 @@ class TestSchema: """Verify the database schema is created correctly.""" - def test_sessions_table_exists(self, simple_db): + @pytest.mark.parametrize( + "kind,name", + [ + ("table", "sessions"), + ("table", "movements"), + ("table", "sets"), + ("table", "weigh_ins"), + ("table", "queries"), + ("table", "movement_definitions"), + ("table", "movement_tags"), + ("view", "training"), + ], + ) + def test_schema_object_exists(self, simple_db, kind, name): rows = simple_db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'" - ).fetchall() - assert len(rows) == 1 - - def test_movements_table_exists(self, simple_db): - rows = simple_db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='movements'" - ).fetchall() - assert len(rows) == 1 - - def test_sets_table_exists(self, simple_db): - rows = simple_db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='sets'" - ).fetchall() - assert len(rows) == 1 - - def test_training_view_exists(self, simple_db): - rows = simple_db.execute( - "SELECT name FROM sqlite_master WHERE type='view' AND name='training'" + "SELECT name FROM sqlite_master WHERE type=? AND name=?", (kind, name) ).fetchall() assert len(rows) == 1 @@ -110,12 +105,12 @@ def test_movement_names(self, simple_db): ) assert names == ["bench-press", "kb-oh-press", "pullups", "squat"] - def test_session_name_nullable(self, simple_db): - """Single-line entries have no session name.""" + def test_session_name_from_movement(self, simple_db): + """Single-line entries use movement name as session name.""" row = simple_db.execute( "SELECT name FROM sessions WHERE date = '2025-01-10'" ).fetchone() - assert row[0] is None + assert row[0] == "pullups" def test_session_name_present(self, simple_db): row = simple_db.execute( @@ -143,6 +138,21 @@ def test_kg_weight(self, simple_db): assert row[0] == 24.0 assert row[1] == "kilogram" + def test_combined_weight_summed_in_db(self, tmp_path): + """24kg+32kg should land as 56kg per set in the training view.""" + from ox.cli import parse_file + + f = tmp_path / "combined.ox" + f.write_text("2025-01-10 * db-press: 24kg+32kg 5x5\n") + conn = create_db(parse_file(f)) + rows = conn.execute( + "SELECT weight_magnitude, weight_unit FROM training " + "WHERE movement_name = 'db-press'" + ).fetchall() + assert len(rows) == 5 + assert all(r == (56.0, "kilogram") for r in rows) + conn.close() + def test_bodyweight_is_null(self, simple_db): row = simple_db.execute( """SELECT weight_magnitude, weight_unit FROM training @@ -262,87 +272,90 @@ def test_volume_query(self, simple_db): class TestWeighInsTable: - """Verify the weigh_ins table is created and populated correctly.""" - - def test_weigh_ins_table_exists(self, simple_db): - rows = simple_db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='weigh_ins'" - ).fetchall() - assert len(rows) == 1 + """Verify the weigh_ins table is populated correctly.""" - def test_row_count(self, log_with_weigh_ins_file): + @pytest.fixture + def weigh_ins_db(self, log_with_weigh_ins_file): from ox.cli import parse_file - log = parse_file(log_with_weigh_ins_file) - conn = create_db(log) - count = conn.execute("SELECT COUNT(*) FROM weigh_ins").fetchone()[0] - assert count == 3 + conn = create_db(parse_file(log_with_weigh_ins_file)) + yield conn conn.close() - def test_time_of_day_null_when_absent(self, log_with_weigh_ins_file): - from ox.cli import parse_file + def test_row_count(self, weigh_ins_db): + assert weigh_ins_db.execute("SELECT COUNT(*) FROM weigh_ins").fetchone()[0] == 3 - log = parse_file(log_with_weigh_ins_file) - conn = create_db(log) - row = conn.execute( - "SELECT time_of_day FROM weigh_ins WHERE date = '2025-01-10'" + def test_nulls_when_absent(self, weigh_ins_db): + row = weigh_ins_db.execute( + "SELECT time_of_day, scale FROM weigh_ins WHERE date = '2025-01-10'" ).fetchone() - assert row[0] is None - conn.close() + assert row == (None, None) - def test_scale_null_when_absent(self, log_with_weigh_ins_file): - from ox.cli import parse_file - - log = parse_file(log_with_weigh_ins_file) - conn = create_db(log) - row = conn.execute( - "SELECT scale FROM weigh_ins WHERE date = '2025-01-10'" + def test_values_stored_correctly(self, weigh_ins_db): + row = weigh_ins_db.execute( + "SELECT weight_magnitude, weight_unit, time_of_day, scale " + "FROM weigh_ins WHERE date = '2025-01-12'" ).fetchone() - assert row[0] is None - conn.close() + assert row == (84.0, "kilogram", "07:00", "gym scale") + + +class TestMovementDefinitionsTable: + """Verify movement_definitions and movement_tags are populated.""" - def test_values_stored_correctly(self, log_with_weigh_ins_file): + def _db_from_src(self, tmp_path, src): from ox.cli import parse_file - log = parse_file(log_with_weigh_ins_file) - conn = create_db(log) + p = tmp_path / "log.ox" + p.write_text(src) + return create_db(parse_file(p)) + + def test_definition_row_inserted(self, tmp_path): + conn = self._db_from_src( + tmp_path, + "@movement kb-oh-press\n" + "equipment: kettlebell\n" + "tag: press\n" + "url: https://example.com\n" + "note: tight elbow\n" + "@end\n", + ) row = conn.execute( - "SELECT weight_magnitude, weight_unit, time_of_day, scale FROM weigh_ins WHERE date = '2025-01-12'" + "SELECT name, equipment, note, url FROM movement_definitions" ).fetchone() - assert row[0] == 84.0 - assert row[1] == "kilogram" - assert row[2] == "07:00" - assert row[3] == "gym scale" + assert row == ( + "kb-oh-press", + "kettlebell", + "tight elbow", + "https://example.com", + ) + conn.close() + + def test_tags_split_into_rows(self, tmp_path): + conn = self._db_from_src( + tmp_path, + "@movement squat\nequipment: barbell\ntags: squat, lower\n@end\n", + ) + tags = [ + r[0] + for r in conn.execute( + "SELECT tag FROM movement_tags mt " + "JOIN movement_definitions md ON md.id = mt.movement_definition_id " + "WHERE md.name = 'squat' ORDER BY tag" + ).fetchall() + ] + assert tags == ["lower", "squat"] conn.close() class TestQueriesTable: """Verify the queries table is populated from StoredQuery objects.""" - def test_queries_table_exists(self, simple_db): - rows = simple_db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='queries'" - ).fetchall() - assert len(rows) == 1 - def test_stored_query_inserted(self, log_with_query_file): from ox.cli import parse_file - log = parse_file(log_with_query_file) - conn = create_db(log) + conn = create_db(parse_file(log_with_query_file)) rows = conn.execute("SELECT name, sql FROM queries").fetchall() assert len(rows) == 1 assert rows[0][0] == "max-pullups" assert "pullups" in rows[0][1] conn.close() - - def test_stored_query_lookup_by_name(self, log_with_query_file): - from ox.cli import parse_file - - log = parse_file(log_with_query_file) - conn = create_db(log) - row = conn.execute( - "SELECT sql FROM queries WHERE name = ?", ("max-pullups",) - ).fetchone() - assert row is not None - conn.close() diff --git a/tests/test_integration.py b/tests/test_integration.py index dfd847d..7ccadee 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -52,19 +52,11 @@ def test_parse_simple_log(self, simple_log_file): assert "kb-oh-press" in movement_names # Check completed vs planned - assert len(log.completed_sessions) >= 2 - # assert len(log.planned_sessions) >= 1 # TODO: Enable when ! flag parsing is fixed + assert len(log.completed_sessions) == 2 + assert len(log.planned_sessions) == 1 - @pytest.mark.skip(reason="Planned session (! flag) parsing not yet implemented") def test_parse_planned_vs_completed(self, simple_log_file): - """Test that flags are parsed correctly. - - Flags indicate session status: - - * = completed - - ! = planned - - TODO: Enable this test when planned session parsing is implemented. - """ + """Flags are parsed: * = completed, ! = planned.""" log = parse_file(simple_log_file) # First two sessions are completed (*) @@ -99,7 +91,7 @@ def test_parse_example_file(self): """ from pathlib import Path - example_file = Path(__file__).parent.parent / "example" / "example.ox" + example_file = Path(__file__).parent.parent / "examples" / "example.ox" if not example_file.exists(): pytest.skip("Example file not found") @@ -266,6 +258,7 @@ def test_weigh_in_fields(self, log_with_weigh_ins_file): assert w1.scale is None assert w2.date == date(2025, 1, 12) + assert w2.weight == 84 * ureg.kilogram assert w2.time_of_day == time(7, 0) assert w2.scale == "gym scale" diff --git a/tests/test_lsp.py b/tests/test_lsp.py new file mode 100644 index 0000000..c834550 --- /dev/null +++ b/tests/test_lsp.py @@ -0,0 +1,284 @@ +"""Tests for the Language Server Protocol implementation.""" + +from types import SimpleNamespace + +import pytest +from lsprotocol import types as lsp +from tree_sitter import Language, Parser +import tree_sitter_ox + +from ox import lsp as ox_lsp +from ox.lsp import ( + _collect_movement_names, + _cursor_wants_movement, + _get_all_diagnostics, + _validate_includes, + completion, + did_change, + did_open, + did_save, + folding_range, + get_diagnostics, +) + + +def _parse_tree(text: str): + parser = Parser(Language(tree_sitter_ox.language())) + return parser.parse(bytes(text, encoding="utf-8")) + + +class TestGetDiagnostics: + def test_valid_text_no_diagnostics(self): + assert get_diagnostics("2025-01-10 * pullups: BW 5x10\n") == [] + + def test_invalid_unit_produces_error_diagnostic(self): + diags = get_diagnostics("2025-01-10 * bench-press: 135lbs 5x5\n") + assert len(diags) >= 1 + d = diags[0] + assert d.severity == lsp.DiagnosticSeverity.Error + assert d.source == "ox" + + def test_positions_are_zero_based(self): + # Bad entry on line 2 (1-based) of file -> line 1 in LSP + text = "2025-01-10 * pullups: BW 5x10\n2025-01-11 * squat: 225lbs 3x5\n" + diags = get_diagnostics(text) + assert len(diags) >= 1 + assert any(d.range.start.line == 1 for d in diags) + + +class TestValidateIncludes: + def test_missing_include_warns(self, tmp_path): + doc = tmp_path / "main.ox" + doc.write_text('@include "missing.ox"\n') + tree = _parse_tree(doc.read_text()) + diags = _validate_includes(tree, f"file://{doc}") + assert len(diags) == 1 + assert diags[0].severity == lsp.DiagnosticSeverity.Warning + assert "missing.ox" in diags[0].message + + def test_existing_include_no_diagnostic(self, tmp_path): + doc = tmp_path / "main.ox" + other = tmp_path / "other.ox" + other.write_text("") + doc.write_text('@include "other.ox"\n') + tree = _parse_tree(doc.read_text()) + diags = _validate_includes(tree, f"file://{doc}") + assert diags == [] + + def test_relative_path_resolved_against_doc_dir(self, tmp_path): + sub = tmp_path / "sub" + sub.mkdir() + doc = sub / "main.ox" + (tmp_path / "other.ox").write_text("") + doc.write_text('@include "../other.ox"\n') + tree = _parse_tree(doc.read_text()) + diags = _validate_includes(tree, f"file://{doc}") + assert diags == [] + + +class TestGetAllDiagnostics: + def test_combines_parse_and_include(self, tmp_path): + doc = tmp_path / "main.ox" + doc.write_text('@include "nope.ox"\n2025-01-10 * squat: 1lbs 5x5\n') + diags = _get_all_diagnostics(doc.read_text(), f"file://{doc}") + severities = {d.severity for d in diags} + assert lsp.DiagnosticSeverity.Error in severities + assert lsp.DiagnosticSeverity.Warning in severities + + +class TestCollectMovementNames: + def test_collects_from_singleline(self): + tree = _parse_tree("2025-01-10 * pullups: BW 5x10\n") + assert _collect_movement_names(tree) == {"pullups"} + + def test_collects_from_session_block(self): + text = ( + "@session\n" + "2025-01-11 * Upper Day\n" + "bench-press: 135lb 5x5\n" + "pullups: BW 5x10\n" + "@end\n" + ) + assert _collect_movement_names(_parse_tree(text)) == {"bench-press", "pullups"} + + def test_collects_from_template(self): + text = '@template "t"\nsquat: 225lb 3x5\n@end\n' + assert _collect_movement_names(_parse_tree(text)) == {"squat"} + + def test_dedupes_across_entries(self): + text = "2025-01-10 * pullups: BW 5x10\n2025-01-11 * pullups: BW 5x10\n" + assert _collect_movement_names(_parse_tree(text)) == {"pullups"} + + +class TestCursorWantsMovement: + def test_after_singleline_prefix_true(self): + text = "2025-01-10 * \n" + tree = _parse_tree(text) + assert _cursor_wants_movement(text, 0, 13, tree) is True + + def test_before_flag_false(self): + text = "2025-01-10 * pullups: BW 5x10\n" + tree = _parse_tree(text) + assert _cursor_wants_movement(text, 0, 0, tree) is False + + def test_inside_session_item_line_true(self): + text = "@session\n2025-01-11 * Upper Day\n\n@end\n" + tree = _parse_tree(text) + assert _cursor_wants_movement(text, 2, 0, tree) is True + + def test_on_session_header_line_false(self): + text = "@session\n2025-01-11 * Upper Day\nbench-press: 135lb 5x5\n@end\n" + tree = _parse_tree(text) + # Header line is row 1 + assert _cursor_wants_movement(text, 1, 15, tree) is False + + def test_at_directive_line_false(self): + text = "@session\n2025-01-11 * Upper Day\n@end\n" + tree = _parse_tree(text) + assert _cursor_wants_movement(text, 0, 2, tree) is False + + def test_note_line_false(self): + text = "@session\n2025-01-11 * Upper Day\nnote: feeling tired\n@end\n" + tree = _parse_tree(text) + assert _cursor_wants_movement(text, 2, 2, tree) is False + + def test_line_out_of_range_false(self): + text = "2025-01-10 * pullups: BW 5x10\n" + tree = _parse_tree(text) + assert _cursor_wants_movement(text, 99, 0, tree) is False + + +@pytest.fixture +def captured_publish(monkeypatch): + calls: list[tuple[str, list[lsp.Diagnostic]]] = [] + + def fake_publish(uri, diagnostics): + calls.append((uri, diagnostics)) + + monkeypatch.setattr(ox_lsp, "publish_diagnostics", fake_publish) + return calls + + +class TestDidOpen: + def test_publishes_diagnostics(self, captured_publish, tmp_path): + text = "2025-01-10 * bench-press: 135lbs 5x5\n" + uri = f"file://{tmp_path / 'a.ox'}" + params = lsp.DidOpenTextDocumentParams( + text_document=lsp.TextDocumentItem( + uri=uri, language_id="ox", version=1, text=text + ) + ) + did_open(params) + assert len(captured_publish) == 1 + pub_uri, diags = captured_publish[0] + assert pub_uri == uri + assert diags == _get_all_diagnostics(text, uri) + + +@pytest.fixture +def stub_workspace(monkeypatch): + documents: dict[str, str] = {} + + def get_doc(uri): + return SimpleNamespace(source=documents[uri]) + + fake_server = SimpleNamespace(workspace=SimpleNamespace(get_text_document=get_doc)) + monkeypatch.setattr(ox_lsp, "server", fake_server) + return documents + + +class TestDidChangeAndSave: + def test_did_change_publishes(self, captured_publish, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = "2025-01-10 * squat: 225lbs 3x5\n" + params = lsp.DidChangeTextDocumentParams( + text_document=lsp.VersionedTextDocumentIdentifier(uri=uri, version=2), + content_changes=[], + ) + did_change(params) + assert len(captured_publish) == 1 + assert captured_publish[0][0] == uri + assert len(captured_publish[0][1]) >= 1 + + def test_did_save_publishes(self, captured_publish, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = "2025-01-10 * pullups: BW 5x10\n" + params = lsp.DidSaveTextDocumentParams( + text_document=lsp.TextDocumentIdentifier(uri=uri) + ) + did_save(params) + assert len(captured_publish) == 1 + assert captured_publish[0][1] == [] + + +class TestFoldingRange: + def test_ranges_between_comments(self, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = ( + "# Section A\n" + "2025-01-10 * pullups: BW 5x10\n" + "2025-01-11 * pullups: BW 5x10\n" + "# Section B\n" + "2025-01-12 * pullups: BW 5x10\n" + ) + params = lsp.FoldingRangeParams( + text_document=lsp.TextDocumentIdentifier(uri=uri) + ) + ranges = folding_range(params) + assert len(ranges) == 2 + assert ranges[0].start_line == 0 + assert ranges[0].end_line == 2 + assert ranges[1].start_line == 3 + assert ranges[1].end_line == 4 + + def test_adjacent_comments_no_range(self, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = "# A\n# B\n2025-01-10 * pullups: BW 5x10\n" + params = lsp.FoldingRangeParams( + text_document=lsp.TextDocumentIdentifier(uri=uri) + ) + ranges = folding_range(params) + # First comment has no body (adjacent to next comment), second spans the rest + assert len(ranges) == 1 + assert ranges[0].start_line == 1 + + def test_trailing_blanks_trimmed(self, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = "# A\n2025-01-10 * pullups: BW 5x10\n\n\n" + params = lsp.FoldingRangeParams( + text_document=lsp.TextDocumentIdentifier(uri=uri) + ) + ranges = folding_range(params) + assert len(ranges) == 1 + assert ranges[0].end_line == 1 + + +class TestCompletion: + def test_returns_movements_in_context(self, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = ( + "2025-01-10 * pullups: BW 5x10\n" + "2025-01-11 * bench-press: 135lb 5x5\n" + "2025-01-12 * \n" + ) + params = lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=2, character=13), + ) + result = completion(params) + labels = [i.label for i in result.items] + assert labels == sorted(labels) + assert set(labels) == {"pullups", "bench-press"} + for item in result.items: + assert item.insert_text.endswith(": ") + assert item.kind == lsp.CompletionItemKind.Value + + def test_empty_outside_context(self, stub_workspace, tmp_path): + uri = f"file://{tmp_path / 'a.ox'}" + stub_workspace[uri] = "2025-01-10 * pullups: BW 5x10\n" + params = lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=0, character=0), + ) + result = completion(params) + assert result.items == [] diff --git a/tests/test_parse.py b/tests/test_parse.py index da2d0d3..e943b68 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -7,68 +7,33 @@ """ import pytest + from ox.parse import weight_text_to_quantity, process_weights from ox.units import ureg class TestWeightTextToQuantity: - """Test parsing individual weight strings. - - This is the lowest-level parsing function. - """ - - def test_parse_kg(self): - """Test parsing kilogram weights.""" - result = weight_text_to_quantity("24kg") - assert result == 24 * ureg.kilogram - - def test_parse_lb(self): - """Test parsing pound weights.""" - result = weight_text_to_quantity("135lb") - assert result == 135 * ureg.pound - - def test_parse_gram(self): - """Test parsing gram weights.""" - result = weight_text_to_quantity("500g") - assert result == 500 * ureg.gram - - def test_parse_ounce(self): - """Test parsing ounce weights.""" - result = weight_text_to_quantity("16oz") - assert result == 16 * ureg.ounce - - def test_parse_stone(self): - """Test parsing stone weights.""" - result = weight_text_to_quantity("12stone") - assert result == 12 * ureg.stone - - def test_parse_pound_alias(self): - """Test parsing 'pound' as long-form unit.""" - result = weight_text_to_quantity("135pound") - assert result == 135 * ureg.pound - - def test_parse_kilogram_alias(self): - """Test parsing 'kilogram' as long-form unit.""" - result = weight_text_to_quantity("24kilogram") - assert result == 24 * ureg.kilogram - - def test_parse_decimal_weight(self): - """Test parsing decimal weights.""" - result = weight_text_to_quantity("2.5kg") - assert result == 2.5 * ureg.kilogram - - def test_rejects_non_mass_unit(self): - """Test that non-mass units are rejected.""" - assert weight_text_to_quantity("100m") is None - assert weight_text_to_quantity("30min") is None - - def test_parse_invalid(self): - """Test invalid weight strings return None.""" - # No unit - assert weight_text_to_quantity("100") is None - - # Invalid format - assert weight_text_to_quantity("abc") is None + """Test parsing individual weight strings.""" + + @pytest.mark.parametrize( + "text,magnitude,unit", + [ + ("24kg", 24, "kilogram"), + ("135lb", 135, "pound"), + ("500g", 500, "gram"), + ("16oz", 16, "ounce"), + ("12stone", 12, "stone"), + ("135pound", 135, "pound"), + ("24kilogram", 24, "kilogram"), + ("2.5kg", 2.5, "kilogram"), + ], + ) + def test_valid(self, text, magnitude, unit): + assert weight_text_to_quantity(text) == magnitude * ureg.parse_units(unit) + + @pytest.mark.parametrize("text", ["100m", "30min", "100", "abc"]) + def test_invalid_returns_none(self, text): + assert weight_text_to_quantity(text) is None class TestProcessWeights: @@ -110,15 +75,8 @@ def test_progressive_weights_explicit_units(self): assert result[1] == 32 * ureg.kilogram assert result[2] == 48 * ureg.kilogram - @pytest.mark.xfail(reason="Known bug: unit not implied across slashes") def test_progressive_weights_implied_unit(self): - """Test progressive weights with implied unit. - - Example: 160/185/210lbs means three weights, all in lbs. - - This is currently BROKEN - the parser doesn't handle implied units. - Marking as xfail so we know it's a known issue. - """ + """Progressive weights inherit the nearest succeeding unit.""" result = process_weights("160/185/210lb") assert len(result) == 3 @@ -126,6 +84,25 @@ def test_progressive_weights_implied_unit(self): assert result[1] == 185 * ureg.pound assert result[2] == 210 * ureg.pound + def test_progressive_weights_mixed_implied_units(self): + """Mixed implied/explicit units: each unitless segment takes the next unit.""" + result = process_weights("60/70kg/160/180lb") + + assert len(result) == 4 + assert result[0] == 60 * ureg.kilogram + assert result[1] == 70 * ureg.kilogram + assert result[2] == 160 * ureg.pound + assert result[3] == 180 * ureg.pound + + def test_progressive_weights_bw_with_implied_unit(self): + """BW segments pass through while implied units resolve.""" + result = process_weights("BW/5/10lb") + + assert len(result) == 3 + assert result[0] is None + assert result[1] == 5 * ureg.pound + assert result[2] == 10 * ureg.pound + def test_combined_and_progressive(self): """Test mixing combined and progressive weights. @@ -192,32 +169,12 @@ def _parse_str(content: str): class TestDurationToken: """Test that ISO 8601 PT duration strings are accepted by the grammar.""" - def test_minutes_only(self): - _, diags = _parse_str("2025-01-10 * run: PT30M\n") - assert not diags - - def test_minutes_and_seconds(self): - _, diags = _parse_str("2025-01-10 * run: PT30M15S\n") - assert not diags - - def test_hours_only(self): - _, diags = _parse_str("2025-01-10 * run: PT1H\n") - assert not diags - - def test_hours_and_minutes(self): - _, diags = _parse_str("2025-01-10 * run: PT1H30M\n") - assert not diags - - def test_hours_minutes_seconds(self): - _, diags = _parse_str("2025-01-10 * run: PT1H30M15S\n") - assert not diags - - def test_fractional_seconds(self): - _, diags = _parse_str("2025-01-10 * run: PT30M15.5S\n") - assert not diags - - def test_seconds_only(self): - _, diags = _parse_str("2025-01-10 * run: PT45S\n") + @pytest.mark.parametrize( + "duration", + ["PT30M", "PT30M15S", "PT1H", "PT1H30M", "PT1H30M15S", "PT30M15.5S", "PT45S"], + ) + def test_accepted(self, duration): + _, diags = _parse_str(f"2025-01-10 * run: {duration}\n") assert not diags def test_old_time_format_rejected(self): @@ -226,75 +183,96 @@ def test_old_time_format_rejected(self): class TestWeighInEntry: - """Test that weigh_in_entry nodes parse correctly.""" - - def test_weight_only(self): - _, diags = _parse_str("2025-01-10 W 185lb\n") + """Grammar accepts weigh-in forms without producing diagnostics.""" + + @pytest.mark.parametrize( + "line", + [ + "2025-01-10 W 185lb\n", + "2025-01-10 W 185lb T06:30\n", + '2025-01-10 W 185lb "bathroom scale"\n', + '2025-01-10 W 83.5kg T06:30 "home scale"\n', + "2025-01-10 W 83.5kg\n", + ], + ) + def test_accepted(self, line): + _, diags = _parse_str(line) assert not diags - def test_weight_with_timestamp(self): - _, diags = _parse_str("2025-01-10 W 185lb T06:30\n") - assert not diags - def test_weight_with_scale(self): - _, diags = _parse_str('2025-01-10 W 185lb "bathroom scale"\n') - assert not diags +class TestBlockDirectives: + """Grammar accepts top-level @-directives without diagnostics. - def test_weight_with_timestamp_and_scale(self): - _, diags = _parse_str('2025-01-10 W 83.5kg T06:30 "home scale"\n') - assert not diags + These aren't yet promoted to data structures, but the grammar must not reject them. + """ - def test_kg_weight(self): - _, diags = _parse_str("2025-01-10 W 83.5kg\n") + def test_movement_block(self): + src = ( + "@movement squat\n" + "equipment: barbell\n" + "tags: squat, lower\n" + "note: back squat\n" + "@end\n" + ) + _, diags = _parse_str(src) assert not diags + def test_template_block(self): + src = "@template upper\nbench-press: 135lb 5x5\n@end\n" + _, diags = _parse_str(src) + assert not diags -class TestProcessWeighInEntry: - """Test process_weigh_in_entry via parse_file.""" - - def test_date(self, log_with_weigh_ins_file): - from datetime import date - from ox.cli import parse_file - - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[0].date == date(2025, 1, 10) - - def test_weight_magnitude_and_unit(self, log_with_weigh_ins_file): - from ox.cli import parse_file - - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[0].weight == 185 * ureg.pound - - def test_time_of_day_absent(self, log_with_weigh_ins_file): - from ox.cli import parse_file - - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[0].time_of_day is None - - def test_time_of_day_present(self, log_with_weigh_ins_file): - from datetime import time - from ox.cli import parse_file - - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[1].time_of_day == time(6, 30) - - def test_scale_absent(self, log_with_weigh_ins_file): - from ox.cli import parse_file + def test_plugin_directive(self): + _, diags = _parse_str('@plugin "my_plugin.py"\n') + assert not diags - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[0].scale is None + def test_include_directive(self): + _, diags = _parse_str('@include "other.ox"\n') + assert not diags - def test_scale_present(self, log_with_weigh_ins_file): - from ox.cli import parse_file - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[2].scale == "gym scale" +class TestMovementDefinitionParsing: + """Test that @movement blocks are parsed into MovementDefinition objects.""" - def test_kg_weight(self, log_with_weigh_ins_file): + def _parse_log(self, tmp_path, src): from ox.cli import parse_file - log = parse_file(log_with_weigh_ins_file) - assert log.weigh_ins[2].weight == 84 * ureg.kilogram + p = tmp_path / "log.ox" + p.write_text(src) + return parse_file(p) + + def test_single_definition(self, tmp_path): + log = self._parse_log( + tmp_path, + "@movement kb-oh-press\n" + "equipment: kettlebell\n" + "tag: press\n" + "url: https://example.com/kb-press\n" + "note: keep elbow tight\n" + "@end\n", + ) + assert len(log.movement_definitions) == 1 + m = log.movement_definitions[0] + assert m.name == "kb-oh-press" + assert m.equipment == "kettlebell" + assert m.tags == ("press",) + assert m.note == "keep elbow tight" + assert m.url == "https://example.com/kb-press" + + def test_tags_plural_comma_separated(self, tmp_path): + log = self._parse_log( + tmp_path, + "@movement squat\nequipment: barbell\ntags: squat, lower\n@end\n", + ) + assert log.movement_definitions[0].tags == ("squat", "lower") + + def test_no_metadata(self, tmp_path): + log = self._parse_log(tmp_path, "@movement burpee\n@end\n") + m = log.movement_definitions[0] + assert m.name == "burpee" + assert m.equipment is None + assert m.tags == () + assert m.note is None class TestQueryEntryParsing: diff --git a/tests/test_plot.py b/tests/test_plot.py new file mode 100644 index 0000000..f19adee --- /dev/null +++ b/tests/test_plot.py @@ -0,0 +1,110 @@ +"""Tests for the plot facade (src/ox/plot.py).""" + +from ox import plot + + +def _text(lines): + return "\n".join(lines) + + +# --- scatter --- + + +class TestScatter: + def test_empty_returns_sentinel(self): + assert plot.scatter([], [], y_label="x") == ["Not enough data to plot."] + + def test_single_point_returns_sentinel(self): + result = plot.scatter(["2025-01-01"], [10.0], y_label="x") + assert result == ["Not enough data to plot."] + + def test_y_label_in_output(self): + lines = plot.scatter( + ["2025-01-01", "2025-02-01", "2025-03-01"], + [100.0, 110.0, 120.0], + y_label="e1rm (lb)", + ) + assert "e1rm (lb)" in _text(lines) + + def test_first_and_last_dates_represented(self): + """First and last dates should appear somewhere on axis — guards #49 clipping.""" + lines = plot.scatter( + ["2025-01-15", "2025-02-15", "2025-03-15"], + [100.0, 110.0, 120.0], + y_label="v", + ) + text = _text(lines) + # mm-dd of first OR last should appear + assert "01-15" in text or "03-15" in text + + def test_returns_nonempty_rows(self): + lines = plot.scatter(["2025-01-01", "2025-02-01"], [50.0, 60.0], y_label="v") + assert len(lines) > 3 + + +# --- multi_series --- + + +class TestMultiSeries: + def test_empty_series_returns_sentinel(self): + assert plot.multi_series([], y_label="v") == ["Not enough data to plot."] + + def test_all_empty_series_returns_sentinel(self): + series = [plot.Series(label="a", dates=[], values=[])] + assert plot.multi_series(series, y_label="v") == ["Not enough data to plot."] + + def test_single_point_across_all_series_returns_sentinel(self): + series = [plot.Series(label="a", dates=["2025-01-01"], values=[10.0])] + assert plot.multi_series(series, y_label="v") == ["Not enough data to plot."] + + def test_labels_appear_in_output(self): + series = [ + plot.Series( + label="home", + dates=["2025-01-01", "2025-02-01"], + values=[180.0, 181.0], + ), + plot.Series( + label="rolling", + dates=["2025-01-01", "2025-02-01"], + values=[180.5, 180.8], + style="line", + ), + ] + text = _text(plot.multi_series(series, y_label="weight (lb)")) + assert "home" in text + assert "rolling" in text + assert "weight (lb)" in text + + def test_mixed_series_lengths_ok(self): + series = [ + plot.Series( + label="a", dates=["2025-01-01", "2025-01-15"], values=[1.0, 2.0] + ), + plot.Series(label="b", dates=["2025-01-10"], values=[1.5], style="line"), + ] + lines = plot.multi_series(series, y_label="v") + assert len(lines) > 3 + + +# --- bar --- + + +class TestBar: + def test_empty_returns_sentinel(self): + assert plot.bar([], [], y_label="v") == ["Not enough data to plot."] + + def test_single_bar_returns_sentinel(self): + assert plot.bar(["a"], [1.0], y_label="v") == ["Not enough data to plot."] + + def test_y_label_in_output(self): + lines = plot.bar( + ["2025-W01", "2025-W02", "2025-W03"], + [100.0, 200.0, 150.0], + y_label="total AU (weekly)", + ) + assert "total AU (weekly)" in _text(lines) + + def test_returns_nonempty_rows(self): + lines = plot.bar(["a", "b", "c"], [1.0, 2.0, 3.0], y_label="v") + assert len(lines) > 3 diff --git a/tests/test_plugins.py b/tests/test_plugins.py index a2b1d13..7d623a6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -2,199 +2,176 @@ import textwrap +from ox.data import TrainingLog from ox.plugins import ( - GENERATOR_PLUGINS, - REPORT_PLUGINS, - _load_from_directory, + PLUGINS, + _load_from_log_directives, _register_descriptors, load_plugins, ) -from ox.reports import REPORTS, get_all_reports, report_usage +from ox.sql_utils import plugin_usage -def _dummy_report(conn, movement="x"): +def _dummy_fn(ctx, movement="x"): return ["col"], [("row",)] -def _dummy_generator(movement="x"): - return "output" +def _make_log(plugin_paths: tuple[str, ...]) -> TrainingLog: + return TrainingLog( + sessions=(), + notes=(), + diagnostics=(), + queries=(), + weigh_ins=(), + plugin_paths=plugin_paths, + ) class TestRegisterDescriptors: """Test descriptor routing and validation.""" def setup_method(self): - REPORT_PLUGINS.clear() - GENERATOR_PLUGINS.clear() + PLUGINS.clear() - def test_routes_report(self): + def test_registers_plugin(self): _register_descriptors( [ { - "type": "report", - "name": "test-report", - "fn": _dummy_report, + "name": "test-plugin", + "fn": _dummy_fn, "description": "A test", "params": [], } ], "test", ) - assert "test-report" in REPORT_PLUGINS - assert GENERATOR_PLUGINS == {} - - def test_routes_generator(self): - _register_descriptors( - [ - { - "type": "generator", - "name": "test-gen", - "fn": _dummy_generator, - "description": "A test", - "params": [], - } - ], - "test", - ) - assert "test-gen" in GENERATOR_PLUGINS - assert REPORT_PLUGINS == {} + assert "test-plugin" in PLUGINS def test_skips_missing_fn(self): _register_descriptors( - [{"type": "report", "name": "bad", "description": "no fn"}], + [{"name": "bad", "description": "no fn"}], "test", ) - assert REPORT_PLUGINS == {} + assert PLUGINS == {} def test_skips_missing_name(self): _register_descriptors( - [{"type": "report", "fn": _dummy_report}], + [{"fn": _dummy_fn}], "test", ) - assert REPORT_PLUGINS == {} - - def test_skips_missing_type(self): - _register_descriptors( - [{"name": "bad", "fn": _dummy_report}], - "test", - ) - assert REPORT_PLUGINS == {} - - def test_skips_unknown_type(self): - _register_descriptors( - [{"type": "widget", "name": "bad", "fn": _dummy_report}], - "test", - ) - assert REPORT_PLUGINS == {} - assert GENERATOR_PLUGINS == {} + assert PLUGINS == {} def test_name_collision_overwrites(self): desc = { - "type": "report", "name": "dup", - "fn": _dummy_report, + "fn": _dummy_fn, "description": "first", "params": [], } _register_descriptors([desc], "first-source") - assert REPORT_PLUGINS["dup"]["description"] == "first" + assert PLUGINS["dup"]["description"] == "first" desc2 = {**desc, "description": "second"} _register_descriptors([desc2], "second-source") - assert REPORT_PLUGINS["dup"]["description"] == "second" + assert PLUGINS["dup"]["description"] == "second" def test_multiple_descriptors_in_one_call(self): _register_descriptors( [ { - "type": "report", - "name": "r1", - "fn": _dummy_report, - "description": "r", + "name": "p1", + "fn": _dummy_fn, + "description": "first", "params": [], }, { - "type": "generator", - "name": "g1", - "fn": _dummy_generator, - "description": "g", + "name": "p2", + "fn": _dummy_fn, + "description": "second", "params": [], }, ], "test", ) - assert "r1" in REPORT_PLUGINS - assert "g1" in GENERATOR_PLUGINS + assert "p1" in PLUGINS + assert "p2" in PLUGINS -class TestLoadFromDirectory: - """Test file-based plugin discovery.""" +class TestLoadFromLogDirectives: + """Test @plugin directive discovery.""" def setup_method(self): - REPORT_PLUGINS.clear() - GENERATOR_PLUGINS.clear() + PLUGINS.clear() - def test_loads_plugin_from_directory(self, tmp_path, monkeypatch): + def test_loads_plugin_from_directive(self, tmp_path): plugin_code = textwrap.dedent("""\ - def _my_fn(conn, x="y"): + def _my_fn(ctx, x="y"): return ["col"], [("row",)] def register(): return [ { - "type": "report", - "name": "from-dir", + "name": "from-directive", "fn": _my_fn, - "description": "loaded from dir", + "description": "loaded from @plugin", "params": [], } ] """) (tmp_path / "my_plugin.py").write_text(plugin_code) - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - _load_from_directory() - assert "from-dir" in REPORT_PLUGINS + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("my_plugin.py",)) + _load_from_log_directives(log, base) + assert "from-directive" in PLUGINS - def test_ignores_file_without_register(self, tmp_path, monkeypatch): + def test_ignores_file_without_register(self, tmp_path): (tmp_path / "no_register.py").write_text("x = 1\n") - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - _load_from_directory() - assert REPORT_PLUGINS == {} + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("no_register.py",)) + _load_from_log_directives(log, base) + assert PLUGINS == {} - def test_handles_register_error(self, tmp_path, monkeypatch): + def test_handles_register_error(self, tmp_path): plugin_code = textwrap.dedent("""\ def register(): raise RuntimeError("boom") """) (tmp_path / "bad_plugin.py").write_text(plugin_code) - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - _load_from_directory() - assert REPORT_PLUGINS == {} + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("bad_plugin.py",)) + _load_from_log_directives(log, base) + assert PLUGINS == {} - def test_handles_import_error(self, tmp_path, monkeypatch): + def test_handles_import_error(self, tmp_path): (tmp_path / "broken.py").write_text("import nonexistent_module_xyz\n") - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - _load_from_directory() - assert REPORT_PLUGINS == {} + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("broken.py",)) + _load_from_log_directives(log, base) + assert PLUGINS == {} - def test_nonexistent_directory(self, tmp_path, monkeypatch): - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path / "nope") - _load_from_directory() - assert REPORT_PLUGINS == {} + def test_missing_file(self, tmp_path): + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("does_not_exist.py",)) + _load_from_log_directives(log, base) + assert PLUGINS == {} class TestLoadPlugins: """Test the top-level load_plugins function.""" - def test_idempotent(self, tmp_path, monkeypatch): + def test_idempotent(self, tmp_path): plugin_code = textwrap.dedent("""\ - def _fn(conn): + def _fn(ctx): return [], [] def register(): return [ { - "type": "report", "name": "idem", "fn": _fn, "description": "test", @@ -203,38 +180,25 @@ def register(): ] """) (tmp_path / "idem.py").write_text(plugin_code) - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - - load_plugins() - assert "idem" in REPORT_PLUGINS + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("idem.py",)) - load_plugins() - assert "idem" in REPORT_PLUGINS + load_plugins(log, base) + assert "idem" in PLUGINS - def test_builtin_e1rm_registered(self, tmp_path, monkeypatch): - """load_plugins() registers the built-in e1rm report.""" - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - load_plugins() - assert "e1rm" in REPORT_PLUGINS - assert REPORT_PLUGINS["e1rm"]["type"] == "report" + load_plugins(log, base) + assert "idem" in PLUGINS - def test_builtin_wendler531_registered(self, tmp_path, monkeypatch): - """load_plugins() registers the built-in wendler531 generator.""" - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) - load_plugins() - assert "wendler531" in GENERATOR_PLUGINS - assert GENERATOR_PLUGINS["wendler531"]["type"] == "generator" - - def test_clears_previous(self, tmp_path, monkeypatch): + def test_clears_previous(self, tmp_path): """After removing a plugin file, reload should not keep stale entries.""" plugin_code = textwrap.dedent("""\ - def _fn(conn): + def _fn(ctx): return [], [] def register(): return [ { - "type": "report", "name": "gone", "fn": _fn, "description": "test", @@ -244,61 +208,23 @@ def register(): """) plugin_file = tmp_path / "gone.py" plugin_file.write_text(plugin_code) - monkeypatch.setattr("ox.plugins.PLUGIN_DIR", tmp_path) + base = tmp_path / "log.ox" + base.write_text("") + log = _make_log(("gone.py",)) - load_plugins() - assert "gone" in REPORT_PLUGINS + load_plugins(log, base) + assert "gone" in PLUGINS plugin_file.unlink() - load_plugins() - assert "gone" not in REPORT_PLUGINS - - -class TestGetAllReports: - """Test merging built-in reports with plugin reports.""" + load_plugins(log, base) + assert "gone" not in PLUGINS - def setup_method(self): - REPORT_PLUGINS.clear() - - def test_returns_builtins_when_no_plugins(self): - result = get_all_reports() - assert "volume" in result - assert "matrix" in result - - def test_merges_plugin_reports(self): - REPORT_PLUGINS["custom"] = { - "type": "report", - "name": "custom", - "fn": _dummy_report, - "description": "Custom report", - "params": [{"name": "x", "type": str, "required": True}], - } - result = get_all_reports() - assert "volume" in result - assert "custom" in result - assert result["custom"]["fn"] is _dummy_report - - def test_does_not_mutate_builtins(self): - REPORT_PLUGINS["custom"] = { - "type": "report", - "name": "custom", - "fn": _dummy_report, - "description": "Custom report", - "params": [], - } - get_all_reports() - assert "custom" not in REPORTS +class TestPluginUsageCommand: + """Test plugin_usage output format.""" -class TestReportUsageCommand: - """Test that report_usage respects the command parameter.""" - - def test_default_command(self): - entry = {"params": [{"name": "x", "type": str, "required": True}]} - usage = report_usage("test", entry) - assert usage.startswith("report test") - - def test_generate_command(self): + def test_starts_with_name(self): entry = {"params": [{"name": "x", "type": str, "required": True}]} - usage = report_usage("test", entry, command="generate") - assert usage.startswith("generate test") + usage = plugin_usage("test", entry) + assert usage.startswith("test ") + assert "run " not in usage diff --git a/tests/test_reports.py b/tests/test_reports.py index 4955806..6818b5c 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -1,17 +1,11 @@ -"""Tests for the reports module.""" +"""Tests for SQL utilities and builtin plugins.""" import pytest from ox.data import TrainingLog -from ox.db import create_db -from ox.reports import ( - REPORTS, - _time_bin_expr, - parse_report_args, - report_usage, - session_matrix, - volume_over_time, -) +from ox.plugins import PLUGINS, PluginContext, TableResult, load_plugins +from ox.sql_utils import _time_bin_expr, parse_plugin_args, plugin_usage +from ox.builtins.volume import volume class TestTimeBins: @@ -41,10 +35,18 @@ def test_invalid_raises(self): class TestVolumeOverTime: - """Test the volume report.""" + """Test the volume plugin.""" + + def _run(self, db, log=None, **kwargs): + if log is None: + log = TrainingLog(sessions=()) + ctx = PluginContext(db=db, log=log) + result = volume(ctx, **kwargs) + assert isinstance(result, TableResult) + return result.columns, result.rows def test_columns(self, example_db): - columns, _ = volume_over_time(example_db, "squat") + columns, _ = self._run(example_db, movement="squat") assert columns == [ "period", "total_volume (lb)", @@ -53,7 +55,7 @@ def test_columns(self, example_db): ] def test_weekly_grouping(self, example_db): - _, rows = volume_over_time(example_db, "squat", bin="weekly") + _, rows = self._run(example_db, movement="squat", bin="weekly") # squat appears in many weeks in example.ox assert len(rows) > 1 # Each row's period should be a date string (Sunday of that week) @@ -65,31 +67,31 @@ def test_weekly_period_is_sunday(self, example_db): """Weekly bin periods should fall on a Sunday.""" import datetime - _, rows = volume_over_time(example_db, "squat", bin="weekly") + _, rows = self._run(example_db, movement="squat", bin="weekly") for row in rows: dt = datetime.date.fromisoformat(row[0]) assert dt.weekday() == 6, f"{row[0]} is not a Sunday" def test_weekly_num_grouping(self, example_db): - _, rows = volume_over_time(example_db, "squat", bin="weekly-num") + _, rows = self._run(example_db, movement="squat", bin="weekly-num") assert len(rows) > 1 for row in rows: assert row[0].startswith("2024-W") def test_monthly_grouping(self, example_db): - _, rows = volume_over_time(example_db, "squat", bin="monthly") + _, rows = self._run(example_db, movement="squat", bin="monthly") assert len(rows) > 1 for row in rows: assert row[0].startswith("2024-") assert len(row[0]) == 7 # "2024-01" def test_daily_grouping(self, example_db): - _, rows = volume_over_time(example_db, "squat", bin="daily") + _, rows = self._run(example_db, movement="squat", bin="daily") for row in rows: assert len(row[0]) == 10 # "2024-01-15" def test_single_movement_filter(self, example_db): - _, rows = volume_over_time(example_db, "squat") + _, rows = self._run(example_db, movement="squat") # All rows should have non-None volume (squat always has weight) for row in rows: assert row[1] is not None # total_volume @@ -97,12 +99,12 @@ def test_single_movement_filter(self, example_db): def test_bodyweight_movement(self, example_db): """Bodyweight movements have NULL volume since weight_magnitude is NULL.""" - _, rows = volume_over_time(example_db, "pullup") + _, rows = self._run(example_db, movement="pullup") # Some pullup sets are bodyweight (NULL magnitude), so volume may be None assert len(rows) > 0 def test_nonexistent_movement(self, example_db): - _, rows = volume_over_time(example_db, "nonexistent-exercise") + _, rows = self._run(example_db, movement="nonexistent-exercise") assert rows == [] def test_volume_values(self, simple_db): @@ -111,7 +113,7 @@ def test_volume_values(self, simple_db): simple_log has bench-press: 135lbs 5x5 on 2025-01-11. Total volume = 135 * 25 = 3375. """ - _, rows = volume_over_time(simple_db, "bench-press", bin="daily") + _, rows = self._run(simple_db, movement="bench-press", bin="daily") assert len(rows) == 1 assert rows[0][0] == "2025-01-11" assert rows[0][1] == 3375.0 # total_volume @@ -119,59 +121,7 @@ def test_volume_values(self, simple_db): assert rows[0][3] == 135.0 # avg_weight_per_rep -class TestSessionMatrix: - """Test the session matrix report.""" - - def test_first_column_is_period(self, example_db): - columns, _ = session_matrix(example_db) - assert columns[0] == "period" - - def test_movement_columns_present(self, example_db): - columns, _ = session_matrix(example_db) - # squat and bench-press are in example.ox - assert "squat" in columns - assert "bench-press" in columns - - def test_movements_sorted_by_frequency(self, example_db): - columns, _ = session_matrix(example_db) - movement_cols = columns[1:] - # Most frequent movement should be first - assert len(movement_cols) > 1 - - def test_rows_have_correct_length(self, example_db): - columns, rows = session_matrix(example_db) - for row in rows: - assert len(row) == len(columns) - - def test_cells_are_integers(self, example_db): - _, rows = session_matrix(example_db) - for row in rows: - for cell in row[1:]: # skip period string - assert isinstance(cell, int) - - def test_zero_fill_for_missing(self, example_db): - """Movements not in a period should have 0, not None.""" - _, rows = session_matrix(example_db) - for row in rows: - for cell in row[1:]: - assert cell >= 0 - - def test_monthly_bin(self, example_db): - columns, rows = session_matrix(example_db, bin="monthly") - assert len(rows) > 0 - for row in rows: - assert len(row[0]) == 7 # "2024-01" - - def test_empty_log(self): - log = TrainingLog(sessions=()) - conn = create_db(log) - columns, rows = session_matrix(conn) - assert columns == ["period"] - assert rows == [] - conn.close() - - -class TestParseReportArgs: +class TestParsePluginArgs: """Test the argument parser.""" def test_basic_flags(self): @@ -179,14 +129,14 @@ def test_basic_flags(self): {"name": "movement", "type": str, "required": True}, {"name": "bin", "type": str, "default": "weekly", "required": False}, ] - result = parse_report_args(params, "--movement kb-swing --bin monthly") + result = parse_plugin_args(params, "--movement kb-swing --bin monthly") assert result == {"movement": "kb-swing", "bin": "monthly"} def test_default_applied(self): params = [ {"name": "bin", "type": str, "default": "weekly", "required": False}, ] - result = parse_report_args(params, "") + result = parse_plugin_args(params, "") assert result == {"bin": "weekly"} def test_missing_required_raises(self): @@ -194,41 +144,41 @@ def test_missing_required_raises(self): {"name": "movement", "type": str, "required": True}, ] with pytest.raises(ValueError, match="Missing required"): - parse_report_args(params, "") + parse_plugin_args(params, "") def test_unknown_flag_raises(self): params = [ {"name": "movement", "type": str, "required": True}, ] with pytest.raises(ValueError, match="Unknown flag"): - parse_report_args(params, "--foo bar") + parse_plugin_args(params, "--foo bar") def test_flag_without_value_raises(self): params = [ {"name": "movement", "type": str, "required": True}, ] with pytest.raises(ValueError, match="requires a value"): - parse_report_args(params, "--movement") + parse_plugin_args(params, "--movement") def test_unexpected_positional_raises(self): params = [ {"name": "movement", "type": str, "required": True}, ] with pytest.raises(ValueError, match="Unexpected argument"): - parse_report_args(params, "kb-swing") + parse_plugin_args(params, "kb-swing") def test_quoted_value(self): params = [ {"name": "movement", "type": str, "required": True}, ] - result = parse_report_args(params, '--movement "kb-swing"') + result = parse_plugin_args(params, '--movement "kb-swing"') assert result == {"movement": "kb-swing"} def test_short_flag(self): params = [ {"name": "movement", "type": str, "required": True, "short": "m"}, ] - result = parse_report_args(params, "-m kb-swing") + result = parse_plugin_args(params, "-m kb-swing") assert result == {"movement": "kb-swing"} def test_mixed_short_and_long(self): @@ -242,7 +192,7 @@ def test_mixed_short_and_long(self): "short": "b", }, ] - result = parse_report_args(params, "-m kb-swing --bin monthly") + result = parse_plugin_args(params, "-m kb-swing --bin monthly") assert result == {"movement": "kb-swing", "bin": "monthly"} def test_unknown_short_flag_raises(self): @@ -250,54 +200,67 @@ def test_unknown_short_flag_raises(self): {"name": "movement", "type": str, "required": True, "short": "m"}, ] with pytest.raises(ValueError, match="Unknown flag: -x"): - parse_report_args(params, "-x foo") + parse_plugin_args(params, "-x foo") def test_short_flag_without_value_raises(self): params = [ {"name": "movement", "type": str, "required": True, "short": "m"}, ] with pytest.raises(ValueError, match="-m requires a value"): - parse_report_args(params, "-m") + parse_plugin_args(params, "-m") -class TestReportUsage: +class TestPluginUsage: """Test usage string generation.""" def test_volume_usage(self): - usage = report_usage("volume", REPORTS["volume"]) + load_plugins() + usage = plugin_usage("volume", PLUGINS["volume"]) assert "--movement" in usage assert "--bin" in usage - assert "report volume" in usage + assert usage.startswith("volume ") + assert "run " not in usage def test_required_not_bracketed(self): - usage = report_usage("volume", REPORTS["volume"]) + load_plugins() + usage = plugin_usage("volume", PLUGINS["volume"]) # Required params should not be in brackets assert "[--movement" not in usage def test_optional_bracketed(self): - usage = report_usage("volume", REPORTS["volume"]) + load_plugins() + usage = plugin_usage("volume", PLUGINS["volume"]) # Optional params should be in brackets assert "[-b/--bin" in usage def test_short_flags_shown(self): - usage = report_usage("volume", REPORTS["volume"]) + load_plugins() + usage = plugin_usage("volume", PLUGINS["volume"]) assert "-m/--movement" in usage assert "-b/--bin" in usage class TestRegistry: - """Test that the REPORTS registry is well-formed.""" + """Test that built-in plugins are well-formed.""" + + def test_all_builtins_registered(self): + load_plugins() + for name in ("volume", "e1rm", "weighin", "wendler531"): + assert name in PLUGINS, f"Builtin '{name}' not registered" - def test_all_reports_have_fn(self): - for name, entry in REPORTS.items(): - assert "fn" in entry, f"Report '{name}' missing 'fn'" + def test_all_plugins_have_fn(self): + load_plugins() + for name, entry in PLUGINS.items(): + assert "fn" in entry, f"Plugin '{name}' missing 'fn'" assert callable(entry["fn"]) - def test_all_reports_have_description(self): - for name, entry in REPORTS.items(): - assert "description" in entry, f"Report '{name}' missing 'description'" + def test_all_plugins_have_description(self): + load_plugins() + for name, entry in PLUGINS.items(): + assert "description" in entry, f"Plugin '{name}' missing 'description'" - def test_all_reports_have_params(self): - for name, entry in REPORTS.items(): - assert "params" in entry, f"Report '{name}' missing 'params'" + def test_all_plugins_have_params(self): + load_plugins() + for name, entry in PLUGINS.items(): + assert "params" in entry, f"Plugin '{name}' missing 'params'" assert isinstance(entry["params"], list) diff --git a/tests/test_srpe.py b/tests/test_srpe.py new file mode 100644 index 0000000..f09aa85 --- /dev/null +++ b/tests/test_srpe.py @@ -0,0 +1,459 @@ +"""Tests for the sRPE (Session Rate of Perceived Exertion) plugin.""" + +from datetime import date, timedelta + +import pytest + +from ox.builtins.srpe import ( + _acwr_report, + _acwr_zone, + _daily_au, + _extract_srpe_data, + _parse_iso_duration_minutes, + _parse_srpe, + _strain_risk, + srpe_report, +) +from ox.cli import parse_file +from ox.db import create_db +from ox.plugins import PlotResult, PluginContext, TableResult + + +# --- Duration parsing --- + + +@pytest.mark.parametrize( + "duration_str, expected_minutes", + [ + ("PT30M", 30.0), + ("PT1H", 60.0), + ("PT1H30M", 90.0), + ("PT50M", 50.0), + ("PT90S", 1.5), + ("PT1H15M30S", 75.5), + ("PT0M", 0.0), + ], +) +def test_parse_iso_duration(duration_str, expected_minutes): + assert _parse_iso_duration_minutes(duration_str) == expected_minutes + + +def test_parse_iso_duration_invalid(): + with pytest.raises(ValueError, match="Invalid ISO 8601 duration"): + _parse_iso_duration_minutes("30M") + + +# --- sRPE string parsing --- + + +def test_parse_srpe_semicolon(): + result = _parse_srpe("srpe: 4; PT30M") + assert result == (4.0, 30.0, 120.0) + + +def test_parse_srpe_comma(): + result = _parse_srpe("srpe: 7, PT50M") + assert result == (7.0, 50.0, 350.0) + + +def test_parse_srpe_no_space(): + result = _parse_srpe("srpe:4;PT30M") + assert result == (4.0, 30.0, 120.0) + + +def test_parse_srpe_decimal_rating(): + result = _parse_srpe("srpe: 6.5; PT45M") + assert result == (6.5, 45.0, 292.5) + + +def test_parse_srpe_no_match(): + assert _parse_srpe("just a regular note") is None + assert _parse_srpe("rpe: 4") is None + + +# --- Fixtures --- + + +@pytest.fixture +def srpe_session_log_content(): + """Log with sRPE as session metadata (item_line in session block).""" + return ( + "@session\n" + "2025-03-10 * Upper EMOM\n" + 'srpe: "4; PT30M"\n' + "bench-press: 135lb 5x5\n" + "@end\n" + "\n" + "@session\n" + "2025-03-12 * Lower EMOM\n" + 'srpe: "7; PT45M"\n' + "squat: 185lb 3x5\n" + "@end\n" + "\n" + "@session\n" + "2025-03-17 * Upper EMOM\n" + 'srpe: "5, PT30M"\n' + "bench-press: 145lb 5x5\n" + "@end\n" + ) + + +@pytest.fixture +def srpe_note_log_content(): + """Log with sRPE embedded in a single-line entry note.""" + return ( + '2025-03-10 * run: PT50M "srpe: 4; PT50M"\n' + '2025-03-14 * run: PT30M "srpe: 6; PT30M"\n' + ) + + +@pytest.fixture +def srpe_mixed_log_content(): + """Log with sRPE in both session metadata and single-line notes.""" + return ( + "@session\n" + "2025-03-10 * Upper EMOM\n" + 'srpe: "5; PT30M"\n' + "bench-press: 135lb 5x5\n" + "@end\n" + "\n" + '2025-03-11 * run: PT40M "srpe: 3; PT40M"\n' + "\n" + "@session\n" + "2025-03-17 * Upper EMOM\n" + 'srpe: "6; PT30M"\n' + "bench-press: 145lb 5x5\n" + "@end\n" + ) + + +def _make_ctx(content, tmp_path): + f = tmp_path / "test.ox" + f.write_text(content) + log = parse_file(f) + db = create_db(log) + return PluginContext(db=db, log=log) + + +# --- Data extraction --- + + +def test_extract_srpe_from_session(srpe_session_log_content, tmp_path): + ctx = _make_ctx(srpe_session_log_content, tmp_path) + data = _extract_srpe_data(ctx) + assert len(data) == 3 + # First entry: rating=4, duration=30min, AU=120 + assert data[0] == ("2025-03-10", 4.0, 30.0, 120.0) + # Second entry: rating=7, duration=45min, AU=315 + assert data[1] == ("2025-03-12", 7.0, 45.0, 315.0) + # Third entry: comma separator, rating=5, duration=30min, AU=150 + assert data[2] == ("2025-03-17", 5.0, 30.0, 150.0) + + +def test_extract_srpe_from_note(srpe_note_log_content, tmp_path): + ctx = _make_ctx(srpe_note_log_content, tmp_path) + data = _extract_srpe_data(ctx) + assert len(data) == 2 + assert data[0] == ("2025-03-10", 4.0, 50.0, 200.0) + assert data[1] == ("2025-03-14", 6.0, 30.0, 180.0) + + +def test_extract_srpe_mixed(srpe_mixed_log_content, tmp_path): + ctx = _make_ctx(srpe_mixed_log_content, tmp_path) + data = _extract_srpe_data(ctx) + assert len(data) == 3 + dates = [d[0] for d in data] + assert dates == ["2025-03-10", "2025-03-11", "2025-03-17"] + + +# --- Plugin output: table --- + + +def test_srpe_table_weekly(srpe_session_log_content, tmp_path): + ctx = _make_ctx(srpe_session_log_content, tmp_path) + result = srpe_report(ctx, bin="weekly", output="table") + assert isinstance(result, TableResult) + assert result.columns == ["period", "sessions", "total_AU", "avg_AU", "max_AU"] + assert len(result.rows) >= 1 + # All three sessions should appear + total_sessions = sum(r[1] for r in result.rows) + assert total_sessions == 3 + + +def test_srpe_table_monthly(srpe_session_log_content, tmp_path): + ctx = _make_ctx(srpe_session_log_content, tmp_path) + result = srpe_report(ctx, bin="monthly", output="table") + assert isinstance(result, TableResult) + # All in March 2025 + assert len(result.rows) == 1 + assert result.rows[0][0] == "2025-03" + assert result.rows[0][1] == 3 # 3 sessions + + +def test_srpe_table_empty(tmp_path): + ctx = _make_ctx("2025-01-10 * pullups: BW 5x10\n", tmp_path) + result = srpe_report(ctx, output="table") + assert isinstance(result, TableResult) + assert result.rows == [] + + +def test_srpe_table_au_values(srpe_session_log_content, tmp_path): + ctx = _make_ctx(srpe_session_log_content, tmp_path) + result = srpe_report(ctx, bin="monthly", output="table") + # Monthly: 120 + 315 + 150 = 585 total AU + row = result.rows[0] + assert row[2] == 585.0 # total_AU + + +# --- Plugin output: plot --- + + +def test_srpe_plot(srpe_mixed_log_content, tmp_path): + ctx = _make_ctx(srpe_mixed_log_content, tmp_path) + result = srpe_report(ctx, bin="weekly", output="plot") + assert isinstance(result, PlotResult) + assert len(result.lines) > 0 + + +def test_srpe_plot_empty(tmp_path): + ctx = _make_ctx("2025-01-10 * pullups: BW 5x10\n", tmp_path) + result = srpe_report(ctx, output="plot") + assert isinstance(result, PlotResult) + assert result.lines == ["No sRPE data found."] + + +def test_srpe_invalid_output(srpe_session_log_content, tmp_path): + ctx = _make_ctx(srpe_session_log_content, tmp_path) + with pytest.raises(ValueError, match="output must be one of"): + srpe_report(ctx, output="stats") + + +# --- ACWR zone classification --- + + +@pytest.mark.parametrize( + "acwr, expected", + [ + (None, "N/A"), + (0.5, "undertraining"), + (0.8, "sweet spot"), + (1.0, "sweet spot"), + (1.3, "sweet spot"), + (1.4, "caution"), + (1.5, "caution"), + (1.6, "danger"), + (2.0, "danger"), + ], +) +def test_acwr_zone(acwr, expected): + assert _acwr_zone(acwr) == expected + + +# --- Strain risk classification --- + + +@pytest.mark.parametrize( + "monotony, strain, expected", + [ + (None, None, "N/A"), + (1.5, 2000, "low"), + (2.5, 3000, "moderate"), # monotony > 2.0 + (1.5, 5000, "moderate"), # strain > 4000 + (2.5, 7000, "HIGH"), # both thresholds exceeded + ], +) +def test_strain_risk(monotony, strain, expected): + assert _strain_risk(monotony, strain) == expected + + +# --- Multi-week fixture for ACWR / monotony / strain --- + + +@pytest.fixture +def srpe_multiweek_content(): + """6 weeks of sRPE data for ACWR/monotony/strain testing.""" + lines = [] + # Week 1: Mon/Wed/Fri pattern + for dt, rpe, dur in [ + ("2025-02-03", 5, "PT40M"), # Mon + ("2025-02-05", 6, "PT45M"), # Wed + ("2025-02-07", 4, "PT30M"), # Fri + ]: + lines.append( + f'@session\n{dt} * Training\nsrpe: "{rpe}; {dur}"\nsquat: 135lb 3x5\n@end\n' + ) + # Week 2 + for dt, rpe, dur in [ + ("2025-02-10", 6, "PT45M"), + ("2025-02-12", 7, "PT50M"), + ("2025-02-14", 5, "PT35M"), + ]: + lines.append( + f'@session\n{dt} * Training\nsrpe: "{rpe}; {dur}"\nsquat: 145lb 3x5\n@end\n' + ) + # Week 3 + for dt, rpe, dur in [ + ("2025-02-17", 7, "PT50M"), + ("2025-02-19", 7, "PT55M"), + ("2025-02-21", 6, "PT40M"), + ]: + lines.append( + f'@session\n{dt} * Training\nsrpe: "{rpe}; {dur}"\nsquat: 155lb 3x5\n@end\n' + ) + # Week 4 + for dt, rpe, dur in [ + ("2025-02-24", 7, "PT50M"), + ("2025-02-26", 8, "PT55M"), + ("2025-02-28", 6, "PT40M"), + ]: + lines.append( + f'@session\n{dt} * Training\nsrpe: "{rpe}; {dur}"\nsquat: 165lb 3x5\n@end\n' + ) + # Week 5: higher intensity spike + for dt, rpe, dur in [ + ("2025-03-03", 8, "PT60M"), + ("2025-03-05", 9, "PT55M"), + ("2025-03-07", 7, "PT45M"), + ]: + lines.append( + f'@session\n{dt} * Training\nsrpe: "{rpe}; {dur}"\nsquat: 175lb 3x5\n@end\n' + ) + # Week 6: deload + for dt, rpe, dur in [ + ("2025-03-10", 3, "PT30M"), + ("2025-03-12", 4, "PT30M"), + ]: + lines.append( + f'@session\n{dt} * Training\nsrpe: "{rpe}; {dur}"\nsquat: 95lb 3x5\n@end\n' + ) + return "\n".join(lines) + + +# --- ACWR report --- + + +def test_acwr_report(srpe_multiweek_content, tmp_path): + ctx = _make_ctx(srpe_multiweek_content, tmp_path) + result = srpe_report(ctx, output="acwr") + assert isinstance(result, TableResult) + assert result.columns == ["date", "acute_AU", "chronic_AU", "ACWR", "zone"] + # Should have a row for each training day + assert len(result.rows) > 0 + # All rows should have 5 elements + for row in result.rows: + assert len(row) == 5 + + +def test_acwr_zones_appear(srpe_multiweek_content, tmp_path): + ctx = _make_ctx(srpe_multiweek_content, tmp_path) + result = srpe_report(ctx, output="acwr") + zones = {row[4] for row in result.rows} + # At least some zones should be classified + assert zones.issubset({"undertraining", "sweet spot", "caution", "danger", "N/A"}) + + +def test_acwr_empty(tmp_path): + ctx = _make_ctx("2025-01-10 * pullups: BW 5x10\n", tmp_path) + result = srpe_report(ctx, output="acwr") + assert isinstance(result, TableResult) + assert result.rows == [] + + +# --- Monotony report --- + + +def test_monotony_report(srpe_multiweek_content, tmp_path): + ctx = _make_ctx(srpe_multiweek_content, tmp_path) + result = srpe_report(ctx, output="monotony") + assert isinstance(result, TableResult) + assert result.columns == [ + "week", + "weekly_AU", + "mean_daily_AU", + "sd_daily_AU", + "monotony", + ] + assert len(result.rows) > 0 + + +def test_monotony_values(srpe_multiweek_content, tmp_path): + ctx = _make_ctx(srpe_multiweek_content, tmp_path) + result = srpe_report(ctx, output="monotony") + for row in result.rows: + week, weekly_au, mean_daily, sd_daily, monotony = row + assert weekly_au > 0 + assert mean_daily > 0 + # Monotony can be None if sd == 0 (e.g., only one day) + if monotony is not None: + assert monotony > 0 + + +def test_monotony_empty(tmp_path): + ctx = _make_ctx("2025-01-10 * pullups: BW 5x10\n", tmp_path) + result = srpe_report(ctx, output="monotony") + assert isinstance(result, TableResult) + assert result.rows == [] + + +# --- Strain report --- + + +def test_strain_report(srpe_multiweek_content, tmp_path): + ctx = _make_ctx(srpe_multiweek_content, tmp_path) + result = srpe_report(ctx, output="strain") + assert isinstance(result, TableResult) + assert result.columns == ["week", "weekly_AU", "monotony", "strain", "risk"] + assert len(result.rows) > 0 + + +def test_strain_risk_levels(srpe_multiweek_content, tmp_path): + ctx = _make_ctx(srpe_multiweek_content, tmp_path) + result = srpe_report(ctx, output="strain") + risks = {row[4] for row in result.rows} + assert risks.issubset({"low", "moderate", "HIGH", "N/A"}) + + +def test_strain_empty(tmp_path): + ctx = _make_ctx("2025-01-10 * pullups: BW 5x10\n", tmp_path) + result = srpe_report(ctx, output="strain") + assert isinstance(result, TableResult) + assert result.rows == [] + + +# --- Unit tests for helper functions --- + + +def test_daily_au_aggregation(): + data = [ + ("2025-03-10", 5.0, 30.0, 150.0), + ("2025-03-10", 3.0, 20.0, 60.0), # same day + ("2025-03-11", 6.0, 40.0, 240.0), + ] + result = _daily_au(data) + assert result[date(2025, 3, 10)] == 210.0 # 150 + 60 + assert result[date(2025, 3, 11)] == 240.0 + + +def test_acwr_report_direct(): + """Test _acwr_report with known data.""" + # 4 weeks of consistent training then a spike + data = [] + # Weeks 1-4: ~300 AU per week (Mon/Wed/Fri, 100 AU each) + for week_offset in range(4): + base = date(2025, 1, 6) + timedelta(weeks=week_offset) + for day_offset in [0, 2, 4]: # Mon, Wed, Fri + d = base + timedelta(days=day_offset) + data.append((d.isoformat(), 5.0, 20.0, 100.0)) + + # Week 5: spike to 600 AU + base = date(2025, 2, 3) + for day_offset in [0, 2, 4]: + d = base + timedelta(days=day_offset) + data.append((d.isoformat(), 10.0, 20.0, 200.0)) + + result = _acwr_report(data) + # Last row should show the spike week + last_row = result.rows[-1] + acwr = last_row[3] + # Acute should be higher than chronic due to spike + assert acwr > 1.0 diff --git a/tests/test_weighin.py b/tests/test_weighin.py index 4b80379..cf0e5fb 100644 --- a/tests/test_weighin.py +++ b/tests/test_weighin.py @@ -3,6 +3,21 @@ import pytest from ox.builtins.weighin import _rolling_avg, _linear_trend, weigh_in_report +from ox.data import TrainingLog +from ox.plugins import PluginContext, PlotResult + + +def _ctx(db): + """Create a PluginContext wrapping a db connection.""" + return PluginContext(db=db, log=TrainingLog(sessions=())) + + +def _run_weighin(db, **kwargs): + """Run weigh_in_report and return (columns, rows) tuple for test compat.""" + result = weigh_in_report(_ctx(db), **kwargs) + if isinstance(result, PlotResult): + return ["plot"], [(line,) for line in result.lines] + return result.columns, result.rows # --------------------------------------------------------------------------- @@ -94,11 +109,11 @@ def test_negative_slope(self): class TestWeighInReportTable: def test_returns_all_rows(self, weigh_in_multi_scale_db): - cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="table") + cols, rows = _run_weighin(weigh_in_multi_scale_db, output="table") assert len(rows) == 8 def test_columns(self, weigh_in_multi_scale_db): - cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="table") + cols, rows = _run_weighin(weigh_in_multi_scale_db, output="table") assert cols == ["date", "weight (lb)", "scale"] def test_unit_conversion(self, log_with_weigh_ins_file, tmp_path): @@ -108,56 +123,59 @@ def test_unit_conversion(self, log_with_weigh_ins_file, tmp_path): log = parse_file(log_with_weigh_ins_file) conn = create_db(log) - cols, rows = weigh_in_report(conn, unit="lb", output="table") + cols, rows = _run_weighin(conn, unit="lb", output="table") # 84kg in lb ≈ 185.19 kg_row = next(r for r in rows if r[2] == "gym scale") assert abs(kg_row[1] - 185.19) < 0.1 conn.close() def test_empty_db_returns_empty_rows(self, simple_db): - cols, rows = weigh_in_report(simple_db, output="table") + cols, rows = _run_weighin(simple_db, output="table") assert rows == [] def test_scale_none_shown_as_empty_string(self, weigh_in_multi_scale_db): - cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="table") + cols, rows = _run_weighin(weigh_in_multi_scale_db, output="table") no_scale_rows = [r for r in rows if r[2] == ""] assert len(no_scale_rows) > 0 def test_invalid_output_raises(self, weigh_in_multi_scale_db): with pytest.raises(ValueError, match="output must be"): - weigh_in_report(weigh_in_multi_scale_db, output="csv") + _run_weighin(weigh_in_multi_scale_db, output="csv") class TestWeighInReportPlot: def test_returns_plot_lines(self, weigh_in_multi_scale_db): - cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="plot") + cols, rows = _run_weighin(weigh_in_multi_scale_db, output="plot") assert cols == ["plot"] assert len(rows) > 0 def test_legend_shows_both_scales(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="plot") + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="plot") text = "\n".join(r[0] for r in rows) assert "home scale" in text assert "(no scale)" in text - def test_legend_shows_rolling_avg(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="plot") + def test_rolling_avg_hidden_by_default(self, weigh_in_multi_scale_db): + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="plot") text = "\n".join(r[0] for r in rows) - assert "rolling avg" in text + assert "rolling avg" not in text + + def test_rolling_avg_shown_when_window_set(self, weigh_in_multi_scale_db): + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="plot", window=7) + text = "\n".join(r[0] for r in rows) + assert "7-day rolling avg" in text def test_custom_window_in_legend(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="plot", window=14) + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="plot", window=14) text = "\n".join(r[0] for r in rows) assert "14-day rolling avg" in text - def test_two_different_markers_used(self, weigh_in_multi_scale_db): - """Two scales must produce two distinct markers in the legend.""" - from ox.builtins.weighin import _MARKERS - - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="plot") + def test_both_scale_series_labeled(self, weigh_in_multi_scale_db): + """Two scales must each appear as labeled series in the legend.""" + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="plot") text = "\n".join(r[0] for r in rows) - markers_found = [m for m in _MARKERS if m in text] - assert len(markers_found) >= 2 + assert "home scale" in text + assert "(no scale)" in text def test_not_enough_data_message(self, tmp_path): """Single weigh-in produces a 'Not enough data' message.""" @@ -167,20 +185,20 @@ def test_not_enough_data_message(self, tmp_path): f = tmp_path / "single.ox" f.write_text("2025-01-01 W 185lb\n") conn = create_db(parse_file(f)) - _cols, rows = weigh_in_report(conn, output="plot") + _cols, rows = _run_weighin(conn, output="plot") assert "Not enough data" in rows[0][0] conn.close() def test_no_data_message(self, simple_db): """No weigh-ins at all returns a message row.""" - _cols, rows = weigh_in_report(simple_db, output="plot") + _cols, rows = _run_weighin(simple_db, output="plot") assert len(rows) == 1 assert "No weigh-in data" in rows[0][0] class TestWeighInReportStats: def test_columns(self, weigh_in_multi_scale_db): - cols, _rows = weigh_in_report(weigh_in_multi_scale_db, output="stats") + cols, _rows = _run_weighin(weigh_in_multi_scale_db, output="stats") assert cols[0] == "scale" assert "count" in cols assert any("min" in c for c in cols) @@ -189,7 +207,7 @@ def test_columns(self, weigh_in_multi_scale_db): assert any("trend" in c for c in cols) def test_one_row_per_scale_plus_all(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="stats") + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="stats") labels = [r[0] for r in rows] assert "(no scale)" in labels assert "home scale" in labels @@ -205,13 +223,13 @@ def test_no_all_row_for_single_scale(self, log_with_weigh_ins_file, tmp_path): f.write_text(content) log = parse_file(f) conn = create_db(log) - _cols, rows = weigh_in_report(conn, output="stats") + _cols, rows = _run_weighin(conn, output="stats") labels = [r[0] for r in rows] assert "(all)" not in labels conn.close() def test_count_matches_measurements(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="stats") + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="stats") row_map = {r[0]: r for r in rows} # 4 entries with no scale, 4 with "home scale" assert row_map["(no scale)"][1] == 4 @@ -219,22 +237,22 @@ def test_count_matches_measurements(self, weigh_in_multi_scale_db): assert row_map["(all)"][1] == 8 def test_min_lte_max(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="stats") + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="stats") for row in rows: mn, mx = row[3], row[4] assert mn <= mx def test_trend_is_numeric_or_none(self, weigh_in_multi_scale_db): - _cols, rows = weigh_in_report(weigh_in_multi_scale_db, output="stats") + _cols, rows = _run_weighin(weigh_in_multi_scale_db, output="stats") for row in rows: trend = row[6] assert trend is None or isinstance(trend, float) def test_unit_conversion_in_stats(self, weigh_in_multi_scale_db): - _cols_lb, rows_lb = weigh_in_report( + _cols_lb, rows_lb = _run_weighin( weigh_in_multi_scale_db, unit="lb", output="stats" ) - _cols_kg, rows_kg = weigh_in_report( + _cols_kg, rows_kg = _run_weighin( weigh_in_multi_scale_db, unit="kg", output="stats" ) # avg in lb should be ~2.205× avg in kg @@ -245,14 +263,14 @@ def test_unit_conversion_in_stats(self, weigh_in_multi_scale_db): class TestWeighInPluginRegistration: def test_weighin_registered(self): - from ox.plugins import load_plugins, REPORT_PLUGINS + from ox.plugins import load_plugins, PLUGINS load_plugins() - assert "weighin" in REPORT_PLUGINS + assert "weighin" in PLUGINS def test_weighin_has_expected_params(self): - from ox.plugins import load_plugins, REPORT_PLUGINS + from ox.plugins import load_plugins, PLUGINS load_plugins() - params = {p["name"] for p in REPORT_PLUGINS["weighin"]["params"]} + params = {p["name"] for p in PLUGINS["weighin"]["params"]} assert params == {"unit", "output", "window"} diff --git a/tests/test_wendler531.py b/tests/test_wendler531.py new file mode 100644 index 0000000..2a2ea89 --- /dev/null +++ b/tests/test_wendler531.py @@ -0,0 +1,207 @@ +"""Tests for the Wendler 5/3/1 plugin.""" + +from datetime import date + +import pytest + +from ox.builtins.wendler531 import ( + WEEK_SCHEMES, + _parse_movements, + _pint_unit, + _round_weight, + register, + wendler531, +) +from ox.plugins import PluginContext, TextResult + + +# --- _round_weight --- + + +@pytest.mark.parametrize( + "weight, unit, expected", + [ + (204.75, "lb", 205), + (203.0, "lb", 205), + (207.4, "lb", 205), + (100.1, "kg", 100.0), + (103.74, "kg", 102.5), + (104.0, "kg", 105.0), + ], +) +def test_round_weight(weight, unit, expected): + assert _round_weight(weight, unit) == expected + + +# --- _parse_movements --- + + +def test_parse_movements_single(): + assert _parse_movements("squat:315") == [("squat", 315.0)] + + +def test_parse_movements_multi(): + assert _parse_movements("squat:315,bench-press:200") == [ + ("squat", 315.0), + ("bench-press", 200.0), + ] + + +def test_parse_movements_strips_whitespace(): + assert _parse_movements(" squat : 315 , deadlift : 405 ") == [ + ("squat", 315.0), + ("deadlift", 405.0), + ] + + +def test_parse_movements_invalid_format(): + with pytest.raises(ValueError, match="Invalid movement format"): + _parse_movements("squat315") + + +# --- _pint_unit --- + + +@pytest.mark.parametrize( + "short, full", + [("lb", "pound"), ("lbs", "pound"), ("kg", "kilogram"), ("stone", "stone")], +) +def test_pint_unit(short, full): + assert _pint_unit(short) == full + + +# --- Cycle generation --- + + +def _ctx(): + return PluginContext(db=None, log=None) + + +def test_wendler531_returns_text_result(): + result = wendler531(_ctx(), movements="squat:300", start_date="2026-01-05") + assert isinstance(result, TextResult) + + +def test_wendler531_week_dates(): + result = wendler531(_ctx(), movements="squat:300", start_date="2026-01-05") + text = result.text + assert "2026-01-05" in text + assert "2026-01-12" in text + assert "2026-01-19" in text + assert "2026-01-26" in text + + +def test_wendler531_has_four_weeks(): + result = wendler531(_ctx(), movements="squat:300", start_date="2026-01-05") + for week in ("531-week-1", "531-week-2", "531-week-3", "531-week-4"): + assert week in result.text + + +def test_wendler531_all_sessions_planned(): + result = wendler531(_ctx(), movements="squat:300", start_date="2026-01-05") + assert result.text.count(" ! ") >= 4 + + +def test_wendler531_week1_weights_lb(): + # 300 * 0.65=195, 0.75=225, 0.85=255 (already round 5) + result = wendler531(_ctx(), movements="squat:300", start_date="2026-01-05") + text = result.text.split("531-week-2")[0] + assert "195lb" in text.replace("pound", "lb") or "195 pound" in text + assert "225" in text + assert "255" in text + + +def test_wendler531_deload_weights(): + # Week 4: 40/50/60% + result = wendler531(_ctx(), movements="squat:300", start_date="2026-01-05") + week4 = result.text.split("531-week-4")[1] + # 300 * 0.4=120, 0.5=150, 0.6=180 + assert "120" in week4 + assert "150" in week4 + assert "180" in week4 + + +def test_wendler531_kg_rounding(): + # 100kg * 0.65 = 65.0kg (exact), * 0.75 = 75kg, * 0.85 = 85kg + result = wendler531( + _ctx(), movements="squat:100", unit="kg", start_date="2026-01-05" + ) + text = result.text + assert "kg" in text or "kilogram" in text + + +def test_wendler531_multiple_movements(): + result = wendler531( + _ctx(), movements="squat:300,bench-press:200", start_date="2026-01-05" + ) + assert "squat" in result.text + assert "bench-press" in result.text + + +def test_wendler531_rm_tag_on_weeks_1_to_3(): + result = wendler531( + _ctx(), movements="squat:300", start_date="2026-01-05", rm="true" + ) + parts = result.text.split("531-week-") + # parts[1]=week1..., parts[4]=week4 + assert "^rm" in parts[1] + assert "^rm" in parts[2] + assert "^rm" in parts[3] + assert "^rm" not in parts[4] + + +def test_wendler531_rm_disabled(): + result = wendler531( + _ctx(), movements="squat:300", start_date="2026-01-05", rm="false" + ) + assert "^rm" not in result.text + + +def test_wendler531_default_date_is_today(): + # Should not raise; uses datetime.now().date() + result = wendler531(_ctx(), movements="squat:300") + today = date.today().isoformat() + assert today in result.text + + +def test_wendler531_invalid_date_raises(): + with pytest.raises(ValueError): + wendler531(_ctx(), movements="squat:300", start_date="01/05/2026") + + +# --- Scheme correctness --- + + +def test_week_schemes_structure(): + for wk, sets in WEEK_SCHEMES.items(): + assert len(sets) == 3 + assert all( + isinstance(pct, float) and isinstance(reps, int) for pct, reps in sets + ) + + +def test_week_schemes_percentages(): + assert [p for p, _ in WEEK_SCHEMES[1]] == [0.65, 0.75, 0.85] + assert [p for p, _ in WEEK_SCHEMES[2]] == [0.70, 0.80, 0.90] + assert [p for p, _ in WEEK_SCHEMES[3]] == [0.75, 0.85, 0.95] + assert [p for p, _ in WEEK_SCHEMES[4]] == [0.40, 0.50, 0.60] + + +# --- Registration --- + + +def test_register_returns_descriptor(): + descriptors = register() + assert len(descriptors) == 1 + desc = descriptors[0] + assert desc["name"] == "wendler531" + assert desc["fn"] is wendler531 + param_names = {p["name"] for p in desc["params"]} + assert param_names == {"movements", "unit", "start_date", "rm"} + + +def test_register_movements_required(): + desc = register()[0] + movements_param = next(p for p in desc["params"] if p["name"] == "movements") + assert movements_param["required"] is True + assert movements_param["short"] == "m" diff --git a/tree-sitter-ox/_index.md b/tree-sitter-ox/_index.md deleted file mode 100644 index 3ecc71a..0000000 --- a/tree-sitter-ox/_index.md +++ /dev/null @@ -1,260 +0,0 @@ ---- -icon: lucide/rocket ---- - -# Ox - -Ox is a plain text format for tracking training. Write your workouts in a simple text file, parse them into structured data, and analyze your progress over time. - -Named after Milo of Croton, the ancient Greek wrestler who allegedly carried a calf daily as it grew into an ox, building his strength progressively. - -Inspired by plain-text accounting systems like [Beancount](https://github.com/beancount/beancount). - -## Documentation - -- **[Getting Started](getting-started.md)** - Your first training log -- **[CLI Reference](cli-reference.md)** - Command-line interface guide -- **[API Reference](api-reference.md)** - Python library usage - -## Quick Start - -Example log: - -Create a training log file (e.g., `training.ox`): - -``` -2025-11-14 * pullups: 24kg 5/5/5 - -@session -2025-11-14 * Upper EMOM -kb-tgu: 32kg 1x4 "easy" -kb-oh-press: 32kg 4x4 -note: felt tired today -@end - -2025-11-14 W gym: 155lbs "morning" -``` - -## Structure - -**Entry**: A record in your log, either single-line or multiline. - -**Item**: Data within the Entry, can be an excercise, note, or measurement. -Items must have associated details. - -**Details**: Specific details about the Item like reps, sets, notes, weights, or times. - -## Syntax - -### Comments - -Use `#` for standalone comments (ignored by parser): - -``` -# Week 1 - Deload -2025-11-14 * pullups: 20kg 5/5/5 - -# This is a note for myself -2025-11-15 * run: 5km -``` - -Comments are not stored as data. Use `note:` items if you want to preserve notes for analysis. - -### Single-line entries - -Useful for single Items (like a single-excercise session or a weigh-in) or when you don't have a reaosn to group Items. - -``` -2025-11-14 * pullups: 24kg 5/5/5 -2025-11-14 * run: 5km 25min -2025-11-14 W gym: 155lbs -``` - -**Format:** - -```ebnf -single_line_entry = date, " ", flag, " ", item, ": ", details ; - -date = digit, digit, digit, digit, "-", digit, digit, "-", digit, digit ; -flag = "*" | "!" | "W" ; -item = identifier ; -details = detail, { " ", detail } ; -``` - -### Multi-line entries (sessions) - -Use tagged blocks for workouts with multiple exercises: - -``` -@session -2025-11-14 * Upper Day -pullups: 24kg 5/5/5 -kb-oh-press: 32kg 4x4 -kb-row: 32kg 4x4 -note: felt strong today -@end -``` - -**Format:** - -```ebnf -multiline_entry = "@session", newline, - date, " ", flag, " ", name, newline, - { item, ": ", details, newline }, - "@end" ; - -name = text_until_newline ; -``` - -The session name (`Upper Day`) can be arbitrary or refer to a predefined template (future feature). - -### Exercise definitions - -It can be useful to define excercises for reference, the syntax below allows this. -All fields shown are options, and you can add arbitaray ones as needed. - -``` -@exercise kb-oh-press -equipment: kettlebell -pattern: press -url: https://example.com/kb-press-tutorial -note: keep elbow tight, don't flare -@end -``` - -**Fields:** -- `equipment`: Type of equipment (kettlebell, barbell, bodyweight, etc.) -- `pattern`: Movement pattern (press, squat, hinge, pull, etc.) -- `url`: Link to tutorial or form reference -- `note`: Form cues or other notes - -### Flags - -- `*` - Completed -- `!` - Planned -- `W` - Weigh-in - -### Item Naming Conventions - -The only rule is that they use no spaces, but we also recommend making them descriptive: - -`{weight-type}-{descriptor}-{movement}` - -`kb-oh-press` == Kettlebell Overhead Press -`bb-back-squat` == Barbell Back Squat - -### Details - -Use Details to describe the Item, these can be used in any order: - -**Weights:** - -``` -24kg single weight -24kg+32kg combined weights (two kettlebells, one in each hand) -24kg/32kg progressive weights (to mactch different sets) -155lbs bodyweight or other measurement -BW bodyweight (inferred if not explicit) -``` - -**Reps:** - -``` -5/3/1 sets of reps -335 3 sets of 5 reps -``` - -**Time:** - -``` -25min -90sec -2hr -``` - -**Distance:** - -``` -5km -3mi -100m -50ft -``` - -**Notes:** - -Notes can either be part of an Item's details (with quotes): - -``` -2025-11-14 * pullups: 24kg 5/5/5 "felt strong" -``` - -Or it's own line (Item) and does not require quotes: - -``` -note: felt really strong today, hit a PR -``` - -**Example combinations:** - -``` -2025-11-14 * pullups: 24kg 5/5/5 "easy" -2025-11-14 * run: 5km 25min "new route" -2025-11-14 * plank: 90sec -``` - -## Examples - -``` -# sinle-line entry -2025-11-14 W bodyweight: 155lbs - -# completed session -@session -2025-11-14 * Strength Day -bb-squat: 135lbs 5/5/5 -bb-press: 95lbs 5/5/5 -bb-deadlift: 225lbs 5/5/5 -@end - -# planned session -@session -2025-11-15 ! Upper Day -pullups: 28kg 5/5/5 -kb-oh-press: 32kg 4x4 -@end - -# single-line entry -2025-11-14 * stretching: 10min -``` - -## Data Structures - -The parser converts entries into Python dataclasses for analysis: - -```python -from ox import parse - -log = parse("training.ox") - -# Get all pullup sessions -for date, movement in log.movements("pullups"): - print(f"{date}: {movement.total_reps} reps @ {movement.top_set_weight}") -``` - -See the [API Reference](api-reference.md) for complete details. - -## Learn More - -- **[Getting Started Guide](getting-started.md)** - Step-by-step tutorial -- **[CLI Commands](cli-reference.md)** - Analyze your logs from the terminal -- **[API Documentation](api-reference.md)** - Use ox as a Python library - -## Roadmap - -Future features planned: - -- **Templates**: Define reusable session templates -- **Programs**: Track mesocycles and progression schemes -- **Visualization**: Built-in progress charts and graphs -- **Export**: Convert logs to CSV, JSON, or other formats diff --git a/tree-sitter-ox/grammar.js b/tree-sitter-ox/grammar.js index ad42985..cc1dcef 100644 --- a/tree-sitter-ox/grammar.js +++ b/tree-sitter-ox/grammar.js @@ -13,12 +13,12 @@ module.exports = grammar({ extras: ($) => [/[ \t]/], // Only spaces and tabs, NOT newlines rules: { - source_file: ($) => repeat(choice($._entry, $.include_directive, $.comment, "\n")), + source_file: ($) => repeat(choice($._entry, $.include_directive, $.plugin_directive, $.comment, "\n")), _entry: ($) => choice( $.singleline_entry, $.session_block, - $.exercise_block, + $.movement_block, $.template_block, $.note_entry, $.query_entry, @@ -31,6 +31,12 @@ module.exports = grammar({ optional("\n") )), + plugin_directive: ($) => prec.right(seq( + "@plugin", + field("path", $.file_path), + optional("\n") + )), + file_path: ($) => seq('"', /[^"\n]+/, '"'), comment: ($) => /#[^\n]*/, @@ -90,10 +96,10 @@ module.exports = grammar({ optional("\n") )), - // @exercise block - exercise_block: ($) => + // @movement block + movement_block: ($) => prec.right(seq( - "@exercise", + "@movement", field("name", $.identifier), "\n", repeat($.metadata_line), @@ -171,8 +177,8 @@ module.exports = grammar({ // BW remains a special bodyweight token weight: ($) => token(choice( /\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)((\+\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))+)?/, // single or combined: 24kg or 24kg+32kg - /\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)((\/\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))+)?/, // single or progressive: 24kg/32kg/48kg - /(BW|\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))(\/(BW|\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)))+/, // mixed BW/concrete progressive: BW/25lb/50lb + /((BW|\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)?)\/)+(BW|\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))/, // progressive (incl. implied units + mixed BW): 24kg/32kg, 160/185/210lb, BW/25lb, 60/70kg/160/180lb + /\d+(\.\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)/, // single: 24kg /BW/ // bodyweight standalone )), diff --git a/tree-sitter-ox/src/grammar.json b/tree-sitter-ox/src/grammar.json index 666a587..c7de5e4 100644 --- a/tree-sitter-ox/src/grammar.json +++ b/tree-sitter-ox/src/grammar.json @@ -15,6 +15,10 @@ "type": "SYMBOL", "name": "include_directive" }, + { + "type": "SYMBOL", + "name": "plugin_directive" + }, { "type": "SYMBOL", "name": "comment" @@ -39,7 +43,7 @@ }, { "type": "SYMBOL", - "name": "exercise_block" + "name": "movement_block" }, { "type": "SYMBOL", @@ -92,6 +96,39 @@ ] } }, + "plugin_directive": { + "type": "PREC_RIGHT", + "value": 0, + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "@plugin" + }, + { + "type": "FIELD", + "name": "path", + "content": { + "type": "SYMBOL", + "name": "file_path" + } + }, + { + "type": "CHOICE", + "members": [ + { + "type": "STRING", + "value": "\n" + }, + { + "type": "BLANK" + } + ] + } + ] + } + }, "file_path": { "type": "SEQ", "members": [ @@ -418,7 +455,7 @@ ] } }, - "exercise_block": { + "movement_block": { "type": "PREC_RIGHT", "value": 0, "content": { @@ -426,7 +463,7 @@ "members": [ { "type": "STRING", - "value": "@exercise" + "value": "@movement" }, { "type": "FIELD", @@ -702,11 +739,11 @@ }, { "type": "PATTERN", - "value": "\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)((\\/\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))+)?" + "value": "((BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)?)\\/)+(BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))" }, { "type": "PATTERN", - "value": "(BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat))(\\/(BW|\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)))+" + "value": "\\d+(\\.\\d+)?(g|gram|kg|kilogram|lb|pound|oz|ounce|stone|t|tonne|grain|gr|ct|carat)" }, { "type": "PATTERN", diff --git a/tree-sitter-ox/src/node-types.json b/tree-sitter-ox/src/node-types.json index 4cbba5a..c9562e6 100644 --- a/tree-sitter-ox/src/node-types.json +++ b/tree-sitter-ox/src/node-types.json @@ -55,32 +55,6 @@ } } }, - { - "type": "exercise_block", - "named": true, - "fields": { - "name": { - "multiple": false, - "required": true, - "types": [ - { - "type": "identifier", - "named": true - } - ] - } - }, - "children": { - "multiple": true, - "required": false, - "types": [ - { - "type": "metadata_line", - "named": true - } - ] - } - }, { "type": "file_path", "named": true, @@ -169,6 +143,32 @@ } } }, + { + "type": "movement_block", + "named": true, + "fields": { + "name": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "metadata_line", + "named": true + } + ] + } + }, { "type": "name", "named": true, @@ -216,6 +216,22 @@ } } }, + { + "type": "plugin_directive", + "named": true, + "fields": { + "path": { + "multiple": false, + "required": true, + "types": [ + { + "type": "file_path", + "named": true + } + ] + } + } + }, { "type": "query_entry", "named": true, @@ -362,17 +378,21 @@ "named": true }, { - "type": "exercise_block", + "type": "include_directive", "named": true }, { - "type": "include_directive", + "type": "movement_block", "named": true }, { "type": "note_entry", "named": true }, + { + "type": "plugin_directive", + "named": true + }, { "type": "query_entry", "named": true @@ -502,11 +522,15 @@ "named": false }, { - "type": "@exercise", + "type": "@include", "named": false }, { - "type": "@include", + "type": "@movement", + "named": false + }, + { + "type": "@plugin", "named": false }, { diff --git a/tree-sitter-ox/src/parser.c b/tree-sitter-ox/src/parser.c index 3b9d080..71c39f0 100644 --- a/tree-sitter-ox/src/parser.c +++ b/tree-sitter-ox/src/parser.c @@ -7,11 +7,11 @@ #endif #define LANGUAGE_VERSION 15 -#define STATE_COUNT 88 +#define STATE_COUNT 91 #define LARGE_STATE_COUNT 2 -#define SYMBOL_COUNT 50 +#define SYMBOL_COUNT 52 #define ALIAS_COUNT 0 -#define TOKEN_COUNT 26 +#define TOKEN_COUNT 27 #define EXTERNAL_TOKEN_COUNT 0 #define FIELD_COUNT 17 #define MAX_ALIAS_SEQUENCE_LENGTH 9 @@ -22,59 +22,62 @@ enum ts_symbol_identifiers { anon_sym_LF = 1, anon_sym_ATinclude = 2, - anon_sym_DQUOTE = 3, - aux_sym_file_path_token1 = 4, - sym_comment = 5, - anon_sym_COLON = 6, - anon_sym_note = 7, - anon_sym_W = 8, - anon_sym_query = 9, - anon_sym_ATsession = 10, - anon_sym_ATend = 11, - anon_sym_ATexercise = 12, - anon_sym_ATtemplate = 13, - anon_sym_note_COLON = 14, - sym_date = 15, - anon_sym_STAR = 16, - anon_sym_BANG = 17, - aux_sym_item_token1 = 18, - aux_sym_name_token1 = 19, - sym_weight = 20, - sym_rep_scheme = 21, - sym_duration = 22, - sym_time_of_day = 23, - sym_distance = 24, - sym_quoted_string = 25, - sym_source_file = 26, - sym__entry = 27, - sym_include_directive = 28, - sym_file_path = 29, - sym_singleline_entry = 30, - sym_note_entry = 31, - sym_weigh_in_entry = 32, - sym_query_entry = 33, - sym_session_block = 34, - sym_exercise_block = 35, - sym_template_block = 36, - sym_item_line = 37, - sym_note_line = 38, - sym_metadata_line = 39, - sym_flag = 40, - sym_item = 41, - sym_identifier = 42, - sym_name = 43, - sym_text_until_newline = 44, - sym_details = 45, - aux_sym_source_file_repeat1 = 46, - aux_sym_session_block_repeat1 = 47, - aux_sym_exercise_block_repeat1 = 48, - aux_sym_details_repeat1 = 49, + anon_sym_ATplugin = 3, + anon_sym_DQUOTE = 4, + aux_sym_file_path_token1 = 5, + sym_comment = 6, + anon_sym_COLON = 7, + anon_sym_note = 8, + anon_sym_W = 9, + anon_sym_query = 10, + anon_sym_ATsession = 11, + anon_sym_ATend = 12, + anon_sym_ATmovement = 13, + anon_sym_ATtemplate = 14, + anon_sym_note_COLON = 15, + sym_date = 16, + anon_sym_STAR = 17, + anon_sym_BANG = 18, + aux_sym_item_token1 = 19, + aux_sym_name_token1 = 20, + sym_weight = 21, + sym_rep_scheme = 22, + sym_duration = 23, + sym_time_of_day = 24, + sym_distance = 25, + sym_quoted_string = 26, + sym_source_file = 27, + sym__entry = 28, + sym_include_directive = 29, + sym_plugin_directive = 30, + sym_file_path = 31, + sym_singleline_entry = 32, + sym_note_entry = 33, + sym_weigh_in_entry = 34, + sym_query_entry = 35, + sym_session_block = 36, + sym_movement_block = 37, + sym_template_block = 38, + sym_item_line = 39, + sym_note_line = 40, + sym_metadata_line = 41, + sym_flag = 42, + sym_item = 43, + sym_identifier = 44, + sym_name = 45, + sym_text_until_newline = 46, + sym_details = 47, + aux_sym_source_file_repeat1 = 48, + aux_sym_session_block_repeat1 = 49, + aux_sym_movement_block_repeat1 = 50, + aux_sym_details_repeat1 = 51, }; static const char * const ts_symbol_names[] = { [ts_builtin_sym_end] = "end", [anon_sym_LF] = "\n", [anon_sym_ATinclude] = "@include", + [anon_sym_ATplugin] = "@plugin", [anon_sym_DQUOTE] = "\"", [aux_sym_file_path_token1] = "file_path_token1", [sym_comment] = "comment", @@ -84,7 +87,7 @@ static const char * const ts_symbol_names[] = { [anon_sym_query] = "query", [anon_sym_ATsession] = "@session", [anon_sym_ATend] = "@end", - [anon_sym_ATexercise] = "@exercise", + [anon_sym_ATmovement] = "@movement", [anon_sym_ATtemplate] = "@template", [anon_sym_note_COLON] = "note:", [sym_date] = "date", @@ -101,13 +104,14 @@ static const char * const ts_symbol_names[] = { [sym_source_file] = "source_file", [sym__entry] = "_entry", [sym_include_directive] = "include_directive", + [sym_plugin_directive] = "plugin_directive", [sym_file_path] = "file_path", [sym_singleline_entry] = "singleline_entry", [sym_note_entry] = "note_entry", [sym_weigh_in_entry] = "weigh_in_entry", [sym_query_entry] = "query_entry", [sym_session_block] = "session_block", - [sym_exercise_block] = "exercise_block", + [sym_movement_block] = "movement_block", [sym_template_block] = "template_block", [sym_item_line] = "item_line", [sym_note_line] = "note_line", @@ -120,7 +124,7 @@ static const char * const ts_symbol_names[] = { [sym_details] = "details", [aux_sym_source_file_repeat1] = "source_file_repeat1", [aux_sym_session_block_repeat1] = "session_block_repeat1", - [aux_sym_exercise_block_repeat1] = "exercise_block_repeat1", + [aux_sym_movement_block_repeat1] = "movement_block_repeat1", [aux_sym_details_repeat1] = "details_repeat1", }; @@ -128,6 +132,7 @@ static const TSSymbol ts_symbol_map[] = { [ts_builtin_sym_end] = ts_builtin_sym_end, [anon_sym_LF] = anon_sym_LF, [anon_sym_ATinclude] = anon_sym_ATinclude, + [anon_sym_ATplugin] = anon_sym_ATplugin, [anon_sym_DQUOTE] = anon_sym_DQUOTE, [aux_sym_file_path_token1] = aux_sym_file_path_token1, [sym_comment] = sym_comment, @@ -137,7 +142,7 @@ static const TSSymbol ts_symbol_map[] = { [anon_sym_query] = anon_sym_query, [anon_sym_ATsession] = anon_sym_ATsession, [anon_sym_ATend] = anon_sym_ATend, - [anon_sym_ATexercise] = anon_sym_ATexercise, + [anon_sym_ATmovement] = anon_sym_ATmovement, [anon_sym_ATtemplate] = anon_sym_ATtemplate, [anon_sym_note_COLON] = anon_sym_note_COLON, [sym_date] = sym_date, @@ -154,13 +159,14 @@ static const TSSymbol ts_symbol_map[] = { [sym_source_file] = sym_source_file, [sym__entry] = sym__entry, [sym_include_directive] = sym_include_directive, + [sym_plugin_directive] = sym_plugin_directive, [sym_file_path] = sym_file_path, [sym_singleline_entry] = sym_singleline_entry, [sym_note_entry] = sym_note_entry, [sym_weigh_in_entry] = sym_weigh_in_entry, [sym_query_entry] = sym_query_entry, [sym_session_block] = sym_session_block, - [sym_exercise_block] = sym_exercise_block, + [sym_movement_block] = sym_movement_block, [sym_template_block] = sym_template_block, [sym_item_line] = sym_item_line, [sym_note_line] = sym_note_line, @@ -173,7 +179,7 @@ static const TSSymbol ts_symbol_map[] = { [sym_details] = sym_details, [aux_sym_source_file_repeat1] = aux_sym_source_file_repeat1, [aux_sym_session_block_repeat1] = aux_sym_session_block_repeat1, - [aux_sym_exercise_block_repeat1] = aux_sym_exercise_block_repeat1, + [aux_sym_movement_block_repeat1] = aux_sym_movement_block_repeat1, [aux_sym_details_repeat1] = aux_sym_details_repeat1, }; @@ -190,6 +196,10 @@ static const TSSymbolMetadata ts_symbol_metadata[] = { .visible = true, .named = false, }, + [anon_sym_ATplugin] = { + .visible = true, + .named = false, + }, [anon_sym_DQUOTE] = { .visible = true, .named = false, @@ -226,7 +236,7 @@ static const TSSymbolMetadata ts_symbol_metadata[] = { .visible = true, .named = false, }, - [anon_sym_ATexercise] = { + [anon_sym_ATmovement] = { .visible = true, .named = false, }, @@ -294,6 +304,10 @@ static const TSSymbolMetadata ts_symbol_metadata[] = { .visible = true, .named = true, }, + [sym_plugin_directive] = { + .visible = true, + .named = true, + }, [sym_file_path] = { .visible = true, .named = true, @@ -318,7 +332,7 @@ static const TSSymbolMetadata ts_symbol_metadata[] = { .visible = true, .named = true, }, - [sym_exercise_block] = { + [sym_movement_block] = { .visible = true, .named = true, }, @@ -370,7 +384,7 @@ static const TSSymbolMetadata ts_symbol_metadata[] = { .visible = false, .named = false, }, - [aux_sym_exercise_block_repeat1] = { + [aux_sym_movement_block_repeat1] = { .visible = false, .named = false, }, @@ -622,7 +636,10 @@ static const TSStateId ts_primary_state_ids[STATE_COUNT] = { [84] = 84, [85] = 85, [86] = 86, - [87] = 81, + [87] = 87, + [88] = 88, + [89] = 89, + [90] = 77, }; static bool ts_lex(TSLexer *lexer, TSStateId state) { @@ -630,1053 +647,1088 @@ static bool ts_lex(TSLexer *lexer, TSStateId state) { eof = lexer->eof(lexer); switch (state) { case 0: - if (eof) ADVANCE(183); + if (eof) ADVANCE(188); ADVANCE_MAP( - '\n', 184, - '!', 203, - '"', 186, - '#', 189, - '*', 202, - ':', 190, + '\n', 189, + '!', 209, + '"', 192, + '#', 195, + '*', 208, + ':', 196, '@', 49, - 'B', 20, - 'P', 19, - 'T', 178, - 'W', 193, - 'n', 113, - 'q', 154, + 'B', 23, + 'P', 22, + 'T', 183, + 'W', 199, + 'n', 118, + 'q', 160, ); if (lookahead == '\t' || lookahead == ' ') SKIP(0); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(7); END_STATE(); case 1: - if (lookahead == '\n') ADVANCE(184); + if (lookahead == '\n') ADVANCE(189); if (lookahead == '\t' || - lookahead == ' ') ADVANCE(212); - if (lookahead != 0) ADVANCE(213); + lookahead == ' ') ADVANCE(218); + if (lookahead != 0) ADVANCE(219); END_STATE(); case 2: - if (lookahead == '"') ADVANCE(236); + if (lookahead == '"') ADVANCE(242); if (lookahead != 0) ADVANCE(2); END_STATE(); case 3: ADVANCE_MAP( - '-', 180, - '.', 168, - '/', 169, - 'c', 21, - 'f', 120, - 'g', 218, - 'i', 97, - 'k', 69, - 'l', 33, - 'm', 234, - 'n', 89, - 'o', 159, - 'p', 116, - 's', 146, - 't', 217, - 'x', 170, - 'y', 22, + '-', 185, + '.', 174, + '/', 18, + 'c', 24, + 'f', 127, + 'g', 224, + 'i', 100, + 'k', 68, + 'l', 36, + 'm', 240, + 'n', 91, + 'o', 162, + 'p', 122, + 's', 151, + 't', 223, + 'x', 175, + 'y', 25, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(8); END_STATE(); case 4: - if (lookahead == '-') ADVANCE(181); + if (lookahead == '-') ADVANCE(186); END_STATE(); case 5: ADVANCE_MAP( - '.', 168, - '/', 169, - 'c', 21, - 'f', 120, - 'g', 218, - 'i', 97, - 'k', 69, - 'l', 33, - 'm', 234, - 'n', 89, - 'o', 159, - 'p', 116, - 's', 146, - 't', 217, - 'x', 170, - 'y', 22, + '.', 174, + '/', 18, + 'c', 24, + 'f', 127, + 'g', 224, + 'i', 100, + 'k', 68, + 'l', 36, + 'm', 240, + 'n', 91, + 'o', 162, + 'p', 122, + 's', 151, + 't', 223, + 'x', 175, + 'y', 25, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(3); END_STATE(); case 6: ADVANCE_MAP( - '.', 168, - '/', 169, - 'c', 21, - 'f', 120, - 'g', 218, - 'i', 97, - 'k', 69, - 'l', 33, - 'm', 234, - 'n', 89, - 'o', 159, - 'p', 116, - 's', 146, - 't', 217, - 'x', 170, - 'y', 22, + '.', 174, + '/', 18, + 'c', 24, + 'f', 127, + 'g', 224, + 'i', 100, + 'k', 68, + 'l', 36, + 'm', 240, + 'n', 91, + 'o', 162, + 'p', 122, + 's', 151, + 't', 223, + 'x', 175, + 'y', 25, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(5); END_STATE(); case 7: ADVANCE_MAP( - '.', 168, - '/', 169, - 'c', 21, - 'f', 120, - 'g', 218, - 'i', 97, - 'k', 69, - 'l', 33, - 'm', 234, - 'n', 89, - 'o', 159, - 'p', 116, - 's', 146, - 't', 217, - 'x', 170, - 'y', 22, + '.', 174, + '/', 18, + 'c', 24, + 'f', 127, + 'g', 224, + 'i', 100, + 'k', 68, + 'l', 36, + 'm', 240, + 'n', 91, + 'o', 162, + 'p', 122, + 's', 151, + 't', 223, + 'x', 175, + 'y', 25, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(6); END_STATE(); case 8: ADVANCE_MAP( - '.', 168, - '/', 169, - 'c', 21, - 'f', 120, - 'g', 218, - 'i', 97, - 'k', 69, - 'l', 33, - 'm', 234, - 'n', 89, - 'o', 159, - 'p', 116, - 's', 146, - 't', 217, - 'x', 170, - 'y', 22, + '.', 174, + '/', 18, + 'c', 24, + 'f', 127, + 'g', 224, + 'i', 100, + 'k', 68, + 'l', 36, + 'm', 240, + 'n', 91, + 'o', 162, + 'p', 122, + 's', 151, + 't', 223, + 'x', 175, + 'y', 25, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(8); END_STATE(); case 9: - if (lookahead == '.') ADVANCE(171); - if (lookahead == 'H') ADVANCE(229); - if (lookahead == 'M') ADVANCE(230); - if (lookahead == 'S') ADVANCE(228); + if (lookahead == '.') ADVANCE(177); + if (lookahead == 'H') ADVANCE(235); + if (lookahead == 'M') ADVANCE(236); + if (lookahead == 'S') ADVANCE(234); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(9); END_STATE(); case 10: - if (lookahead == '.') ADVANCE(171); - if (lookahead == 'S') ADVANCE(228); + if (lookahead == '.') ADVANCE(177); + if (lookahead == 'S') ADVANCE(234); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(10); END_STATE(); case 11: ADVANCE_MAP( - '.', 172, - 'c', 30, - 'g', 225, - 'k', 68, - 'l', 32, - 'o', 160, - 'p', 121, - 's', 152, - 't', 224, + '.', 178, + '/', 19, + 'c', 33, + 'g', 231, + 'k', 67, + 'l', 35, + 'o', 164, + 'p', 131, + 's', 157, + 't', 230, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(11); END_STATE(); case 12: ADVANCE_MAP( - '.', 177, - 'c', 31, - 'g', 221, - 'k', 70, - 'l', 34, - 'o', 161, - 'p', 123, - 's', 153, - 't', 220, + '.', 180, + 'c', 34, + 'g', 227, + 'k', 69, + 'l', 37, + 'o', 166, + 'p', 132, + 's', 158, + 't', 226, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(12); END_STATE(); case 13: - if (lookahead == ':') ADVANCE(179); + ADVANCE_MAP( + '/', 19, + 'c', 24, + 'f', 127, + 'g', 224, + 'i', 100, + 'k', 68, + 'l', 36, + 'm', 240, + 'n', 91, + 'o', 162, + 'p', 122, + 's', 151, + 't', 223, + 'y', 25, + ); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(13); END_STATE(); case 14: - if (lookahead == '@') ADVANCE(206); - if (lookahead == 'n') ADVANCE(209); - if (lookahead == '\t' || - lookahead == ' ') SKIP(14); - if (lookahead != 0 && - (lookahead < '\t' || '\r' < lookahead) && - lookahead != ':') ADVANCE(211); + ADVANCE_MAP( + '/', 19, + 'c', 33, + 'g', 231, + 'k', 67, + 'l', 35, + 'o', 164, + 'p', 131, + 's', 157, + 't', 230, + ); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(14); END_STATE(); case 15: - if (lookahead == '@') ADVANCE(206); + if (lookahead == ':') ADVANCE(184); + END_STATE(); + case 16: + if (lookahead == '@') ADVANCE(212); + if (lookahead == 'n') ADVANCE(215); if (lookahead == '\t' || - lookahead == ' ') SKIP(15); + lookahead == ' ') SKIP(16); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && - lookahead != ':') ADVANCE(211); - END_STATE(); - case 16: - if (lookahead == 'B') ADVANCE(20); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(11); + lookahead != ':') ADVANCE(217); END_STATE(); case 17: - if (lookahead == 'M') ADVANCE(230); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(17); + if (lookahead == '@') ADVANCE(212); + if (lookahead == '\t' || + lookahead == ' ') SKIP(17); + if (lookahead != 0 && + (lookahead < '\t' || '\r' < lookahead) && + lookahead != ':') ADVANCE(217); END_STATE(); case 18: - if (lookahead == 'S') ADVANCE(228); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(18); + if (lookahead == 'B') ADVANCE(23); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(232); END_STATE(); case 19: - if (lookahead == 'T') ADVANCE(166); + if (lookahead == 'B') ADVANCE(23); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(11); END_STATE(); case 20: - if (lookahead == 'W') ADVANCE(222); + if (lookahead == 'M') ADVANCE(236); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(20); END_STATE(); case 21: - if (lookahead == 'a') ADVANCE(130); - if (lookahead == 'e') ADVANCE(102); - if (lookahead == 'm') ADVANCE(232); - if (lookahead == 't') ADVANCE(215); + if (lookahead == 'S') ADVANCE(234); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(21); END_STATE(); case 22: - if (lookahead == 'a') ADVANCE(131); - if (lookahead == 'd') ADVANCE(232); + if (lookahead == 'T') ADVANCE(172); END_STATE(); case 23: - if (lookahead == 'a') ADVANCE(88); + if (lookahead == 'W') ADVANCE(228); END_STATE(); case 24: - if (lookahead == 'a') ADVANCE(87); + if (lookahead == 'a') ADVANCE(136); + if (lookahead == 'e') ADVANCE(106); + if (lookahead == 'm') ADVANCE(238); + if (lookahead == 't') ADVANCE(221); END_STATE(); case 25: - if (lookahead == 'a') ADVANCE(143); + if (lookahead == 'a') ADVANCE(137); + if (lookahead == 'd') ADVANCE(238); END_STATE(); case 26: - if (lookahead == 'a') ADVANCE(91); + if (lookahead == 'a') ADVANCE(90); END_STATE(); case 27: - if (lookahead == 'a') ADVANCE(141); + if (lookahead == 'a') ADVANCE(89); END_STATE(); case 28: - if (lookahead == 'a') ADVANCE(145); + if (lookahead == 'a') ADVANCE(147); END_STATE(); case 29: - if (lookahead == 'a') ADVANCE(149); + if (lookahead == 'a') ADVANCE(93); END_STATE(); case 30: - if (lookahead == 'a') ADVANCE(134); - if (lookahead == 't') ADVANCE(222); + if (lookahead == 'a') ADVANCE(145); END_STATE(); case 31: - if (lookahead == 'a') ADVANCE(136); - if (lookahead == 't') ADVANCE(214); + if (lookahead == 'a') ADVANCE(149); END_STATE(); case 32: - if (lookahead == 'b') ADVANCE(222); + if (lookahead == 'a') ADVANCE(154); END_STATE(); case 33: - if (lookahead == 'b') ADVANCE(215); + if (lookahead == 'a') ADVANCE(139); + if (lookahead == 't') ADVANCE(228); END_STATE(); case 34: - if (lookahead == 'b') ADVANCE(214); + if (lookahead == 'a') ADVANCE(141); + if (lookahead == 't') ADVANCE(220); END_STATE(); case 35: - ADVANCE_MAP( - 'c', 21, - 'f', 120, - 'g', 218, - 'i', 97, - 'k', 69, - 'l', 33, - 'm', 234, - 'n', 89, - 'o', 159, - 'p', 116, - 's', 146, - 't', 217, - 'y', 22, - ); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(35); + if (lookahead == 'b') ADVANCE(228); END_STATE(); case 36: - ADVANCE_MAP( - 'c', 30, - 'g', 225, - 'k', 68, - 'l', 32, - 'o', 160, - 'p', 121, - 's', 152, - 't', 224, - ); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(36); + if (lookahead == 'b') ADVANCE(221); END_STATE(); case 37: - if (lookahead == 'c') ADVANCE(83); + if (lookahead == 'b') ADVANCE(220); END_STATE(); case 38: ADVANCE_MAP( - 'c', 31, - 'g', 221, - 'k', 70, - 'l', 34, - 'o', 161, - 'p', 123, - 's', 153, - 't', 220, + 'c', 34, + 'g', 227, + 'k', 69, + 'l', 37, + 'o', 166, + 'p', 132, + 's', 158, + 't', 226, ); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(38); END_STATE(); case 39: - if (lookahead == 'c') ADVANCE(77); + if (lookahead == 'c') ADVANCE(55); END_STATE(); case 40: - if (lookahead == 'c') ADVANCE(55); + if (lookahead == 'c') ADVANCE(50); END_STATE(); case 41: - if (lookahead == 'c') ADVANCE(50); + if (lookahead == 'c') ADVANCE(57); END_STATE(); case 42: - if (lookahead == 'c') ADVANCE(57); + if (lookahead == 'c') ADVANCE(85); END_STATE(); case 43: - if (lookahead == 'd') ADVANCE(222); + if (lookahead == 'd') ADVANCE(228); END_STATE(); case 44: - if (lookahead == 'd') ADVANCE(232); + if (lookahead == 'd') ADVANCE(238); END_STATE(); case 45: - if (lookahead == 'd') ADVANCE(215); + if (lookahead == 'd') ADVANCE(221); END_STATE(); case 46: - if (lookahead == 'd') ADVANCE(196); + if (lookahead == 'd') ADVANCE(202); END_STATE(); case 47: - if (lookahead == 'd') ADVANCE(214); + if (lookahead == 'd') ADVANCE(220); END_STATE(); case 48: if (lookahead == 'd') ADVANCE(58); END_STATE(); case 49: - if (lookahead == 'e') ADVANCE(94); - if (lookahead == 'i') ADVANCE(95); + if (lookahead == 'e') ADVANCE(97); + if (lookahead == 'i') ADVANCE(98); + if (lookahead == 'm') ADVANCE(119); + if (lookahead == 'p') ADVANCE(83); if (lookahead == 's') ADVANCE(51); - if (lookahead == 't') ADVANCE(63); + if (lookahead == 't') ADVANCE(61); END_STATE(); case 50: - if (lookahead == 'e') ADVANCE(222); + if (lookahead == 'e') ADVANCE(228); END_STATE(); case 51: - if (lookahead == 'e') ADVANCE(138); + if (lookahead == 'e') ADVANCE(143); END_STATE(); case 52: - if (lookahead == 'e') ADVANCE(129); + if (lookahead == 'e') ADVANCE(135); END_STATE(); case 53: - if (lookahead == 'e') ADVANCE(232); + if (lookahead == 'e') ADVANCE(238); END_STATE(); case 54: - if (lookahead == 'e') ADVANCE(232); - if (lookahead == 'l') ADVANCE(76); + if (lookahead == 'e') ADVANCE(238); + if (lookahead == 'l') ADVANCE(81); END_STATE(); case 55: - if (lookahead == 'e') ADVANCE(215); + if (lookahead == 'e') ADVANCE(221); END_STATE(); case 56: - if (lookahead == 'e') ADVANCE(192); + if (lookahead == 'e') ADVANCE(198); END_STATE(); case 57: - if (lookahead == 'e') ADVANCE(214); + if (lookahead == 'e') ADVANCE(220); END_STATE(); case 58: - if (lookahead == 'e') ADVANCE(185); + if (lookahead == 'e') ADVANCE(190); END_STATE(); case 59: - if (lookahead == 'e') ADVANCE(198); + if (lookahead == 'e') ADVANCE(205); END_STATE(); case 60: - if (lookahead == 'e') ADVANCE(199); + if (lookahead == 'e') ADVANCE(197); END_STATE(); case 61: - if (lookahead == 'e') ADVANCE(162); - if (lookahead == 'i') ADVANCE(95); - if (lookahead == 's') ADVANCE(51); - if (lookahead == 't') ADVANCE(63); + if (lookahead == 'e') ADVANCE(92); END_STATE(); case 62: - if (lookahead == 'e') ADVANCE(191); + if (lookahead == 'e') ADVANCE(94); END_STATE(); case 63: - if (lookahead == 'e') ADVANCE(90); + if (lookahead == 'e') ADVANCE(134); END_STATE(); case 64: - if (lookahead == 'e') ADVANCE(132); + if (lookahead == 'e') ADVANCE(134); + if (lookahead == 'r') ADVANCE(53); END_STATE(); case 65: - if (lookahead == 'e') ADVANCE(128); + if (lookahead == 'e') ADVANCE(111); END_STATE(); case 66: - if (lookahead == 'e') ADVANCE(128); - if (lookahead == 'r') ADVANCE(53); + if (lookahead == 'e') ADVANCE(155); END_STATE(); case 67: - if (lookahead == 'e') ADVANCE(151); + if (lookahead == 'g') ADVANCE(228); + if (lookahead == 'i') ADVANCE(87); END_STATE(); case 68: - if (lookahead == 'g') ADVANCE(222); - if (lookahead == 'i') ADVANCE(85); + if (lookahead == 'g') ADVANCE(221); + if (lookahead == 'i') ADVANCE(86); + if (lookahead == 'm') ADVANCE(238); END_STATE(); case 69: - if (lookahead == 'g') ADVANCE(215); - if (lookahead == 'i') ADVANCE(84); - if (lookahead == 'm') ADVANCE(232); + if (lookahead == 'g') ADVANCE(220); + if (lookahead == 'i') ADVANCE(88); END_STATE(); case 70: - if (lookahead == 'g') ADVANCE(214); - if (lookahead == 'i') ADVANCE(86); + if (lookahead == 'g') ADVANCE(78); END_STATE(); case 71: - if (lookahead == 'g') ADVANCE(133); - if (lookahead == 'm') ADVANCE(67); + if (lookahead == 'g') ADVANCE(138); + if (lookahead == 'm') ADVANCE(66); END_STATE(); case 72: - if (lookahead == 'g') ADVANCE(135); + if (lookahead == 'g') ADVANCE(140); END_STATE(); case 73: - if (lookahead == 'g') ADVANCE(137); + if (lookahead == 'g') ADVANCE(142); END_STATE(); case 74: - if (lookahead == 'h') ADVANCE(232); + if (lookahead == 'h') ADVANCE(238); END_STATE(); case 75: - if (lookahead == 'i') ADVANCE(232); + if (lookahead == 'i') ADVANCE(238); END_STATE(); case 76: - if (lookahead == 'i') ADVANCE(92); + if (lookahead == 'i') ADVANCE(98); + if (lookahead == 'm') ADVANCE(119); + if (lookahead == 'p') ADVANCE(83); + if (lookahead == 's') ADVANCE(51); + if (lookahead == 't') ADVANCE(61); END_STATE(); case 77: - if (lookahead == 'i') ADVANCE(140); + if (lookahead == 'i') ADVANCE(99); + if (lookahead == 'm') ADVANCE(221); END_STATE(); case 78: - if (lookahead == 'i') ADVANCE(96); - if (lookahead == 'm') ADVANCE(215); + if (lookahead == 'i') ADVANCE(102); END_STATE(); case 79: - if (lookahead == 'i') ADVANCE(93); - if (lookahead == 'm') ADVANCE(222); + if (lookahead == 'i') ADVANCE(96); + if (lookahead == 'm') ADVANCE(228); END_STATE(); case 80: - if (lookahead == 'i') ADVANCE(98); - if (lookahead == 'm') ADVANCE(214); + if (lookahead == 'i') ADVANCE(101); + if (lookahead == 'm') ADVANCE(220); END_STATE(); case 81: - if (lookahead == 'i') ADVANCE(119); + if (lookahead == 'i') ADVANCE(95); END_STATE(); case 82: - if (lookahead == 'l') ADVANCE(29); + if (lookahead == 'i') ADVANCE(126); END_STATE(); case 83: - if (lookahead == 'l') ADVANCE(155); + if (lookahead == 'l') ADVANCE(159); END_STATE(); case 84: - if (lookahead == 'l') ADVANCE(114); + if (lookahead == 'l') ADVANCE(32); END_STATE(); case 85: - if (lookahead == 'l') ADVANCE(115); + if (lookahead == 'l') ADVANCE(161); END_STATE(); case 86: - if (lookahead == 'l') ADVANCE(126); + if (lookahead == 'l') ADVANCE(120); END_STATE(); case 87: - if (lookahead == 'm') ADVANCE(222); + if (lookahead == 'l') ADVANCE(121); END_STATE(); case 88: - if (lookahead == 'm') ADVANCE(215); + if (lookahead == 'l') ADVANCE(130); END_STATE(); case 89: - if (lookahead == 'm') ADVANCE(75); + if (lookahead == 'm') ADVANCE(228); END_STATE(); case 90: - if (lookahead == 'm') ADVANCE(127); + if (lookahead == 'm') ADVANCE(221); END_STATE(); case 91: - if (lookahead == 'm') ADVANCE(214); + if (lookahead == 'm') ADVANCE(75); END_STATE(); case 92: - if (lookahead == 'm') ADVANCE(67); + if (lookahead == 'm') ADVANCE(133); END_STATE(); case 93: - if (lookahead == 'n') ADVANCE(222); + if (lookahead == 'm') ADVANCE(220); END_STATE(); case 94: - if (lookahead == 'n') ADVANCE(46); - if (lookahead == 'x') ADVANCE(64); + if (lookahead == 'm') ADVANCE(65); END_STATE(); case 95: - if (lookahead == 'n') ADVANCE(37); + if (lookahead == 'm') ADVANCE(66); END_STATE(); case 96: - if (lookahead == 'n') ADVANCE(215); + if (lookahead == 'n') ADVANCE(228); END_STATE(); case 97: - if (lookahead == 'n') ADVANCE(233); + if (lookahead == 'n') ADVANCE(46); END_STATE(); case 98: - if (lookahead == 'n') ADVANCE(214); + if (lookahead == 'n') ADVANCE(42); END_STATE(); case 99: - if (lookahead == 'n') ADVANCE(195); + if (lookahead == 'n') ADVANCE(221); END_STATE(); case 100: - if (lookahead == 'n') ADVANCE(40); + if (lookahead == 'n') ADVANCE(239); END_STATE(); case 101: - if (lookahead == 'n') ADVANCE(45); + if (lookahead == 'n') ADVANCE(220); END_STATE(); case 102: - if (lookahead == 'n') ADVANCE(147); + if (lookahead == 'n') ADVANCE(191); END_STATE(); case 103: - if (lookahead == 'n') ADVANCE(43); + if (lookahead == 'n') ADVANCE(201); END_STATE(); case 104: - if (lookahead == 'n') ADVANCE(106); + if (lookahead == 'n') ADVANCE(39); END_STATE(); case 105: - if (lookahead == 'n') ADVANCE(47); + if (lookahead == 'n') ADVANCE(45); END_STATE(); case 106: - if (lookahead == 'n') ADVANCE(55); + if (lookahead == 'n') ADVANCE(153); END_STATE(); case 107: - if (lookahead == 'n') ADVANCE(50); + if (lookahead == 'n') ADVANCE(43); END_STATE(); case 108: - if (lookahead == 'n') ADVANCE(57); + if (lookahead == 'n') ADVANCE(55); END_STATE(); case 109: - if (lookahead == 'n') ADVANCE(41); + if (lookahead == 'n') ADVANCE(47); END_STATE(); case 110: - if (lookahead == 'n') ADVANCE(107); + if (lookahead == 'n') ADVANCE(108); END_STATE(); case 111: - if (lookahead == 'n') ADVANCE(42); + if (lookahead == 'n') ADVANCE(150); END_STATE(); case 112: - if (lookahead == 'n') ADVANCE(108); + if (lookahead == 'n') ADVANCE(113); END_STATE(); case 113: - if (lookahead == 'o') ADVANCE(148); + if (lookahead == 'n') ADVANCE(50); END_STATE(); case 114: - if (lookahead == 'o') ADVANCE(71); + if (lookahead == 'n') ADVANCE(57); END_STATE(); case 115: - if (lookahead == 'o') ADVANCE(72); + if (lookahead == 'n') ADVANCE(40); END_STATE(); case 116: - if (lookahead == 'o') ADVANCE(156); + if (lookahead == 'n') ADVANCE(114); END_STATE(); case 117: - if (lookahead == 'o') ADVANCE(142); + if (lookahead == 'n') ADVANCE(41); END_STATE(); case 118: - if (lookahead == 'o') ADVANCE(106); + if (lookahead == 'o') ADVANCE(152); END_STATE(); case 119: - if (lookahead == 'o') ADVANCE(99); + if (lookahead == 'o') ADVANCE(168); END_STATE(); case 120: - if (lookahead == 'o') ADVANCE(117); - if (lookahead == 't') ADVANCE(232); + if (lookahead == 'o') ADVANCE(71); END_STATE(); case 121: - if (lookahead == 'o') ADVANCE(157); + if (lookahead == 'o') ADVANCE(72); END_STATE(); case 122: - if (lookahead == 'o') ADVANCE(107); + if (lookahead == 'o') ADVANCE(163); END_STATE(); case 123: - if (lookahead == 'o') ADVANCE(158); + if (lookahead == 'o') ADVANCE(146); END_STATE(); case 124: if (lookahead == 'o') ADVANCE(108); END_STATE(); case 125: - if (lookahead == 'o') ADVANCE(150); + if (lookahead == 'o') ADVANCE(113); END_STATE(); case 126: - if (lookahead == 'o') ADVANCE(73); + if (lookahead == 'o') ADVANCE(103); END_STATE(); case 127: - if (lookahead == 'p') ADVANCE(82); + if (lookahead == 'o') ADVANCE(123); + if (lookahead == 't') ADVANCE(238); END_STATE(); case 128: - if (lookahead == 'r') ADVANCE(232); + if (lookahead == 'o') ADVANCE(114); END_STATE(); case 129: - if (lookahead == 'r') ADVANCE(163); + if (lookahead == 'o') ADVANCE(156); END_STATE(); case 130: - if (lookahead == 'r') ADVANCE(25); + if (lookahead == 'o') ADVANCE(73); END_STATE(); case 131: - if (lookahead == 'r') ADVANCE(44); + if (lookahead == 'o') ADVANCE(165); END_STATE(); case 132: - if (lookahead == 'r') ADVANCE(39); + if (lookahead == 'o') ADVANCE(167); END_STATE(); case 133: - if (lookahead == 'r') ADVANCE(23); + if (lookahead == 'p') ADVANCE(84); END_STATE(); case 134: - if (lookahead == 'r') ADVANCE(27); + if (lookahead == 'r') ADVANCE(238); END_STATE(); case 135: - if (lookahead == 'r') ADVANCE(24); + if (lookahead == 'r') ADVANCE(169); END_STATE(); case 136: if (lookahead == 'r') ADVANCE(28); END_STATE(); case 137: - if (lookahead == 'r') ADVANCE(26); + if (lookahead == 'r') ADVANCE(44); END_STATE(); case 138: - if (lookahead == 's') ADVANCE(139); + if (lookahead == 'r') ADVANCE(26); END_STATE(); case 139: - if (lookahead == 's') ADVANCE(81); + if (lookahead == 'r') ADVANCE(30); END_STATE(); case 140: - if (lookahead == 's') ADVANCE(59); + if (lookahead == 'r') ADVANCE(27); END_STATE(); case 141: - if (lookahead == 't') ADVANCE(222); + if (lookahead == 'r') ADVANCE(31); END_STATE(); case 142: - if (lookahead == 't') ADVANCE(232); + if (lookahead == 'r') ADVANCE(29); END_STATE(); case 143: - if (lookahead == 't') ADVANCE(215); + if (lookahead == 's') ADVANCE(144); END_STATE(); case 144: - if (lookahead == 't') ADVANCE(66); + if (lookahead == 's') ADVANCE(82); END_STATE(); case 145: - if (lookahead == 't') ADVANCE(214); + if (lookahead == 't') ADVANCE(228); END_STATE(); case 146: - if (lookahead == 't') ADVANCE(118); + if (lookahead == 't') ADVANCE(238); END_STATE(); case 147: - if (lookahead == 't') ADVANCE(76); + if (lookahead == 't') ADVANCE(221); END_STATE(); case 148: - if (lookahead == 't') ADVANCE(56); + if (lookahead == 't') ADVANCE(64); END_STATE(); case 149: - if (lookahead == 't') ADVANCE(60); + if (lookahead == 't') ADVANCE(220); END_STATE(); case 150: - if (lookahead == 't') ADVANCE(62); + if (lookahead == 't') ADVANCE(204); END_STATE(); case 151: - if (lookahead == 't') ADVANCE(65); + if (lookahead == 't') ADVANCE(124); END_STATE(); case 152: - if (lookahead == 't') ADVANCE(122); + if (lookahead == 't') ADVANCE(56); END_STATE(); case 153: - if (lookahead == 't') ADVANCE(124); + if (lookahead == 't') ADVANCE(81); END_STATE(); case 154: - if (lookahead == 'u') ADVANCE(52); + if (lookahead == 't') ADVANCE(59); END_STATE(); case 155: - if (lookahead == 'u') ADVANCE(48); + if (lookahead == 't') ADVANCE(63); END_STATE(); case 156: - if (lookahead == 'u') ADVANCE(101); + if (lookahead == 't') ADVANCE(60); END_STATE(); case 157: - if (lookahead == 'u') ADVANCE(103); + if (lookahead == 't') ADVANCE(125); END_STATE(); case 158: - if (lookahead == 'u') ADVANCE(105); + if (lookahead == 't') ADVANCE(128); END_STATE(); case 159: - if (lookahead == 'u') ADVANCE(100); - if (lookahead == 'z') ADVANCE(215); + if (lookahead == 'u') ADVANCE(70); END_STATE(); case 160: - if (lookahead == 'u') ADVANCE(109); - if (lookahead == 'z') ADVANCE(222); + if (lookahead == 'u') ADVANCE(52); END_STATE(); case 161: - if (lookahead == 'u') ADVANCE(111); - if (lookahead == 'z') ADVANCE(214); + if (lookahead == 'u') ADVANCE(48); END_STATE(); case 162: - if (lookahead == 'x') ADVANCE(64); + if (lookahead == 'u') ADVANCE(104); + if (lookahead == 'z') ADVANCE(221); END_STATE(); case 163: - if (lookahead == 'y') ADVANCE(194); + if (lookahead == 'u') ADVANCE(105); END_STATE(); case 164: - if (lookahead == '\t' || - lookahead == ' ') SKIP(164); - if (lookahead != 0 && - (lookahead < '\t' || '\r' < lookahead) && - lookahead != ':') ADVANCE(211); + if (lookahead == 'u') ADVANCE(115); + if (lookahead == 'z') ADVANCE(228); END_STATE(); case 165: - if (lookahead == '\t' || - lookahead == ' ') ADVANCE(187); - if (lookahead != 0 && - lookahead != '\t' && - lookahead != '\n' && - lookahead != '"') ADVANCE(188); + if (lookahead == 'u') ADVANCE(107); END_STATE(); case 166: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(9); + if (lookahead == 'u') ADVANCE(117); + if (lookahead == 'z') ADVANCE(220); END_STATE(); case 167: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(13); + if (lookahead == 'u') ADVANCE(109); END_STATE(); case 168: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(35); + if (lookahead == 'v') ADVANCE(62); END_STATE(); case 169: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(226); + if (lookahead == 'y') ADVANCE(200); END_STATE(); case 170: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(227); + if (lookahead == '\t' || + lookahead == ' ') SKIP(170); + if (lookahead != 0 && + (lookahead < '\t' || '\r' < lookahead) && + lookahead != ':') ADVANCE(217); END_STATE(); case 171: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(18); + if (lookahead == '\t' || + lookahead == ' ') ADVANCE(193); + if (lookahead != 0 && + lookahead != '\t' && + lookahead != '\n' && + lookahead != '"') ADVANCE(194); END_STATE(); case 172: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(36); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(9); END_STATE(); case 173: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(231); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(15); END_STATE(); case 174: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(4); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(13); END_STATE(); case 175: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(201); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(233); END_STATE(); case 176: if (('0' <= lookahead && lookahead <= '9')) ADVANCE(12); END_STATE(); case 177: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(38); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(21); END_STATE(); case 178: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(167); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(14); END_STATE(); case 179: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(173); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(237); END_STATE(); case 180: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(174); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(38); END_STATE(); case 181: - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(175); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(4); END_STATE(); case 182: - if (eof) ADVANCE(183); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(207); + END_STATE(); + case 183: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(173); + END_STATE(); + case 184: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(179); + END_STATE(); + case 185: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(181); + END_STATE(); + case 186: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(182); + END_STATE(); + case 187: + if (eof) ADVANCE(188); ADVANCE_MAP( - '\n', 184, - '!', 203, + '\n', 189, + '!', 209, '"', 2, - '#', 189, - '*', 202, - '@', 61, - 'B', 20, - 'P', 19, - 'T', 178, - 'W', 193, - 'n', 125, - 'q', 154, + '#', 195, + '*', 208, + '@', 76, + 'B', 23, + 'P', 22, + 'T', 183, + 'W', 199, + 'n', 129, + 'q', 160, ); if (lookahead == '\t' || - lookahead == ' ') SKIP(182); + lookahead == ' ') SKIP(187); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(7); END_STATE(); - case 183: + case 188: ACCEPT_TOKEN(ts_builtin_sym_end); END_STATE(); - case 184: + case 189: ACCEPT_TOKEN(anon_sym_LF); END_STATE(); - case 185: + case 190: ACCEPT_TOKEN(anon_sym_ATinclude); END_STATE(); - case 186: + case 191: + ACCEPT_TOKEN(anon_sym_ATplugin); + END_STATE(); + case 192: ACCEPT_TOKEN(anon_sym_DQUOTE); END_STATE(); - case 187: + case 193: ACCEPT_TOKEN(aux_sym_file_path_token1); if (lookahead == '\t' || - lookahead == ' ') ADVANCE(187); + lookahead == ' ') ADVANCE(193); if (lookahead != 0 && lookahead != '\t' && lookahead != '\n' && - lookahead != '"') ADVANCE(188); + lookahead != '"') ADVANCE(194); END_STATE(); - case 188: + case 194: ACCEPT_TOKEN(aux_sym_file_path_token1); if (lookahead != 0 && lookahead != '\n' && - lookahead != '"') ADVANCE(188); + lookahead != '"') ADVANCE(194); END_STATE(); - case 189: + case 195: ACCEPT_TOKEN(sym_comment); if (lookahead != 0 && - lookahead != '\n') ADVANCE(189); + lookahead != '\n') ADVANCE(195); END_STATE(); - case 190: + case 196: ACCEPT_TOKEN(anon_sym_COLON); END_STATE(); - case 191: + case 197: ACCEPT_TOKEN(anon_sym_note); END_STATE(); - case 192: + case 198: ACCEPT_TOKEN(anon_sym_note); - if (lookahead == ':') ADVANCE(200); + if (lookahead == ':') ADVANCE(206); END_STATE(); - case 193: + case 199: ACCEPT_TOKEN(anon_sym_W); END_STATE(); - case 194: + case 200: ACCEPT_TOKEN(anon_sym_query); END_STATE(); - case 195: + case 201: ACCEPT_TOKEN(anon_sym_ATsession); END_STATE(); - case 196: + case 202: ACCEPT_TOKEN(anon_sym_ATend); END_STATE(); - case 197: + case 203: ACCEPT_TOKEN(anon_sym_ATend); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 198: - ACCEPT_TOKEN(anon_sym_ATexercise); + case 204: + ACCEPT_TOKEN(anon_sym_ATmovement); END_STATE(); - case 199: + case 205: ACCEPT_TOKEN(anon_sym_ATtemplate); END_STATE(); - case 200: + case 206: ACCEPT_TOKEN(anon_sym_note_COLON); END_STATE(); - case 201: + case 207: ACCEPT_TOKEN(sym_date); END_STATE(); - case 202: + case 208: ACCEPT_TOKEN(anon_sym_STAR); END_STATE(); - case 203: + case 209: ACCEPT_TOKEN(anon_sym_BANG); END_STATE(); - case 204: + case 210: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == ':') ADVANCE(200); + if (lookahead == ':') ADVANCE(206); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && - lookahead != ' ') ADVANCE(211); + lookahead != ' ') ADVANCE(217); END_STATE(); - case 205: + case 211: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == 'd') ADVANCE(197); + if (lookahead == 'd') ADVANCE(203); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 206: + case 212: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == 'e') ADVANCE(208); + if (lookahead == 'e') ADVANCE(214); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 207: + case 213: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == 'e') ADVANCE(204); + if (lookahead == 'e') ADVANCE(210); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 208: + case 214: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == 'n') ADVANCE(205); + if (lookahead == 'n') ADVANCE(211); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 209: + case 215: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == 'o') ADVANCE(210); + if (lookahead == 'o') ADVANCE(216); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 210: + case 216: ACCEPT_TOKEN(aux_sym_item_token1); - if (lookahead == 't') ADVANCE(207); + if (lookahead == 't') ADVANCE(213); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 211: + case 217: ACCEPT_TOKEN(aux_sym_item_token1); if (lookahead != 0 && (lookahead < '\t' || '\r' < lookahead) && lookahead != ' ' && - lookahead != ':') ADVANCE(211); + lookahead != ':') ADVANCE(217); END_STATE(); - case 212: + case 218: ACCEPT_TOKEN(aux_sym_name_token1); if (lookahead == '\t' || - lookahead == ' ') ADVANCE(212); + lookahead == ' ') ADVANCE(218); if (lookahead != 0 && lookahead != '\t' && - lookahead != '\n') ADVANCE(213); + lookahead != '\n') ADVANCE(219); END_STATE(); - case 213: + case 219: ACCEPT_TOKEN(aux_sym_name_token1); if (lookahead != 0 && - lookahead != '\n') ADVANCE(213); + lookahead != '\n') ADVANCE(219); END_STATE(); - case 214: + case 220: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); END_STATE(); - case 215: + case 221: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); - if (lookahead == '/') ADVANCE(16); + if (lookahead == '/') ADVANCE(19); END_STATE(); - case 216: + case 222: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); - if (lookahead == '/') ADVANCE(16); - if (lookahead == 'a') ADVANCE(78); + if (lookahead == '/') ADVANCE(19); + if (lookahead == 'a') ADVANCE(77); END_STATE(); - case 217: + case 223: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); - if (lookahead == '/') ADVANCE(16); - if (lookahead == 'o') ADVANCE(104); + if (lookahead == '/') ADVANCE(19); + if (lookahead == 'o') ADVANCE(110); END_STATE(); - case 218: + case 224: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); - if (lookahead == '/') ADVANCE(16); - if (lookahead == 'r') ADVANCE(216); + if (lookahead == '/') ADVANCE(19); + if (lookahead == 'r') ADVANCE(222); END_STATE(); - case 219: + case 225: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); if (lookahead == 'a') ADVANCE(80); END_STATE(); - case 220: + case 226: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); - if (lookahead == 'o') ADVANCE(112); + if (lookahead == 'o') ADVANCE(116); END_STATE(); - case 221: + case 227: ACCEPT_TOKEN(sym_weight); if (lookahead == '+') ADVANCE(176); - if (lookahead == 'r') ADVANCE(219); + if (lookahead == 'r') ADVANCE(225); END_STATE(); - case 222: + case 228: ACCEPT_TOKEN(sym_weight); - if (lookahead == '/') ADVANCE(16); + if (lookahead == '/') ADVANCE(19); END_STATE(); - case 223: + case 229: ACCEPT_TOKEN(sym_weight); - if (lookahead == '/') ADVANCE(16); + if (lookahead == '/') ADVANCE(19); if (lookahead == 'a') ADVANCE(79); END_STATE(); - case 224: + case 230: ACCEPT_TOKEN(sym_weight); - if (lookahead == '/') ADVANCE(16); - if (lookahead == 'o') ADVANCE(110); + if (lookahead == '/') ADVANCE(19); + if (lookahead == 'o') ADVANCE(112); END_STATE(); - case 225: + case 231: ACCEPT_TOKEN(sym_weight); - if (lookahead == '/') ADVANCE(16); - if (lookahead == 'r') ADVANCE(223); + if (lookahead == '/') ADVANCE(19); + if (lookahead == 'r') ADVANCE(229); END_STATE(); - case 226: + case 232: ACCEPT_TOKEN(sym_rep_scheme); - if (lookahead == '/') ADVANCE(169); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(226); + ADVANCE_MAP( + '.', 178, + '/', 18, + 'c', 33, + 'g', 231, + 'k', 67, + 'l', 35, + 'o', 164, + 'p', 131, + 's', 157, + 't', 230, + ); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(232); END_STATE(); - case 227: + case 233: ACCEPT_TOKEN(sym_rep_scheme); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(227); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(233); END_STATE(); - case 228: + case 234: ACCEPT_TOKEN(sym_duration); END_STATE(); - case 229: + case 235: ACCEPT_TOKEN(sym_duration); - if (('0' <= lookahead && lookahead <= '9')) ADVANCE(17); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(20); END_STATE(); - case 230: + case 236: ACCEPT_TOKEN(sym_duration); if (('0' <= lookahead && lookahead <= '9')) ADVANCE(10); END_STATE(); - case 231: + case 237: ACCEPT_TOKEN(sym_time_of_day); END_STATE(); - case 232: + case 238: ACCEPT_TOKEN(sym_distance); END_STATE(); - case 233: + case 239: ACCEPT_TOKEN(sym_distance); if (lookahead == 'c') ADVANCE(74); END_STATE(); - case 234: + case 240: ACCEPT_TOKEN(sym_distance); - if (lookahead == 'e') ADVANCE(144); - if (lookahead == 'i') ADVANCE(235); - if (lookahead == 'm') ADVANCE(232); + if (lookahead == 'e') ADVANCE(148); + if (lookahead == 'i') ADVANCE(241); + if (lookahead == 'm') ADVANCE(238); END_STATE(); - case 235: + case 241: ACCEPT_TOKEN(sym_distance); if (lookahead == 'l') ADVANCE(54); END_STATE(); - case 236: + case 242: ACCEPT_TOKEN(sym_quoted_string); END_STATE(); default: @@ -1689,16 +1741,16 @@ static const TSLexerMode ts_lex_modes[STATE_COUNT] = { [1] = {.lex_state = 0}, [2] = {.lex_state = 0}, [3] = {.lex_state = 0}, - [4] = {.lex_state = 182}, - [5] = {.lex_state = 182}, - [6] = {.lex_state = 182}, - [7] = {.lex_state = 182}, - [8] = {.lex_state = 182}, - [9] = {.lex_state = 182}, - [10] = {.lex_state = 182}, - [11] = {.lex_state = 182}, - [12] = {.lex_state = 182}, - [13] = {.lex_state = 182}, + [4] = {.lex_state = 187}, + [5] = {.lex_state = 187}, + [6] = {.lex_state = 187}, + [7] = {.lex_state = 187}, + [8] = {.lex_state = 187}, + [9] = {.lex_state = 187}, + [10] = {.lex_state = 187}, + [11] = {.lex_state = 187}, + [12] = {.lex_state = 187}, + [13] = {.lex_state = 187}, [14] = {.lex_state = 0}, [15] = {.lex_state = 0}, [16] = {.lex_state = 0}, @@ -1727,52 +1779,55 @@ static const TSLexerMode ts_lex_modes[STATE_COUNT] = { [39] = {.lex_state = 0}, [40] = {.lex_state = 0}, [41] = {.lex_state = 0}, - [42] = {.lex_state = 14}, - [43] = {.lex_state = 182}, - [44] = {.lex_state = 14}, - [45] = {.lex_state = 14}, - [46] = {.lex_state = 14}, - [47] = {.lex_state = 14}, - [48] = {.lex_state = 182}, - [49] = {.lex_state = 15}, - [50] = {.lex_state = 15}, - [51] = {.lex_state = 15}, - [52] = {.lex_state = 1}, - [53] = {.lex_state = 0}, - [54] = {.lex_state = 14}, - [55] = {.lex_state = 14}, - [56] = {.lex_state = 1}, - [57] = {.lex_state = 164}, + [42] = {.lex_state = 0}, + [43] = {.lex_state = 0}, + [44] = {.lex_state = 16}, + [45] = {.lex_state = 16}, + [46] = {.lex_state = 16}, + [47] = {.lex_state = 16}, + [48] = {.lex_state = 187}, + [49] = {.lex_state = 16}, + [50] = {.lex_state = 187}, + [51] = {.lex_state = 17}, + [52] = {.lex_state = 17}, + [53] = {.lex_state = 17}, + [54] = {.lex_state = 1}, + [55] = {.lex_state = 16}, + [56] = {.lex_state = 0}, + [57] = {.lex_state = 16}, [58] = {.lex_state = 0}, - [59] = {.lex_state = 1}, - [60] = {.lex_state = 15}, - [61] = {.lex_state = 164}, - [62] = {.lex_state = 0}, - [63] = {.lex_state = 15}, - [64] = {.lex_state = 0}, + [59] = {.lex_state = 170}, + [60] = {.lex_state = 1}, + [61] = {.lex_state = 0}, + [62] = {.lex_state = 17}, + [63] = {.lex_state = 1}, + [64] = {.lex_state = 17}, [65] = {.lex_state = 0}, - [66] = {.lex_state = 0}, + [66] = {.lex_state = 170}, [67] = {.lex_state = 0}, [68] = {.lex_state = 0}, - [69] = {.lex_state = 182}, - [70] = {.lex_state = 182}, + [69] = {.lex_state = 0}, + [70] = {.lex_state = 187}, [71] = {.lex_state = 0}, [72] = {.lex_state = 0}, [73] = {.lex_state = 0}, - [74] = {.lex_state = 0}, + [74] = {.lex_state = 187}, [75] = {.lex_state = 0}, [76] = {.lex_state = 0}, - [77] = {.lex_state = 182}, - [78] = {.lex_state = 182}, + [77] = {.lex_state = 170}, + [78] = {.lex_state = 0}, [79] = {.lex_state = 0}, [80] = {.lex_state = 0}, - [81] = {.lex_state = 164}, - [82] = {.lex_state = 0}, - [83] = {.lex_state = 165}, - [84] = {.lex_state = 0}, - [85] = {.lex_state = 0}, + [81] = {.lex_state = 0}, + [82] = {.lex_state = 187}, + [83] = {.lex_state = 0}, + [84] = {.lex_state = 187}, + [85] = {.lex_state = 171}, [86] = {.lex_state = 0}, - [87] = {.lex_state = 1}, + [87] = {.lex_state = 0}, + [88] = {.lex_state = 0}, + [89] = {.lex_state = 0}, + [90] = {.lex_state = 1}, }; static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { @@ -1780,6 +1835,7 @@ static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { [ts_builtin_sym_end] = ACTIONS(1), [anon_sym_LF] = ACTIONS(1), [anon_sym_ATinclude] = ACTIONS(1), + [anon_sym_ATplugin] = ACTIONS(1), [anon_sym_DQUOTE] = ACTIONS(1), [sym_comment] = ACTIONS(1), [anon_sym_COLON] = ACTIONS(1), @@ -1788,7 +1844,7 @@ static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { [anon_sym_query] = ACTIONS(1), [anon_sym_ATsession] = ACTIONS(1), [anon_sym_ATend] = ACTIONS(1), - [anon_sym_ATexercise] = ACTIONS(1), + [anon_sym_ATmovement] = ACTIONS(1), [anon_sym_ATtemplate] = ACTIONS(1), [anon_sym_note_COLON] = ACTIONS(1), [sym_date] = ACTIONS(1), @@ -1801,1031 +1857,1123 @@ static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { [sym_distance] = ACTIONS(1), }, [STATE(1)] = { - [sym_source_file] = STATE(84), - [sym__entry] = STATE(2), - [sym_include_directive] = STATE(2), - [sym_singleline_entry] = STATE(2), - [sym_note_entry] = STATE(2), - [sym_weigh_in_entry] = STATE(2), - [sym_query_entry] = STATE(2), - [sym_session_block] = STATE(2), - [sym_exercise_block] = STATE(2), - [sym_template_block] = STATE(2), - [aux_sym_source_file_repeat1] = STATE(2), + [sym_source_file] = STATE(72), + [sym__entry] = STATE(3), + [sym_include_directive] = STATE(3), + [sym_plugin_directive] = STATE(3), + [sym_singleline_entry] = STATE(3), + [sym_note_entry] = STATE(3), + [sym_weigh_in_entry] = STATE(3), + [sym_query_entry] = STATE(3), + [sym_session_block] = STATE(3), + [sym_movement_block] = STATE(3), + [sym_template_block] = STATE(3), + [aux_sym_source_file_repeat1] = STATE(3), [ts_builtin_sym_end] = ACTIONS(3), [anon_sym_LF] = ACTIONS(5), [anon_sym_ATinclude] = ACTIONS(7), + [anon_sym_ATplugin] = ACTIONS(9), [sym_comment] = ACTIONS(5), - [anon_sym_ATsession] = ACTIONS(9), - [anon_sym_ATexercise] = ACTIONS(11), - [anon_sym_ATtemplate] = ACTIONS(13), - [sym_date] = ACTIONS(15), + [anon_sym_ATsession] = ACTIONS(11), + [anon_sym_ATmovement] = ACTIONS(13), + [anon_sym_ATtemplate] = ACTIONS(15), + [sym_date] = ACTIONS(17), }, }; static const uint16_t ts_small_parse_table[] = { - [0] = 8, - ACTIONS(7), 1, + [0] = 9, + ACTIONS(19), 1, + ts_builtin_sym_end, + ACTIONS(24), 1, anon_sym_ATinclude, - ACTIONS(9), 1, + ACTIONS(27), 1, + anon_sym_ATplugin, + ACTIONS(30), 1, anon_sym_ATsession, - ACTIONS(11), 1, - anon_sym_ATexercise, - ACTIONS(13), 1, + ACTIONS(33), 1, + anon_sym_ATmovement, + ACTIONS(36), 1, anon_sym_ATtemplate, - ACTIONS(15), 1, + ACTIONS(39), 1, sym_date, - ACTIONS(17), 1, - ts_builtin_sym_end, - ACTIONS(19), 2, + ACTIONS(21), 2, anon_sym_LF, sym_comment, - STATE(3), 10, + STATE(2), 11, sym__entry, sym_include_directive, + sym_plugin_directive, sym_singleline_entry, sym_note_entry, sym_weigh_in_entry, sym_query_entry, sym_session_block, - sym_exercise_block, + sym_movement_block, sym_template_block, aux_sym_source_file_repeat1, - [35] = 8, - ACTIONS(21), 1, - ts_builtin_sym_end, - ACTIONS(26), 1, + [39] = 9, + ACTIONS(7), 1, anon_sym_ATinclude, - ACTIONS(29), 1, + ACTIONS(9), 1, + anon_sym_ATplugin, + ACTIONS(11), 1, anon_sym_ATsession, - ACTIONS(32), 1, - anon_sym_ATexercise, - ACTIONS(35), 1, + ACTIONS(13), 1, + anon_sym_ATmovement, + ACTIONS(15), 1, anon_sym_ATtemplate, - ACTIONS(38), 1, + ACTIONS(17), 1, sym_date, - ACTIONS(23), 2, + ACTIONS(42), 1, + ts_builtin_sym_end, + ACTIONS(44), 2, anon_sym_LF, sym_comment, - STATE(3), 10, + STATE(2), 11, sym__entry, sym_include_directive, + sym_plugin_directive, sym_singleline_entry, sym_note_entry, sym_weigh_in_entry, sym_query_entry, sym_session_block, - sym_exercise_block, + sym_movement_block, sym_template_block, aux_sym_source_file_repeat1, - [70] = 9, - ACTIONS(43), 1, + [78] = 9, + ACTIONS(48), 1, anon_sym_LF, - ACTIONS(45), 1, + ACTIONS(50), 1, sym_weight, - ACTIONS(47), 1, + ACTIONS(52), 1, sym_rep_scheme, - ACTIONS(49), 1, + ACTIONS(54), 1, sym_duration, - ACTIONS(51), 1, + ACTIONS(56), 1, sym_distance, - ACTIONS(53), 1, + ACTIONS(58), 1, sym_quoted_string, - STATE(5), 1, + STATE(6), 1, aux_sym_details_repeat1, - STATE(33), 1, + STATE(32), 1, sym_details, - ACTIONS(41), 7, + ACTIONS(46), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [104] = 7, - ACTIONS(45), 1, + [113] = 7, + ACTIONS(62), 1, sym_weight, - ACTIONS(47), 1, + ACTIONS(65), 1, sym_rep_scheme, - ACTIONS(49), 1, + ACTIONS(68), 1, sym_duration, - ACTIONS(51), 1, + ACTIONS(71), 1, sym_distance, - ACTIONS(53), 1, + ACTIONS(74), 1, sym_quoted_string, - STATE(6), 1, + STATE(5), 1, aux_sym_details_repeat1, - ACTIONS(55), 8, + ACTIONS(60), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [133] = 7, - ACTIONS(59), 1, + [143] = 7, + ACTIONS(50), 1, sym_weight, - ACTIONS(62), 1, + ACTIONS(52), 1, sym_rep_scheme, - ACTIONS(65), 1, + ACTIONS(54), 1, sym_duration, - ACTIONS(68), 1, + ACTIONS(56), 1, sym_distance, - ACTIONS(71), 1, + ACTIONS(58), 1, sym_quoted_string, - STATE(6), 1, + STATE(5), 1, aux_sym_details_repeat1, - ACTIONS(57), 8, + ACTIONS(77), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [162] = 1, - ACTIONS(74), 13, + [173] = 2, + ACTIONS(81), 1, + sym_rep_scheme, + ACTIONS(79), 13, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, sym_weight, - sym_rep_scheme, sym_duration, sym_distance, sym_quoted_string, - [178] = 1, - ACTIONS(76), 13, + [192] = 2, + ACTIONS(85), 1, + sym_rep_scheme, + ACTIONS(83), 13, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, sym_weight, - sym_rep_scheme, sym_duration, sym_distance, sym_quoted_string, - [194] = 1, - ACTIONS(78), 13, + [211] = 2, + ACTIONS(89), 1, + sym_rep_scheme, + ACTIONS(87), 13, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, sym_weight, - sym_rep_scheme, sym_duration, sym_distance, sym_quoted_string, - [210] = 1, - ACTIONS(80), 13, + [230] = 2, + ACTIONS(93), 1, + sym_rep_scheme, + ACTIONS(91), 13, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, sym_weight, - sym_rep_scheme, sym_duration, sym_distance, sym_quoted_string, - [226] = 1, - ACTIONS(82), 13, + [249] = 2, + ACTIONS(97), 1, + sym_rep_scheme, + ACTIONS(95), 13, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, sym_weight, - sym_rep_scheme, sym_duration, sym_distance, sym_quoted_string, - [242] = 4, - ACTIONS(86), 1, + [268] = 4, + ACTIONS(101), 1, anon_sym_LF, - ACTIONS(88), 1, + ACTIONS(103), 1, sym_time_of_day, - ACTIONS(90), 1, + ACTIONS(105), 1, sym_quoted_string, - ACTIONS(84), 7, + ACTIONS(99), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [261] = 3, - ACTIONS(94), 1, + [288] = 3, + ACTIONS(109), 1, anon_sym_LF, - ACTIONS(96), 1, + ACTIONS(111), 1, sym_quoted_string, - ACTIONS(92), 7, + ACTIONS(107), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [277] = 2, - ACTIONS(100), 1, - anon_sym_LF, - ACTIONS(98), 7, + [305] = 1, + ACTIONS(113), 9, ts_builtin_sym_end, + anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [290] = 1, - ACTIONS(102), 8, - ts_builtin_sym_end, + [317] = 2, + ACTIONS(117), 1, anon_sym_LF, + ACTIONS(115), 8, + ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [301] = 1, - ACTIONS(104), 8, - ts_builtin_sym_end, + [331] = 2, + ACTIONS(121), 1, anon_sym_LF, + ACTIONS(119), 8, + ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [312] = 2, - ACTIONS(108), 1, - anon_sym_LF, - ACTIONS(106), 7, + [345] = 1, + ACTIONS(123), 9, ts_builtin_sym_end, + anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [325] = 1, - ACTIONS(110), 8, + [357] = 1, + ACTIONS(125), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [336] = 2, - ACTIONS(114), 1, + [369] = 2, + ACTIONS(127), 1, anon_sym_LF, - ACTIONS(112), 7, + ACTIONS(125), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [349] = 2, - ACTIONS(118), 1, - anon_sym_LF, - ACTIONS(116), 7, + [383] = 1, + ACTIONS(129), 9, ts_builtin_sym_end, + anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [362] = 1, - ACTIONS(120), 8, - ts_builtin_sym_end, + [395] = 2, + ACTIONS(131), 1, anon_sym_LF, + ACTIONS(129), 8, + ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [373] = 1, - ACTIONS(122), 8, + [409] = 1, + ACTIONS(133), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [384] = 2, - ACTIONS(126), 1, + [421] = 2, + ACTIONS(137), 1, anon_sym_LF, - ACTIONS(124), 7, + ACTIONS(135), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [397] = 1, - ACTIONS(128), 8, + [435] = 1, + ACTIONS(139), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [408] = 2, - ACTIONS(130), 1, - anon_sym_LF, - ACTIONS(128), 7, + [447] = 1, + ACTIONS(141), 9, ts_builtin_sym_end, + anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [421] = 1, - ACTIONS(132), 8, + [459] = 1, + ACTIONS(143), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [432] = 1, - ACTIONS(134), 8, + [471] = 1, + ACTIONS(145), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [443] = 2, - ACTIONS(138), 1, + [483] = 2, + ACTIONS(149), 1, anon_sym_LF, - ACTIONS(136), 7, + ACTIONS(147), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [456] = 1, - ACTIONS(140), 8, - ts_builtin_sym_end, + [497] = 2, + ACTIONS(153), 1, anon_sym_LF, + ACTIONS(151), 8, + ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [467] = 1, - ACTIONS(142), 8, + [511] = 1, + ACTIONS(155), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [478] = 1, - ACTIONS(144), 8, - ts_builtin_sym_end, + [523] = 2, + ACTIONS(159), 1, anon_sym_LF, + ACTIONS(157), 8, + ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [489] = 2, - ACTIONS(148), 1, + [537] = 2, + ACTIONS(163), 1, anon_sym_LF, - ACTIONS(146), 7, + ACTIONS(161), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [502] = 2, - ACTIONS(152), 1, + [551] = 2, + ACTIONS(167), 1, anon_sym_LF, - ACTIONS(150), 7, + ACTIONS(165), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [515] = 1, - ACTIONS(154), 8, + [565] = 1, + ACTIONS(169), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [526] = 1, - ACTIONS(156), 8, + [577] = 1, + ACTIONS(171), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [537] = 1, - ACTIONS(158), 8, + [589] = 1, + ACTIONS(173), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [548] = 1, - ACTIONS(160), 8, + [601] = 1, + ACTIONS(175), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [559] = 2, - ACTIONS(164), 1, + [613] = 1, + ACTIONS(177), 9, + ts_builtin_sym_end, anon_sym_LF, - ACTIONS(162), 7, + anon_sym_ATinclude, + anon_sym_ATplugin, + sym_comment, + anon_sym_ATsession, + anon_sym_ATmovement, + anon_sym_ATtemplate, + sym_date, + [625] = 2, + ACTIONS(181), 1, + anon_sym_LF, + ACTIONS(179), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [572] = 1, - ACTIONS(166), 8, + [639] = 2, + ACTIONS(185), 1, + anon_sym_LF, + ACTIONS(183), 8, + ts_builtin_sym_end, + anon_sym_ATinclude, + anon_sym_ATplugin, + sym_comment, + anon_sym_ATsession, + anon_sym_ATmovement, + anon_sym_ATtemplate, + sym_date, + [653] = 1, + ACTIONS(187), 9, ts_builtin_sym_end, anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [583] = 2, - ACTIONS(168), 1, + [665] = 2, + ACTIONS(189), 1, anon_sym_LF, - ACTIONS(166), 7, + ACTIONS(187), 8, ts_builtin_sym_end, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [596] = 2, - ACTIONS(170), 1, - anon_sym_LF, - ACTIONS(132), 7, + [679] = 1, + ACTIONS(191), 9, ts_builtin_sym_end, + anon_sym_LF, anon_sym_ATinclude, + anon_sym_ATplugin, sym_comment, anon_sym_ATsession, - anon_sym_ATexercise, + anon_sym_ATmovement, anon_sym_ATtemplate, sym_date, - [609] = 5, - ACTIONS(172), 1, + [691] = 5, + ACTIONS(193), 1, anon_sym_ATend, - ACTIONS(174), 1, + ACTIONS(195), 1, anon_sym_note_COLON, - ACTIONS(176), 1, + ACTIONS(197), 1, aux_sym_item_token1, - STATE(80), 1, + STATE(87), 1, sym_item, - STATE(47), 3, + STATE(49), 3, sym_item_line, sym_note_line, aux_sym_session_block_repeat1, - [627] = 7, - ACTIONS(45), 1, - sym_weight, - ACTIONS(47), 1, - sym_rep_scheme, - ACTIONS(49), 1, - sym_duration, - ACTIONS(51), 1, - sym_distance, - ACTIONS(53), 1, - sym_quoted_string, - STATE(5), 1, - aux_sym_details_repeat1, - STATE(75), 1, - sym_details, - [649] = 5, - ACTIONS(178), 1, - anon_sym_ATend, - ACTIONS(180), 1, + [709] = 5, + ACTIONS(195), 1, anon_sym_note_COLON, - ACTIONS(183), 1, + ACTIONS(197), 1, aux_sym_item_token1, - STATE(80), 1, + ACTIONS(199), 1, + anon_sym_ATend, + STATE(87), 1, sym_item, - STATE(44), 3, + STATE(49), 3, sym_item_line, sym_note_line, aux_sym_session_block_repeat1, - [667] = 5, - ACTIONS(174), 1, + [727] = 5, + ACTIONS(195), 1, anon_sym_note_COLON, - ACTIONS(176), 1, + ACTIONS(197), 1, aux_sym_item_token1, - ACTIONS(186), 1, + ACTIONS(201), 1, anon_sym_ATend, - STATE(80), 1, + STATE(87), 1, sym_item, - STATE(46), 3, + STATE(45), 3, sym_item_line, sym_note_line, aux_sym_session_block_repeat1, - [685] = 5, - ACTIONS(174), 1, + [745] = 5, + ACTIONS(195), 1, anon_sym_note_COLON, - ACTIONS(176), 1, + ACTIONS(197), 1, aux_sym_item_token1, - ACTIONS(188), 1, + ACTIONS(203), 1, anon_sym_ATend, - STATE(80), 1, + STATE(87), 1, sym_item, STATE(44), 3, sym_item_line, sym_note_line, aux_sym_session_block_repeat1, - [703] = 5, - ACTIONS(174), 1, + [763] = 7, + ACTIONS(50), 1, + sym_weight, + ACTIONS(52), 1, + sym_rep_scheme, + ACTIONS(54), 1, + sym_duration, + ACTIONS(56), 1, + sym_distance, + ACTIONS(58), 1, + sym_quoted_string, + STATE(6), 1, + aux_sym_details_repeat1, + STATE(83), 1, + sym_details, + [785] = 5, + ACTIONS(205), 1, + anon_sym_ATend, + ACTIONS(207), 1, anon_sym_note_COLON, - ACTIONS(176), 1, + ACTIONS(210), 1, aux_sym_item_token1, - ACTIONS(190), 1, - anon_sym_ATend, - STATE(80), 1, + STATE(87), 1, sym_item, - STATE(44), 3, + STATE(49), 3, sym_item_line, sym_note_line, aux_sym_session_block_repeat1, - [721] = 5, - ACTIONS(192), 1, + [803] = 5, + ACTIONS(213), 1, anon_sym_note, - ACTIONS(194), 1, + ACTIONS(215), 1, anon_sym_W, - ACTIONS(196), 1, + ACTIONS(217), 1, anon_sym_query, - STATE(61), 1, + STATE(66), 1, sym_flag, - ACTIONS(198), 2, + ACTIONS(219), 2, anon_sym_STAR, anon_sym_BANG, - [738] = 4, - ACTIONS(200), 1, + [820] = 4, + ACTIONS(221), 1, anon_sym_ATend, - ACTIONS(202), 1, + ACTIONS(223), 1, aux_sym_item_token1, - STATE(73), 1, + STATE(81), 1, sym_identifier, - STATE(49), 2, + STATE(51), 2, sym_metadata_line, - aux_sym_exercise_block_repeat1, - [752] = 4, - ACTIONS(205), 1, + aux_sym_movement_block_repeat1, + [834] = 4, + ACTIONS(226), 1, anon_sym_ATend, - ACTIONS(207), 1, + ACTIONS(228), 1, aux_sym_item_token1, - STATE(73), 1, + STATE(81), 1, sym_identifier, - STATE(51), 2, + STATE(53), 2, sym_metadata_line, - aux_sym_exercise_block_repeat1, - [766] = 4, - ACTIONS(207), 1, + aux_sym_movement_block_repeat1, + [848] = 4, + ACTIONS(228), 1, aux_sym_item_token1, - ACTIONS(209), 1, + ACTIONS(230), 1, anon_sym_ATend, - STATE(73), 1, + STATE(81), 1, sym_identifier, - STATE(49), 2, + STATE(51), 2, sym_metadata_line, - aux_sym_exercise_block_repeat1, - [780] = 3, - ACTIONS(211), 1, + aux_sym_movement_block_repeat1, + [862] = 3, + ACTIONS(232), 1, anon_sym_LF, - ACTIONS(213), 1, + ACTIONS(234), 1, aux_sym_name_token1, - STATE(72), 1, + STATE(79), 1, sym_text_until_newline, - [790] = 2, - STATE(59), 1, + [872] = 2, + ACTIONS(238), 1, + anon_sym_note_COLON, + ACTIONS(236), 2, + anon_sym_ATend, + aux_sym_item_token1, + [880] = 2, + STATE(63), 1, sym_flag, - ACTIONS(215), 2, + ACTIONS(240), 2, anon_sym_STAR, anon_sym_BANG, - [798] = 2, - ACTIONS(219), 1, + [888] = 2, + ACTIONS(244), 1, anon_sym_note_COLON, - ACTIONS(217), 2, + ACTIONS(242), 2, anon_sym_ATend, aux_sym_item_token1, - [806] = 2, - ACTIONS(223), 1, - anon_sym_note_COLON, - ACTIONS(221), 2, - anon_sym_ATend, + [896] = 2, + ACTIONS(246), 1, + anon_sym_DQUOTE, + STATE(29), 1, + sym_file_path, + [903] = 2, + ACTIONS(248), 1, aux_sym_item_token1, - [814] = 2, - ACTIONS(225), 1, + STATE(67), 1, + sym_identifier, + [910] = 2, + ACTIONS(250), 1, aux_sym_name_token1, - STATE(66), 1, + STATE(71), 1, sym_name, - [821] = 2, - ACTIONS(227), 1, - aux_sym_item_token1, - STATE(68), 1, - sym_identifier, - [828] = 2, - ACTIONS(229), 1, + [917] = 2, + ACTIONS(246), 1, anon_sym_DQUOTE, - STATE(32), 1, + STATE(40), 1, sym_file_path, - [835] = 2, - ACTIONS(225), 1, + [924] = 1, + ACTIONS(252), 2, + anon_sym_ATend, + aux_sym_item_token1, + [929] = 2, + ACTIONS(250), 1, aux_sym_name_token1, - STATE(67), 1, + STATE(88), 1, sym_name, - [842] = 1, - ACTIONS(231), 2, + [936] = 1, + ACTIONS(254), 2, anon_sym_ATend, aux_sym_item_token1, - [847] = 2, - ACTIONS(233), 1, - aux_sym_item_token1, - STATE(76), 1, - sym_item, - [854] = 1, - ACTIONS(235), 2, + [941] = 1, + ACTIONS(256), 2, anon_sym_LF, anon_sym_COLON, - [859] = 1, - ACTIONS(237), 2, - anon_sym_ATend, + [946] = 2, + ACTIONS(258), 1, aux_sym_item_token1, - [864] = 1, - ACTIONS(239), 1, - anon_sym_LF, - [868] = 1, - ACTIONS(241), 1, - sym_date, - [872] = 1, - ACTIONS(243), 1, - anon_sym_LF, - [876] = 1, - ACTIONS(245), 1, + STATE(76), 1, + sym_item, + [953] = 1, + ACTIONS(260), 1, anon_sym_LF, - [880] = 1, - ACTIONS(247), 1, + [957] = 1, + ACTIONS(262), 1, + sym_weight, + [961] = 1, + ACTIONS(264), 1, anon_sym_LF, - [884] = 1, - ACTIONS(249), 1, + [965] = 1, + ACTIONS(266), 1, sym_quoted_string, - [888] = 1, - ACTIONS(251), 1, - sym_quoted_string, - [892] = 1, - ACTIONS(253), 1, - anon_sym_LF, - [896] = 1, - ACTIONS(255), 1, + [969] = 1, + ACTIONS(268), 1, anon_sym_LF, - [900] = 1, - ACTIONS(257), 1, + [973] = 1, + ACTIONS(270), 1, + ts_builtin_sym_end, + [977] = 1, + ACTIONS(272), 1, + anon_sym_DQUOTE, + [981] = 1, + ACTIONS(274), 1, + sym_quoted_string, + [985] = 1, + ACTIONS(276), 1, anon_sym_COLON, - [904] = 1, - ACTIONS(259), 1, + [989] = 1, + ACTIONS(278), 1, anon_sym_COLON, - [908] = 1, - ACTIONS(261), 1, + [993] = 1, + ACTIONS(280), 1, + aux_sym_item_token1, + [997] = 1, + ACTIONS(282), 1, + anon_sym_LF, + [1001] = 1, + ACTIONS(284), 1, anon_sym_LF, - [912] = 1, - ACTIONS(263), 1, + [1005] = 1, + ACTIONS(286), 1, + anon_sym_LF, + [1009] = 1, + ACTIONS(288), 1, anon_sym_COLON, - [916] = 1, - ACTIONS(265), 1, + [1013] = 1, + ACTIONS(290), 1, sym_quoted_string, - [920] = 1, - ACTIONS(267), 1, + [1017] = 1, + ACTIONS(292), 1, + anon_sym_LF, + [1021] = 1, + ACTIONS(294), 1, sym_quoted_string, - [924] = 1, - ACTIONS(269), 1, + [1025] = 1, + ACTIONS(296), 1, + aux_sym_file_path_token1, + [1029] = 1, + ACTIONS(298), 1, anon_sym_LF, - [928] = 1, - ACTIONS(271), 1, + [1033] = 1, + ACTIONS(300), 1, anon_sym_COLON, - [932] = 1, - ACTIONS(273), 1, - aux_sym_item_token1, - [936] = 1, - ACTIONS(275), 1, + [1037] = 1, + ACTIONS(302), 1, anon_sym_LF, - [940] = 1, - ACTIONS(277), 1, - aux_sym_file_path_token1, - [944] = 1, - ACTIONS(279), 1, - ts_builtin_sym_end, - [948] = 1, - ACTIONS(281), 1, - anon_sym_DQUOTE, - [952] = 1, - ACTIONS(283), 1, - sym_weight, - [956] = 1, - ACTIONS(273), 1, + [1041] = 1, + ACTIONS(304), 1, + sym_date, + [1045] = 1, + ACTIONS(280), 1, aux_sym_name_token1, }; static const uint32_t ts_small_parse_table_map[] = { [SMALL_STATE(2)] = 0, - [SMALL_STATE(3)] = 35, - [SMALL_STATE(4)] = 70, - [SMALL_STATE(5)] = 104, - [SMALL_STATE(6)] = 133, - [SMALL_STATE(7)] = 162, - [SMALL_STATE(8)] = 178, - [SMALL_STATE(9)] = 194, - [SMALL_STATE(10)] = 210, - [SMALL_STATE(11)] = 226, - [SMALL_STATE(12)] = 242, - [SMALL_STATE(13)] = 261, - [SMALL_STATE(14)] = 277, - [SMALL_STATE(15)] = 290, - [SMALL_STATE(16)] = 301, - [SMALL_STATE(17)] = 312, - [SMALL_STATE(18)] = 325, - [SMALL_STATE(19)] = 336, - [SMALL_STATE(20)] = 349, - [SMALL_STATE(21)] = 362, - [SMALL_STATE(22)] = 373, - [SMALL_STATE(23)] = 384, - [SMALL_STATE(24)] = 397, - [SMALL_STATE(25)] = 408, - [SMALL_STATE(26)] = 421, - [SMALL_STATE(27)] = 432, - [SMALL_STATE(28)] = 443, - [SMALL_STATE(29)] = 456, - [SMALL_STATE(30)] = 467, - [SMALL_STATE(31)] = 478, - [SMALL_STATE(32)] = 489, - [SMALL_STATE(33)] = 502, - [SMALL_STATE(34)] = 515, - [SMALL_STATE(35)] = 526, - [SMALL_STATE(36)] = 537, - [SMALL_STATE(37)] = 548, - [SMALL_STATE(38)] = 559, - [SMALL_STATE(39)] = 572, - [SMALL_STATE(40)] = 583, - [SMALL_STATE(41)] = 596, - [SMALL_STATE(42)] = 609, - [SMALL_STATE(43)] = 627, - [SMALL_STATE(44)] = 649, - [SMALL_STATE(45)] = 667, - [SMALL_STATE(46)] = 685, - [SMALL_STATE(47)] = 703, - [SMALL_STATE(48)] = 721, - [SMALL_STATE(49)] = 738, - [SMALL_STATE(50)] = 752, - [SMALL_STATE(51)] = 766, - [SMALL_STATE(52)] = 780, - [SMALL_STATE(53)] = 790, - [SMALL_STATE(54)] = 798, - [SMALL_STATE(55)] = 806, - [SMALL_STATE(56)] = 814, - [SMALL_STATE(57)] = 821, - [SMALL_STATE(58)] = 828, - [SMALL_STATE(59)] = 835, - [SMALL_STATE(60)] = 842, - [SMALL_STATE(61)] = 847, - [SMALL_STATE(62)] = 854, - [SMALL_STATE(63)] = 859, - [SMALL_STATE(64)] = 864, - [SMALL_STATE(65)] = 868, - [SMALL_STATE(66)] = 872, - [SMALL_STATE(67)] = 876, - [SMALL_STATE(68)] = 880, - [SMALL_STATE(69)] = 884, - [SMALL_STATE(70)] = 888, - [SMALL_STATE(71)] = 892, - [SMALL_STATE(72)] = 896, - [SMALL_STATE(73)] = 900, - [SMALL_STATE(74)] = 904, - [SMALL_STATE(75)] = 908, - [SMALL_STATE(76)] = 912, - [SMALL_STATE(77)] = 916, - [SMALL_STATE(78)] = 920, - [SMALL_STATE(79)] = 924, - [SMALL_STATE(80)] = 928, - [SMALL_STATE(81)] = 932, - [SMALL_STATE(82)] = 936, - [SMALL_STATE(83)] = 940, - [SMALL_STATE(84)] = 944, - [SMALL_STATE(85)] = 948, - [SMALL_STATE(86)] = 952, - [SMALL_STATE(87)] = 956, + [SMALL_STATE(3)] = 39, + [SMALL_STATE(4)] = 78, + [SMALL_STATE(5)] = 113, + [SMALL_STATE(6)] = 143, + [SMALL_STATE(7)] = 173, + [SMALL_STATE(8)] = 192, + [SMALL_STATE(9)] = 211, + [SMALL_STATE(10)] = 230, + [SMALL_STATE(11)] = 249, + [SMALL_STATE(12)] = 268, + [SMALL_STATE(13)] = 288, + [SMALL_STATE(14)] = 305, + [SMALL_STATE(15)] = 317, + [SMALL_STATE(16)] = 331, + [SMALL_STATE(17)] = 345, + [SMALL_STATE(18)] = 357, + [SMALL_STATE(19)] = 369, + [SMALL_STATE(20)] = 383, + [SMALL_STATE(21)] = 395, + [SMALL_STATE(22)] = 409, + [SMALL_STATE(23)] = 421, + [SMALL_STATE(24)] = 435, + [SMALL_STATE(25)] = 447, + [SMALL_STATE(26)] = 459, + [SMALL_STATE(27)] = 471, + [SMALL_STATE(28)] = 483, + [SMALL_STATE(29)] = 497, + [SMALL_STATE(30)] = 511, + [SMALL_STATE(31)] = 523, + [SMALL_STATE(32)] = 537, + [SMALL_STATE(33)] = 551, + [SMALL_STATE(34)] = 565, + [SMALL_STATE(35)] = 577, + [SMALL_STATE(36)] = 589, + [SMALL_STATE(37)] = 601, + [SMALL_STATE(38)] = 613, + [SMALL_STATE(39)] = 625, + [SMALL_STATE(40)] = 639, + [SMALL_STATE(41)] = 653, + [SMALL_STATE(42)] = 665, + [SMALL_STATE(43)] = 679, + [SMALL_STATE(44)] = 691, + [SMALL_STATE(45)] = 709, + [SMALL_STATE(46)] = 727, + [SMALL_STATE(47)] = 745, + [SMALL_STATE(48)] = 763, + [SMALL_STATE(49)] = 785, + [SMALL_STATE(50)] = 803, + [SMALL_STATE(51)] = 820, + [SMALL_STATE(52)] = 834, + [SMALL_STATE(53)] = 848, + [SMALL_STATE(54)] = 862, + [SMALL_STATE(55)] = 872, + [SMALL_STATE(56)] = 880, + [SMALL_STATE(57)] = 888, + [SMALL_STATE(58)] = 896, + [SMALL_STATE(59)] = 903, + [SMALL_STATE(60)] = 910, + [SMALL_STATE(61)] = 917, + [SMALL_STATE(62)] = 924, + [SMALL_STATE(63)] = 929, + [SMALL_STATE(64)] = 936, + [SMALL_STATE(65)] = 941, + [SMALL_STATE(66)] = 946, + [SMALL_STATE(67)] = 953, + [SMALL_STATE(68)] = 957, + [SMALL_STATE(69)] = 961, + [SMALL_STATE(70)] = 965, + [SMALL_STATE(71)] = 969, + [SMALL_STATE(72)] = 973, + [SMALL_STATE(73)] = 977, + [SMALL_STATE(74)] = 981, + [SMALL_STATE(75)] = 985, + [SMALL_STATE(76)] = 989, + [SMALL_STATE(77)] = 993, + [SMALL_STATE(78)] = 997, + [SMALL_STATE(79)] = 1001, + [SMALL_STATE(80)] = 1005, + [SMALL_STATE(81)] = 1009, + [SMALL_STATE(82)] = 1013, + [SMALL_STATE(83)] = 1017, + [SMALL_STATE(84)] = 1021, + [SMALL_STATE(85)] = 1025, + [SMALL_STATE(86)] = 1029, + [SMALL_STATE(87)] = 1033, + [SMALL_STATE(88)] = 1037, + [SMALL_STATE(89)] = 1041, + [SMALL_STATE(90)] = 1045, }; static const TSParseActionEntry ts_parse_actions[] = { [0] = {.entry = {.count = 0, .reusable = false}}, [1] = {.entry = {.count = 1, .reusable = false}}, RECOVER(), [3] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 0, 0, 0), - [5] = {.entry = {.count = 1, .reusable = true}}, SHIFT(2), - [7] = {.entry = {.count = 1, .reusable = true}}, SHIFT(58), - [9] = {.entry = {.count = 1, .reusable = true}}, SHIFT(64), - [11] = {.entry = {.count = 1, .reusable = true}}, SHIFT(57), - [13] = {.entry = {.count = 1, .reusable = true}}, SHIFT(56), - [15] = {.entry = {.count = 1, .reusable = true}}, SHIFT(48), - [17] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 1, 0, 0), - [19] = {.entry = {.count = 1, .reusable = true}}, SHIFT(3), - [21] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), - [23] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(3), - [26] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(58), - [29] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(64), - [32] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(57), - [35] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(56), - [38] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(48), - [41] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 4, 0, 8), - [43] = {.entry = {.count = 1, .reusable = true}}, SHIFT(31), - [45] = {.entry = {.count = 1, .reusable = true}}, SHIFT(8), - [47] = {.entry = {.count = 1, .reusable = true}}, SHIFT(11), - [49] = {.entry = {.count = 1, .reusable = true}}, SHIFT(9), - [51] = {.entry = {.count = 1, .reusable = true}}, SHIFT(10), - [53] = {.entry = {.count = 1, .reusable = true}}, SHIFT(7), - [55] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_details, 1, 0, 16), - [57] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), - [59] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(8), + [5] = {.entry = {.count = 1, .reusable = true}}, SHIFT(3), + [7] = {.entry = {.count = 1, .reusable = true}}, SHIFT(61), + [9] = {.entry = {.count = 1, .reusable = true}}, SHIFT(58), + [11] = {.entry = {.count = 1, .reusable = true}}, SHIFT(86), + [13] = {.entry = {.count = 1, .reusable = true}}, SHIFT(59), + [15] = {.entry = {.count = 1, .reusable = true}}, SHIFT(60), + [17] = {.entry = {.count = 1, .reusable = true}}, SHIFT(50), + [19] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), + [21] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(2), + [24] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(61), + [27] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(58), + [30] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(86), + [33] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(59), + [36] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(60), + [39] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(50), + [42] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 1, 0, 0), + [44] = {.entry = {.count = 1, .reusable = true}}, SHIFT(2), + [46] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 4, 0, 8), + [48] = {.entry = {.count = 1, .reusable = true}}, SHIFT(26), + [50] = {.entry = {.count = 1, .reusable = true}}, SHIFT(11), + [52] = {.entry = {.count = 1, .reusable = false}}, SHIFT(7), + [54] = {.entry = {.count = 1, .reusable = true}}, SHIFT(9), + [56] = {.entry = {.count = 1, .reusable = true}}, SHIFT(10), + [58] = {.entry = {.count = 1, .reusable = true}}, SHIFT(8), + [60] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), [62] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(11), - [65] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(9), - [68] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(10), - [71] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(7), - [74] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 14), - [76] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 10), - [78] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 12), - [80] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 13), - [82] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 11), - [84] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 3, 0, 3), - [86] = {.entry = {.count = 1, .reusable = true}}, SHIFT(22), - [88] = {.entry = {.count = 1, .reusable = true}}, SHIFT(13), - [90] = {.entry = {.count = 1, .reusable = true}}, SHIFT(14), - [92] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 4, 0, 5), - [94] = {.entry = {.count = 1, .reusable = true}}, SHIFT(27), - [96] = {.entry = {.count = 1, .reusable = true}}, SHIFT(28), - [98] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 4, 0, 6), - [100] = {.entry = {.count = 1, .reusable = true}}, SHIFT(29), - [102] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_session_block, 9, 0, 20), - [104] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_include_directive, 3, 0, 1), - [106] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_note_entry, 3, 0, 2), - [108] = {.entry = {.count = 1, .reusable = true}}, SHIFT(21), - [110] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_file_path, 3, 0, 0), - [112] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_exercise_block, 4, 0, 4), - [114] = {.entry = {.count = 1, .reusable = true}}, SHIFT(24), - [116] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_template_block, 4, 0, 4), - [118] = {.entry = {.count = 1, .reusable = true}}, SHIFT(26), - [120] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_note_entry, 4, 0, 2), - [122] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 4, 0, 3), - [124] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_query_entry, 4, 0, 7), - [126] = {.entry = {.count = 1, .reusable = true}}, SHIFT(30), - [128] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_exercise_block, 5, 0, 4), - [130] = {.entry = {.count = 1, .reusable = true}}, SHIFT(34), - [132] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_template_block, 5, 0, 4), - [134] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 5, 0, 5), - [136] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 5, 0, 9), - [138] = {.entry = {.count = 1, .reusable = true}}, SHIFT(36), - [140] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 5, 0, 6), - [142] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_query_entry, 5, 0, 7), - [144] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 5, 0, 8), - [146] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_include_directive, 2, 0, 1), - [148] = {.entry = {.count = 1, .reusable = true}}, SHIFT(16), - [150] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 5, 0, 15), - [152] = {.entry = {.count = 1, .reusable = true}}, SHIFT(37), - [154] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_exercise_block, 6, 0, 4), - [156] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_template_block, 6, 0, 4), - [158] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 6, 0, 9), - [160] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 6, 0, 15), - [162] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_session_block, 7, 0, 20), - [164] = {.entry = {.count = 1, .reusable = true}}, SHIFT(39), - [166] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_session_block, 8, 0, 20), - [168] = {.entry = {.count = 1, .reusable = true}}, SHIFT(15), - [170] = {.entry = {.count = 1, .reusable = true}}, SHIFT(35), - [172] = {.entry = {.count = 1, .reusable = false}}, SHIFT(20), - [174] = {.entry = {.count = 1, .reusable = true}}, SHIFT(78), - [176] = {.entry = {.count = 1, .reusable = false}}, SHIFT(74), - [178] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_session_block_repeat1, 2, 0, 0), - [180] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_session_block_repeat1, 2, 0, 0), SHIFT_REPEAT(78), - [183] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_session_block_repeat1, 2, 0, 0), SHIFT_REPEAT(74), - [186] = {.entry = {.count = 1, .reusable = false}}, SHIFT(38), - [188] = {.entry = {.count = 1, .reusable = false}}, SHIFT(40), - [190] = {.entry = {.count = 1, .reusable = false}}, SHIFT(41), - [192] = {.entry = {.count = 1, .reusable = true}}, SHIFT(77), - [194] = {.entry = {.count = 1, .reusable = true}}, SHIFT(86), - [196] = {.entry = {.count = 1, .reusable = true}}, SHIFT(69), - [198] = {.entry = {.count = 1, .reusable = true}}, SHIFT(81), - [200] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_exercise_block_repeat1, 2, 0, 0), - [202] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_exercise_block_repeat1, 2, 0, 0), SHIFT_REPEAT(62), - [205] = {.entry = {.count = 1, .reusable = false}}, SHIFT(19), - [207] = {.entry = {.count = 1, .reusable = false}}, SHIFT(62), - [209] = {.entry = {.count = 1, .reusable = false}}, SHIFT(25), - [211] = {.entry = {.count = 1, .reusable = false}}, SHIFT(60), - [213] = {.entry = {.count = 1, .reusable = true}}, SHIFT(71), - [215] = {.entry = {.count = 1, .reusable = true}}, SHIFT(87), - [217] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_note_line, 3, 0, 18), - [219] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_note_line, 3, 0, 18), - [221] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_item_line, 4, 0, 22), - [223] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_item_line, 4, 0, 22), - [225] = {.entry = {.count = 1, .reusable = true}}, SHIFT(79), - [227] = {.entry = {.count = 1, .reusable = true}}, SHIFT(62), - [229] = {.entry = {.count = 1, .reusable = true}}, SHIFT(83), - [231] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_metadata_line, 3, 0, 17), - [233] = {.entry = {.count = 1, .reusable = true}}, SHIFT(74), - [235] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_identifier, 1, 0, 0), - [237] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_metadata_line, 4, 0, 21), - [239] = {.entry = {.count = 1, .reusable = true}}, SHIFT(65), - [241] = {.entry = {.count = 1, .reusable = true}}, SHIFT(53), - [243] = {.entry = {.count = 1, .reusable = true}}, SHIFT(42), - [245] = {.entry = {.count = 1, .reusable = true}}, SHIFT(45), - [247] = {.entry = {.count = 1, .reusable = true}}, SHIFT(50), - [249] = {.entry = {.count = 1, .reusable = true}}, SHIFT(70), - [251] = {.entry = {.count = 1, .reusable = true}}, SHIFT(23), - [253] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_text_until_newline, 1, 0, 0), - [255] = {.entry = {.count = 1, .reusable = true}}, SHIFT(63), - [257] = {.entry = {.count = 1, .reusable = true}}, SHIFT(52), - [259] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_item, 1, 0, 0), - [261] = {.entry = {.count = 1, .reusable = true}}, SHIFT(55), - [263] = {.entry = {.count = 1, .reusable = true}}, SHIFT(4), - [265] = {.entry = {.count = 1, .reusable = true}}, SHIFT(17), - [267] = {.entry = {.count = 1, .reusable = true}}, SHIFT(82), - [269] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_name, 1, 0, 0), - [271] = {.entry = {.count = 1, .reusable = true}}, SHIFT(43), - [273] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_flag, 1, 0, 0), - [275] = {.entry = {.count = 1, .reusable = true}}, SHIFT(54), - [277] = {.entry = {.count = 1, .reusable = true}}, SHIFT(85), - [279] = {.entry = {.count = 1, .reusable = true}}, ACCEPT_INPUT(), - [281] = {.entry = {.count = 1, .reusable = true}}, SHIFT(18), - [283] = {.entry = {.count = 1, .reusable = true}}, SHIFT(12), + [65] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(7), + [68] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(9), + [71] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(10), + [74] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 2, 0, 19), SHIFT_REPEAT(8), + [77] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_details, 1, 0, 16), + [79] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 11), + [81] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_details_repeat1, 1, 0, 11), + [83] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 14), + [85] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_details_repeat1, 1, 0, 14), + [87] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 12), + [89] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_details_repeat1, 1, 0, 12), + [91] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 13), + [93] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_details_repeat1, 1, 0, 13), + [95] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_details_repeat1, 1, 0, 10), + [97] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_details_repeat1, 1, 0, 10), + [99] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 3, 0, 3), + [101] = {.entry = {.count = 1, .reusable = true}}, SHIFT(38), + [103] = {.entry = {.count = 1, .reusable = true}}, SHIFT(13), + [105] = {.entry = {.count = 1, .reusable = true}}, SHIFT(15), + [107] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 4, 0, 5), + [109] = {.entry = {.count = 1, .reusable = true}}, SHIFT(22), + [111] = {.entry = {.count = 1, .reusable = true}}, SHIFT(23), + [113] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_template_block, 6, 0, 4), + [115] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 4, 0, 6), + [117] = {.entry = {.count = 1, .reusable = true}}, SHIFT(24), + [119] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_query_entry, 4, 0, 7), + [121] = {.entry = {.count = 1, .reusable = true}}, SHIFT(25), + [123] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_include_directive, 3, 0, 1), + [125] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_movement_block, 5, 0, 4), + [127] = {.entry = {.count = 1, .reusable = true}}, SHIFT(34), + [129] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_template_block, 5, 0, 4), + [131] = {.entry = {.count = 1, .reusable = true}}, SHIFT(14), + [133] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 5, 0, 5), + [135] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 5, 0, 9), + [137] = {.entry = {.count = 1, .reusable = true}}, SHIFT(36), + [139] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 5, 0, 6), + [141] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_query_entry, 5, 0, 7), + [143] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 5, 0, 8), + [145] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_plugin_directive, 3, 0, 1), + [147] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_note_entry, 3, 0, 2), + [149] = {.entry = {.count = 1, .reusable = true}}, SHIFT(35), + [151] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_plugin_directive, 2, 0, 1), + [153] = {.entry = {.count = 1, .reusable = true}}, SHIFT(27), + [155] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_file_path, 3, 0, 0), + [157] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_movement_block, 4, 0, 4), + [159] = {.entry = {.count = 1, .reusable = true}}, SHIFT(18), + [161] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 5, 0, 15), + [163] = {.entry = {.count = 1, .reusable = true}}, SHIFT(37), + [165] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_template_block, 4, 0, 4), + [167] = {.entry = {.count = 1, .reusable = true}}, SHIFT(20), + [169] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_movement_block, 6, 0, 4), + [171] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_note_entry, 4, 0, 2), + [173] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 6, 0, 9), + [175] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_singleline_entry, 6, 0, 15), + [177] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_weigh_in_entry, 4, 0, 3), + [179] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_session_block, 7, 0, 20), + [181] = {.entry = {.count = 1, .reusable = true}}, SHIFT(41), + [183] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_include_directive, 2, 0, 1), + [185] = {.entry = {.count = 1, .reusable = true}}, SHIFT(17), + [187] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_session_block, 8, 0, 20), + [189] = {.entry = {.count = 1, .reusable = true}}, SHIFT(43), + [191] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_session_block, 9, 0, 20), + [193] = {.entry = {.count = 1, .reusable = false}}, SHIFT(42), + [195] = {.entry = {.count = 1, .reusable = true}}, SHIFT(84), + [197] = {.entry = {.count = 1, .reusable = false}}, SHIFT(75), + [199] = {.entry = {.count = 1, .reusable = false}}, SHIFT(21), + [201] = {.entry = {.count = 1, .reusable = false}}, SHIFT(33), + [203] = {.entry = {.count = 1, .reusable = false}}, SHIFT(39), + [205] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_session_block_repeat1, 2, 0, 0), + [207] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_session_block_repeat1, 2, 0, 0), SHIFT_REPEAT(84), + [210] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_session_block_repeat1, 2, 0, 0), SHIFT_REPEAT(75), + [213] = {.entry = {.count = 1, .reusable = true}}, SHIFT(82), + [215] = {.entry = {.count = 1, .reusable = true}}, SHIFT(68), + [217] = {.entry = {.count = 1, .reusable = true}}, SHIFT(70), + [219] = {.entry = {.count = 1, .reusable = true}}, SHIFT(77), + [221] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_movement_block_repeat1, 2, 0, 0), + [223] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_movement_block_repeat1, 2, 0, 0), SHIFT_REPEAT(65), + [226] = {.entry = {.count = 1, .reusable = false}}, SHIFT(31), + [228] = {.entry = {.count = 1, .reusable = false}}, SHIFT(65), + [230] = {.entry = {.count = 1, .reusable = false}}, SHIFT(19), + [232] = {.entry = {.count = 1, .reusable = false}}, SHIFT(62), + [234] = {.entry = {.count = 1, .reusable = true}}, SHIFT(78), + [236] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_note_line, 3, 0, 18), + [238] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_note_line, 3, 0, 18), + [240] = {.entry = {.count = 1, .reusable = true}}, SHIFT(90), + [242] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_item_line, 4, 0, 22), + [244] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_item_line, 4, 0, 22), + [246] = {.entry = {.count = 1, .reusable = true}}, SHIFT(85), + [248] = {.entry = {.count = 1, .reusable = true}}, SHIFT(65), + [250] = {.entry = {.count = 1, .reusable = true}}, SHIFT(69), + [252] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_metadata_line, 3, 0, 17), + [254] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_metadata_line, 4, 0, 21), + [256] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_identifier, 1, 0, 0), + [258] = {.entry = {.count = 1, .reusable = true}}, SHIFT(75), + [260] = {.entry = {.count = 1, .reusable = true}}, SHIFT(52), + [262] = {.entry = {.count = 1, .reusable = true}}, SHIFT(12), + [264] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_name, 1, 0, 0), + [266] = {.entry = {.count = 1, .reusable = true}}, SHIFT(74), + [268] = {.entry = {.count = 1, .reusable = true}}, SHIFT(46), + [270] = {.entry = {.count = 1, .reusable = true}}, ACCEPT_INPUT(), + [272] = {.entry = {.count = 1, .reusable = true}}, SHIFT(30), + [274] = {.entry = {.count = 1, .reusable = true}}, SHIFT(16), + [276] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_item, 1, 0, 0), + [278] = {.entry = {.count = 1, .reusable = true}}, SHIFT(4), + [280] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_flag, 1, 0, 0), + [282] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_text_until_newline, 1, 0, 0), + [284] = {.entry = {.count = 1, .reusable = true}}, SHIFT(64), + [286] = {.entry = {.count = 1, .reusable = true}}, SHIFT(55), + [288] = {.entry = {.count = 1, .reusable = true}}, SHIFT(54), + [290] = {.entry = {.count = 1, .reusable = true}}, SHIFT(28), + [292] = {.entry = {.count = 1, .reusable = true}}, SHIFT(57), + [294] = {.entry = {.count = 1, .reusable = true}}, SHIFT(80), + [296] = {.entry = {.count = 1, .reusable = true}}, SHIFT(73), + [298] = {.entry = {.count = 1, .reusable = true}}, SHIFT(89), + [300] = {.entry = {.count = 1, .reusable = true}}, SHIFT(48), + [302] = {.entry = {.count = 1, .reusable = true}}, SHIFT(47), + [304] = {.entry = {.count = 1, .reusable = true}}, SHIFT(56), }; #ifdef __cplusplus diff --git a/tree-sitter-ox/test/corpus/exercise_block.txt b/tree-sitter-ox/test/corpus/movement_block.txt similarity index 87% rename from tree-sitter-ox/test/corpus/exercise_block.txt rename to tree-sitter-ox/test/corpus/movement_block.txt index 453002f..71d1d45 100644 --- a/tree-sitter-ox/test/corpus/exercise_block.txt +++ b/tree-sitter-ox/test/corpus/movement_block.txt @@ -1,10 +1,10 @@ ================== -Exercise Block +Movement Block ================== -@exercise kb-oh-press +@movement kb-oh-press equipment: kettlebell -pattern: press +tag: press url: https://example.com/kb-press note: keep elbow tight @end @@ -12,7 +12,7 @@ note: keep elbow tight --- (source_file - (exercise_block + (movement_block name: (identifier) (metadata_line key: (identifier) diff --git a/uv.lock b/uv.lock index 1f5bd58..8288fb6 100644 --- a/uv.lock +++ b/uv.lock @@ -449,12 +449,13 @@ wheels = [ [[package]] name = "ox" -version = "0.2.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "click" }, { name = "numpy" }, { name = "pint" }, + { name = "plotext" }, { name = "prompt-toolkit" }, { name = "pygls" }, { name = "rich" }, @@ -475,6 +476,7 @@ requires-dist = [ { name = "click", specifier = ">=8.3.1" }, { name = "numpy", specifier = ">=2.3.5" }, { name = "pint", specifier = ">=0.25.2" }, + { name = "plotext", specifier = ">=5.3.2" }, { name = "prompt-toolkit", specifier = ">=3.0.52" }, { name = "pygls", specifier = ">=1.3.0" }, { name = "rich", specifier = ">=14.2.0" }, @@ -541,6 +543,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + [[package]] name = "pluggy" version = "1.6.0"