acts_as_calculator_editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +107 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/acts_as_calculator_editor/application.js +26 -0
  6. data/app/assets/javascripts/acts_as_calculator_editor/dentaku_tokenizer.js +139 -0
  7. data/app/assets/javascripts/acts_as_calculator_editor/expression_editor_controller.js +380 -0
  8. data/app/assets/javascripts/acts_as_calculator_editor/list_filter_controller.js +39 -0
  9. data/app/assets/javascripts/acts_as_calculator_editor/template_editor_controller.js +86 -0
  10. data/app/assets/stylesheets/acts_as_calculator_editor/application.css +2 -0
  11. data/app/controllers/acts_as_calculator_editor/application_controller.rb +115 -0
  12. data/app/controllers/acts_as_calculator_editor/exports_controller.rb +41 -0
  13. data/app/controllers/acts_as_calculator_editor/formula_versions_controller.rb +128 -0
  14. data/app/controllers/acts_as_calculator_editor/formulas_controller.rb +94 -0
  15. data/app/controllers/acts_as_calculator_editor/lookup_tables_controller.rb +112 -0
  16. data/app/controllers/acts_as_calculator_editor/templates_controller.rb +130 -0
  17. data/app/controllers/acts_as_calculator_editor/validations_controller.rb +23 -0
  18. data/app/helpers/acts_as_calculator_editor/application_helper.rb +85 -0
  19. data/app/jobs/acts_as_calculator_editor/application_job.rb +6 -0
  20. data/app/mailers/acts_as_calculator_editor/application_mailer.rb +8 -0
  21. data/app/models/acts_as_calculator_editor/application_record.rb +7 -0
  22. data/app/views/acts_as_calculator_editor/formula_versions/_expression_field.html.erb +64 -0
  23. data/app/views/acts_as_calculator_editor/formula_versions/_variable_fields.html.erb +51 -0
  24. data/app/views/acts_as_calculator_editor/formula_versions/index.html.erb +45 -0
  25. data/app/views/acts_as_calculator_editor/formula_versions/new.html.erb +80 -0
  26. data/app/views/acts_as_calculator_editor/formula_versions/show.html.erb +75 -0
  27. data/app/views/acts_as_calculator_editor/formulas/_form.html.erb +23 -0
  28. data/app/views/acts_as_calculator_editor/formulas/edit.html.erb +11 -0
  29. data/app/views/acts_as_calculator_editor/formulas/index.html.erb +71 -0
  30. data/app/views/acts_as_calculator_editor/formulas/new.html.erb +10 -0
  31. data/app/views/acts_as_calculator_editor/formulas/show.html.erb +72 -0
  32. data/app/views/acts_as_calculator_editor/lookup_tables/_entry_fields.html.erb +24 -0
  33. data/app/views/acts_as_calculator_editor/lookup_tables/_form.html.erb +47 -0
  34. data/app/views/acts_as_calculator_editor/lookup_tables/edit.html.erb +12 -0
  35. data/app/views/acts_as_calculator_editor/lookup_tables/index.html.erb +67 -0
  36. data/app/views/acts_as_calculator_editor/lookup_tables/new.html.erb +11 -0
  37. data/app/views/acts_as_calculator_editor/lookup_tables/show.html.erb +71 -0
  38. data/app/views/acts_as_calculator_editor/shared/_error_message.html.erb +12 -0
  39. data/app/views/acts_as_calculator_editor/shared/_export_panel.html.erb +52 -0
  40. data/app/views/acts_as_calculator_editor/shared/_flash.html.erb +8 -0
  41. data/app/views/acts_as_calculator_editor/shared/_page_header.html.erb +16 -0
  42. data/app/views/acts_as_calculator_editor/shared/error.html.erb +11 -0
  43. data/app/views/acts_as_calculator_editor/templates/_form.html.erb +124 -0
  44. data/app/views/acts_as_calculator_editor/templates/_lexxy_toolbar.html.erb +101 -0
  45. data/app/views/acts_as_calculator_editor/templates/index.html.erb +83 -0
  46. data/app/views/acts_as_calculator_editor/templates/new.html.erb +13 -0
  47. data/app/views/acts_as_calculator_editor/templates/preview.html.erb +29 -0
  48. data/app/views/acts_as_calculator_editor/templates/show.html.erb +111 -0
  49. data/app/views/layouts/acts_as_calculator_editor/application.html.erb +52 -0
  50. data/config/importmap.rb +13 -0
  51. data/config/routes.rb +46 -0
  52. data/lib/acts_as_calculator_editor/build_export_document.rb +37 -0
  53. data/lib/acts_as_calculator_editor/build_sample_result.rb +26 -0
  54. data/lib/acts_as_calculator_editor/cast_entry_rows.rb +52 -0
  55. data/lib/acts_as_calculator_editor/cast_export_decimal.rb +19 -0
  56. data/lib/acts_as_calculator_editor/cast_variable_rows.rb +90 -0
  57. data/lib/acts_as_calculator_editor/delete_lookup_table.rb +54 -0
  58. data/lib/acts_as_calculator_editor/engine.rb +32 -0
  59. data/lib/acts_as_calculator_editor/export_formulas.rb +89 -0
  60. data/lib/acts_as_calculator_editor/export_lookup_tables.rb +49 -0
  61. data/lib/acts_as_calculator_editor/export_owner_reference.rb +18 -0
  62. data/lib/acts_as_calculator_editor/export_templates.rb +51 -0
  63. data/lib/acts_as_calculator_editor/publish_lookup_table.rb +53 -0
  64. data/lib/acts_as_calculator_editor/render_template_preview.rb +60 -0
  65. data/lib/acts_as_calculator_editor/validate_expression.rb +127 -0
  66. data/lib/acts_as_calculator_editor/version.rb +5 -0
  67. data/lib/acts_as_calculator_editor.rb +76 -0
  68. data/lib/tasks/acts_as_calculator_editor_tasks.rake +6 -0
  69. metadata +248 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7b0730de5b7917653aa2dc71dbf72e481ffccbe0a5c8e739b0e26620b4cd5c0e
4
+ data.tar.gz: ec17f8bf185a98d896eb0ef88a1d6780da76a0d73645c9da20bb0c1171bfb98e
5
+ SHA512:
6
+ metadata.gz: 302ab53a61fb1fdde36d9138c7ee2be129659dfb3db0617067c1875a768f6ef4a090e6ffb2dc5feed0a0fe977d547e86b9649d142aa7eaee3d2f76cf9c36839c
7
+ data.tar.gz: 6e01ac956b1cc1596ee2a1ab6f4d10548f8030b631690aab87f53498ef29fb3b80cd37e3ec994c54df17f4bd1b4eb09cb2f9c99ca1340771e27b1767ec5ae96b
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # ActsAsCalculatorEditor
2
+
3
+ A mountable Rails engine providing a Hotwire UI for authoring
4
+ [`acts_as_calculator`](https://github.com/lautarograc/acts_as_calculator)
5
+ formulas and templates — CRUD, live validation preview, and JSON export. No
6
+ permissions layer; wire the engine's `current_owner` and route constraints to
7
+ your host app's auth as needed.
8
+
9
+ This gem never duplicates the core gem's persistence or calculation logic —
10
+ every write goes through `acts_as_calculator`'s own service objects
11
+ (`PublishFormulaVersion`, `PublishTemplate`, `ImportLookupTable`, etc.),
12
+ called in-process rather than over HTTP. See
13
+ [`docs/core-gem-contract.md`](docs/core-gem-contract.md) for the full
14
+ contract this gem is built against.
15
+
16
+ ## Installation
17
+
18
+ Add both gems (this one is not yet published, so a path or git reference is
19
+ required until it is):
20
+
21
+ ```ruby
22
+ gem "acts_as_calculator"
23
+ gem "acts_as_calculator_editor"
24
+ ```
25
+
26
+ Mount the engine:
27
+
28
+ ```ruby
29
+ # config/routes.rb
30
+ mount ActsAsCalculatorEditor::Engine => "/calculator-editor"
31
+ ```
32
+
33
+ Pin Lexxy in your host's importmap (this gem does not do it for you, since a
34
+ host may use a different JS bundler):
35
+
36
+ ```ruby
37
+ # config/importmap.rb
38
+ pin "lexxy"
39
+ ```
40
+ ```js
41
+ // app/javascript/application.js
42
+ import "lexxy"
43
+ ```
44
+
45
+ Run `acts_as_calculator`'s own install generator if you haven't already —
46
+ this engine reads and writes its tables directly, it ships no migrations of
47
+ its own.
48
+
49
+ ## The two editors
50
+
51
+ - **Templates** (`format: "html"`) use [Lexxy](https://github.com/basecamp/lexxy)
52
+ — a real rich text editor, since template bodies are genuinely prose/HTML
53
+ meant for a payslip, invoice, or policy document.
54
+ - **Formula expressions** deliberately do **not** use Lexxy. A Dentaku
55
+ expression like `salary - (salary * federal_2026)` is not prose, and
56
+ Lexxy's editor value is HTML even in "plain" mode — an expression using `<`
57
+ without a following space (`salary <bonus`) gets parsed as the start of an
58
+ HTML tag and silently truncated, producing a shorter expression that still
59
+ parses as valid Dentaku but computes something different. See
60
+ [`docs/lexxy-decision.md`](docs/lexxy-decision.md) for the full
61
+ investigation, including a rejected markdown-based workaround. The formula
62
+ editor is a plain textarea with a Stimulus-driven syntax-highlighting
63
+ overlay and live validation instead.
64
+
65
+ ## A note on Lexxy and Rails version
66
+
67
+ `lexxy` requires Rails ≥ 8.0.2, and this gemspec's floors match that — it
68
+ cannot install against Rails 7.1/7.2 even though `acts_as_calculator` itself
69
+ supports those versions. Lexxy also unconditionally touches `ActionText` at
70
+ boot on Rails 8.0/8.1 (a `to_prepare` hook), regardless of whether any view
71
+ in your app renders rich text — this gem requires `action_text/engine`
72
+ itself to guarantee that's loaded, but if your host app hand-picks railties
73
+ rather than requiring `rails/all`, be aware Action Text (and transitively,
74
+ Active Storage) will load as a result of installing this gem.
75
+
76
+ Lexxy also sets `config.lexxy.override_action_text_defaults = true` on Rails
77
+ 8.0/8.1, which aliases `rich_text_area` to its own helper **globally** — if
78
+ your host app has its own Action Text fields and doesn't want them swapped to
79
+ Lexxy, set that config to `false` yourself. This gem doesn't set it (a third
80
+ gem changing a host-level setting silently would be worse than the problem).
81
+
82
+ ## Export / import
83
+
84
+ `GET /export` produces the same JSON document shape `acts_as_calculator`'s
85
+ own generator/rake task import, with `?key=`, `?scope=`, `?full_history=`,
86
+ and `?disposition=` query params — see
87
+ [`docs/core-gem-contract.md`](docs/core-gem-contract.md) for the exact
88
+ shape and the deliberate differences from the core gem's API response
89
+ serializers. There is no import UI yet; use the core gem's own
90
+ `rails generate acts_as_calculator:import` or `POST /import` (if
91
+ `enable_api` is on) to bring a document back in.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ bundle install
97
+ bundle exec rspec
98
+ bundle exec rubocop
99
+ ```
100
+
101
+ The dummy app lives at `spec/dummy` and builds its schema by running
102
+ `acts_as_calculator`'s own install-generator migration, so an upstream
103
+ column rename shows up as a red spec here rather than silent drift.
104
+
105
+ See `docs/` for the full integration contract and the Lexxy decision
106
+ writeup, and `.claude/` for the harness (agents + skills) that built this
107
+ gem — read `CLAUDE.md` for the pointer and change log.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,26 @@
1
+ // Entry point for this engine's JavaScript, loaded by the layout with
2
+ // `javascript_import_module_tag "acts_as_calculator_editor/application"`.
3
+ //
4
+ // It starts its OWN Stimulus application rather than registering onto the host's. Two
5
+ // reasons: a host may not run Stimulus at all, and even when it does, its controller
6
+ // identifiers are its own namespace — registering "expression-editor" into a shared
7
+ // application would be this gem reaching into the host app's registry to claim a name.
8
+ //
9
+ // The root element is the layout's `<body class="acts-as-calculator-editor">`, so nothing
10
+ // outside this engine's own pages is ever scanned.
11
+
12
+ import { Application } from "@hotwired/stimulus"
13
+ import ExpressionEditorController from "acts_as_calculator_editor/expression_editor_controller"
14
+ import TemplateEditorController from "acts_as_calculator_editor/template_editor_controller"
15
+ import ListFilterController from "acts_as_calculator_editor/list_filter_controller"
16
+
17
+ const root = document.querySelector(".acts-as-calculator-editor") || document.documentElement
18
+
19
+ const application = Application.start(root)
20
+ application.debug = false
21
+
22
+ application.register("expression-editor", ExpressionEditorController)
23
+ application.register("template-editor", TemplateEditorController)
24
+ application.register("list-filter", ListFilterController)
25
+
26
+ export { application }
@@ -0,0 +1,139 @@
1
+ // A tokenizer for Dentaku expressions, used for the syntax-highlighting overlay and for
2
+ // working out what word the caret sits in for autocomplete.
3
+ //
4
+ // This is deliberately NOT a parser. It never decides whether an expression is valid — the
5
+ // server does that, through the real Dentaku parser with the real function registry
6
+ // (POST /validations). Anything this file concludes is a colouring hint, and a wrong hint
7
+ // must never be able to change the submitted string.
8
+ //
9
+ // Operator set is docs/core-gem-contract.md §5, verified there against Dentaku 4.0.2:
10
+ // + - * / % ^ < > <= >= == = != and or && ||
11
+ // Note `<` and `>` are ordinary operators here. That is the whole reason this field is a
12
+ // textarea and not a rich text editor (docs/lexxy-decision.md §3) — an HTML round-trip eats
13
+ // `salary <bonus` down to `salary `, silently, and it still parses.
14
+
15
+ // Longest-first: `<=` must be tried before `<`, or `a <= b` lexes as `<` then `=`.
16
+ const OPERATORS = [
17
+ "<=", ">=", "==", "!=", "&&", "||",
18
+ "+", "-", "*", "/", "%", "^", "<", ">", "="
19
+ ]
20
+
21
+ // Word-shaped operators and literals. `and`/`or` are operators in Dentaku, not functions.
22
+ const WORD_OPERATORS = new Set([ "and", "or" ])
23
+ const LITERALS = new Set([ "true", "false" ])
24
+
25
+ const IDENTIFIER_START = /[A-Za-z_]/
26
+ const IDENTIFIER_CHAR = /[A-Za-z0-9_]/
27
+ const DIGIT = /[0-9]/
28
+
29
+ // Dentaku is case-insensitive (BuildCalculator defaults to `case_sensitive: false`), so every
30
+ // name comparison here folds case — matching what the server reports back in `identifiers`.
31
+ export function normalize(name) {
32
+ return String(name).toLowerCase()
33
+ }
34
+
35
+ export function tokenize(source) {
36
+ const tokens = []
37
+ const text = String(source ?? "")
38
+ let i = 0
39
+
40
+ const push = (type, start, end) => tokens.push({ type, start, end, value: text.slice(start, end) })
41
+
42
+ while (i < text.length) {
43
+ const char = text[i]
44
+
45
+ if (char === " " || char === "\t" || char === "\n" || char === "\r") {
46
+ const start = i
47
+ while (i < text.length && /\s/.test(text[i])) i++
48
+ push("whitespace", start, i)
49
+ continue
50
+ }
51
+
52
+ // Strings. An unterminated quote runs to end-of-input and is tagged so the overlay can
53
+ // show it as broken without claiming to know why.
54
+ if (char === "'" || char === '"') {
55
+ const start = i
56
+ const quote = char
57
+ i++
58
+ while (i < text.length && text[i] !== quote) i++
59
+ const terminated = i < text.length
60
+ if (terminated) i++
61
+ push(terminated ? "string" : "unterminated-string", start, i)
62
+ continue
63
+ }
64
+
65
+ if (DIGIT.test(char) || (char === "." && DIGIT.test(text[i + 1] ?? ""))) {
66
+ const start = i
67
+ while (i < text.length && DIGIT.test(text[i])) i++
68
+ if (text[i] === ".") {
69
+ i++
70
+ while (i < text.length && DIGIT.test(text[i])) i++
71
+ }
72
+ push("number", start, i)
73
+ continue
74
+ }
75
+
76
+ if (IDENTIFIER_START.test(char)) {
77
+ const start = i
78
+ while (i < text.length && IDENTIFIER_CHAR.test(text[i])) i++
79
+ const word = text.slice(start, i)
80
+ const folded = normalize(word)
81
+
82
+ if (WORD_OPERATORS.has(folded)) {
83
+ push("operator", start, i)
84
+ } else if (LITERALS.has(folded)) {
85
+ push("literal", start, i)
86
+ } else {
87
+ // A name immediately followed by `(` is a call. Whitespace between the two is legal
88
+ // Dentaku, so skip it before deciding.
89
+ let lookahead = i
90
+ while (lookahead < text.length && /\s/.test(text[lookahead])) lookahead++
91
+ push(text[lookahead] === "(" ? "function" : "identifier", start, i)
92
+ }
93
+ continue
94
+ }
95
+
96
+ if (char === "(" || char === ")") {
97
+ push("paren", i, i + 1)
98
+ i++
99
+ continue
100
+ }
101
+
102
+ if (char === ",") {
103
+ push("comma", i, i + 1)
104
+ i++
105
+ continue
106
+ }
107
+
108
+ const operator = OPERATORS.find((candidate) => text.startsWith(candidate, i))
109
+ if (operator) {
110
+ push("operator", i, i + operator.length)
111
+ i += operator.length
112
+ continue
113
+ }
114
+
115
+ // Anything unrecognised is still emitted, so the overlay's concatenated text is always
116
+ // byte-for-byte the textarea's text. If that ever stops being true the overlay drifts out
117
+ // of alignment with the caret, which is the one bug this design can actually produce.
118
+ push("unknown", i, i + 1)
119
+ i++
120
+ }
121
+
122
+ return tokens
123
+ }
124
+
125
+ // The identifier the caret is sitting inside or at the end of, for autocomplete. Returns
126
+ // null when the caret is not in a word (so typing `1 + ` does not open a menu).
127
+ export function wordAt(source, caret) {
128
+ const text = String(source ?? "")
129
+ let start = caret
130
+
131
+ while (start > 0 && IDENTIFIER_CHAR.test(text[start - 1])) start--
132
+ if (start === caret) return null
133
+ if (!IDENTIFIER_START.test(text[start])) return null
134
+
135
+ let end = caret
136
+ while (end < text.length && IDENTIFIER_CHAR.test(text[end])) end++
137
+
138
+ return { start, end, value: text.slice(start, caret) }
139
+ }
@@ -0,0 +1,380 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { tokenize, wordAt, normalize } from "acts_as_calculator_editor/dentaku_tokenizer"
3
+
4
+ // The formula `expression` field.
5
+ //
6
+ // Three jobs, none of which may ever alter the submitted string:
7
+ // 1. a syntax-highlighting overlay (a <pre> behind a transparent <textarea>)
8
+ // 2. debounced validation against POST /validations
9
+ // 3. autocomplete over the function list and the declared variable names
10
+ //
11
+ // The textarea IS the field. It keeps its `name` and `id`, it is never replaced, and nothing
12
+ // here writes to `value` except the autocomplete accept path — which splices at exact offsets
13
+ // and leaves the rest of the string untouched. This is the whole point of
14
+ // docs/lexxy-decision.md §3: the value that reaches the server is the string the operator
15
+ // typed, character for character.
16
+ // Every field of a variable row, in the sibling section of the form. Kept as one selector so
17
+ // the delegated listener and the readers below cannot drift apart.
18
+ const VARIABLE_FIELD = '[name^="version[variables]"]'
19
+ const VARIABLE_NAME_FIELD = 'input[name^="version[variables]"][name$="[name]"]'
20
+
21
+ export default class extends Controller {
22
+ static targets = [ "input", "highlight", "status", "suggestions" ]
23
+
24
+ static values = {
25
+ validateUrl: String,
26
+ functions: { type: Array, default: [] },
27
+ scope: { type: String, default: "" },
28
+ delay: { type: Number, default: 400 }
29
+ }
30
+
31
+ connect() {
32
+ this.debounceTimer = null
33
+ this.inFlight = null
34
+ this.activeSuggestion = -1
35
+ this.suggestions = []
36
+
37
+ // The variable rows are NOT inside this controller's element — they are a sibling section
38
+ // of the same <form>. A Stimulus `data-action` only routes to a controller found on an
39
+ // ancestor, so `input->expression-editor#onVariablesChanged` written on those fields never
40
+ // fires; declaring a variable left a stale "not declared as a variable" error on screen
41
+ // until the expression itself was touched again. This controller already reaches out to
42
+ // the form to READ those fields (`variableNameFields`), so it listens out there too,
43
+ // delegated from the form rather than bound per row — the row count is not fixed.
44
+ this.variablesListener = (event) => {
45
+ if (event.target?.matches?.(VARIABLE_FIELD)) this.onVariablesChanged()
46
+ }
47
+
48
+ this.form = this.element.closest("form")
49
+ this.form?.addEventListener("input", this.variablesListener)
50
+ this.form?.addEventListener("change", this.variablesListener)
51
+
52
+ this.render()
53
+ this.scheduleValidation({ immediate: true })
54
+ }
55
+
56
+ disconnect() {
57
+ clearTimeout(this.debounceTimer)
58
+ this.inFlight?.abort()
59
+
60
+ this.form?.removeEventListener("input", this.variablesListener)
61
+ this.form?.removeEventListener("change", this.variablesListener)
62
+ }
63
+
64
+ // --- input plumbing ------------------------------------------------------------------
65
+
66
+ onInput() {
67
+ this.render()
68
+ this.refreshSuggestions()
69
+ this.scheduleValidation()
70
+ }
71
+
72
+ // Declared variable names live in sibling form rows the operator edits on this same page,
73
+ // so they are re-read rather than cached: renaming a variable should re-colour the
74
+ // expression and re-validate it without a reload.
75
+ onVariablesChanged() {
76
+ this.render()
77
+ this.scheduleValidation()
78
+ }
79
+
80
+ syncScroll() {
81
+ if (!this.hasHighlightTarget) return
82
+
83
+ this.highlightTarget.scrollTop = this.inputTarget.scrollTop
84
+ this.highlightTarget.scrollLeft = this.inputTarget.scrollLeft
85
+ }
86
+
87
+ // --- highlighting --------------------------------------------------------------------
88
+
89
+ render() {
90
+ if (!this.hasHighlightTarget) return
91
+
92
+ const declared = this.declaredNames()
93
+ const known = new Set(this.functionsValue.map(normalize))
94
+ const source = this.inputTarget.value
95
+
96
+ const html = tokenize(source).map((token) => {
97
+ const classes = [ `aac-tok aac-tok--${token.type}` ]
98
+
99
+ if (token.type === "identifier" && !declared.has(normalize(token.value))) {
100
+ classes.push("aac-tok--undeclared")
101
+ }
102
+ if (token.type === "function" && known.size > 0 && !known.has(normalize(token.value))) {
103
+ classes.push("aac-tok--unknown-function")
104
+ }
105
+
106
+ return `<span class="${classes.join(" ")}">${escapeHtml(token.value)}</span>`
107
+ }).join("")
108
+
109
+ // The trailing newline keeps a final "\n" in the textarea from collapsing the <pre>'s
110
+ // last line, which would put the overlay one line out of step with the caret.
111
+ this.highlightTarget.innerHTML = `${html}\n`
112
+ this.syncScroll()
113
+ }
114
+
115
+ declaredNames() {
116
+ return new Set(this.variableNameFields().map((field) => normalize(field.value)).filter(Boolean))
117
+ }
118
+
119
+ variableNameFields() {
120
+ if (!this.form) return []
121
+
122
+ return Array.from(this.form.querySelectorAll(VARIABLE_NAME_FIELD))
123
+ }
124
+
125
+ // --- validation ----------------------------------------------------------------------
126
+
127
+ scheduleValidation({ immediate = false } = {}) {
128
+ clearTimeout(this.debounceTimer)
129
+
130
+ if (!this.hasValidateUrlValue) return
131
+
132
+ this.debounceTimer = setTimeout(() => this.validate(), immediate ? 0 : this.delayValue)
133
+ }
134
+
135
+ async validate() {
136
+ const expression = this.inputTarget.value
137
+
138
+ // Nothing typed yet is not a problem worth shouting about — the server would call it
139
+ // `blank`, which is true but not useful on an untouched form.
140
+ if (expression.trim() === "") {
141
+ this.serverIdentifiers = []
142
+ this.renderStatus(null)
143
+ return
144
+ }
145
+
146
+ this.inFlight?.abort()
147
+ this.inFlight = new AbortController()
148
+
149
+ const payload = {
150
+ expression,
151
+ scope: this.scopeValue,
152
+ variables: this.variableRows()
153
+ }
154
+
155
+ try {
156
+ const response = await fetch(this.validateUrlValue, {
157
+ method: "POST",
158
+ signal: this.inFlight.signal,
159
+ headers: {
160
+ "Content-Type": "application/json",
161
+ "Accept": "application/json",
162
+ "X-CSRF-Token": csrfToken()
163
+ },
164
+ body: JSON.stringify(payload)
165
+ })
166
+
167
+ const result = await response.json()
168
+ this.serverIdentifiers = Array.isArray(result.identifiers) ? result.identifiers : []
169
+ this.renderStatus(result)
170
+ } catch (error) {
171
+ if (error.name === "AbortError") return
172
+
173
+ // A failed check is not a failed expression. Say which one it was.
174
+ this.renderStatus({ unreachable: true })
175
+ }
176
+ }
177
+
178
+ // Mirrors the row shape in _workspace/03_rails_report.md §3. `required` is sent as a real
179
+ // boolean rather than "1"/"0" because this payload is JSON, not a form post — but the name
180
+ // is the only field validation actually reads.
181
+ variableRows() {
182
+ const form = this.form
183
+ if (!form) return []
184
+
185
+ return this.variableNameFields().map((field) => {
186
+ const prefix = field.name.replace(/\[name\]$/, "")
187
+ const sourceType = form.querySelector(`[name="${prefix}[source_type]"]`)
188
+ const config = form.querySelector(`[name="${prefix}[source_config]"]`)
189
+ const required = form.querySelector(`input[type="checkbox"][name="${prefix}[required]"]`)
190
+
191
+ return {
192
+ name: field.value,
193
+ source_type: sourceType?.value || "context",
194
+ source_config: config?.value || "",
195
+ required: required ? required.checked : true
196
+ }
197
+ }).filter((row) => row.name.trim() !== "")
198
+ }
199
+
200
+ renderStatus(result) {
201
+ if (!this.hasStatusTarget) return
202
+
203
+ if (result === null) {
204
+ this.statusTarget.innerHTML = ""
205
+ return
206
+ }
207
+
208
+ if (result.unreachable) {
209
+ this.statusTarget.innerHTML = message("aac-status--unknown", "Could not reach the checker — the expression itself has not been judged.")
210
+ return
211
+ }
212
+
213
+ // The endpoint has TWO response shapes and both have to be handled here. The 200 shape is
214
+ // `{valid, errors, warnings, identifiers}`; anything ApplicationController rescues comes
215
+ // back as `{error: {type, message}}` with a 4xx — most reachably a 422 from
216
+ // `CastVariableRows`, which raises while a `source_config` is half-typed
217
+ // (`{"table":` is not yet a JSON object). Reading only the 200 shape left `parts` empty,
218
+ // so the status region silently blanked mid-keystroke and never said why.
219
+ if (result.error) {
220
+ this.statusTarget.innerHTML = message("aac-status--error", result.error.message || "The checker rejected this request.")
221
+ return
222
+ }
223
+
224
+ const parts = []
225
+
226
+ if (result.valid) {
227
+ // Carefully worded, per contract §7.10 and the Rails report's note: this endpoint sees
228
+ // syntax, declared names and lookup-table existence. It cannot see whether an
229
+ // `attribute` or `method` variable resolves against the host's model, because that
230
+ // needs a real record. Saying "valid" here would be a claim the check did not make.
231
+ parts.push(message("aac-status--ok", "Syntax and variable wiring check out. Attribute and method variables are only verified against real data when the formula runs."))
232
+ }
233
+
234
+ for (const error of result.errors || []) {
235
+ parts.push(message("aac-status--error", error.message))
236
+ }
237
+ for (const warning of result.warnings || []) {
238
+ parts.push(message("aac-status--warning", warning.message))
239
+ }
240
+
241
+ this.statusTarget.innerHTML = parts.join("")
242
+ this.render()
243
+ }
244
+
245
+ // --- autocomplete --------------------------------------------------------------------
246
+
247
+ refreshSuggestions() {
248
+ const word = wordAt(this.inputTarget.value, this.inputTarget.selectionStart)
249
+
250
+ if (!word || word.value.length === 0) return this.closeSuggestions()
251
+
252
+ const needle = normalize(word.value)
253
+ const declared = Array.from(this.declaredNames())
254
+
255
+ const candidates = [
256
+ ...declared.map((name) => ({ name, kind: "variable" })),
257
+ ...this.functionsValue.map((name) => ({ name, kind: "function" }))
258
+ ]
259
+
260
+ const seen = new Set()
261
+ this.suggestions = candidates
262
+ .filter(({ name }) => {
263
+ const folded = normalize(name)
264
+ if (!folded.startsWith(needle) || folded === needle || seen.has(folded)) return false
265
+ seen.add(folded)
266
+ return true
267
+ })
268
+ .slice(0, 8)
269
+
270
+ if (this.suggestions.length === 0) return this.closeSuggestions()
271
+
272
+ this.wordRange = word
273
+ this.activeSuggestion = 0
274
+ this.renderSuggestions()
275
+ }
276
+
277
+ renderSuggestions() {
278
+ if (!this.hasSuggestionsTarget) return
279
+
280
+ this.suggestionsTarget.innerHTML = this.suggestions.map((suggestion, index) => `
281
+ <li role="option" id="aac-suggestion-${index}" aria-selected="${index === this.activeSuggestion}"
282
+ class="aac-suggestion ${index === this.activeSuggestion ? "is-active" : ""}"
283
+ data-index="${index}"
284
+ data-action="mousedown->expression-editor#pick">
285
+ <span class="aac-suggestion__name">${escapeHtml(suggestion.name)}</span>
286
+ <span class="aac-suggestion__kind">${suggestion.kind}</span>
287
+ </li>
288
+ `).join("")
289
+
290
+ this.suggestionsTarget.hidden = false
291
+ this.inputTarget.setAttribute("aria-expanded", "true")
292
+ this.inputTarget.setAttribute("aria-activedescendant", `aac-suggestion-${this.activeSuggestion}`)
293
+ }
294
+
295
+ closeSuggestions() {
296
+ this.suggestions = []
297
+ this.activeSuggestion = -1
298
+
299
+ if (this.hasSuggestionsTarget) {
300
+ this.suggestionsTarget.hidden = true
301
+ this.suggestionsTarget.innerHTML = ""
302
+ }
303
+
304
+ this.inputTarget.setAttribute("aria-expanded", "false")
305
+ this.inputTarget.removeAttribute("aria-activedescendant")
306
+ }
307
+
308
+ onKeydown(event) {
309
+ if (this.suggestions.length === 0) return
310
+
311
+ switch (event.key) {
312
+ case "ArrowDown":
313
+ event.preventDefault()
314
+ this.activeSuggestion = (this.activeSuggestion + 1) % this.suggestions.length
315
+ this.renderSuggestions()
316
+ break
317
+ case "ArrowUp":
318
+ event.preventDefault()
319
+ this.activeSuggestion = (this.activeSuggestion - 1 + this.suggestions.length) % this.suggestions.length
320
+ this.renderSuggestions()
321
+ break
322
+ case "Enter":
323
+ case "Tab":
324
+ // Only swallow the key while the menu is genuinely open, so Enter still submits the
325
+ // form and Tab still moves on when it is not.
326
+ event.preventDefault()
327
+ this.accept(this.activeSuggestion)
328
+ break
329
+ case "Escape":
330
+ event.preventDefault()
331
+ this.closeSuggestions()
332
+ break
333
+ }
334
+ }
335
+
336
+ pick(event) {
337
+ // mousedown, not click: the textarea would blur first and close the menu.
338
+ event.preventDefault()
339
+ this.accept(Number(event.currentTarget.dataset.index))
340
+ }
341
+
342
+ accept(index) {
343
+ const suggestion = this.suggestions[index]
344
+ if (!suggestion || !this.wordRange) return this.closeSuggestions()
345
+
346
+ const { start, end } = this.wordRange
347
+ const source = this.inputTarget.value
348
+ const insert = suggestion.kind === "function" ? `${suggestion.name}(` : suggestion.name
349
+
350
+ this.inputTarget.value = source.slice(0, start) + insert + source.slice(end)
351
+ const caret = start + insert.length
352
+ this.inputTarget.setSelectionRange(caret, caret)
353
+
354
+ this.closeSuggestions()
355
+ this.inputTarget.focus()
356
+ this.render()
357
+ this.scheduleValidation()
358
+ }
359
+
360
+ onBlur() {
361
+ // Deferred so a mousedown on a suggestion still lands.
362
+ setTimeout(() => this.closeSuggestions(), 120)
363
+ }
364
+ }
365
+
366
+ function message(className, text) {
367
+ return `<p class="aac-status ${className}">${escapeHtml(text)}</p>`
368
+ }
369
+
370
+ function csrfToken() {
371
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
372
+ }
373
+
374
+ function escapeHtml(value) {
375
+ return String(value)
376
+ .replace(/&/g, "&amp;")
377
+ .replace(/</g, "&lt;")
378
+ .replace(/>/g, "&gt;")
379
+ .replace(/"/g, "&quot;")
380
+ }