diff --git a/.gitignore b/.gitignore
index 9c38039..8b040c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -207,3 +207,4 @@ artifacts/
log*.txt
.omo/
+.omx/
diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/.openspec.yaml b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/.openspec.yaml
new file mode 100644
index 0000000..aee4ef1
--- /dev/null
+++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-07
diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/design.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/design.md
new file mode 100644
index 0000000..043872f
--- /dev/null
+++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/design.md
@@ -0,0 +1,88 @@
+## Context
+
+ChapterTool currently transforms chapter timestamps through `ExpressionService`, a synchronous custom evaluator for one-line infix and postfix expressions. The Avalonia shell binds one expression string and one apply flag into export options; preview/save projection uses `ChapterExpressionService` through `ChapterOutputProjectionService`. A later expression editor added metadata, token classification, completion, and diagnostics for that custom grammar.
+
+The revised direction is to replace that expression language as the user-facing feature with Lua, using Lua-CSharp (`LuaCSharp`, from `nuskey8/Lua-CSharp`). The expression surface should become a Lua script surface with presets, external scripts, and Lua-aware editor services rather than preserving the old formula/postfix grammar as a parallel mode.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Make Lua the expression language for chapter-time transforms.
+- Remove postfix expression authoring/evaluation as a product requirement for the new expression feature.
+- Support formula-like Lua expression shorthand (`t + 1`) without requiring users to type `return`, while also supporting explicit `return ...` chunks and reusable `transform(chapter)` functions.
+- Add Core-level Lua script presets as structured metadata so UI and tests do not duplicate script text.
+- Replace expression authoring metadata/analysis with Lua-oriented symbols, diagnostics, highlighting spans, and completion/LSP-style candidates.
+- Allow Avalonia users to load external `.lua` script text through platform picker abstractions.
+- Keep preview/save/export projection deterministic: invalid scripts preserve original chapter times and emit warnings.
+
+**Non-Goals:**
+
+- Preserve old infix/postfix grammar semantics as a first-class expression mode.
+- Add arbitrary file-system access from Core during export; UI may read selected external script text and pass it into Core.
+- Provide a full Lua debugger, package manager, or persistent user script library.
+- Expose Lua standard-library OS/file/network capabilities to scripts.
+
+## Decisions
+
+1. Replace expression options with Lua script options and keep only intentional compatibility seams.
+
+ `ChapterExportOptions` will retain `ApplyExpression` for workflow compatibility, and the expression payload becomes Lua expression/script text plus optional preset/source metadata. The only compatibility seams intentionally preserved are: the `ApplyExpression` enable/disable workflow, the `t`/`fps` variable names, and expression shorthand without `return`. The implementation and tests should not preserve postfix expressions, the old custom parser as a parallel mode, or broad old-grammar equivalence.
+
+2. Keep Core script execution source-text based.
+
+ External file selection is a UI/platform concern. The Avalonia tool reads the chosen `.lua` file, stores the script text and display path in the ViewModel, then passes script text through export options. This avoids Core depending on paths, current directories, or UI permissions, and it makes tests deterministic.
+
+3. Define a small Lua contract with expression shorthand, direct `return`, and `transform(context)`.
+
+ For each non-separator chapter, Core sets Lua globals for `t`, `fps`, `index`, `count`, and a `chapter` table. If the input does not contain an explicit Lua `return` or `transform` declaration, Core treats it as a single Lua expression and evaluates `return ()`; this preserves low-friction usage for `t + 1`, `t - 0.5`, `t * fps / fps`, and similar arithmetic. A script may also explicitly `return ` or define `function transform(chapter) ... end`. The numeric result is seconds. Returning nil, non-numeric values, NaN, or infinity is a structured failure.
+
+4. Use a restricted Lua-CSharp state per evaluation.
+
+ Core creates a fresh `LuaState` for deterministic, isolated evaluation and exposes only safe helpers needed for transforms, especially math helpers. It must not expose OS/file/process IO. Built-in scripts are small enough that per-chapter execution is acceptable; if performance becomes an issue later, cached script plans can be added behind the same service interface.
+
+5. Model presets as data, not UI literals.
+
+ Core exposes `LuaExpressionScriptPreset` values with stable ids, localization keys/display names, descriptions, and script text. Avalonia binds these presets and copies the selected preset script into the editor/script field. Initial presets: identity (`t` or `return t`), offset seconds, round to nearest frame, and half-frame earlier adjustment.
+
+6. Formalize a shared preview/save projection pipeline.
+
+ Preview, save, and text preview must not each reimplement expression application. They should call one Core projection service that accepts an immutable `ChapterInfo` snapshot and projection options, then returns projected chapters plus diagnostics. The pipeline order is:
+
+ 1. Start from the current source `ChapterInfo` snapshot; do not mutate it.
+ 2. If `ApplyExpression` is false, keep chapter times unchanged. If true, evaluate the Lua expression/script for each non-separator chapter.
+ 3. Normalize successful expression results to valid time bounds and refresh derived frame display/accuracy from the effective frame rate.
+ 4. Preserve separator rows as structural separators; do not run Lua on separators.
+ 5. Apply output-only chapter numbering/order shift.
+ 6. Apply output-only name generation/template substitution.
+ 7. Return both `Info` (for projected row preview) and `OutputChapters` (non-separator export rows) with accumulated diagnostics.
+
+ The save service then serializes/export-formats only the projected output; UI preview uses the same projected result. If Lua diagnostics occur, the affected chapter keeps its original time and downstream numbering/naming still proceeds on the projected list.
+
+7. Replace expression authoring with Lua authoring.
+
+ The existing expression editor control should evolve from custom grammar token metadata to Lua tokenization/classification, Lua diagnostics, and completion/LSP-style candidates for provided globals (`t`, `fps`, `index`, `count`, `chapter`), safe math helpers, and preset snippets. If a full LSP server is not embedded in the first pass, the service boundary should still be Lua-oriented and testable so it can be backed by an LSP implementation later without changing ViewModels.
+
+8. Extend diagnostics rather than throwing.
+
+ Lua compile/runtime/return-shape failures produce `InvalidExpression.Lua*` warning diagnostics. Projection continues to normalize negative/too-large successful results with existing `InvalidExpressionTime` diagnostics, and all diagnostics travel with the projection result so preview and save report the same issues.
+
+## Risks / Trade-offs
+
+- [Risk] Removing custom formula/postfix behavior may break users who rely on non-Lua-only syntax such as `M_PI` constants or postfix token lists. → Mitigation: preserve only the agreed low-friction seams (`ApplyExpression`, `t`/`fps`, shorthand like `t + 1`), provide Lua presets/snippets, and avoid rebuilding the old grammar by accident.
+- [Risk] Lua-CSharp APIs are asynchronous while export/projection code is synchronous. → Mitigation: isolate blocking inside the Lua expression service with `GetAwaiter().GetResult()` after keeping scripts local and CPU-bound; revisit async projection only if needed.
+- [Risk] Opening all Lua standard libraries may expose file or OS operations. → Mitigation: use a restricted standard-library set or explicit safe helper table; tests SHALL verify scripts cannot rely on external file IO through the expression service contract.
+- [Risk] Running a script per chapter is slower than the formula evaluator. → Mitigation: scripts are expected to be short; add caching only if measured, and keep preview/save on the same projection path so performance fixes benefit both.
+- [Risk] Full Lua LSP integration may be larger than the transform work. → Mitigation: design the Core/UI authoring boundary around Lua completions/diagnostics first and keep any LSP-backed implementation swappable.
+
+## Migration Plan
+
+1. Add Core Lua models/options and the LuaCSharp dependency.
+2. Implement `LuaExpressionScriptService` with expression shorthand wrapping and integrate it into a shared output projection service as the only user-facing expression evaluator.
+3. Add Lua presets and Core tests for script success/failure behavior.
+4. Replace expression authoring metadata/analyzer behavior with Lua-oriented classification, completion, and diagnostics.
+5. Extend Avalonia ViewModels/services/resources and editor UI for Lua scripts, presets, file picking, and Lua completion/highlighting.
+6. Update preview/save projection tests so one projected result path covers UI preview, text preview, save options, diagnostics, order shift, and naming.
+7. Run focused/full solution validation.
+
+Rollback is possible by reverting to the previous custom expression evaluator before archiving this change; once archived, docs and tests should describe Lua as the expression language.
diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/proposal.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/proposal.md
new file mode 100644
index 0000000..74909dc
--- /dev/null
+++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/proposal.md
@@ -0,0 +1,32 @@
+## Why
+
+Current expression transforms use a custom infix/postfix grammar that is costly to improve and disconnected from standard tooling. Moving the expression feature to Lua gives users a real scripting language for reusable chapter-time transforms, external project scripts, presets, and editor services such as Lua-aware highlighting and completion.
+
+## What Changes
+
+- Replace the custom expression grammar as the user-facing expression language with Lua scripts powered by the `LuaCSharp` package from `nuskey8/Lua-CSharp`.
+- Stop treating postfix expressions as a required or supported expression authoring target in the new implementation.
+- Define a deterministic Lua expression/script contract: users may type simple arithmetic such as `t + 1` without `return`, or write full Lua `return ...` / `transform(chapter)` scripts.
+- Add built-in Lua script presets for common workflows such as identity, offset seconds, NTSC half-frame style adjustment, and frame rounding.
+- Allow users to choose an external `.lua` script from the Avalonia expression tool and apply it through a shared preview/save projection pipeline.
+- Replace expression editor language services with Lua-aware highlighting, diagnostics, and completion/LSP-style authoring support while preserving the low-friction arithmetic input style users already expect.
+- Report Lua compile/runtime/invalid-return failures as structured diagnostics without crashing or mutating the source chapter list.
+
+## Capabilities
+
+### New Capabilities
+
+- None.
+
+### Modified Capabilities
+
+- `chapter-core-transform-export`: expression transforms and expression authoring metadata/analysis use Lua scripts and Lua diagnostics instead of the custom formula/postfix grammar.
+- `avalonia-ui-shell`: expression UI exposes Lua presets, external Lua script picking, and Lua-aware editor highlighting/completion/diagnostics.
+
+## Impact
+
+- `src/ChapterTool.Core`: add Lua script expression models/services, replace custom expression transform integration with Lua script evaluation, expose Lua authoring metadata/analysis, and formalize the shared output projection pipeline used by preview/save/export.
+- `src/ChapterTool.Avalonia`: update expression editor/tool/main save flow for Lua script text, presets, external script selection, and Lua completion/highlighting.
+- `tests/ChapterTool.Core.Tests`: update expression tests for Lua success, presets, external script text, invalid returns, and failure diagnostics; remove postfix-target expectations from the new behavior.
+- `tests/ChapterTool.Avalonia.Tests`: update ViewModel/headless tests for Lua editor behavior, presets, file picker interaction, and diagnostics.
+- Dependency: add NuGet package `LuaCSharp` to the Core project.
diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/avalonia-ui-shell/spec.md
new file mode 100644
index 0000000..e5d0444
--- /dev/null
+++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/avalonia-ui-shell/spec.md
@@ -0,0 +1,76 @@
+## MODIFIED Requirements
+
+### Requirement: Expression editor authoring experience
+The Avalonia shell SHALL use a dedicated Lua expression/script editor for all expression inputs instead of a plain text box or the previous custom formula grammar editor, while allowing simple arithmetic expressions without requiring an explicit `return`.
+
+#### Scenario: Main expression input uses the Lua expression editor
+- **WHEN** the main window is rendered
+- **THEN** the expression input SHALL provide Lua syntax highlighting for expression script tokens
+- **AND** it SHALL expose Lua-aware completion candidates based on the caret token, including provided globals, safe helper functions, and discoverable `preset.*` entries
+
+#### Scenario: Expression tool uses the same Lua editor
+- **WHEN** the expression tool window is opened
+- **THEN** its expression input SHALL use the same Lua editor behavior as the main window
+
+#### Scenario: Completion items are visually categorized
+- **WHEN** the Lua expression completion popup is open
+- **THEN** completion items SHALL visually identify whether they are variables, functions, keywords, or presets using category labels and distinct colors
+
+#### Scenario: Tab accepts Lua completion
+- **WHEN** a completion popup is open for a Lua prefix
+- **THEN** pressing `Tab` SHALL insert the selected completion
+- **AND** focus SHALL remain in the expression editor
+
+#### Scenario: Lua syntax errors show correction guidance
+- **WHEN** the user enters an invalid Lua expression script
+- **THEN** the editor SHALL display the specific Lua syntax or validation problem
+- **AND** it SHALL display a correction suggestion derived from the diagnostic
+
+#### Scenario: Valid Lua script clears errors
+- **WHEN** the user corrects an invalid Lua expression script to a valid script
+- **THEN** the error and suggestion feedback SHALL be cleared
+
+## ADDED Requirements
+
+### Requirement: Lua expression script authoring
+The Avalonia shell SHALL allow users to apply Lua expression transforms with built-in presets and external script selection.
+
+#### Scenario: Expression tool exposes Lua script editing
+- **WHEN** the expression tool window is opened
+- **THEN** it SHALL present Lua script editing as the expression authoring surface
+- **AND** it SHALL NOT require users to choose or understand the previous formula/postfix grammar
+
+#### Scenario: Built-in Lua preset can populate script text
+- **WHEN** the user selects a built-in Lua script preset in the expression tool or accepts a `preset.*` completion in the editor
+- **THEN** the tool SHALL show or insert the preset script text and apply that script through the owner ViewModel when the user applies the tool
+
+#### Scenario: External Lua script can be selected
+- **WHEN** the user chooses an external `.lua` script from the main expression input or expression tool
+- **THEN** the UI SHALL expose the load action as a button at the right side of the Lua expression input
+- **AND** it SHALL use the file picker service abstraction to select and read the script text
+- **AND** applying or using the loaded script SHALL pass that script text into chapter preview and save options without requiring Core to read the file path
+
+#### Scenario: Lua expression or script participates in preview and save
+- **WHEN** expression application is enabled and the current chapters are previewed or saved
+- **THEN** the main ViewModel SHALL project chapter output using Lua expression/script options
+- **AND** Lua diagnostics SHALL be surfaced through the same status/log diagnostic path as expression diagnostics
+
+#### Scenario: Simple arithmetic remains approachable through Lua
+- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1`
+- **THEN** preview and save SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax
+
+### Requirement: Preview and save use shared Lua projection
+The Avalonia shell SHALL route preview, text preview, and save through the same Lua expression projection options exposed by the main ViewModel.
+
+#### Scenario: Main ViewModel builds one projection option set
+- **WHEN** the main ViewModel previews or saves chapters with expression application enabled
+- **THEN** it SHALL pass the same Lua expression/script text, preset/source metadata, order shift, naming, format, and language options into the Core projection/export path
+
+#### Scenario: Save does not re-read external script files
+- **WHEN** a user has loaded an external Lua script in the expression tool and later saves chapters
+- **THEN** the ViewModel SHALL pass the already-loaded script text to Core
+- **AND** save SHALL NOT require Core or the save service to re-read the external script path
+
+#### Scenario: Preview matches save projection
+- **WHEN** the user previews projected chapters and then saves without changing options
+- **THEN** the times, generated numbers, generated names, and Lua diagnostics used for preview SHALL match the data used for save output
diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/chapter-core-transform-export/spec.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/chapter-core-transform-export/spec.md
new file mode 100644
index 0000000..14fe247
--- /dev/null
+++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/chapter-core-transform-export/spec.md
@@ -0,0 +1,103 @@
+## MODIFIED Requirements
+
+### Requirement: Expression transforms
+The system SHALL transform chapter times through Lua scripts using structured success or failure results.
+
+#### Scenario: Lua script receives chapter time and fps
+- **WHEN** a Lua expression script references `t` or `fps`
+- **THEN** Core SHALL evaluate it using chapter time in seconds and the current frame rate
+
+#### Scenario: Invalid expression does not crash
+- **WHEN** Lua compilation, Lua execution, or Lua return conversion fails
+- **THEN** Core SHALL return a failure diagnostic and preserve original chapter time behavior
+
+#### Scenario: Postfix expressions are not a required expression target
+- **WHEN** expression transform behavior is implemented for this change
+- **THEN** Core SHALL NOT require postfix expression authoring or postfix token-list evaluation as part of the user-facing expression workflow
+
+#### Scenario: Lua script receives chapter context
+- **WHEN** Lua expression mode is applied to a non-separator chapter
+- **THEN** Core SHALL provide the script with chapter time `t`, frame rate `fps`, one-based chapter `index`, non-separator chapter `count`, and a chapter context table
+- **AND** the numeric Lua result SHALL become the chapter time in seconds
+
+#### Scenario: Lua transform function is supported
+- **WHEN** a Lua script defines `transform(chapter)` and that function returns a numeric value
+- **THEN** Core SHALL call the function for each non-separator chapter and use its returned seconds value
+
+#### Scenario: Lua expression shorthand is supported
+- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` without an explicit `return`
+- **THEN** Core SHALL evaluate it as a returned Lua expression
+- **AND** the numeric result SHALL become the transformed seconds value for the current chapter
+
+#### Scenario: Lua direct return is supported
+- **WHEN** a Lua script directly returns a numeric expression such as `return t + 1`
+- **THEN** Core SHALL use that numeric return as the transformed seconds value for the current chapter
+
+#### Scenario: Built-in Lua presets are available
+- **WHEN** Lua expression presets are requested
+- **THEN** Core SHALL expose stable preset identifiers, display names, descriptions, and script text for common transforms including identity, offset seconds, frame rounding, and half-frame earlier adjustment
+
+#### Scenario: Lua invalid return preserves chapter time
+- **WHEN** a Lua script returns nil, a string, a boolean, NaN, infinity, or another non-finite non-numeric result
+- **THEN** Core SHALL report an `InvalidExpression.Lua*` diagnostic
+- **AND** the original chapter time SHALL be preserved for that chapter
+
+### Requirement: Expression authoring metadata
+The Core expression subsystem SHALL expose Lua expression globals, safe helper functions, script presets, snippets, and editor symbols as structured metadata for UI authoring features.
+
+#### Scenario: Metadata exposes Lua tokens
+- **WHEN** expression authoring metadata is requested
+- **THEN** the result SHALL include Lua globals `t`, `fps`, `index`, `count`, and `chapter`
+- **AND** it SHALL include supported safe math/helper functions, Lua keywords needed for transforms, and built-in Lua script presets under a discoverable `preset.*` namespace with stable identifiers
+
+### Requirement: Expression authoring analysis
+The Core expression subsystem SHALL analyze Lua expression script text for editor classification, completion, and validation without mutating chapter data.
+
+#### Scenario: Analyze valid Lua expression shorthand
+- **WHEN** the Lua expression text `t + math.floor(fps / 2)` is analyzed
+- **THEN** the analysis SHALL classify Lua keyword, global, operator, function/member, punctuation, and number spans
+- **AND** it SHALL report no diagnostics
+
+#### Scenario: Analyze invalid Lua expression shorthand
+- **WHEN** the Lua expression text `t +` is analyzed
+- **THEN** the analysis SHALL include an `InvalidExpression.Lua*` diagnostic
+- **AND** the diagnostic SHALL include a correction suggestion suitable for display to the user
+
+#### Scenario: Completion uses Lua caret context
+- **WHEN** completions are requested after a Lua prefix such as `math.flo`
+- **THEN** the result SHALL include `math.floor` or `floor` according to the exposed helper style
+- **AND** accepting the completion SHALL identify the replacement span for only the Lua prefix token
+
+#### Scenario: Preset completion is discoverable
+- **WHEN** completions are requested after `pre` or `preset.`
+- **THEN** the result SHALL expose a `preset` namespace entry and concrete preset entries such as `preset.round-to-frame`
+- **AND** accepting a concrete preset completion SHALL insert that preset script text rather than the display identifier
+
+#### Scenario: Completion items expose categories
+- **WHEN** expression completions are returned
+- **THEN** each completion SHALL include a token kind/category suitable for the UI to visually distinguish variables, functions, keywords, and presets
+
+## ADDED Requirements
+
+### Requirement: Shared preview and save projection pipeline
+The system SHALL use one Core output projection pipeline for preview and save so Lua expression transforms, diagnostics, numbering, and naming are consistent.
+
+#### Scenario: Projection does not mutate source chapters
+- **WHEN** preview or save requests projected output from a source chapter set
+- **THEN** Core SHALL return projected chapter data without mutating the original `ChapterInfo` input
+
+#### Scenario: Projection order is deterministic
+- **WHEN** expression application, order shift, and name generation are all enabled
+- **THEN** Core SHALL first apply Lua expression transforms to non-separator chapter times
+- **AND** it SHALL normalize transformed times and refresh frame display state
+- **AND** it SHALL then apply output numbering/order shift and output name generation
+
+#### Scenario: Preview and save share diagnostics
+- **WHEN** Lua expression projection emits diagnostics during preview or save
+- **THEN** the projection result SHALL include those diagnostics in the same structured form for both callers
+- **AND** chapters whose Lua expression failed SHALL retain their original times while the rest of the output projection continues
+
+#### Scenario: Separators are structural during projection
+- **WHEN** projected output contains separator rows
+- **THEN** Core SHALL NOT execute Lua expressions for separator rows
+- **AND** exported `OutputChapters` SHALL include only non-separator chapters after projection
diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/tasks.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/tasks.md
new file mode 100644
index 0000000..d36bdd7
--- /dev/null
+++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/tasks.md
@@ -0,0 +1,29 @@
+## 1. Core Lua expression support
+
+- [x] 1.1 Add `LuaCSharp` dependency and Lua expression script option models while keeping `ApplyExpression` workflow compatibility.
+- [x] 1.2 Add Core Lua script preset metadata for identity, offset seconds, frame rounding, and half-frame earlier adjustment.
+- [x] 1.3 Implement a Lua expression script service that evaluates shorthand arithmetic expressions, direct `return` scripts, and `transform(chapter)` scripts with `t`, `fps`, `index`, `count`, and `chapter` context.
+- [x] 1.4 Replace user-facing custom formula/postfix transform integration in projection/export options with Lua expression/script evaluation while preserving only `ApplyExpression`, `t`/`fps`, and shorthand compatibility seams.
+- [x] 1.5 Formalize the shared preview/save projection pipeline: source snapshot, Lua transform, normalization/frame refresh, separator preservation, order shift, naming, projected `Info`, `OutputChapters`, and diagnostics.
+- [x] 1.6 Add Core tests for Lua shorthand arithmetic (`t+1` without `return`), direct returns, transform functions, presets, invalid returns, compile/runtime failures, normalization, and no-postfix requirement.
+- [x] 1.7 Add Core projection tests proving preview/save projection order, non-mutation, separator handling, diagnostics, order shift, and naming consistency.
+
+## 2. Lua authoring services
+
+- [x] 2.1 Replace expression authoring metadata with Lua globals, safe helpers, Lua keywords, snippets, and preset metadata.
+- [x] 2.2 Replace expression analysis/classification with Lua-oriented token spans, diagnostics, completion replacement ranges, and shorthand-expression validation.
+- [x] 2.3 Add tests for Lua authoring metadata, Lua highlighting spans, Lua completion, and Lua diagnostic suggestions.
+
+## 3. Avalonia Lua expression UI and services
+
+- [x] 3.1 Extend main ViewModel state and current export options with Lua script text, selected Lua preset, external script display path, and one method for building shared projection/export options.
+- [x] 3.2 Extend `ExpressionToolViewModel` with preset application, external `.lua` picker/read command, and apply-to-owner behavior.
+- [x] 3.3 Update expression tool and main expression UI to use Lua script editor behavior and responsive layout without formula/postfix mode selection.
+- [x] 3.4 Add localized user-facing strings for Lua expression presets, external script selection, script load feedback, and Lua diagnostics.
+- [x] 3.5 Add Avalonia ViewModel/headless tests for Lua editor rendering, preset selection, external script loading via picker abstraction, simple `t+1` shorthand preview/save behavior, and preview/save projection consistency.
+
+## 4. Validation
+
+- [x] 4.1 Run `openspec validate add-lua-expression-scripts --strict`.
+- [x] 4.2 Run focused Core and Avalonia tests affected by expression changes.
+- [x] 4.3 Run `dotnet test ChapterTool.Avalonia.slnx --no-restore` after restore/build updates.
diff --git a/openspec/specs/avalonia-ui-shell/spec.md b/openspec/specs/avalonia-ui-shell/spec.md
index f8e3b9f..486eb65 100644
--- a/openspec/specs/avalonia-ui-shell/spec.md
+++ b/openspec/specs/avalonia-ui-shell/spec.md
@@ -247,31 +247,78 @@ The Avalonia language tool SHALL allow users to choose Simplified Chinese, Engli
- **THEN** the selected culture tag SHALL be persisted to settings and applied to the current application localization manager
### Requirement: Expression editor authoring experience
-The Avalonia shell SHALL use a dedicated expression editor for all expression inputs instead of a plain text box.
+The Avalonia shell SHALL use a dedicated Lua expression/script editor for all expression inputs instead of a plain text box or the previous custom formula grammar editor, while allowing simple arithmetic expressions without requiring an explicit `return`.
-#### Scenario: Main expression input uses the expression editor
+#### Scenario: Main expression input uses the Lua expression editor
- **WHEN** the main window is rendered
-- **THEN** the expression input SHALL provide syntax highlighting for expression tokens
-- **AND** it SHALL expose context-aware completion candidates based on the caret token
+- **THEN** the expression input SHALL provide Lua syntax highlighting for expression script tokens
+- **AND** it SHALL expose Lua-aware completion candidates based on the caret token, including provided globals, safe helper functions, and discoverable `preset.*` entries
-#### Scenario: Expression tool uses the same editor
+#### Scenario: Expression tool uses the same Lua editor
- **WHEN** the expression tool window is opened
- **THEN** its expression input SHALL use the same editor behavior as the main window
-#### Scenario: Tab accepts completion
-- **WHEN** a completion popup is open for an expression prefix
+#### Scenario: Completion items are visually categorized
+- **WHEN** the Lua expression completion popup is open
+- **THEN** completion items SHALL visually identify whether they are variables, functions, keywords, or presets using category labels and distinct colors
+
+#### Scenario: Tab accepts Lua completion
+- **WHEN** a completion popup is open for a Lua prefix
- **THEN** pressing `Tab` SHALL insert the selected completion
- **AND** focus SHALL remain in the expression editor
-#### Scenario: Syntax errors show correction guidance
-- **WHEN** the user enters an invalid expression
-- **THEN** the editor SHALL display the specific syntax problem
+#### Scenario: Lua syntax errors show correction guidance
+- **WHEN** the user enters an invalid Lua expression script
+- **THEN** the editor SHALL display the specific Lua syntax or validation problem
- **AND** it SHALL display a correction suggestion derived from the diagnostic
-#### Scenario: Valid expression clears errors
-- **WHEN** the user corrects an invalid expression to a valid expression
+#### Scenario: Valid Lua script clears errors
+- **WHEN** the user corrects an invalid Lua expression script to a valid script
- **THEN** the error and suggestion feedback SHALL be cleared
+### Requirement: Lua expression script authoring
+The Avalonia shell SHALL allow users to apply Lua expression transforms with built-in presets and external script selection.
+
+#### Scenario: Expression tool exposes Lua script editing
+- **WHEN** the expression tool window is opened
+- **THEN** it SHALL present Lua script editing as the expression authoring surface
+- **AND** it SHALL NOT require users to choose or understand the previous formula/postfix grammar
+
+#### Scenario: Built-in Lua preset can populate script text
+- **WHEN** the user selects a built-in Lua script preset in the expression tool or accepts a `preset.*` completion in the editor
+- **THEN** the tool SHALL show or insert the preset script text and apply that script through the owner ViewModel when the user applies the tool
+
+#### Scenario: External Lua script can be selected
+- **WHEN** the user chooses an external `.lua` script from the main expression input or expression tool
+- **THEN** the UI SHALL expose the load action as a button at the right side of the Lua expression input
+- **AND** it SHALL use the file picker service abstraction to select and read the script text
+- **AND** applying or using the loaded script SHALL pass that script text into chapter preview and save options without requiring Core to read the file path
+
+#### Scenario: Lua expression or script participates in preview and save
+- **WHEN** expression application is enabled and the current chapters are previewed or saved
+- **THEN** the main ViewModel SHALL project chapter output using Lua expression/script options
+- **AND** Lua diagnostics SHALL be surfaced through the same status/log diagnostic path as expression diagnostics
+
+#### Scenario: Simple arithmetic remains approachable through Lua
+- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1`
+- **THEN** preview and save SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax
+
+### Requirement: Preview and save use shared Lua projection
+The Avalonia shell SHALL route preview, text preview, and save through the same Lua expression projection options exposed by the main ViewModel.
+
+#### Scenario: Main ViewModel builds one projection option set
+- **WHEN** the main ViewModel previews or saves chapters with expression application enabled
+- **THEN** it SHALL pass the same Lua expression/script text, preset/source metadata, order shift, naming, format, and language options into the Core projection/export path
+
+#### Scenario: Save does not re-read external script files
+- **WHEN** a user has loaded an external Lua script in the expression tool and later saves chapters
+- **THEN** the ViewModel SHALL pass the already-loaded script text to Core
+- **AND** save SHALL NOT require Core or the save service to re-read the external script path
+
+#### Scenario: Preview matches save projection
+- **WHEN** the user previews projected chapters and then saves without changing options
+- **THEN** the times, generated numbers, generated names, and Lua diagnostics used for preview SHALL match the data used for save output
+
### Requirement: Unified settings command
The Avalonia shell SHALL expose a unified Settings command that opens a settings panel for durable application, external tool, appearance, and platform preferences.
diff --git a/openspec/specs/chapter-core-transform-export/spec.md b/openspec/specs/chapter-core-transform-export/spec.md
index 4c804c6..3a851cf 100644
--- a/openspec/specs/chapter-core-transform-export/spec.md
+++ b/openspec/specs/chapter-core-transform-export/spec.md
@@ -54,19 +54,46 @@ The system SHALL provide deterministic time parsing, formatting, frame-rate sele
- **THEN** Core SHALL return the default `Fps23976` option with `FrameRateConfidence.Low` and an evaluated chapter count of zero
### Requirement: Expression transforms
-The system SHALL transform chapter times through supported infix and postfix expressions using structured success or failure results.
+The system SHALL transform chapter times through Lua scripts using structured success or failure results.
-#### Scenario: Expression uses chapter time and fps
-- **WHEN** an expression references `t` or `fps`
+#### Scenario: Lua script receives chapter time and fps
+- **WHEN** a Lua expression script references `t` or `fps`
- **THEN** Core SHALL evaluate it using chapter time in seconds and the current frame rate
#### Scenario: Invalid expression does not crash
-- **WHEN** expression parsing or evaluation fails
+- **WHEN** Lua compilation, Lua execution, or Lua return conversion fails
- **THEN** Core SHALL return a failure diagnostic and preserve original chapter time behavior
-#### Scenario: Unsupported legacy operators are explicit
-- **WHEN** `and`, `or`, `xor`, or other incomplete legacy operators are encountered
-- **THEN** the behavior SHALL be either tested as supported or reported as unsupported; it SHALL NOT be silently documented as complete
+#### Scenario: Postfix expressions are not a required expression target
+- **WHEN** expression transform behavior is implemented for this change
+- **THEN** Core SHALL NOT require postfix expression authoring or postfix token-list evaluation as part of the user-facing expression workflow
+
+#### Scenario: Lua script receives chapter context
+- **WHEN** Lua expression mode is applied to a non-separator chapter
+- **THEN** Core SHALL provide the script with chapter time `t`, frame rate `fps`, one-based chapter `index`, non-separator chapter `count`, and a chapter context table
+- **AND** the numeric Lua result SHALL become the chapter time in seconds
+
+#### Scenario: Lua transform function is supported
+- **WHEN** a Lua script defines `transform(chapter)` and that function returns a numeric value
+- **THEN** Core SHALL call the function for each non-separator chapter and use its returned seconds value
+
+#### Scenario: Lua expression shorthand is supported
+- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` without an explicit `return`
+- **THEN** Core SHALL evaluate it as a returned Lua expression
+- **AND** the numeric result SHALL become the transformed seconds value for the current chapter
+
+#### Scenario: Lua direct return is supported
+- **WHEN** a Lua script directly returns a numeric expression such as `return t + 1`
+- **THEN** Core SHALL use that numeric return as the transformed seconds value for the current chapter
+
+#### Scenario: Built-in Lua presets are available
+- **WHEN** Lua expression presets are requested
+- **THEN** Core SHALL expose stable preset identifiers, display names, descriptions, and script text for common transforms including identity, offset seconds, frame rounding, and half-frame earlier adjustment
+
+#### Scenario: Lua invalid return preserves chapter time
+- **WHEN** a Lua script returns nil, a string, a boolean, NaN, infinity, or another non-finite non-numeric result
+- **THEN** Core SHALL report an `InvalidExpression.Lua*` diagnostic
+- **AND** the original chapter time SHALL be preserved for that chapter
### Requirement: Chapter editing operations
The system SHALL expose chapter editing as Core operations callable by ViewModels.
@@ -157,26 +184,59 @@ The system SHALL export Matroska chapter XML in a legacy-compatible structured f
- **THEN** each `ChapterLanguage` SHALL contain that code exactly when the code is valid
### Requirement: Expression authoring metadata
-The Core expression subsystem SHALL expose supported variables, constants, functions, and operators as structured metadata for UI authoring features.
+The Core expression subsystem SHALL expose Lua expression globals, safe helper functions, script presets, snippets, and editor symbols as structured metadata for UI authoring features.
-#### Scenario: Metadata exposes supported tokens
+#### Scenario: Metadata exposes Lua tokens
- **WHEN** expression authoring metadata is requested
-- **THEN** the result SHALL include variables `t` and `fps`, the supported mathematical constants, supported functions with arity, and supported operators with display text
+- **THEN** the result SHALL include Lua globals `t`, `fps`, `index`, `count`, and `chapter`
+- **AND** it SHALL include supported safe math/helper functions, Lua keywords needed for transforms, and built-in Lua script presets under a discoverable `preset.*` namespace with stable identifiers
### Requirement: Expression authoring analysis
-The Core expression subsystem SHALL analyze expression text for editor classification, completion, and validation without mutating chapter data.
+The Core expression subsystem SHALL analyze Lua expression script text for editor classification, completion, and validation without mutating chapter data.
-#### Scenario: Analyze valid expression
-- **WHEN** the expression `t + floor(fps / 2)` is analyzed
-- **THEN** the analysis SHALL classify variable, operator, function, punctuation, and number spans
+#### Scenario: Analyze valid Lua expression shorthand
+- **WHEN** the Lua expression text `t + math.floor(fps / 2)` is analyzed
+- **THEN** the analysis SHALL classify Lua keyword, global, operator, function/member, punctuation, and number spans
- **AND** it SHALL report no diagnostics
-#### Scenario: Analyze invalid expression
-- **WHEN** the expression `t +` is analyzed
-- **THEN** the analysis SHALL include an `InvalidExpression.*` diagnostic
+#### Scenario: Analyze invalid Lua expression shorthand
+- **WHEN** the Lua expression text `t +` is analyzed
+- **THEN** the analysis SHALL include an `InvalidExpression.Lua*` diagnostic
- **AND** the diagnostic SHALL include a correction suggestion suitable for display to the user
-#### Scenario: Completion uses caret context
-- **WHEN** completions are requested after the prefix `flo`
-- **THEN** the result SHALL include `floor`
-- **AND** accepting the completion SHALL identify the replacement span for only the prefix token
+#### Scenario: Completion uses Lua caret context
+- **WHEN** completions are requested after a Lua prefix such as `math.flo`
+- **THEN** the result SHALL include `math.floor` or `floor` according to the exposed helper style
+- **AND** accepting the completion SHALL identify the replacement span for only the Lua prefix token
+
+#### Scenario: Preset completion is discoverable
+- **WHEN** completions are requested after `pre` or `preset.`
+- **THEN** the result SHALL expose a `preset` namespace entry and concrete preset entries such as `preset.round-to-frame`
+- **AND** accepting a concrete preset completion SHALL insert that preset script text rather than the display identifier
+
+#### Scenario: Completion items expose categories
+- **WHEN** expression completions are returned
+- **THEN** each completion SHALL include a token kind/category suitable for the UI to visually distinguish variables, functions, keywords, and presets
+
+### Requirement: Shared preview and save projection pipeline
+The system SHALL use one Core output projection pipeline for preview and save so Lua expression transforms, diagnostics, numbering, and naming are consistent.
+
+#### Scenario: Projection does not mutate source chapters
+- **WHEN** preview or save requests projected output from a source chapter set
+- **THEN** Core SHALL return projected chapter data without mutating the original `ChapterInfo` input
+
+#### Scenario: Projection order is deterministic
+- **WHEN** expression application, order shift, and name generation are all enabled
+- **THEN** Core SHALL first apply Lua expression transforms to non-separator chapter times
+- **AND** it SHALL normalize transformed times and refresh frame display state
+- **AND** it SHALL then apply output numbering/order shift and output name generation
+
+#### Scenario: Preview and save share diagnostics
+- **WHEN** Lua expression projection emits diagnostics during preview or save
+- **THEN** the projection result SHALL include those diagnostics in the same structured form for both callers
+- **AND** chapters whose Lua expression failed SHALL retain their original times while the rest of the output projection continues
+
+#### Scenario: Separators are structural during projection
+- **WHEN** projected output contains separator rows
+- **THEN** Core SHALL NOT execute Lua expressions for separator rows
+- **AND** exported `OutputChapters` SHALL include only non-separator chapters after projection
diff --git a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs
index 4876bbd..736c3ae 100644
--- a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs
+++ b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs
@@ -23,7 +23,7 @@ public sealed class AppCompositionRoot : IDisposable
{
private readonly string? startupPath;
private readonly ChapterTimeFormatter formatter = new();
- private readonly ExpressionService expressionService = new();
+ private readonly LuaExpressionScriptService expressionService = new();
private readonly FrameRateService frameRateService = new();
private readonly ApplicationLogPanelProvider logService = new(capacity: 500, minimumLevel: LogLevel.Information);
private readonly AppLocalizationManager localizationManager = new();
diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx
index a374b31..8687d30 100644
--- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx
+++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx
@@ -803,4 +803,55 @@
Zones
+
+ Open Lua expression script
+
+
+ Lua script files
+
+
+ Preset
+
+
+ Browse Lua...
+
+
+ Lua preset selected: {preset}
+
+
+ Lua script loaded: {path}
+
+
+ Lua expression failed: {message}
+
+
+ Lua expression syntax error: {message}
+
+
+ Lua expression runtime error: {message}
+
+
+ Lua expression must return a finite number.
+
+
+ Lua expression did not return a value.
+
+
+ Unknown Lua token: {message}
+
+
+ Lua expression was canceled.
+
+
+ Check the Lua expression syntax.
+
+
+ Check that referenced Lua variables and functions exist.
+
+
+ Return a finite number of seconds.
+
+
+ Load Lua...
+
diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx
index 945ab26..b1bab58 100644
--- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx
+++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx
@@ -803,4 +803,55 @@
Zones
+
+ Lua 式スクリプトを開く
+
+
+ Lua スクリプト ファイル
+
+
+ プリセット
+
+
+ Lua を参照...
+
+
+ Lua プリセットを選択しました: {preset}
+
+
+ Lua スクリプトを読み込みました: {path}
+
+
+ Lua 式に失敗しました: {message}
+
+
+ Lua 式の構文エラー: {message}
+
+
+ Lua 式の実行時エラー: {message}
+
+
+ Lua 式は有限の数値を返す必要があります。
+
+
+ Lua 式が値を返しませんでした。
+
+
+ 不明な Lua トークン: {message}
+
+
+ Lua 式はキャンセルされました。
+
+
+ Lua 式の構文を確認してください。
+
+
+ 参照している Lua 変数と関数が存在するか確認してください。
+
+
+ 有限の秒数を返してください。
+
+
+ Lua 読み込み...
+
diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx
index 3c5d82b..6e6d52c 100644
--- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx
+++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx
@@ -803,4 +803,55 @@
Zones
+
+ 打开 Lua 表达式脚本
+
+
+ Lua 脚本文件
+
+
+ 预设
+
+
+ 浏览 Lua...
+
+
+ 已选择 Lua 预设:{preset}
+
+
+ 已加载 Lua 脚本:{path}
+
+
+ Lua 表达式失败:{message}
+
+
+ Lua 表达式语法错误:{message}
+
+
+ Lua 表达式运行错误:{message}
+
+
+ Lua 表达式必须返回有限数字。
+
+
+ Lua 表达式没有返回值。
+
+
+ 未知 Lua 标记:{message}
+
+
+ Lua 表达式已取消。
+
+
+ 检查 Lua 表达式语法。
+
+
+ 检查引用的 Lua 变量和函数是否存在。
+
+
+ 返回一个有限的秒数数值。
+
+
+ 加载 Lua...
+
diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs
index 235f54f..43453fc 100644
--- a/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs
+++ b/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs
@@ -35,6 +35,14 @@ public sealed class AvaloniaFilePickerService(Window owner, IAppLocalizer locali
return files.Count > 0 ? files[0].Path.LocalPath : null;
}
+ public async ValueTask PickLuaExpressionScriptAsync(CancellationToken cancellationToken)
+ {
+ var files = await owner.StorageProvider.OpenFilePickerAsync(CreateLuaExpressionScriptOptions(localizer));
+
+ cancellationToken.ThrowIfCancellationRequested();
+ return files.Count > 0 ? files[0].Path.LocalPath : null;
+ }
+
public async ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken)
{
var folders = await owner.StorageProvider.OpenFolderPickerAsync(CreateSaveDirectoryOptions(localizer));
@@ -82,6 +90,18 @@ internal static FilePickerOpenOptions CreateChapterNameTemplateOptions(IAppLocal
]
};
+ internal static FilePickerOpenOptions CreateLuaExpressionScriptOptions(IAppLocalizer localizer) =>
+ new()
+ {
+ Title = localizer.GetString("FilePicker.OpenLuaExpressionScript.Title"),
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new FilePickerFileType(localizer.GetString("FilePicker.LuaScriptFiles")) { Patterns = ["*.lua"] },
+ FilePickerFileTypes.All
+ ]
+ };
+
internal static FolderPickerOpenOptions CreateSaveDirectoryOptions(IAppLocalizer localizer) =>
new()
{
diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs
index 2b6c1fc..5e1d7fa 100644
--- a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs
+++ b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs
@@ -169,7 +169,7 @@ private Control CreateContent(Window window, string id, MainWindowViewModel view
},
"color-settings" => new ColorSettingsView { DataContext = new ColorSettingsViewModel(themeSettingsStore, themeApplicationService) },
"language" => new LanguageToolView { DataContext = new LanguageToolViewModel(viewModel) },
- "expression" => new ExpressionToolView { DataContext = new ExpressionToolViewModel(viewModel) },
+ "expression" => new ExpressionToolView { DataContext = new ExpressionToolViewModel(viewModel, new AvaloniaFilePickerService(window, localizer)) },
"template-names" => new TemplateNamesToolView { DataContext = new TemplateNamesToolViewModel(viewModel) },
"zones" => new TextToolView { DataContext = new TextToolViewModel(viewModel.CreateZonesText) },
"forward-shift" => new ForwardShiftToolView { DataContext = new ForwardShiftToolViewModel(viewModel) },
diff --git a/src/ChapterTool.Avalonia/Services/IFilePickerService.cs b/src/ChapterTool.Avalonia/Services/IFilePickerService.cs
index dab8a73..d6b4c00 100644
--- a/src/ChapterTool.Avalonia/Services/IFilePickerService.cs
+++ b/src/ChapterTool.Avalonia/Services/IFilePickerService.cs
@@ -8,5 +8,7 @@ public interface IFilePickerService
ValueTask PickChapterNameTemplateAsync(CancellationToken cancellationToken);
+ ValueTask PickLuaExpressionScriptAsync(CancellationToken cancellationToken);
+
ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken);
}
diff --git a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs
index 585dc14..8747480 100644
--- a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs
+++ b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs
@@ -68,7 +68,7 @@ public MainWindowViewModel(
this.windowService = windowService;
this.formatter = formatter;
this.frameRateService = frameRateService ?? new FrameRateService();
- outputProjectionService = new ChapterOutputProjectionService(new ExpressionService());
+ outputProjectionService = new ChapterOutputProjectionService();
this.logService = logService;
this.logger = logger;
@@ -497,6 +497,18 @@ public string Expression
}
} = "t";
+ public string LuaExpressionPresetId
+ {
+ get;
+ set => SetProperty(ref field, value);
+ } = string.Empty;
+
+ public string LuaExpressionSourceName
+ {
+ get;
+ set => SetProperty(ref field, value);
+ } = string.Empty;
+
public string? SaveDirectory
{
get;
@@ -635,7 +647,7 @@ public string BuildPreview()
var projection = CurrentOutputProjection();
var options = CurrentExportOptionsForProjectedInfo();
- var result = new ChapterExportService(formatter, new ExpressionService()).Export(projection.Info, options);
+ var result = new ChapterExportService(formatter).Export(projection.Info, options);
if (!result.Success)
{
return string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => diagnostic.Message));
@@ -695,7 +707,9 @@ private ChapterExportOptions CurrentExportOptions() =>
ChapterNameTemplateText: ChapterNameTemplateText,
OrderShift: OrderShift,
ApplyExpression: ApplyExpression,
- Expression: Expression);
+ Expression: Expression,
+ LuaExpressionPresetId: LuaExpressionPresetId,
+ LuaExpressionSourceName: LuaExpressionSourceName);
private async ValueTask LoadPathAsync(string path, CancellationToken cancellationToken)
{
diff --git a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs
index 0e6bd7d..986191a 100644
--- a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs
+++ b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs
@@ -7,6 +7,7 @@
using ChapterTool.Avalonia.Localization;
using ChapterTool.Core.Exporting;
using ChapterTool.Core.Services;
+using ChapterTool.Core.Transform;
using ChapterTool.Infrastructure.Configuration;
namespace ChapterTool.Avalonia.ViewModels;
@@ -642,34 +643,117 @@ private void ReplaceLanguages(IReadOnlyList options)
public sealed record LanguageOptionViewModel(string CultureName, string DisplayName);
-public sealed class ExpressionToolViewModel(MainWindowViewModel owner) : ObservableViewModel
+public sealed class ExpressionToolViewModel : ObservableViewModel
{
+ private readonly MainWindowViewModel owner;
+ private readonly IFilePickerService? filePicker;
+
+ public ExpressionToolViewModel(MainWindowViewModel owner, IFilePickerService? filePicker = null)
+ {
+ this.owner = owner;
+ this.filePicker = filePicker;
+ Expression = owner.Expression;
+ ApplyExpression = owner.ApplyExpression;
+ LuaExpressionSourceName = owner.LuaExpressionSourceName;
+ Presets = new LuaExpressionScriptService().Presets
+ .Select(static preset => new LuaExpressionPresetViewModel(preset.Id, preset.DisplayName, preset.Description, preset.ScriptText))
+ .ToList();
+ SelectedPresetIndex = Presets.ToList().FindIndex(preset => string.Equals(preset.Id, owner.LuaExpressionPresetId, StringComparison.Ordinal));
+ BrowseScriptCommand = new UiCommand(async (_, token) => await BrowseScriptAsync(token), _ => this.filePicker is not null);
+ ApplyCommand = new UiCommand((parameter, _) =>
+ {
+ if (parameter is ExpressionToolViewModel viewModel)
+ {
+ owner.Expression = string.IsNullOrWhiteSpace(viewModel.Expression) ? "t" : viewModel.Expression;
+ owner.ApplyExpression = viewModel.ApplyExpression;
+ owner.LuaExpressionPresetId = viewModel.SelectedPreset?.Id ?? string.Empty;
+ owner.LuaExpressionSourceName = viewModel.LuaExpressionSourceName;
+ }
+
+ return ValueTask.CompletedTask;
+ });
+ }
+
public IAppLocalizer Localizer => owner.Localizer;
+ public IReadOnlyList Presets { get; }
+
+ public LuaExpressionPresetViewModel? SelectedPreset =>
+ SelectedPresetIndex >= 0 && SelectedPresetIndex < Presets.Count ? Presets[SelectedPresetIndex] : null;
+
+ public int SelectedPresetIndex
+ {
+ get;
+ set
+ {
+ if (!SetProperty(ref field, value))
+ {
+ return;
+ }
+
+ OnPropertyChanged(nameof(SelectedPreset));
+ if (SelectedPreset is { } preset)
+ {
+ Expression = preset.ScriptText;
+ LuaExpressionSourceName = preset.DisplayName;
+ StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionPresetSelected", ("preset", preset.DisplayName)));
+ }
+ }
+ } = -1;
+
public string Expression
{
get;
set => SetProperty(ref field, value);
- } = owner.Expression;
+ } = "t";
public bool ApplyExpression
{
get;
set => SetProperty(ref field, value);
- } = owner.ApplyExpression;
+ }
- public UiCommand ApplyCommand { get; } = new((parameter, _) =>
+ public string LuaExpressionSourceName
+ {
+ get;
+ set => SetProperty(ref field, value);
+ } = string.Empty;
+
+ public string StatusText
+ {
+ get;
+ private set => SetProperty(ref field, value);
+ } = string.Empty;
+
+ public bool CanBrowseScript => filePicker is not null;
+
+ public UiCommand BrowseScriptCommand { get; }
+
+ public UiCommand ApplyCommand { get; }
+
+ private async ValueTask BrowseScriptAsync(CancellationToken cancellationToken)
{
- if (parameter is ExpressionToolViewModel viewModel)
+ if (filePicker is null)
{
- owner.Expression = string.IsNullOrWhiteSpace(viewModel.Expression) ? "t" : viewModel.Expression;
- owner.ApplyExpression = viewModel.ApplyExpression;
+ return;
}
- return ValueTask.CompletedTask;
- });
+ var path = await filePicker.PickLuaExpressionScriptAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ var text = await File.ReadAllTextAsync(path, cancellationToken);
+ Expression = text;
+ LuaExpressionSourceName = Path.GetFileName(path);
+ SelectedPresetIndex = -1;
+ StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionScriptLoaded", ("path", LuaExpressionSourceName)));
+ }
}
+public sealed record LuaExpressionPresetViewModel(string Id, string DisplayName, string Description, string ScriptText);
+
public sealed class TemplateNamesToolViewModel(MainWindowViewModel owner) : ObservableViewModel
{
public bool UseTemplateNames
diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml
index e3157de..d76971d 100644
--- a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml
+++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml
@@ -1,11 +1,15 @@
+
+
+
+
+
+
-
-
+
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Foreground="{Binding Kind, Converter={StaticResource CompletionKindBrushConverter}}" />
+
+ Opacity="0.72"
+ TextTrimming="CharacterEllipsis"
+ VerticalAlignment="Center" />
+
diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs
index 9dd0cbb..ec31d09 100644
--- a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs
+++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs
@@ -1,11 +1,14 @@
+using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
+using Avalonia.Data.Converters;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
+using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
@@ -15,6 +18,46 @@
namespace ChapterTool.Avalonia.Views.Controls;
+public sealed class ExpressionCompletionKindBrushConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var kind = value is ExpressionTokenKind tokenKind ? tokenKind : ExpressionTokenKind.Unknown;
+ return string.Equals(parameter?.ToString(), "icon", StringComparison.OrdinalIgnoreCase)
+ ? Icon(kind)
+ : Foreground(kind);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException();
+
+ private static string Icon(ExpressionTokenKind kind) => kind switch
+ {
+ ExpressionTokenKind.Variable => "◈",
+ ExpressionTokenKind.Constant => "◆",
+ ExpressionTokenKind.Function => "ƒ",
+ ExpressionTokenKind.Keyword => "K",
+ ExpressionTokenKind.Snippet => "◇",
+ ExpressionTokenKind.String => "S",
+ ExpressionTokenKind.Number => "#",
+ _ => "•"
+ };
+
+ private static IBrush Foreground(ExpressionTokenKind kind) => kind switch
+ {
+ ExpressionTokenKind.Variable => Brush("#0550ae"),
+ ExpressionTokenKind.Constant => Brush("#8250df"),
+ ExpressionTokenKind.Function => Brush("#953800"),
+ ExpressionTokenKind.Keyword => Brush("#cf222e"),
+ ExpressionTokenKind.Snippet => Brush("#6f42c1"),
+ ExpressionTokenKind.String => Brush("#0a3069"),
+ ExpressionTokenKind.Number => Brush("#116329"),
+ _ => Brush("#57606a")
+ };
+
+ private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color));
+}
+
+
public sealed partial class ExpressionEditor : UserControl
{
public static readonly StyledProperty TextProperty =
@@ -23,6 +66,9 @@ public sealed partial class ExpressionEditor : UserControl
public static readonly StyledProperty LocalizerProperty =
AvaloniaProperty.Register(nameof(Localizer));
+ public static readonly StyledProperty EditorHeightProperty =
+ AvaloniaProperty.Register(nameof(EditorHeight), 25.6);
+
private readonly IExpressionAuthoringService authoringService = new ExpressionAuthoringService();
private readonly TextEditor editor;
private readonly ExpressionColorizer colorizer;
@@ -54,8 +100,8 @@ public ExpressionEditor()
Background = Brushes.Transparent,
Foreground = Brush("#24292f"),
Padding = new Thickness(6, 3),
- MinHeight = 25.6,
- Height = 25.6,
+ MinHeight = EditorHeight,
+ Height = EditorHeight,
HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
Document = new TextDocument(Text)
@@ -90,6 +136,12 @@ public IAppLocalizer? Localizer
set => SetValue(LocalizerProperty, value);
}
+ public double EditorHeight
+ {
+ get => GetValue(EditorHeightProperty);
+ set => SetValue(EditorHeightProperty, value);
+ }
+
public IReadOnlyList CurrentCompletions { get; private set; } = [];
public string EditorText => editor.Text;
@@ -141,6 +193,12 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
{
AnalyzeAndRender();
}
+ else if (change.Property == EditorHeightProperty && editor is not null)
+ {
+ var height = Math.Max(25.6, change.GetNewValue());
+ editor.MinHeight = height;
+ editor.Height = height;
+ }
}
public void AcceptFirstCompletion()
@@ -228,19 +286,31 @@ private void OnEditorFocusChanged(object? sender, RoutedEventArgs args)
private void OnPointerPressed(object? sender, PointerPressedEventArgs args)
{
+ if ((args.Source as Visual)?.FindAncestorOfType() is not null)
+ {
+ return;
+ }
+
Dispatcher.UIThread.Post(FocusInnerEditor);
}
private void OnWrapperGotFocus(object? sender, RoutedEventArgs args)
{
- FocusInnerEditor();
+ if (!editor.IsKeyboardFocusWithin)
+ {
+ FocusInnerEditor();
+ }
}
private void FocusInnerEditor()
{
editor.Focus();
editor.TextArea.Focus();
- editor.CaretOffset = Math.Clamp(editor.CaretOffset, 0, editor.Document.TextLength);
+ var caretOffset = Math.Clamp(editor.CaretOffset, 0, editor.Document.TextLength);
+ if (editor.CaretOffset != caretOffset)
+ {
+ editor.CaretOffset = caretOffset;
+ }
}
private void AnalyzeAndRender()
@@ -270,7 +340,7 @@ private void RenderDiagnostics(ExpressionAnalysisResult result)
private void RenderCompletionPopup()
{
- if (!shouldShowCompletion || !editor.IsKeyboardFocusWithin || CurrentCompletions.Count == 0)
+ if (!shouldShowCompletion || (!editor.IsKeyboardFocusWithin && !IsCompletionPopupInteractionActive()) || CurrentCompletions.Count == 0)
{
CloseCompletionPopup();
return;
@@ -290,6 +360,12 @@ private void RenderCompletionPopup()
CloseDiagnosticPopup();
}
+ private bool IsCompletionPopupInteractionActive()
+ {
+ return CompletionPopup.IsOpen
+ && CompletionPanel.IsPointerOver;
+ }
+
private void InsertCompletion(ExpressionCompletion completion)
{
editor.Document.Replace(completion.ReplacementStart, completion.ReplacementLength, completion.InsertText);
@@ -466,8 +542,32 @@ private void OnCompletionSelectionChanged(object? sender, SelectionChangedEventA
shouldShowCompletion = CurrentCompletions.Count > 0;
}
+ private void OnCompletionListPointerPressed(object? sender, PointerPressedEventArgs args)
+ {
+ if (!CompletionPopup.IsOpen || !args.GetCurrentPoint(CompletionList).Properties.IsLeftButtonPressed)
+ {
+ return;
+ }
+
+ var item = (args.Source as Visual)?.FindAncestorOfType();
+ if (item?.DataContext is not ExpressionCompletion completion)
+ {
+ return;
+ }
+
+ args.Handled = true;
+ CompletionList.SelectedItem = completion;
+ InsertCompletion(completion);
+ }
+
private void OnCompletionListDoubleTapped(object? sender, RoutedEventArgs args)
{
+ if (!CompletionPopup.IsOpen)
+ {
+ return;
+ }
+
+ args.Handled = true;
AcceptSelectedCompletion();
}
@@ -529,6 +629,9 @@ private static IBrush ForegroundFor(ExpressionTokenKind kind) =>
ExpressionTokenKind.Variable => Brush("#0550ae"),
ExpressionTokenKind.Constant => Brush("#8250df"),
ExpressionTokenKind.Function => Brush("#953800"),
+ ExpressionTokenKind.Keyword => Brush("#cf222e"),
+ ExpressionTokenKind.Snippet => Brush("#8250df"),
+ ExpressionTokenKind.String => Brush("#0a3069"),
ExpressionTokenKind.Operator => Brush("#cf222e"),
ExpressionTokenKind.Punctuation => Brush("#57606a"),
ExpressionTokenKind.Number => Brush("#116329"),
diff --git a/src/ChapterTool.Avalonia/Views/MainWindow.axaml b/src/ChapterTool.Avalonia/Views/MainWindow.axaml
index 3e57a26..1c25026 100644
--- a/src/ChapterTool.Avalonia/Views/MainWindow.axaml
+++ b/src/ChapterTool.Avalonia/Views/MainWindow.axaml
@@ -569,7 +569,7 @@
Classes="optionCell"
Grid.Row="2"
Grid.Column="0"
- ColumnDefinitions="78,78,*"
+ ColumnDefinitions="78,78,*,Auto"
ColumnSpacing="6.4">
+
diff --git a/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs b/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs
index 133e26b..b89b6cf 100644
--- a/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs
+++ b/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs
@@ -1,8 +1,10 @@
-using System.Reflection;
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
+using Avalonia.VisualTree;
+using AvaloniaEdit;
using ChapterTool.Avalonia.Services;
using ChapterTool.Avalonia.ViewModels;
using ChapterTool.Core.Exporting;
@@ -36,6 +38,7 @@ public MainWindow(
ReloadCommand = new UiCommand(async (_, _) => await LoadAsync(), _ => viewModel.ReloadCommand.CanExecute());
AppendMplsCommand = new UiCommand(async (_, _) => await AppendMplsAsync(), _ => viewModel.CanAppendMpls);
LoadChapterNameTemplateCommand = new UiCommand(async (_, _) => await LoadChapterNameTemplateAsync());
+ LoadLuaExpressionScriptCommand = new UiCommand(async (_, _) => await LoadLuaExpressionScriptAsync());
SaveCommand = new UiCommand(async (_, _) => await SaveAsync(null), _ => viewModel.SaveCommand.CanExecute());
SaveToCommand = new UiCommand(async (_, _) => await SaveToAsync(), _ => viewModel.SaveCommand.CanExecute());
PreviewCommand = new UiCommand(async (_, _) =>
@@ -82,6 +85,8 @@ public MainWindow(
public UiCommand LoadChapterNameTemplateCommand { get; }
+ public UiCommand LoadLuaExpressionScriptCommand { get; }
+
public UiCommand SaveCommand { get; }
public UiCommand SaveToCommand { get; }
@@ -188,6 +193,21 @@ private async Task LoadChapterNameTemplateAsync()
viewModel.ChapterNameModeIndex = 2;
}
+ private async Task LoadLuaExpressionScriptAsync()
+ {
+ var path = await filePickerService.PickLuaExpressionScriptAsync(CancellationToken.None);
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ var text = await File.ReadAllTextAsync(path, CancellationToken.None);
+ viewModel.Expression = string.IsNullOrWhiteSpace(text) ? "t" : text;
+ viewModel.LuaExpressionSourceName = Path.GetFileName(path);
+ viewModel.LuaExpressionPresetId = string.Empty;
+ viewModel.ApplyExpression = true;
+ }
+
private async Task OpenRelatedMediaAsync()
{
await viewModel.OpenRelatedMediaCommand.ExecuteAsync();
@@ -267,6 +287,11 @@ private async void OnDrop(object? sender, DragEventArgs args)
private async void OnKeyDown(object? sender, KeyEventArgs args)
{
+ if (IsTextInputKeyScope(args.Source as Visual))
+ {
+ return;
+ }
+
var gesture = Gesture(args);
if (args.Key == Key.Insert)
{
@@ -326,6 +351,14 @@ private async void OnKeyDown(object? sender, KeyEventArgs args)
await shortcutRouter.RouteAsync(gesture);
}
+ private static bool IsTextInputKeyScope(Visual? source)
+ {
+ return source is TextBox or NumericUpDown or TextEditor
+ || source?.FindAncestorOfType() is not null
+ || source?.FindAncestorOfType() is not null
+ || source?.FindAncestorOfType() is not null;
+ }
+
private static string? Gesture(KeyEventArgs args)
{
var control = args.KeyModifiers.HasFlag(KeyModifiers.Control);
diff --git a/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml b/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml
index 85635c9..fa9bafa 100644
--- a/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml
+++ b/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml
@@ -1,14 +1,58 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ChapterTool.Core/ChapterTool.Core.csproj b/src/ChapterTool.Core/ChapterTool.Core.csproj
index 9709eb2..192eced 100644
--- a/src/ChapterTool.Core/ChapterTool.Core.csproj
+++ b/src/ChapterTool.Core/ChapterTool.Core.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs b/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs
index 8f44f19..054922e 100644
--- a/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs
+++ b/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs
@@ -10,5 +10,7 @@ public sealed record ChapterExportOptions(
int OrderShift = 0,
bool ApplyExpression = false,
string Expression = "t",
+ string LuaExpressionPresetId = "",
+ string LuaExpressionSourceName = "",
bool EmitBom = true,
bool ProjectOutput = true);
diff --git a/src/ChapterTool.Core/Exporting/ChapterExportService.cs b/src/ChapterTool.Core/Exporting/ChapterExportService.cs
index 5191b7a..585eb86 100644
--- a/src/ChapterTool.Core/Exporting/ChapterExportService.cs
+++ b/src/ChapterTool.Core/Exporting/ChapterExportService.cs
@@ -11,16 +11,28 @@
namespace ChapterTool.Core.Exporting;
-public sealed class ChapterExportService(
- IChapterTimeFormatter timeFormatter,
- IExpressionService expressionService)
+public sealed class ChapterExportService
{
- private readonly ChapterConversionService chapterConversionService = new(timeFormatter);
+ private readonly IChapterTimeFormatter timeFormatter;
+ private readonly ILuaExpressionScriptService luaExpressionService;
+ private readonly ChapterConversionService chapterConversionService;
+
+ public ChapterExportService(IChapterTimeFormatter timeFormatter, ILuaExpressionScriptService? luaExpressionService = null)
+ {
+ this.timeFormatter = timeFormatter;
+ this.luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService();
+ chapterConversionService = new ChapterConversionService(timeFormatter);
+ }
+
+ public ChapterExportService(IChapterTimeFormatter timeFormatter, IExpressionService _)
+ : this(timeFormatter, new LuaExpressionScriptService())
+ {
+ }
public ChapterExportResult Export(ChapterInfo info, ChapterExportOptions options)
{
var projection = options.ProjectOutput
- ? new ChapterOutputProjectionService(expressionService).Project(info, options)
+ ? new ChapterOutputProjectionService(luaExpressionService).Project(info, options)
: new ChapterOutputProjectionResult(info, []);
info = projection.Info;
var outputInfo = info with { Chapters = projection.OutputChapters };
diff --git a/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs b/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs
index 906f2cf..45749b6 100644
--- a/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs
+++ b/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs
@@ -4,12 +4,24 @@
namespace ChapterTool.Core.Exporting;
-public sealed class ChapterOutputProjectionService(IExpressionService expressionService)
+public sealed class ChapterOutputProjectionService
{
+ private readonly ILuaExpressionScriptService luaExpressionService;
+
+ public ChapterOutputProjectionService(ILuaExpressionScriptService? luaExpressionService = null)
+ {
+ this.luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService();
+ }
+
+ public ChapterOutputProjectionService(IExpressionService _)
+ : this(new LuaExpressionScriptService())
+ {
+ }
+
public ChapterOutputProjectionResult Project(ChapterInfo info, ChapterExportOptions options)
{
var diagnostics = new List();
- var expressionResult = new ChapterExpressionService(expressionService).Apply(info, options.ApplyExpression, options.Expression);
+ var expressionResult = new ChapterExpressionService(luaExpressionService).Apply(info, options.ApplyExpression, options.Expression);
diagnostics.AddRange(expressionResult.Diagnostics);
var effectiveShift = NormalizeOrderShift(options.OrderShift, diagnostics);
diff --git a/src/ChapterTool.Core/Transform/ChapterExpressionService.cs b/src/ChapterTool.Core/Transform/ChapterExpressionService.cs
index ecdebc0..fee9b08 100644
--- a/src/ChapterTool.Core/Transform/ChapterExpressionService.cs
+++ b/src/ChapterTool.Core/Transform/ChapterExpressionService.cs
@@ -4,8 +4,20 @@
namespace ChapterTool.Core.Transform;
-public sealed class ChapterExpressionService(IExpressionService expressionService)
+public sealed class ChapterExpressionService
{
+ private readonly ILuaExpressionScriptService luaExpressionService;
+
+ public ChapterExpressionService(ILuaExpressionScriptService? luaExpressionService = null)
+ {
+ this.luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService();
+ }
+
+ public ChapterExpressionService(IExpressionService _)
+ : this(new LuaExpressionScriptService())
+ {
+ }
+
public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, string expression)
{
if (!applyExpression)
@@ -14,6 +26,9 @@ public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, str
}
var diagnostics = new List();
+ var nonSeparatorCount = info.Chapters.Count(static chapter => !chapter.IsSeparator);
+ var nonSeparatorIndex = 0;
+ var framesPerSecond = (decimal)info.FramesPerSecond;
var chapters = info.Chapters.Select(chapter =>
{
if (chapter.IsSeparator)
@@ -21,7 +36,11 @@ public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, str
return chapter;
}
- var evaluated = expressionService.EvaluateInfix(expression, (decimal)chapter.Time.TotalSeconds, (decimal)info.FramesPerSecond);
+ nonSeparatorIndex++;
+ var originalSeconds = (decimal)chapter.Time.TotalSeconds;
+ var evaluated = luaExpressionService.Evaluate(
+ expression,
+ new LuaExpressionContext(chapter, nonSeparatorIndex, nonSeparatorCount, originalSeconds, framesPerSecond));
diagnostics.AddRange(evaluated.Diagnostics);
if (!evaluated.Success)
{
@@ -34,7 +53,7 @@ public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, str
diagnostics.Add(diagnostic);
}
- var frameDisplay = FormatFrames(normalized, (decimal)info.FramesPerSecond);
+ var frameDisplay = FormatFrames(normalized, framesPerSecond);
return chapter with
{
Time = TimeSpan.FromSeconds((double)normalized),
diff --git a/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs b/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs
index 7081cf9..a106149 100644
--- a/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs
+++ b/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs
@@ -9,6 +9,9 @@ public enum ExpressionTokenKind
Variable,
Constant,
Function,
+ Keyword,
+ Snippet,
+ String,
Operator,
Punctuation,
Comment
@@ -33,7 +36,21 @@ public sealed record ExpressionCompletion(
string Description,
int ReplacementStart,
int ReplacementLength,
- string InsertText);
+ string InsertText)
+{
+ public string KindLabel => Kind switch
+ {
+ ExpressionTokenKind.Variable => "VAR",
+ ExpressionTokenKind.Constant => "CONST",
+ ExpressionTokenKind.Function => "FUNC",
+ ExpressionTokenKind.Keyword => "KEY",
+ ExpressionTokenKind.Snippet => "PRESET",
+ ExpressionTokenKind.String => "STR",
+ ExpressionTokenKind.Number => "NUM",
+ _ => Kind.ToString().ToUpperInvariant()
+ };
+}
+
public sealed record ExpressionDiagnosticSuggestion(
string Code,
diff --git a/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs b/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs
index 7bca5c9..ed5efab 100644
--- a/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs
+++ b/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs
@@ -3,404 +3,343 @@
namespace ChapterTool.Core.Transform;
-public sealed class ExpressionAuthoringService(IExpressionService? expressionService = null) : IExpressionAuthoringService
+public sealed class ExpressionAuthoringService(ILuaExpressionScriptService? luaExpressionService = null) : IExpressionAuthoringService
{
- private readonly IExpressionService expressionService = expressionService ?? new ExpressionService();
-
- public IReadOnlyList Symbols { get; } =
- [
- new("t", ExpressionTokenKind.Variable, "Current chapter time in seconds."),
- new("fps", ExpressionTokenKind.Variable, "Current frame rate."),
- new("M_E", ExpressionTokenKind.Constant, "Euler's number."),
- new("M_LOG2E", ExpressionTokenKind.Constant, "Log2(e)."),
- new("M_LOG10E", ExpressionTokenKind.Constant, "Log10(e)."),
- new("M_PI", ExpressionTokenKind.Constant, "Pi."),
- new("M_PI_2", ExpressionTokenKind.Constant, "Pi divided by 2."),
- new("M_PI_4", ExpressionTokenKind.Constant, "Pi divided by 4."),
- new("M_1_PI", ExpressionTokenKind.Constant, "1 divided by pi."),
- new("M_2_PI", ExpressionTokenKind.Constant, "2 divided by pi."),
- new("M_2_SQRTPI", ExpressionTokenKind.Constant, "2 divided by sqrt(pi)."),
- new("M_LN2", ExpressionTokenKind.Constant, "Natural log of 2."),
- new("M_LN10", ExpressionTokenKind.Constant, "Natural log of 10."),
- new("M_SQRT2", ExpressionTokenKind.Constant, "Square root of 2."),
- new("M_SQRT1_2", ExpressionTokenKind.Constant, "1 divided by square root of 2."),
- new("abs", ExpressionTokenKind.Function, "Absolute value.", 1, "abs()"),
- new("acos", ExpressionTokenKind.Function, "Arc cosine.", 1, "acos()"),
- new("asin", ExpressionTokenKind.Function, "Arc sine.", 1, "asin()"),
- new("atan", ExpressionTokenKind.Function, "Arc tangent.", 1, "atan()"),
- new("atan2", ExpressionTokenKind.Function, "Arc tangent of two values.", 2, "atan2(, )"),
- new("cos", ExpressionTokenKind.Function, "Cosine.", 1, "cos()"),
- new("sin", ExpressionTokenKind.Function, "Sine.", 1, "sin()"),
- new("tan", ExpressionTokenKind.Function, "Tangent.", 1, "tan()"),
- new("cosh", ExpressionTokenKind.Function, "Hyperbolic cosine.", 1, "cosh()"),
- new("sinh", ExpressionTokenKind.Function, "Hyperbolic sine.", 1, "sinh()"),
- new("tanh", ExpressionTokenKind.Function, "Hyperbolic tangent.", 1, "tanh()"),
- new("exp", ExpressionTokenKind.Function, "e raised to a power.", 1, "exp()"),
- new("log", ExpressionTokenKind.Function, "Natural logarithm.", 1, "log()"),
- new("log10", ExpressionTokenKind.Function, "Base-10 logarithm.", 1, "log10()"),
- new("sqrt", ExpressionTokenKind.Function, "Square root.", 1, "sqrt()"),
- new("ceil", ExpressionTokenKind.Function, "Round up.", 1, "ceil()"),
- new("floor", ExpressionTokenKind.Function, "Round down.", 1, "floor()"),
- new("round", ExpressionTokenKind.Function, "Round to nearest integer.", 1, "round()"),
- new("int", ExpressionTokenKind.Function, "Truncate to integer.", 1, "int()"),
- new("sign", ExpressionTokenKind.Function, "Sign of the value.", 1, "sign()"),
- new("pow", ExpressionTokenKind.Function, "Raise a value to a power.", 2, "pow(, )"),
- new("max", ExpressionTokenKind.Function, "Larger of two values.", 2, "max(, )"),
- new("min", ExpressionTokenKind.Function, "Smaller of two values.", 2, "min(, )"),
- new("+", ExpressionTokenKind.Operator, "Add."),
- new("-", ExpressionTokenKind.Operator, "Subtract or negate."),
- new("*", ExpressionTokenKind.Operator, "Multiply."),
- new("/", ExpressionTokenKind.Operator, "Divide."),
- new("%", ExpressionTokenKind.Operator, "Modulo."),
- new("^", ExpressionTokenKind.Operator, "Power."),
- new(">", ExpressionTokenKind.Operator, "Greater than."),
- new("<", ExpressionTokenKind.Operator, "Less than."),
- new(">=", ExpressionTokenKind.Operator, "Greater than or equal."),
- new("<=", ExpressionTokenKind.Operator, "Less than or equal."),
- new("?", ExpressionTokenKind.Operator, "Ternary condition."),
- new(":", ExpressionTokenKind.Operator, "Ternary separator.")
- ];
-
- public ExpressionAnalysisResult Analyze(string? expression, int caretIndex, decimal timeSeconds = 0, decimal framesPerSecond = 24)
+ private static readonly HashSet Keywords = new(StringComparer.Ordinal)
+ {
+ "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while"
+ };
+
+ private static readonly HashSet Operators = new(StringComparer.Ordinal)
+ {
+ "+", "-", "*", "/", "%", "^", "#", "==", "~=", "<=", ">=", "<", ">", "=", ".."
+ };
+
+ private readonly ILuaExpressionScriptService luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService();
+
+ public IReadOnlyList Symbols { get; } = BuildSymbols(luaExpressionService ?? new LuaExpressionScriptService());
+
+ public ExpressionAnalysisResult Analyze(string expression, int caretIndex, decimal timeSeconds = 0, decimal framesPerSecond = 24)
{
expression ??= string.Empty;
caretIndex = Math.Clamp(caretIndex, 0, expression.Length);
+ var spans = Tokenize(expression);
+ var completions = Complete(expression, caretIndex).ToList();
+ var diagnostics = completions.Count > 0 && IsCompletionOnly(expression, caretIndex)
+ ? []
+ : Diagnostics(expression, spans, timeSeconds, framesPerSecond);
+ return new ExpressionAnalysisResult(spans, completions, diagnostics);
+ }
- var spans = Classify(expression);
- var token = CurrentToken(expression, caretIndex);
- var completions = Complete(token);
- var diagnostics = Validate(expression, timeSeconds, framesPerSecond);
- if (ShouldSuppressPrefixDiagnostic(expression, caretIndex, token, completions, diagnostics))
- {
- diagnostics = [];
- }
+ private static IReadOnlyList BuildSymbols(ILuaExpressionScriptService luaExpressionService)
+ {
+ var symbols = new List
+ {
+ new("t", ExpressionTokenKind.Variable, "Current chapter time in seconds."),
+ new("fps", ExpressionTokenKind.Variable, "Current frame rate."),
+ new("index", ExpressionTokenKind.Variable, "One-based index of the current non-separator chapter."),
+ new("count", ExpressionTokenKind.Variable, "Total non-separator chapter count."),
+ new("chapter", ExpressionTokenKind.Variable, "Current chapter context table."),
+ new("chapter.time", ExpressionTokenKind.Variable, "Current chapter time in seconds."),
+ new("chapter.name", ExpressionTokenKind.Variable, "Current chapter name."),
+ new("chapter.number", ExpressionTokenKind.Variable, "Current chapter number."),
+ new("math.floor", ExpressionTokenKind.Function, "Round down.", 1, "math.floor()"),
+ new("math.ceil", ExpressionTokenKind.Function, "Round up.", 1, "math.ceil()"),
+ new("math.abs", ExpressionTokenKind.Function, "Absolute value.", 1, "math.abs()"),
+ new("math.min", ExpressionTokenKind.Function, "Smaller of values.", null, "math.min()"),
+ new("math.max", ExpressionTokenKind.Function, "Larger of values.", null, "math.max()"),
+ new("math.sqrt", ExpressionTokenKind.Function, "Square root.", 1, "math.sqrt()"),
+ new("math.sin", ExpressionTokenKind.Function, "Sine.", 1, "math.sin()"),
+ new("math.cos", ExpressionTokenKind.Function, "Cosine.", 1, "math.cos()"),
+ new("math.tan", ExpressionTokenKind.Function, "Tangent.", 1, "math.tan()"),
+ new("floor", ExpressionTokenKind.Function, "Alias for math.floor.", 1, "floor()"),
+ new("ceil", ExpressionTokenKind.Function, "Alias for math.ceil.", 1, "ceil()"),
+ new("round", ExpressionTokenKind.Function, "Round to nearest integer.", 1, "round()"),
+ new("sin", ExpressionTokenKind.Function, "Alias for math.sin.", 1, "sin()"),
+ new("sqrt", ExpressionTokenKind.Function, "Alias for math.sqrt.", 1, "sqrt()"),
+ new("sign", ExpressionTokenKind.Function, "Sign of the value.", 1, "sign()"),
+ new("return", ExpressionTokenKind.Keyword, "Return the transformed time."),
+ new("preset", ExpressionTokenKind.Snippet, "Built-in Lua expression presets. Type preset. to browse them.", null, "preset."),
+ new("function transform(chapter)\n return t\nend", ExpressionTokenKind.Snippet, "Reusable transform function snippet.", null, "function transform(chapter)\n return t\nend")
+ };
- return new ExpressionAnalysisResult(spans, completions, diagnostics);
+ symbols.AddRange(luaExpressionService.Presets.Select(static preset => new ExpressionSymbol(
+ $"preset.{preset.Id}",
+ ExpressionTokenKind.Snippet,
+ preset.Description,
+ InsertText: preset.ScriptText)));
+
+ return symbols;
}
- private IReadOnlyList Complete((int Start, int Length, string Prefix) token)
+ private List Complete(string expression, int caretIndex)
{
- var (start, length, prefix) = token;
- if (prefix.Length == 0)
+ var token = CurrentToken(expression, caretIndex);
+ if (token.Prefix.Length == 0)
{
return [];
}
- return Symbols
- .Where(symbol => symbol.Kind is ExpressionTokenKind.Variable or ExpressionTokenKind.Constant or ExpressionTokenKind.Function)
- .Where(symbol => symbol.Text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- .OrderBy(SymbolSortOrder)
- .ThenBy(symbol => symbol.Text.Length)
- .ThenBy(symbol => symbol.Text, StringComparer.Ordinal)
+ var candidates = Symbols
+ .Where(symbol => symbol.Text.StartsWith(token.Prefix, StringComparison.OrdinalIgnoreCase) ||
+ symbol.InsertText.StartsWith(token.Prefix, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static symbol => CompletionRank(symbol.Kind))
+ .ThenBy(static symbol => symbol.Text, StringComparer.OrdinalIgnoreCase)
.Select(symbol => new ExpressionCompletion(
symbol.Text,
symbol.Kind,
symbol.Description,
- start,
- length,
+ token.Start,
+ token.Length,
string.IsNullOrEmpty(symbol.InsertText) ? symbol.Text : symbol.InsertText))
.ToList();
- }
-
- private static int SymbolSortOrder(ExpressionSymbol symbol) =>
- symbol.Kind switch
- {
- ExpressionTokenKind.Function => 0,
- ExpressionTokenKind.Variable => 1,
- ExpressionTokenKind.Constant => 2,
- _ => 3
- };
- private static bool ShouldSuppressPrefixDiagnostic(
- string expression,
- int caretIndex,
- (int Start, int Length, string Prefix) token,
- IReadOnlyList completions,
- IReadOnlyList diagnostics)
- {
- if (diagnostics.Count == 0 || completions.Count == 0 || token.Prefix.Length == 0)
- {
- return false;
+ if (token.Prefix.Contains('.', StringComparison.Ordinal) && candidates.Count == 0)
+ {
+ var suffix = token.Prefix[(token.Prefix.LastIndexOf('.') + 1)..];
+ candidates = Symbols
+ .Where(symbol => symbol.Text.Contains('.', StringComparison.Ordinal) && symbol.Text[(symbol.Text.LastIndexOf('.') + 1)..].StartsWith(suffix, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static symbol => symbol.Text, StringComparer.OrdinalIgnoreCase)
+ .Select(symbol => new ExpressionCompletion(
+ symbol.Text,
+ symbol.Kind,
+ symbol.Description,
+ token.Start,
+ token.Length,
+ string.IsNullOrEmpty(symbol.InsertText) ? symbol.Text : symbol.InsertText))
+ .ToList();
}
- if (caretIndex != token.Start + token.Length)
- {
- return false;
- }
-
- var currentToken = expression.Substring(token.Start, token.Length);
- if (currentToken.Length == 0 || string.Equals(currentToken, completions[0].Text, StringComparison.Ordinal))
- {
- return false;
- }
+ return candidates;
+ }
- return diagnostics.All(static diagnostic => diagnostic.Diagnostic.Code is "InvalidExpression.UnknownToken" or "InvalidExpression.UnsupportedFunction" or "InvalidExpression.UnsupportedToken");
+ private static bool IsCompletionOnly(string expression, int caretIndex)
+ {
+ var token = CurrentToken(expression, caretIndex);
+ return token.Prefix.Length > 0 &&
+ expression[..token.Start].Trim().Length == 0 &&
+ expression[(token.Start + token.Length)..].Trim().Length == 0;
}
- private IReadOnlyList Validate(string expression, decimal timeSeconds, decimal framesPerSecond)
+ private List Diagnostics(string expression, IReadOnlyList spans, decimal timeSeconds, decimal framesPerSecond)
{
- if (string.IsNullOrWhiteSpace(expression))
+ if (spans.Any(static span => span.Kind == ExpressionTokenKind.Unknown))
{
- return [];
+ var unknown = spans.First(static span => span.Kind == ExpressionTokenKind.Unknown);
+ return [Diagnostic("InvalidExpression.LuaUnknownToken", $"Unsupported Lua token '{unknown.Text}'.", "Expression.Suggestion.CheckLuaSyntax", "Check the Lua expression syntax.", unknown.Start, unknown.Length)];
}
- var result = expressionService.EvaluateInfix(expression, timeSeconds, framesPerSecond);
+ var result = luaExpressionService.Evaluate(
+ expression,
+ new LuaExpressionContext(new Models.Chapter(1, TimeSpan.FromSeconds((double)timeSeconds), "Chapter"), 1, 1, timeSeconds, framesPerSecond));
if (result.Success)
{
return [];
}
- return result.Diagnostics
- .Select(diagnostic => new ExpressionAuthoringDiagnostic(
- diagnostic,
- Suggest(diagnostic.Code),
- DiagnosticStart(expression, diagnostic),
- DiagnosticLength(expression, diagnostic)))
- .ToList();
- }
-
- private static int DiagnosticStart(string expression, ChapterDiagnostic diagnostic)
- {
- var token = TokenArgument(diagnostic);
- if (string.IsNullOrEmpty(token))
+ var diagnostic = result.Diagnostics.FirstOrDefault();
+ if (diagnostic is null)
{
- return Math.Max(0, expression.Length - 1);
+ return [];
}
- var index = expression.LastIndexOf(token, StringComparison.Ordinal);
- return index >= 0 ? index : Math.Max(0, expression.Length - token.Length);
+ var target = LastMeaningfulSpan(spans) ?? new ExpressionTokenSpan(Math.Max(0, expression.Length - 1), expression.Length > 0 ? 1 : 0, string.Empty, ExpressionTokenKind.Unknown);
+ var suggestion = SuggestionFor(diagnostic.Code, expression);
+ return [new ExpressionAuthoringDiagnostic(diagnostic, suggestion, target.Start, Math.Max(1, target.Length))];
}
- private static int DiagnosticLength(string expression, ChapterDiagnostic diagnostic)
- {
- var token = TokenArgument(diagnostic);
- if (!string.IsNullOrEmpty(token))
+ private static ExpressionDiagnosticSuggestion SuggestionFor(string diagnosticCode, string expression) =>
+ diagnosticCode switch
{
- return token.Length;
- }
-
- return expression.Length == 0 ? 0 : 1;
- }
+ "InvalidExpression.LuaCompile" when EndsWithOperator(expression) => new("Expression.Suggestion.AddOperand", "Add the missing operand before applying this token."),
+ "InvalidExpression.LuaCompile" => new("Expression.Suggestion.CheckLuaSyntax", "Check the Lua syntax or complete the expression."),
+ "InvalidExpression.LuaRuntime" => new("Expression.Suggestion.CheckLuaRuntime", "Check that referenced Lua variables and functions exist."),
+ "InvalidExpression.LuaInvalidReturn" => new("Expression.Suggestion.ReturnNumber", "Return a finite number of seconds."),
+ _ => new("Expression.Suggestion.CheckLuaSyntax", "Check the Lua expression syntax.")
+ };
- private static string? TokenArgument(ChapterDiagnostic diagnostic)
+ private static bool EndsWithOperator(string expression)
{
- if (diagnostic.Arguments is null)
- {
- return null;
- }
-
- return diagnostic.Arguments.TryGetValue("token", out var token) ? token as string : null;
+ var trimmed = expression.TrimEnd();
+ return trimmed.EndsWith('+') || trimmed.EndsWith('-') || trimmed.EndsWith('*') || trimmed.EndsWith('/') || trimmed.EndsWith('%') || trimmed.EndsWith('^') || trimmed.EndsWith('.');
}
- private static ExpressionDiagnosticSuggestion Suggest(string code) =>
- code switch
- {
- "InvalidExpression.Incomplete" => new("Expression.Suggestion.CompleteExpression", "Complete the expression so it produces one value."),
- "InvalidExpression.InsufficientOperands" => new("Expression.Suggestion.AddOperand", "Add the missing operand before applying this token."),
- "InvalidExpression.InvalidCharacter" => new("Expression.Suggestion.RemoveInvalidCharacter", "Remove the invalid character or replace it with a supported token."),
- "InvalidExpression.MisplacedComma" => new("Expression.Suggestion.FixComma", "Use commas only between function arguments."),
- "InvalidExpression.MissingOperandBeforeParen" => new("Expression.Suggestion.AddOperandBeforeParen", "Add an operand before the closing parenthesis."),
- "InvalidExpression.MissingOperator" => new("Expression.Suggestion.AddOperator", "Add an operator between adjacent values."),
- "InvalidExpression.MissingOperatorBeforeFunction" => new("Expression.Suggestion.AddOperatorBeforeFunction", "Add an operator before the function call."),
- "InvalidExpression.MissingOperatorBeforeParen" => new("Expression.Suggestion.AddOperatorBeforeParen", "Add an operator before the opening parenthesis."),
- "InvalidExpression.OperatorRequiresLeftOperand" => new("Expression.Suggestion.AddLeftOperand", "Add a left operand before this operator."),
- "InvalidExpression.TernaryMissingCondition" => new("Expression.Suggestion.AddTernaryCondition", "Add a condition before '?'."),
- "InvalidExpression.TernaryMissingTrueExpression" => new("Expression.Suggestion.AddTernaryTrueExpression", "Add the true expression between '?' and ':'."),
- "InvalidExpression.TernaryUnmatchedColon" => new("Expression.Suggestion.MatchTernaryQuestion", "Add a matching '?' before this ':'."),
- "InvalidExpression.TernaryUnmatchedQuestion" => new("Expression.Suggestion.MatchTernaryColon", "Add a matching ':' and false expression."),
- "InvalidExpression.UnbalancedParentheses" => new("Expression.Suggestion.BalanceParentheses", "Add or remove parentheses so every '(' has a matching ')'."),
- "InvalidExpression.UnknownToken" => new("Expression.Suggestion.UseKnownToken", "Use a supported variable, constant, function, or operator."),
- "InvalidExpression.UnsupportedFunction" => new("Expression.Suggestion.UseSupportedFunction", "Replace the function with a supported expression function."),
- "InvalidExpression.UnsupportedOperator" => new("Expression.Suggestion.UseSupportedOperator", "Replace the operator with a supported expression operator."),
- "InvalidExpression.UnsupportedToken" => new("Expression.Suggestion.UseSupportedToken", "Replace the token with a supported expression token."),
- _ => new("Expression.Suggestion.CheckSyntax", "Check the expression syntax.")
- };
+ private static ExpressionAuthoringDiagnostic Diagnostic(string code, string message, string suggestionCode, string suggestion, int start, int length) =>
+ new(new ChapterDiagnostic(DiagnosticSeverity.Warning, code, message, Arguments: new Dictionary(StringComparer.Ordinal) { ["message"] = message }), new ExpressionDiagnosticSuggestion(suggestionCode, suggestion), start, length);
+
+ private static ExpressionTokenSpan? LastMeaningfulSpan(IReadOnlyList spans) =>
+ spans.LastOrDefault(static span => span.Kind != ExpressionTokenKind.Comment);
- private IReadOnlyList Classify(string expression)
+ private static int CompletionRank(ExpressionTokenKind kind) => kind switch
+ {
+ ExpressionTokenKind.Function => 0,
+ ExpressionTokenKind.Variable => 1,
+ ExpressionTokenKind.Keyword => 2,
+ ExpressionTokenKind.Snippet => 3,
+ _ => 4
+ };
+
+ private static List Tokenize(string expression)
{
var spans = new List();
for (var i = 0; i < expression.Length;)
{
- var span = TryReadComment(expression, ref i)
- ?? TryReadNumber(expression, ref i)
- ?? TryReadIdentifier(expression, ref i)
- ?? TryReadTwoCharOperator(expression, ref i)
- ?? TryReadSingleCharOperator(expression, ref i)
- ?? TryReadPunctuation(expression, ref i)
- ?? TryReadUnknown(expression, ref i);
-
- if (span is not null)
+ var c = expression[i];
+ if (char.IsWhiteSpace(c))
{
- spans.Add(span);
+ i++;
+ continue;
}
- }
- return spans;
- }
-
- private static ExpressionTokenSpan? TryReadComment(string expression, ref int i)
- {
- if (i >= expression.Length || expression[i] != '/' || i + 1 >= expression.Length || expression[i + 1] != '/')
- {
- return null;
- }
-
- var span = new ExpressionTokenSpan(i, expression.Length - i, expression[i..], ExpressionTokenKind.Comment);
- i = expression.Length;
- return span;
- }
+ if (c == '-' && i + 1 < expression.Length && expression[i + 1] == '-')
+ {
+ spans.Add(new ExpressionTokenSpan(i, expression.Length - i, expression[i..], ExpressionTokenKind.Comment));
+ break;
+ }
- private static ExpressionTokenSpan? TryReadNumber(string expression, ref int i)
- {
- if (i >= expression.Length)
- {
- return null;
- }
+ if (char.IsDigit(c) || c == '.')
+ {
+ var number = TryReadNumberOrDot(expression, ref i);
+ spans.Add(number);
+ continue;
+ }
- var c = expression[i];
- if (!char.IsDigit(c) && c != '.')
- {
- return null;
- }
+ if (char.IsLetter(c) || c == '_')
+ {
+ spans.Add(ReadIdentifierChain(expression, ref i));
+ continue;
+ }
- var start = i++;
- while (i < expression.Length && (char.IsDigit(expression[i]) || expression[i] == '.'))
- {
- i++;
- }
+ var two = i + 1 < expression.Length ? expression.Substring(i, 2) : string.Empty;
+ if (Operators.Contains(two))
+ {
+ spans.Add(new ExpressionTokenSpan(i, 2, two, ExpressionTokenKind.Operator));
+ i += 2;
+ continue;
+ }
- return new ExpressionTokenSpan(start, i - start, expression[start..i], ExpressionTokenKind.Number);
- }
+ if (Operators.Contains(c.ToString(CultureInfo.InvariantCulture)))
+ {
+ spans.Add(new ExpressionTokenSpan(i, 1, c.ToString(CultureInfo.InvariantCulture), ExpressionTokenKind.Operator));
+ i++;
+ continue;
+ }
- private ExpressionTokenSpan? TryReadIdentifier(string expression, ref int i)
- {
- if (i >= expression.Length)
- {
- return null;
- }
+ if (c is '(' or ')' or ',' or ';' or '{' or '}' or '[' or ']')
+ {
+ spans.Add(new ExpressionTokenSpan(i, 1, c.ToString(CultureInfo.InvariantCulture), ExpressionTokenKind.Punctuation));
+ i++;
+ continue;
+ }
- var c = expression[i];
- if (!char.IsLetter(c) && c != '_')
- {
- return null;
- }
+ if (c is '\'' or '"')
+ {
+ spans.Add(ReadString(expression, ref i));
+ continue;
+ }
- var start = i++;
- while (i < expression.Length && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_'))
- {
+ spans.Add(new ExpressionTokenSpan(i, 1, c.ToString(CultureInfo.InvariantCulture), ExpressionTokenKind.Unknown));
i++;
}
- var text = expression[start..i];
- return new ExpressionTokenSpan(start, i - start, text, KindForSymbol(text));
+ return spans;
}
- private static ExpressionTokenSpan? TryReadTwoCharOperator(string expression, ref int i)
+ private static ExpressionTokenSpan TryReadNumberOrDot(string expression, ref int i)
{
- if (i >= expression.Length)
+ var start = i;
+ if (expression[i] == '.' && (i + 1 >= expression.Length || !char.IsDigit(expression[i + 1])))
{
- return null;
+ i++;
+ return new ExpressionTokenSpan(start, 1, ".", ExpressionTokenKind.Punctuation);
}
- var c = expression[i];
- if (c is not ('>' or '<') || i + 1 >= expression.Length || expression[i + 1] != '=')
+ i++;
+ while (i < expression.Length && (char.IsDigit(expression[i]) || expression[i] == '.'))
{
- return null;
+ i++;
}
- var span = new ExpressionTokenSpan(i, 2, expression.Substring(i, 2), ExpressionTokenKind.Operator);
- i += 2;
- return span;
+ return new ExpressionTokenSpan(start, i - start, expression[start..i], ExpressionTokenKind.Number);
}
- private static ExpressionTokenSpan? TryReadSingleCharOperator(string expression, ref int i)
+ private static ExpressionTokenSpan ReadIdentifierChain(string expression, ref int i)
{
- if (i >= expression.Length)
+ var start = i;
+ i++;
+ while (i < expression.Length)
{
- return null;
- }
+ if (char.IsLetterOrDigit(expression[i]) || expression[i] == '_')
+ {
+ i++;
+ continue;
+ }
- var c = expression[i];
- if (!"+-*/%^<>?:".Contains(c, StringComparison.Ordinal))
- {
- return null;
+ if (expression[i] == '.' && i + 1 < expression.Length && (char.IsLetter(expression[i + 1]) || expression[i + 1] == '_'))
+ {
+ i += 2;
+ while (i < expression.Length && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_'))
+ {
+ i++;
+ }
+ continue;
+ }
+
+ break;
}
- var span = new ExpressionTokenSpan(i, 1, c.ToString(CultureInfo.InvariantCulture), ExpressionTokenKind.Operator);
- i++;
- return span;
+ var text = expression[start..i];
+ return new ExpressionTokenSpan(start, i - start, text, KindForIdentifier(text));
}
- private static ExpressionTokenSpan? TryReadPunctuation(string expression, ref int i)
+ private static ExpressionTokenSpan ReadString(string expression, ref int i)
{
- if (i >= expression.Length)
+ var start = i;
+ var quote = expression[i++];
+ while (i < expression.Length)
{
- return null;
- }
+ if (expression[i] == quote && expression[i - 1] != '\\')
+ {
+ i++;
+ break;
+ }
- var c = expression[i];
- if (!"(),".Contains(c, StringComparison.Ordinal))
- {
- return null;
+ i++;
}
- var span = new ExpressionTokenSpan(i, 1, c.ToString(CultureInfo.InvariantCulture), ExpressionTokenKind.Punctuation);
- i++;
- return span;
+ return new ExpressionTokenSpan(start, i - start, expression[start..i], ExpressionTokenKind.String);
}
- private static ExpressionTokenSpan? TryReadUnknown(string expression, ref int i)
+ private static ExpressionTokenKind KindForIdentifier(string text)
{
- if (i >= expression.Length)
+ if (Keywords.Contains(text))
{
- return null;
+ return ExpressionTokenKind.Keyword;
}
- var c = expression[i];
- if (char.IsWhiteSpace(c))
+ if (text is "t" or "fps" or "index" or "count" or "chapter" || text.StartsWith("chapter.", StringComparison.Ordinal))
{
- i++;
- return null;
+ return ExpressionTokenKind.Variable;
}
- var span = new ExpressionTokenSpan(i, 1, c.ToString(CultureInfo.InvariantCulture), ExpressionTokenKind.Unknown);
- i++;
- return span;
- }
-
- private ExpressionTokenKind KindForSymbol(string text)
- {
- var symbol = Symbols.FirstOrDefault(symbol => string.Equals(symbol.Text, text, StringComparison.Ordinal));
- if (symbol?.Kind is ExpressionTokenKind.Variable or ExpressionTokenKind.Constant or ExpressionTokenKind.Function)
+ if (text.StartsWith("math.", StringComparison.Ordinal) || text is "floor" or "ceil" or "round" or "abs" or "min" or "max" or "sqrt" or "sin" or "cos" or "tan" or "sign")
{
- return symbol.Kind;
+ return ExpressionTokenKind.Function;
}
- var matchingKinds = Symbols
- .Where(symbol => symbol.Kind is ExpressionTokenKind.Variable or ExpressionTokenKind.Constant or ExpressionTokenKind.Function)
- .Where(symbol => symbol.Text.StartsWith(text, StringComparison.OrdinalIgnoreCase))
- .Select(static symbol => symbol.Kind)
- .Distinct()
- .ToList();
-
- return matchingKinds.Count switch
- {
- 1 => matchingKinds[0],
- > 1 when matchingKinds.Contains(ExpressionTokenKind.Function) => ExpressionTokenKind.Function,
- _ => ExpressionTokenKind.Unknown
- };
+ return ExpressionTokenKind.Variable;
}
private static (int Start, int Length, string Prefix) CurrentToken(string expression, int caretIndex)
{
var start = caretIndex;
- while (start > 0 && IsIdentifierPart(expression[start - 1]))
+ while (start > 0 && IsCompletionPart(expression[start - 1]))
{
start--;
}
var end = caretIndex;
- while (end < expression.Length && IsIdentifierPart(expression[end]))
+ while (end < expression.Length && IsCompletionPart(expression[end]))
{
end++;
}
@@ -408,5 +347,5 @@ private static (int Start, int Length, string Prefix) CurrentToken(string expres
return (start, end - start, expression[start..caretIndex]);
}
- private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_';
+ private static bool IsCompletionPart(char c) => char.IsLetterOrDigit(c) || c is '_' or '.' or '-';
}
diff --git a/src/ChapterTool.Core/Transform/LuaExpressionModels.cs b/src/ChapterTool.Core/Transform/LuaExpressionModels.cs
new file mode 100644
index 0000000..05aef4b
--- /dev/null
+++ b/src/ChapterTool.Core/Transform/LuaExpressionModels.cs
@@ -0,0 +1,29 @@
+using ChapterTool.Core.Diagnostics;
+using ChapterTool.Core.Models;
+
+namespace ChapterTool.Core.Transform;
+
+public sealed record LuaExpressionContext(
+ Chapter Chapter,
+ int Index,
+ int Count,
+ decimal TimeSeconds,
+ decimal FramesPerSecond);
+
+public sealed record LuaExpressionEvaluationResult(
+ bool Success,
+ decimal Value,
+ IReadOnlyList Diagnostics);
+
+public sealed record LuaExpressionScriptPreset(
+ string Id,
+ string DisplayName,
+ string Description,
+ string ScriptText);
+
+public interface ILuaExpressionScriptService
+{
+ IReadOnlyList Presets { get; }
+
+ LuaExpressionEvaluationResult Evaluate(string scriptText, LuaExpressionContext context);
+}
diff --git a/src/ChapterTool.Core/Transform/LuaExpressionScriptService.cs b/src/ChapterTool.Core/Transform/LuaExpressionScriptService.cs
new file mode 100644
index 0000000..aadf582
--- /dev/null
+++ b/src/ChapterTool.Core/Transform/LuaExpressionScriptService.cs
@@ -0,0 +1,167 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using ChapterTool.Core.Diagnostics;
+using Lua;
+using Lua.Standard;
+
+namespace ChapterTool.Core.Transform;
+
+public sealed partial class LuaExpressionScriptService : ILuaExpressionScriptService
+{
+ private static readonly Regex ReturnOrTransformPattern = ReturnOrTransformRegex();
+
+ public IReadOnlyList Presets { get; } =
+ [
+ new(
+ "identity",
+ "Identity",
+ "Keep chapter times unchanged.",
+ "t"),
+ new(
+ "offset-seconds",
+ "Offset seconds",
+ "Add a fixed number of seconds to every chapter time; edit offset_seconds as needed.",
+ "local offset_seconds = 1\nreturn t + offset_seconds"),
+ new(
+ "round-to-frame",
+ "Round to nearest frame",
+ "Snap chapter time to the nearest frame for the current frame rate.",
+ "return math.floor(t * fps + 0.5) / fps"),
+ new(
+ "half-frame-earlier",
+ "Half frame earlier",
+ "Move chapter time half a frame earlier for the current frame rate.",
+ "return t - (0.5 / fps)")
+ ];
+
+ public LuaExpressionEvaluationResult Evaluate(string scriptText, LuaExpressionContext context)
+ {
+ var fallback = context.TimeSeconds;
+ try
+ {
+ var source = NormalizeSource(scriptText);
+ using var state = LuaState.Create();
+ ConfigureState(state, context);
+
+ var results = state.DoStringAsync(source, "chapter-expression.lua", CancellationToken.None)
+ .GetAwaiter()
+ .GetResult();
+
+ if (results.Length > 0)
+ {
+ return NumericResult(results[0], fallback);
+ }
+
+ var transform = state.Environment["transform"];
+ if (transform.TryRead(out _))
+ {
+ var callResults = state.CallAsync(transform, [state.Environment["chapter"]], CancellationToken.None)
+ .GetAwaiter()
+ .GetResult();
+ return callResults.Length == 0
+ ? Failure(fallback, "InvalidExpression.LuaMissingReturn", "Lua transform did not return a value.")
+ : NumericResult(callResults[0], fallback);
+ }
+
+ return Failure(fallback, "InvalidExpression.LuaMissingReturn", "Lua expression did not return a value.");
+ }
+ catch (LuaCompileException exception)
+ {
+ return Failure(fallback, "InvalidExpression.LuaCompile", exception.Message);
+ }
+ catch (LuaRuntimeException exception)
+ {
+ return Failure(fallback, "InvalidExpression.LuaRuntime", exception.Message);
+ }
+ catch (OperationCanceledException exception)
+ {
+ return Failure(fallback, "InvalidExpression.LuaCanceled", exception.Message);
+ }
+ catch (Exception exception) when (exception is InvalidOperationException or ArgumentException or OverflowException)
+ {
+ return Failure(fallback, "InvalidExpression.Lua", exception.Message);
+ }
+ }
+
+ private static string NormalizeSource(string scriptText)
+ {
+ var source = string.IsNullOrWhiteSpace(scriptText) ? "t" : scriptText.Trim();
+ return ReturnOrTransformPattern.IsMatch(source) ? source : $"return ({source})";
+ }
+
+ private static void ConfigureState(LuaState state, LuaExpressionContext context)
+ {
+ state.OpenMathLibrary();
+ state.OpenStringLibrary();
+ state.OpenTableLibrary();
+
+ state.Environment["t"] = (double)context.TimeSeconds;
+ state.Environment["fps"] = (double)context.FramesPerSecond;
+ state.Environment["index"] = context.Index;
+ state.Environment["count"] = context.Count;
+
+ var chapter = new LuaTable
+ {
+ ["number"] = context.Chapter.Number,
+ ["time"] = (double)context.TimeSeconds,
+ ["name"] = context.Chapter.Name,
+ ["frames"] = context.Chapter.FramesInfo,
+ ["index"] = context.Index,
+ ["count"] = context.Count,
+ ["fps"] = (double)context.FramesPerSecond
+ };
+ state.Environment["chapter"] = chapter;
+
+ if (state.Environment["math"].TryRead(out var math))
+ {
+ foreach (var name in new[] { "abs", "acos", "asin", "atan", "ceil", "cos", "exp", "floor", "log", "max", "min", "pow", "sin", "sqrt", "tan" })
+ {
+ if (math.TryGetValue(name, out var value))
+ {
+ state.Environment[name] = value;
+ }
+ }
+ }
+
+ state.Environment["round"] = new LuaFunction((luaContext, _) =>
+ {
+ var value = luaContext.GetArgument(0);
+ return new ValueTask(luaContext.Return(Math.Round(value)));
+ });
+ state.Environment["sign"] = new LuaFunction((luaContext, _) =>
+ {
+ var value = luaContext.GetArgument(0);
+ return new ValueTask(luaContext.Return(Math.Sign(value)));
+ });
+ }
+
+ private static LuaExpressionEvaluationResult NumericResult(LuaValue value, decimal fallback)
+ {
+ if (!value.TryRead(out var number) || double.IsNaN(number) || double.IsInfinity(number))
+ {
+ return Failure(fallback, "InvalidExpression.LuaInvalidReturn", $"Lua expression returned {value.TypeToString()} instead of a finite number.");
+ }
+
+ try
+ {
+ return new LuaExpressionEvaluationResult(true, (decimal)number, []);
+ }
+ catch (OverflowException exception)
+ {
+ return Failure(fallback, "InvalidExpression.LuaInvalidReturn", exception.Message);
+ }
+ }
+
+ private static LuaExpressionEvaluationResult Failure(decimal fallback, string code, string message) =>
+ new(
+ false,
+ fallback,
+ [new ChapterDiagnostic(
+ DiagnosticSeverity.Warning,
+ code,
+ message,
+ Arguments: new Dictionary(StringComparer.Ordinal) { ["message"] = message })]);
+
+ [GeneratedRegex(@"\breturn\b|\bfunction\s+transform\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ReturnOrTransformRegex();
+}
diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs
index 734403c..a91f64e 100644
--- a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs
+++ b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs
@@ -340,6 +340,7 @@ internal sealed class FakeFilePickerService : IFilePickerService
public string? MplsPath { get; set; }
public string? ChapterNameTemplatePath { get; set; }
public string? SaveDirectoryPath { get; set; }
+ public string? LuaExpressionScriptPath { get; set; }
public int SourcePickCount { get; private set; }
public int SaveDirectoryPickCount { get; private set; }
@@ -354,6 +355,8 @@ internal sealed class FakeFilePickerService : IFilePickerService
public ValueTask PickChapterNameTemplateAsync(CancellationToken cancellationToken) => ValueTask.FromResult(ChapterNameTemplatePath);
+ public ValueTask PickLuaExpressionScriptAsync(CancellationToken cancellationToken) => ValueTask.FromResult(LuaExpressionScriptPath);
+
public ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken)
{
SaveDirectoryPickCount++;
diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs
index 9ab93d7..c30f0d1 100644
--- a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs
+++ b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs
@@ -1,7 +1,9 @@
using Avalonia.Automation;
using Avalonia.Controls;
+using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
+using ChapterTool.Avalonia.Views.Controls;
using ChapterTool.Core.Models;
namespace ChapterTool.Avalonia.Tests.Headless;
@@ -72,6 +74,26 @@ public async Task Keyboard_shortcuts_route_through_rendered_window()
Assert.Contains("preview", host.WindowService.Opened);
}
+ [AvaloniaFact]
+ public async Task Delete_key_in_expression_editor_does_not_delete_selected_chapter_rows()
+ {
+ using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult(
+ "movie.txt",
+ MainWindowHeadlessTestHost.Option("OGM", "movie.txt", "Intro", "Ending")));
+ await host.LoadAsync("movie.txt");
+ host.SelectRows(1);
+
+ var expressionBox = host.RequiredControl("ExpressionBox");
+ expressionBox.Text = "time";
+ expressionBox.MoveCaretToEnd();
+ await MainWindowHeadlessTestHost.ExecuteLayoutAsync(host.Window);
+
+ host.Window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, string.Empty);
+ await MainWindowHeadlessTestHost.ExecuteLayoutAsync(host.Window);
+
+ Assert.Equal(2, host.ViewModel.Rows.Count);
+ }
+
[AvaloniaFact]
public async Task Context_menu_items_respect_capability_flags()
{
diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs
index a8a5133..50cb3d9 100644
--- a/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs
+++ b/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs
@@ -4,6 +4,7 @@
using ChapterTool.Avalonia.ViewModels;
using ChapterTool.Avalonia.Views.Controls;
using ChapterTool.Avalonia.Views.Tools;
+using ChapterTool.Core.Transform;
namespace ChapterTool.Avalonia.Tests.Headless;
@@ -163,6 +164,33 @@ public async Task Expression_editor_opens_completion_popup_for_prefix_input_with
}
}
+
+ [AvaloniaFact]
+ public async Task Expression_editor_exposes_colored_preset_completion_namespace()
+ {
+ using var host = new MainWindowHeadlessTestHost(localizer: new AppLocalizationManager("en-US"));
+ var editor = new ExpressionEditor
+ {
+ Localizer = host.Localizer,
+ Text = "preset."
+ };
+ var window = await MainWindowHeadlessTestHost.RenderToolAsync(editor, new object());
+ try
+ {
+ editor.MoveCaretToEnd();
+ await MainWindowHeadlessTestHost.ExecuteLayoutAsync(window);
+
+ var completion = Assert.Single(editor.CurrentCompletions, item => item.Text == "preset.round-to-frame");
+ Assert.Equal(ExpressionTokenKind.Snippet, completion.Kind);
+ Assert.Equal("PRESET", completion.KindLabel);
+ Assert.Contains("fps", completion.InsertText, StringComparison.Ordinal);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
[AvaloniaFact]
public async Task Expression_editor_reports_trailing_binary_operator()
{
@@ -179,7 +207,7 @@ public async Task Expression_editor_reports_trailing_binary_operator()
await MainWindowHeadlessTestHost.ExecuteLayoutAsync(window);
Assert.True(editor.HasDiagnostic);
- Assert.Contains("requires more operands", editor.DiagnosticTooltipText, StringComparison.Ordinal);
+ Assert.Contains("Lua expression syntax error", editor.DiagnosticTooltipText, StringComparison.Ordinal);
Assert.Contains("Add the missing operand", editor.DiagnosticTooltipText, StringComparison.Ordinal);
}
finally
@@ -189,11 +217,11 @@ public async Task Expression_editor_reports_trailing_binary_operator()
}
[AvaloniaTheory]
- [InlineData("2+", "requires more operands", "Add the missing operand")]
- [InlineData("2^", "requires more operands", "Add the missing operand")]
- [InlineData("2?", "matching ':'", "Add a matching ':'")]
- [InlineData("floor(", "Unbalanced parentheses", "every '(' has a matching ')'")]
- [InlineData("floor()", "Missing operand before ')'", "Add an operand before the closing parenthesis")]
+ [InlineData("2+", "Lua expression syntax error", "Add the missing operand")]
+ [InlineData("2^", "Lua expression syntax error", "Add the missing operand")]
+ [InlineData("2?", "Unknown Lua token", "Check the Lua expression syntax")]
+ [InlineData("floor(", "Lua expression syntax error", "Check the Lua expression syntax")]
+ [InlineData("floor()", "Lua expression runtime error", "Check that referenced Lua variables and functions exist")]
public async Task Expression_editor_tooltip_reports_incomplete_expression_errors(
string expression,
string expectedDiagnostic,
diff --git a/tests/ChapterTool.Avalonia.Tests/Services/AvaloniaPickerServiceTests.cs b/tests/ChapterTool.Avalonia.Tests/Services/AvaloniaPickerServiceTests.cs
index 8077cc4..9b114bd 100644
--- a/tests/ChapterTool.Avalonia.Tests/Services/AvaloniaPickerServiceTests.cs
+++ b/tests/ChapterTool.Avalonia.Tests/Services/AvaloniaPickerServiceTests.cs
@@ -13,6 +13,7 @@ public void File_picker_options_use_localized_titles_and_file_type_names()
var source = AvaloniaFilePickerService.CreateSourceOptions(localizer);
var mpls = AvaloniaFilePickerService.CreateMplsOptions(localizer);
var template = AvaloniaFilePickerService.CreateChapterNameTemplateOptions(localizer);
+ var luaScript = AvaloniaFilePickerService.CreateLuaExpressionScriptOptions(localizer);
var saveDirectory = AvaloniaFilePickerService.CreateSaveDirectoryOptions(localizer);
var executable = AvaloniaSettingsPickerService.CreateExecutableOptions("选择工具", localizer);
@@ -22,6 +23,8 @@ public void File_picker_options_use_localized_titles_and_file_type_names()
Assert.Equal("MPLS 播放列表", mpls.FileTypeFilter?.First().Name);
Assert.Equal("打开章节名称模板", template.Title);
Assert.Equal("文本文件", template.FileTypeFilter?.First().Name);
+ Assert.Equal("打开 Lua 表达式脚本", luaScript.Title);
+ Assert.Equal("Lua 脚本文件", luaScript.FileTypeFilter?.First().Name);
Assert.Equal("保存章节到", saveDirectory.Title);
Assert.Equal("选择工具", executable.Title);
Assert.Equal("可执行文件", executable.FileTypeFilter?.First().Name);
diff --git a/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs
index a6948d2..ee68b08 100644
--- a/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -543,6 +543,8 @@ public async Task SaveProjectsChaptersAndDelegatesNeutralOptions()
Assert.Equal(0, save.LastOptions.OrderShift);
Assert.False(save.LastOptions.ApplyExpression);
Assert.Equal("t + 1", save.LastOptions.Expression);
+ Assert.Equal(string.Empty, save.LastOptions.LuaExpressionPresetId);
+ Assert.Equal(string.Empty, save.LastOptions.LuaExpressionSourceName);
Assert.NotNull(save.LastInfo);
Assert.Equal(3, save.LastInfo.Chapters[0].Number);
Assert.Equal("Chapter 01", save.LastInfo.Chapters[0].Name);
@@ -560,6 +562,7 @@ public async Task ExpressionAppliesToRowsPreviewAndSavedInfo()
vm.ApplyExpression = true;
vm.Expression = "t + 1";
+ vm.LuaExpressionSourceName = "inline";
Assert.Equal("00:00:01.000", vm.Rows[0].TimeText);
Assert.Equal("24", vm.Rows[0].FramesInfo);
@@ -574,6 +577,32 @@ public async Task ExpressionAppliesToRowsPreviewAndSavedInfo()
Assert.Equal(FrameAccuracy.Accurate, save.LastInfo.Chapters[0].FrameAccuracy);
Assert.NotNull(save.LastOptions);
Assert.False(save.LastOptions.ApplyExpression);
+ Assert.Equal("t + 1", save.LastOptions.Expression);
+ Assert.Equal("inline", save.LastOptions.LuaExpressionSourceName);
+ }
+
+
+ [Fact]
+ public async Task PreviewAndSaveUseSameLuaProjectionForTimesNamesAndNumbers()
+ {
+ var save = new FakeSaveService();
+ var vm = CreateViewModel(saveService: save);
+ await vm.LoadCommand.ExecuteAsync("movie.txt");
+ vm.SaveFormat = ChapterExportFormat.Txt;
+ vm.ApplyExpression = true;
+ vm.Expression = "t + 2";
+ vm.AutoGenerateNames = true;
+ vm.OrderShift = 3;
+
+ var preview = vm.BuildPreview();
+ await vm.SaveDirectoryCommand.ExecuteAsync("out");
+
+ Assert.Contains("CHAPTER04=00:00:02.000", preview, StringComparison.Ordinal);
+ Assert.Contains("CHAPTER04NAME=Chapter 01", preview, StringComparison.Ordinal);
+ Assert.NotNull(save.LastInfo);
+ Assert.Equal(4, save.LastInfo.Chapters[0].Number);
+ Assert.Equal("Chapter 01", save.LastInfo.Chapters[0].Name);
+ Assert.Equal(TimeSpan.FromSeconds(2), save.LastInfo.Chapters[0].Time);
}
[Fact]
diff --git a/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs b/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs
index 965dc4a..e5767a0 100644
--- a/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs
+++ b/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs
@@ -105,6 +105,40 @@ public async Task ExpressionTemplateAndForwardShiftToolsApplyToOwner()
Assert.Equal("00:00:05.000", owner.Rows[0].TimeText);
}
+
+ [Fact]
+ public async Task ExpressionToolAppliesLuaPresetAndExternalScriptToOwner()
+ {
+ var owner = CreateOwner();
+ await owner.LoadCommand.ExecuteAsync("movie.txt");
+ var scriptPath = Path.Combine(Path.GetTempPath(), $"chaptertool-{Guid.NewGuid():N}.lua");
+ await File.WriteAllTextAsync(scriptPath, "t + 2");
+ try
+ {
+ var picker = new FakeFilePicker(scriptPath);
+ var expression = new ExpressionToolViewModel(owner, picker) { ApplyExpression = true };
+
+ expression.SelectedPresetIndex = expression.Presets.ToList().FindIndex(preset => preset.Id == "round-to-frame");
+
+ Assert.Equal("round-to-frame", expression.SelectedPreset?.Id);
+ Assert.Contains("fps", expression.Expression, StringComparison.Ordinal);
+ Assert.Equal("Round to nearest frame", expression.LuaExpressionSourceName);
+
+ await expression.BrowseScriptCommand.ExecuteAsync();
+ await expression.ApplyCommand.ExecuteAsync(expression);
+
+ Assert.Equal("t + 2", owner.Expression);
+ Assert.True(owner.ApplyExpression);
+ Assert.Equal(string.Empty, owner.LuaExpressionPresetId);
+ Assert.Equal(Path.GetFileName(scriptPath), owner.LuaExpressionSourceName);
+ Assert.Equal("00:00:07.000", owner.Rows[0].TimeText);
+ }
+ finally
+ {
+ File.Delete(scriptPath);
+ }
+ }
+
private static MainWindowViewModel CreateOwner()
{
var formatter = new ChapterTimeFormatter();
@@ -147,6 +181,19 @@ public ValueTask SaveAsync(ChapterInfo info, ChapterExportO
ValueTask.FromResult(new ChapterExportResult(true, string.Empty, ".txt", []));
}
+ private sealed class FakeFilePicker(string luaScriptPath) : IFilePickerService
+ {
+ public ValueTask PickSourceAsync(CancellationToken cancellationToken) => ValueTask.FromResult(null);
+
+ public ValueTask PickMplsAsync(CancellationToken cancellationToken) => ValueTask.FromResult(null);
+
+ public ValueTask PickChapterNameTemplateAsync(CancellationToken cancellationToken) => ValueTask.FromResult(null);
+
+ public ValueTask PickLuaExpressionScriptAsync(CancellationToken cancellationToken) => ValueTask.FromResult(luaScriptPath);
+
+ public ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken) => ValueTask.FromResult(null);
+ }
+
private sealed class FakeWindowService : IWindowService
{
public ValueTask ShowAsync(string windowId, object? parameter, CancellationToken cancellationToken) => ValueTask.CompletedTask;
diff --git a/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs b/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs
new file mode 100644
index 0000000..35bb75e
--- /dev/null
+++ b/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs
@@ -0,0 +1,98 @@
+using ChapterTool.Core.Exporting;
+using ChapterTool.Core.Models;
+using ChapterTool.Core.Transform;
+
+namespace ChapterTool.Core.Tests.Exporting;
+
+public sealed class ChapterOutputProjectionServiceTests
+{
+ private readonly ChapterOutputProjectionService service = new(new LuaExpressionScriptService());
+
+ [Fact]
+ public void Projection_applies_lua_before_numbering_and_names_without_mutating_source()
+ {
+ var source = Sample();
+ var originalFirst = source.Chapters[0];
+
+ var result = service.Project(
+ source,
+ new ChapterExportOptions(
+ ChapterExportFormat.Txt,
+ AutoGenerateNames: true,
+ OrderShift: 2,
+ ApplyExpression: true,
+ Expression: "t + index"));
+
+ Assert.Equal(TimeSpan.FromSeconds(10), source.Chapters[0].Time);
+ Assert.Same(originalFirst, source.Chapters[0]);
+ Assert.Empty(result.Diagnostics);
+ Assert.Equal([3, 4, 5], result.OutputChapters.Select(static chapter => chapter.Number));
+ Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03"], result.OutputChapters.Select(static chapter => chapter.Name));
+ Assert.Equal([11, 22, 33], result.OutputChapters.Select(static chapter => (int)chapter.Time.TotalSeconds));
+ Assert.Equal("264", result.OutputChapters[0].FramesInfo);
+ Assert.True(result.OutputChapters[0].FrameAccuracy is FrameAccuracy.Accurate);
+ }
+
+ [Fact]
+ public void Projection_preserves_separators_and_excludes_them_from_output_chapters()
+ {
+ var separator = new Chapter(0, Chapter.SeparatorTime, "---");
+ var info = Sample() with
+ {
+ Chapters = [Sample().Chapters[0], separator, Sample().Chapters[1]]
+ };
+
+ var result = service.Project(
+ info,
+ new ChapterExportOptions(ChapterExportFormat.Txt, ApplyExpression: true, Expression: "index"));
+
+ Assert.Equal(3, result.Info.Chapters.Count);
+ Assert.True(result.Info.Chapters[1].IsSeparator);
+ Assert.Equal(0, result.Info.Chapters[1].Number);
+ Assert.Equal(2, result.OutputChapters.Count);
+ Assert.Equal([1, 2], result.OutputChapters.Select(static chapter => (int)chapter.Time.TotalSeconds));
+ }
+
+ [Fact]
+ public void Projection_keeps_original_time_for_failed_lua_and_continues_numbering_and_naming()
+ {
+ var result = service.Project(
+ Sample(),
+ new ChapterExportOptions(
+ ChapterExportFormat.Txt,
+ AutoGenerateNames: true,
+ ApplyExpression: true,
+ Expression: "return bad()"));
+
+ Assert.Equal(3, result.Diagnostics.Count);
+ Assert.All(result.Diagnostics, diagnostic => Assert.Equal("InvalidExpression.LuaRuntime", diagnostic.Code));
+ Assert.Equal([10, 20, 30], result.OutputChapters.Select(static chapter => (int)chapter.Time.TotalSeconds));
+ Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03"], result.OutputChapters.Select(static chapter => chapter.Name));
+ }
+
+ [Fact]
+ public void Projection_normalizes_lua_time_and_reports_diagnostic()
+ {
+ var result = service.Project(
+ Sample(),
+ new ChapterExportOptions(ChapterExportFormat.Txt, ApplyExpression: true, Expression: "-1"));
+
+ Assert.Equal(3, result.Diagnostics.Count);
+ Assert.All(result.Diagnostics, diagnostic => Assert.Equal("InvalidExpressionTime", diagnostic.Code));
+ Assert.All(result.OutputChapters, chapter => Assert.Equal(TimeSpan.Zero, chapter.Time));
+ }
+
+ private static ChapterInfo Sample() =>
+ new(
+ "Movie",
+ "movie.mkv",
+ 0,
+ "Text",
+ 24,
+ TimeSpan.FromMinutes(1),
+ [
+ new Chapter(1, TimeSpan.FromSeconds(10), "Intro"),
+ new Chapter(2, TimeSpan.FromSeconds(20), "Middle"),
+ new Chapter(3, TimeSpan.FromSeconds(30), "End")
+ ]);
+}
diff --git a/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs
index bc279c9..4efe10f 100644
--- a/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs
+++ b/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs
@@ -7,70 +7,98 @@ public sealed class ExpressionAuthoringServiceTests
private readonly ExpressionAuthoringService service = new();
[Fact]
- public void Symbols_include_variables_constants_functions_and_operators()
+ public void Symbols_include_lua_globals_helpers_keywords_and_presets()
{
Assert.Contains(service.Symbols, symbol => symbol is { Text: "t", Kind: ExpressionTokenKind.Variable });
Assert.Contains(service.Symbols, symbol => symbol is { Text: "fps", Kind: ExpressionTokenKind.Variable });
- Assert.Contains(service.Symbols, symbol => symbol is { Text: "M_PI", Kind: ExpressionTokenKind.Constant });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "index", Kind: ExpressionTokenKind.Variable });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "count", Kind: ExpressionTokenKind.Variable });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "chapter.time", Kind: ExpressionTokenKind.Variable });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "math.floor", Kind: ExpressionTokenKind.Function, Arity: 1 });
Assert.Contains(service.Symbols, symbol => symbol is { Text: "floor", Kind: ExpressionTokenKind.Function, Arity: 1 });
- Assert.Contains(service.Symbols, symbol => symbol is { Text: "+", Kind: ExpressionTokenKind.Operator });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "return", Kind: ExpressionTokenKind.Keyword });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "preset", Kind: ExpressionTokenKind.Snippet });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "preset.identity", Kind: ExpressionTokenKind.Snippet });
+ Assert.Contains(service.Symbols, symbol => symbol is { Text: "preset.round-to-frame", Kind: ExpressionTokenKind.Snippet });
}
[Fact]
- public void Analyze_classifies_valid_expression_without_diagnostics()
+ public void Analyze_classifies_valid_lua_shorthand_without_diagnostics()
{
- var result = service.Analyze("t + floor(fps / 2)", 4);
+ var result = service.Analyze("t + math.floor(fps / 2)", 10);
Assert.Empty(result.Diagnostics);
Assert.Contains(result.Spans, span => span is { Text: "t", Kind: ExpressionTokenKind.Variable });
Assert.Contains(result.Spans, span => span is { Text: "+", Kind: ExpressionTokenKind.Operator });
- Assert.Contains(result.Spans, span => span is { Text: "floor", Kind: ExpressionTokenKind.Function });
+ Assert.Contains(result.Spans, span => span is { Text: "math.floor", Kind: ExpressionTokenKind.Function });
Assert.Contains(result.Spans, span => span is { Text: "(", Kind: ExpressionTokenKind.Punctuation });
Assert.Contains(result.Spans, span => span is { Text: "2", Kind: ExpressionTokenKind.Number });
}
[Fact]
- public void Analyze_returns_completion_for_current_prefix()
+ public void Analyze_classifies_transform_function_script()
{
- var result = service.Analyze("flo", 3);
- var completion = Assert.Single(result.Completions, item => item.Text == "floor");
+ var result = service.Analyze("function transform(chapter)\n return chapter.time + index\nend", 8);
+
+ Assert.Empty(result.Diagnostics);
+ Assert.Contains(result.Spans, span => span is { Text: "function", Kind: ExpressionTokenKind.Keyword });
+ Assert.Contains(result.Spans, span => span is { Text: "chapter", Kind: ExpressionTokenKind.Variable });
+ Assert.Contains(result.Spans, span => span is { Text: "return", Kind: ExpressionTokenKind.Keyword });
+ Assert.Contains(result.Spans, span => span is { Text: "chapter.time", Kind: ExpressionTokenKind.Variable });
+ }
+
+ [Fact]
+ public void Analyze_returns_completion_for_math_member_prefix()
+ {
+ var result = service.Analyze("math.flo", 8);
+ var completion = Assert.Single(result.Completions, item => item.Text == "math.floor");
Assert.Equal(0, completion.ReplacementStart);
- Assert.Equal(3, completion.ReplacementLength);
- Assert.Equal("floor()", completion.InsertText);
+ Assert.Equal(8, completion.ReplacementLength);
+ Assert.Equal("math.floor()", completion.InsertText);
Assert.Empty(result.Diagnostics);
}
[Fact]
- public void Analyze_treats_case_insensitive_prefix_as_completion_instead_of_unknown_token()
+ public void Analyze_returns_completion_for_lua_global_prefix()
{
- var result = service.Analyze("S", 1);
+ var result = service.Analyze("cha", 3);
- Assert.Contains(result.Completions, item => item.Text == "sin");
- Assert.Contains(result.Completions, item => item.Text == "sqrt");
- Assert.Contains(result.Completions, item => item.Text == "sign");
+ Assert.Contains(result.Completions, item => item.Text == "chapter");
+ Assert.Contains(result.Completions, item => item.Text == "chapter.time");
Assert.Empty(result.Diagnostics);
- Assert.Contains(result.Spans, span => span is { Text: "S", Kind: ExpressionTokenKind.Function });
}
[Fact]
- public void Analyze_sorts_functions_before_variables_and_constants()
+ public void Analyze_returns_preset_snippet_completion()
{
- var result = service.Analyze("M_", 2);
+ var result = service.Analyze("preset.", 7);
+ var completion = Assert.Single(result.Completions, item => item.Text == "preset.round-to-frame");
- Assert.All(result.Completions, completion => Assert.Equal(ExpressionTokenKind.Constant, completion.Kind));
+ Assert.Equal(ExpressionTokenKind.Snippet, completion.Kind);
+ Assert.Equal("PRESET", completion.KindLabel);
+ Assert.Contains("fps", completion.InsertText, StringComparison.Ordinal);
+ }
- var mixed = service.Analyze("s", 1).Completions.Take(4).ToList();
- Assert.All(mixed, completion => Assert.Equal(ExpressionTokenKind.Function, completion.Kind));
+
+ [Fact]
+ public void Analyze_returns_discoverable_preset_namespace_completion()
+ {
+ var result = service.Analyze("pre", 3);
+ var completion = Assert.Single(result.Completions, item => item.Text == "preset");
+
+ Assert.Equal(ExpressionTokenKind.Snippet, completion.Kind);
+ Assert.Equal("preset.", completion.InsertText);
+ Assert.Contains("presets", completion.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
- public void Analyze_returns_diagnostic_with_suggestion_for_invalid_expression()
+ public void Analyze_reports_lua_diagnostic_with_suggestion_for_invalid_shorthand()
{
var result = service.Analyze("t +", 3);
var diagnostic = Assert.Single(result.Diagnostics);
- Assert.StartsWith("InvalidExpression.", diagnostic.Diagnostic.Code, StringComparison.Ordinal);
+ Assert.StartsWith("InvalidExpression.Lua", diagnostic.Diagnostic.Code, StringComparison.Ordinal);
Assert.Equal("Expression.Suggestion.AddOperand", diagnostic.Suggestion.Code);
Assert.False(string.IsNullOrWhiteSpace(diagnostic.Suggestion.Message));
Assert.Equal(2, diagnostic.Start);
@@ -78,44 +106,22 @@ public void Analyze_returns_diagnostic_with_suggestion_for_invalid_expression()
}
[Fact]
- public void Analyze_reports_missing_right_operand_for_trailing_binary_operator()
+ public void Analyze_reports_lua_runtime_suggestion_for_unknown_function()
{
- var result = service.Analyze("2^", 2);
+ var result = service.Analyze("missing(t)", 10);
var diagnostic = Assert.Single(result.Diagnostics);
- Assert.Equal("InvalidExpression.InsufficientOperands", diagnostic.Diagnostic.Code);
- Assert.Equal("Expression.Suggestion.AddOperand", diagnostic.Suggestion.Code);
+ Assert.Equal("InvalidExpression.LuaRuntime", diagnostic.Diagnostic.Code);
+ Assert.Equal("Expression.Suggestion.CheckLuaRuntime", diagnostic.Suggestion.Code);
}
- [Theory]
- [InlineData("2+", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2-", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2*", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2/", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2%", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2^", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2>", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2<", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2>=", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2<=", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("+", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("-", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2?", "InvalidExpression.TernaryUnmatchedQuestion", "Expression.Suggestion.MatchTernaryColon")]
- [InlineData("2?3:", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- [InlineData("2?3", "InvalidExpression.TernaryUnmatchedQuestion", "Expression.Suggestion.MatchTernaryColon")]
- [InlineData("floor(", "InvalidExpression.UnbalancedParentheses", "Expression.Suggestion.BalanceParentheses")]
- [InlineData("floor()", "InvalidExpression.MissingOperandBeforeParen", "Expression.Suggestion.AddOperandBeforeParen")]
- [InlineData("floor(2,", "InvalidExpression.MisplacedComma", "Expression.Suggestion.FixComma")]
- [InlineData("(2+", "InvalidExpression.InsufficientOperands", "Expression.Suggestion.AddOperand")]
- public void Analyze_reports_specific_suggestion_for_incomplete_expressions(
- string expression,
- string expectedCode,
- string expectedSuggestionCode)
+ [Fact]
+ public void Analyze_reports_return_number_suggestion_for_invalid_return_type()
{
- var result = service.Analyze(expression, expression.Length);
+ var result = service.Analyze("return 'bad'", 12);
var diagnostic = Assert.Single(result.Diagnostics);
- Assert.Equal(expectedCode, diagnostic.Diagnostic.Code);
- Assert.Equal(expectedSuggestionCode, diagnostic.Suggestion.Code);
+ Assert.Equal("InvalidExpression.LuaInvalidReturn", diagnostic.Diagnostic.Code);
+ Assert.Equal("Expression.Suggestion.ReturnNumber", diagnostic.Suggestion.Code);
}
}
diff --git a/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs
new file mode 100644
index 0000000..d0f1bb4
--- /dev/null
+++ b/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs
@@ -0,0 +1,97 @@
+using ChapterTool.Core.Models;
+using ChapterTool.Core.Transform;
+
+namespace ChapterTool.Core.Tests.Transform;
+
+public sealed class LuaExpressionScriptServiceTests
+{
+ private readonly LuaExpressionScriptService service = new();
+
+ [Fact]
+ public void Presets_include_common_lua_transforms()
+ {
+ Assert.Contains(service.Presets, preset => preset.Id == "identity" && preset.ScriptText == "t");
+ Assert.Contains(service.Presets, preset => preset.Id == "offset-seconds");
+ Assert.Contains(service.Presets, preset => preset.Id == "round-to-frame");
+ Assert.Contains(service.Presets, preset => preset.Id == "half-frame-earlier");
+ }
+
+ [Fact]
+ public void Evaluates_shorthand_arithmetic_without_return()
+ {
+ var result = service.Evaluate("t + 1", Context(timeSeconds: 10, fps: 24));
+
+ Assert.True(result.Success, DiagnosticText(result));
+ Assert.Equal(11, result.Value);
+ }
+
+ [Fact]
+ public void Evaluates_direct_return_script()
+ {
+ var result = service.Evaluate("return t + math.floor(fps / 2)", Context(timeSeconds: 10, fps: 24));
+
+ Assert.True(result.Success, DiagnosticText(result));
+ Assert.Equal(22, result.Value);
+ }
+
+ [Fact]
+ public void Evaluates_transform_function_with_chapter_context()
+ {
+ var script = "function transform(chapter) return chapter.time + index + count + chapter.number end";
+
+ var result = service.Evaluate(script, Context(timeSeconds: 10, fps: 24, index: 2, count: 4, number: 3));
+
+ Assert.True(result.Success, DiagnosticText(result));
+ Assert.Equal(19, result.Value);
+ }
+
+ [Fact]
+ public void Supports_safe_math_aliases_for_low_friction_arithmetic()
+ {
+ var result = service.Evaluate("floor(t * fps + 0.5) / fps", Context(timeSeconds: 10.49m, fps: 10));
+
+ Assert.True(result.Success, DiagnosticText(result));
+ Assert.Equal(10.5m, result.Value);
+ }
+
+ [Theory]
+ [InlineData("return nil")]
+ [InlineData("return 'bad'")]
+ [InlineData("return 0/0")]
+ public void Invalid_return_preserves_original_time(string script)
+ {
+ var result = service.Evaluate(script, Context(timeSeconds: 10, fps: 24));
+
+ Assert.False(result.Success);
+ Assert.Equal(10, result.Value);
+ Assert.StartsWith("InvalidExpression.Lua", Assert.Single(result.Diagnostics).Code, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("return t +", "InvalidExpression.LuaCompile")]
+ [InlineData("return missing()", "InvalidExpression.LuaRuntime")]
+ [InlineData("t 1 +", "InvalidExpression.LuaCompile")]
+ public void Lua_failures_are_structured_diagnostics(string script, string expectedCode)
+ {
+ var result = service.Evaluate(script, Context(timeSeconds: 10, fps: 24));
+
+ Assert.False(result.Success);
+ Assert.Equal(10, result.Value);
+ Assert.Equal(expectedCode, Assert.Single(result.Diagnostics).Code);
+ }
+
+ [Fact]
+ public void Script_cannot_use_io_library()
+ {
+ var result = service.Evaluate("return io.open('chapters.txt')", Context(timeSeconds: 10, fps: 24));
+
+ Assert.False(result.Success);
+ Assert.Equal("InvalidExpression.LuaRuntime", Assert.Single(result.Diagnostics).Code);
+ }
+
+ private static LuaExpressionContext Context(decimal timeSeconds, decimal fps, int index = 1, int count = 1, int number = 1) =>
+ new(new Chapter(number, TimeSpan.FromSeconds((double)timeSeconds), "Intro", ""), index, count, timeSeconds, fps);
+
+ private static string DiagnosticText(LuaExpressionEvaluationResult result) =>
+ string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"));
+}