hecks 3.2.1 → 3.3.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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
- data/lib/hecks/bluebook/query.rb +8 -5
- data/lib/hecks/cli/interview_draft.rb +86 -31
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +179 -0
- data/lib/hecks/doors/cli_runner.rb +14 -1
- data/lib/hecks/doors/launcher_options.rb +13 -3
- data/lib/hecks/doors/mcp_door_scope.rb +2 -1
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
- data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -0
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/gate/stages.yml +113 -8
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
- data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
- data/lib/hecks/hecks/adapters/git.rb +59 -0
- data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
- data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +217 -0
- data/lib/hecks/hecks/hecks.hecksagon +24 -0
- data/lib/hecks/hecks/hecks.world +8 -3
- data/lib/hecks/hecks/site.world +5 -2
- data/lib/hecks/language/bluebook/query.bluebook +25 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
- data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
- data/lib/hecks/projections/deploy/box/settings.rb +17 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
- data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
- data/lib/hecks/projections/deploy/box.rb +15 -2
- data/lib/hecks/projections/deploy/fargate.rb +3 -3
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/site/live_edge.rb +162 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
- data/lib/hecks/quality_control/quality_control.bluebook +76 -124
- data/lib/hecks/runtime/interpreting.rb +3 -22
- data/lib/hecks/runtime/needs.rb +48 -0
- data/lib/hecks/runtime/query_interpreter.rb +3 -0
- data/lib/hecks/runtime/registry/verification.rb +3 -3
- data/lib/hecks/rust_build/host.rb +147 -0
- data/lib/hecks/rust_build/kernel_input.rb +40 -18
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
- data/lib/hecks/rust_build/project_rust.rb +3 -10
- data/lib/hecks/rust_build.rb +1 -0
- data/lib/hecks/site/bluebook/site.bluebook +53 -1
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
- data/lib/hecks/tools/gate.rb +1 -1
- data/lib/hecks/tools/rust_comment_style.rb +1 -3
- data/lib/hecks/tools/tools_doc.rb +204 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +151 -0
- data/qa/settings.yml +1 -2
- data/rust/build/src/lineage_pass.rs +30 -15
- data/rust/build/src/main.rs +4 -2
- data/rust/build/src/pipeline.rs +8 -19
- data/rust/codegen/src/bridging.rs +7 -7
- data/rust/codegen/src/json.rs +214 -23
- data/rust/codegen/src/literal.rs +6 -3
- data/rust/codegen/src/main.rs +20 -9
- data/rust/codegen/src/naming.rs +4 -2
- data/rust/codegen/src/optional_pass.rs +226 -0
- data/rust/{build → codegen}/src/sidecars.rs +9 -5
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +5 -0
- data/rust/host/src/needs.rs +99 -2
- data/rust/host/src/resend.rs +4 -4
- data/rust/host/src/ui_schema.rs +1 -1
- data/rust/parser/src/emit.rs +12 -0
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/mod.rs +1 -0
- data/rust/parser/src/parse/needs.rs +60 -0
- data/rust/parser/src/parse/query.rs +3 -0
- data/rust/src/kernel/cli.rs +16 -6
- data/rust/src/kernel/needs.rs +104 -9
- metadata +22 -8
- data/lib/hecks/rust_build/append_optionals.rb +0 -79
- data/lib/hecks/rust_build/rust_literal.rb +0 -33
- data/lib/hecks/three_zero/forms.yml +0 -127
- data/lib/hecks/three_zero.rb +0 -12
- data/rust/build/src/json.rs +0 -421
- data/rust/build/src/optional_pass.rs +0 -150
|
@@ -119,25 +119,31 @@ fn world_opener_name(line: &str) -> Option<String> {
|
|
|
119
119
|
Some(quoted[..quoted.find('"')?].to_string())
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Where an adapter class can live: the driven adapters, and the era plugin that holds `PostgresEra`,
|
|
123
|
+
// the one adapter that declares itself lineage-capable.
|
|
124
|
+
const ADAPTER_DIRS: [&str; 2] = ["lib/hecks/adapters/driven", "lib/hecks/ports/persistence/plugins/era"];
|
|
125
|
+
|
|
122
126
|
// Reads each adapter's own source file rather than invoking Ruby.
|
|
123
127
|
fn lineage_capable_adapter_names(root: &Path) -> Result<Vec<String>, String> {
|
|
124
|
-
let dir = root.join("lib/hecks/adapters/driven");
|
|
125
|
-
let entries = std::fs::read_dir(&dir).map_err(|e| format!("reading {}: {e}", dir.display()))?;
|
|
126
|
-
|
|
127
128
|
let mut names = Vec::new();
|
|
128
|
-
for
|
|
129
|
-
let
|
|
130
|
-
let
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
for relative in ADAPTER_DIRS {
|
|
130
|
+
let dir = root.join(relative);
|
|
131
|
+
let entries = std::fs::read_dir(&dir).map_err(|e| format!("reading {}: {e}", dir.display()))?;
|
|
132
|
+
|
|
133
|
+
for entry in entries {
|
|
134
|
+
let entry = entry.map_err(|e| format!("reading {}: {e}", dir.display()))?;
|
|
135
|
+
let path = entry.path();
|
|
136
|
+
if path.extension().and_then(|e| e.to_str()) != Some("rb") {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
let text = std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
|
|
141
|
+
if !declares_lineage_capable_true(&text) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if let Some(name) = top_level_class_name(&text) {
|
|
145
|
+
names.push(name);
|
|
146
|
+
}
|
|
141
147
|
}
|
|
142
148
|
}
|
|
143
149
|
|
|
@@ -278,6 +284,15 @@ mod tests {
|
|
|
278
284
|
assert!(!declares_lineage_capable_true("no such method here\n"));
|
|
279
285
|
}
|
|
280
286
|
|
|
287
|
+
// The adapter moved out of `lib/hecks/adapters/driven` once and the scan silently found none, which
|
|
288
|
+
// left every lineage-capable aggregate out of a Ruby-free build's `ir.json`.
|
|
289
|
+
#[test]
|
|
290
|
+
fn finds_postgres_era_in_the_real_source_tree() {
|
|
291
|
+
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
292
|
+
let names = lineage_capable_adapter_names(&root).expect("scans the adapter directories");
|
|
293
|
+
assert!(names.iter().any(|name| name == "PostgresEra"), "lineage-capable adapters found: {names:?}");
|
|
294
|
+
}
|
|
295
|
+
|
|
281
296
|
#[test]
|
|
282
297
|
fn finds_the_top_level_class_name() {
|
|
283
298
|
assert_eq!(top_level_class_name("module Hecks\n module Adapters\n class Postgres\n def x; end\n"), Some("Postgres".to_string()));
|
data/rust/build/src/main.rs
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
mod build_artifact;
|
|
6
6
|
mod cargo_sync;
|
|
7
|
+
// The one JSON implementation, compiled from `hecks-codegen`'s own source: neither crate depends on
|
|
8
|
+
// the other, and a packaged workspace ships both side by side. This crate reads only part of it.
|
|
9
|
+
#[allow(dead_code)]
|
|
10
|
+
#[path = "../../codegen/src/json.rs"]
|
|
7
11
|
mod json;
|
|
8
12
|
mod lineage_pass;
|
|
9
|
-
mod optional_pass;
|
|
10
13
|
mod pipeline;
|
|
11
14
|
mod reserved_names;
|
|
12
15
|
mod resolve;
|
|
13
16
|
mod root;
|
|
14
|
-
mod sidecars;
|
|
15
17
|
mod subprocess;
|
|
16
18
|
mod tmp;
|
|
17
19
|
|
data/rust/build/src/pipeline.rs
CHANGED
|
@@ -7,9 +7,7 @@ use crate::build_artifact;
|
|
|
7
7
|
use crate::cargo_sync;
|
|
8
8
|
use crate::json::Json;
|
|
9
9
|
use crate::lineage_pass;
|
|
10
|
-
use crate::optional_pass;
|
|
11
10
|
use crate::resolve;
|
|
12
|
-
use crate::sidecars;
|
|
13
11
|
use crate::subprocess;
|
|
14
12
|
use crate::tmp::TempDir;
|
|
15
13
|
|
|
@@ -77,7 +75,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
|
|
|
77
75
|
if let Some(p) = &hecksagon_path {
|
|
78
76
|
target_files.push(p.clone());
|
|
79
77
|
}
|
|
80
|
-
let target_ir_text =
|
|
78
|
+
let target_ir_text = parse_chapter(&parser_bin, &target_chapter_name, &target_files)?;
|
|
81
79
|
// Only the target chapter gets a `lineage` key — a framework chapter or
|
|
82
80
|
// `meta` never does, matching `derive_lineage` (Ruby) re-`JSON.parse`ing
|
|
83
81
|
// rather than chaining a live object through both passes.
|
|
@@ -113,7 +111,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
|
|
|
113
111
|
fw_path.display()
|
|
114
112
|
));
|
|
115
113
|
}
|
|
116
|
-
let fw_ir_text =
|
|
114
|
+
let fw_ir_text = parse_chapter(&parser_bin, &fw_chapter_name, std::slice::from_ref(&fw_path))?;
|
|
117
115
|
chapters.push(Chapter {
|
|
118
116
|
mod_name: fw_name.to_lowercase(),
|
|
119
117
|
source_label: format!("{domain} (attaches {fw_name:?})"),
|
|
@@ -141,7 +139,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
|
|
|
141
139
|
pkg_bluebooks.push(path.clone());
|
|
142
140
|
}
|
|
143
141
|
}
|
|
144
|
-
let pkg_ir_text =
|
|
142
|
+
let pkg_ir_text = parse_chapter(&parser_bin, &pkg_chapter_name, &pkg_bluebooks)?;
|
|
145
143
|
chapters.push(Chapter {
|
|
146
144
|
// Derived from the declared chapter name (asserted equal to
|
|
147
145
|
// `expected_chapter_name` above), not the raw `pkg_name` — that
|
|
@@ -172,7 +170,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
|
|
|
172
170
|
// The self-hosted language, compiled in too, under the same "Bluebook"
|
|
173
171
|
// chapter name.
|
|
174
172
|
let grammar_files = resolve::grammar_files(root)?;
|
|
175
|
-
let meta_ir_text =
|
|
173
|
+
let meta_ir_text = parse_chapter(&parser_bin, "Bluebook", &grammar_files)?;
|
|
176
174
|
|
|
177
175
|
let out_root = root.join("rust/src/generated");
|
|
178
176
|
std::fs::create_dir_all(&out_root).map_err(|e| format!("creating {}: {e}", out_root.display()))?;
|
|
@@ -213,12 +211,6 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
|
|
|
213
211
|
}
|
|
214
212
|
run_codegen_full(&codegen_bin, &codegen_args)?;
|
|
215
213
|
|
|
216
|
-
sidecars::write(&out_root.join("meta"), &meta_ir_text, META_SOURCE_LABEL)?;
|
|
217
|
-
sidecars::write(&out_root.join(&target_mod_name), &target_ir_text, domain)?;
|
|
218
|
-
for c in &chapters {
|
|
219
|
-
sidecars::write(&out_root.join(&c.mod_name), &c.ir_text, &c.source_label)?;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
214
|
let cargo_toml_path = root.join("rust/Cargo.toml");
|
|
223
215
|
cargo_sync::run(&out_root, &cargo_toml_path, &target_mod_name)?;
|
|
224
216
|
|
|
@@ -233,18 +225,15 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
|
|
|
233
225
|
Ok(())
|
|
234
226
|
}
|
|
235
227
|
|
|
236
|
-
// Parses via `hecks-parse chapter
|
|
237
|
-
//
|
|
238
|
-
fn
|
|
228
|
+
// Parses via `hecks-parse chapter`. `hecks-codegen` marks the IR and writes its `ir.json` and
|
|
229
|
+
// `metadata.rs` itself, so the text goes through as `hecks-parse` printed it.
|
|
230
|
+
fn parse_chapter(parser_bin: &Path, chapter_name: &str, files: &[PathBuf]) -> Result<String, String> {
|
|
239
231
|
let parser_bin_str = parser_bin.to_string_lossy().to_string();
|
|
240
232
|
let mut args: Vec<String> = vec!["chapter".to_string(), "--chapter".to_string(), chapter_name.to_string()];
|
|
241
233
|
args.extend(files.iter().map(|p| p.to_string_lossy().to_string()));
|
|
242
234
|
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
|
243
235
|
|
|
244
|
-
|
|
245
|
-
let mut ir = Json::parse(&stdout).map_err(|e| format!("parsing hecks-parse chapter output for {chapter_name}: {e}"))?;
|
|
246
|
-
optional_pass::run(&mut ir);
|
|
247
|
-
Ok(crate::json::write(&ir))
|
|
236
|
+
subprocess::run_capture(&parser_bin_str, &arg_refs)
|
|
248
237
|
}
|
|
249
238
|
|
|
250
239
|
fn run_codegen_full(codegen_bin: &Path, args: &[String]) -> Result<(), String> {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! Port of the retired Ruby generator's `bridging.rb`: cross-type coercion checks for `:set` and `:append` RHS.
|
|
2
2
|
|
|
3
|
-
use crate::json::Json;
|
|
3
|
+
use crate::json::{Json, Number};
|
|
4
4
|
use crate::literal::Literal;
|
|
5
5
|
use crate::naming;
|
|
6
6
|
use std::collections::{HashMap, HashSet};
|
|
@@ -312,15 +312,15 @@ pub fn arithmetic_amount_expr(source: &Json, command: &Json, value_objects_by_na
|
|
|
312
312
|
let kind = source.get("kind").map(Json::to_s).unwrap_or_default();
|
|
313
313
|
if kind == "literal" {
|
|
314
314
|
let value = source.get("value")?;
|
|
315
|
-
if
|
|
315
|
+
if matches!(value.number(), Some(Number::Int(_))) {
|
|
316
316
|
return Some(naming::literal_rhs(value));
|
|
317
317
|
}
|
|
318
318
|
if !matches!(value, Json::Object(_)) {
|
|
319
319
|
return None;
|
|
320
320
|
}
|
|
321
321
|
let key_value = value.get_raw(target_integer_field)?;
|
|
322
|
-
return match key_value {
|
|
323
|
-
|
|
322
|
+
return match key_value.number() {
|
|
323
|
+
Some(Number::Int(_)) => Some(naming::literal_rhs(key_value)),
|
|
324
324
|
_ => None,
|
|
325
325
|
};
|
|
326
326
|
}
|
|
@@ -347,9 +347,9 @@ pub fn clamp_bounds_ints(source: &Json) -> Option<(i64, i64)> {
|
|
|
347
347
|
if items.len() != 2 {
|
|
348
348
|
return None;
|
|
349
349
|
}
|
|
350
|
-
let
|
|
351
|
-
let
|
|
352
|
-
Some((
|
|
350
|
+
let Some(Number::Int(min)) = items[0].number() else { return None };
|
|
351
|
+
let Some(Number::Int(max)) = items[1].number() else { return None };
|
|
352
|
+
Some((min, max))
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
/// An aggregate attribute a creating command's arguments never mention.
|
data/rust/codegen/src/json.rs
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
|
-
//! A minimal, dependency-free JSON
|
|
2
|
-
//!
|
|
1
|
+
//! A minimal, dependency-free, mutable JSON value indexed by string key, like the Ruby `ir[:key]`
|
|
2
|
+
//! Hash, with a reader and a pretty writer. The writer matches `JSON.pretty_generate`
|
|
3
|
+
//! byte-for-byte, including its empty-array quirk.
|
|
3
4
|
|
|
4
5
|
use std::fmt;
|
|
6
|
+
use std::fmt::Write as _;
|
|
5
7
|
|
|
6
8
|
#[derive(Debug, Clone, PartialEq)]
|
|
7
9
|
pub enum Json {
|
|
8
10
|
Null,
|
|
9
11
|
Bool(bool),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Float(f64),
|
|
12
|
+
/// Raw literal text as scanned, so numbers round-trip unchanged; `number` reads it as Ruby wrote it.
|
|
13
|
+
Number(String),
|
|
13
14
|
String(String),
|
|
14
15
|
Array(Vec<Json>),
|
|
15
|
-
|
|
16
|
+
/// Insertion-ordered pairs, matching the declaration order of the Ruby Hash; byte-exact
|
|
17
|
+
/// re-emission depends on key order.
|
|
16
18
|
Object(Vec<(String, Json)>),
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/// A `Json::Number` as Ruby wrote it: a `.` or an exponent means a Float, anything else an
|
|
22
|
+
/// Integer. Kept apart: a whole-number Float default (`0.0`) must not render as `0`.
|
|
23
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
24
|
+
pub enum Number {
|
|
25
|
+
Int(i64),
|
|
26
|
+
Float(f64),
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
impl Json {
|
|
20
30
|
pub fn parse(text: &str) -> Result<Json, String> {
|
|
21
31
|
let mut p = Parser { bytes: text.as_bytes(), pos: 0 };
|
|
@@ -44,6 +54,13 @@ impl Json {
|
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
pub fn get_mut(&mut self, key: &str) -> Option<&mut Json> {
|
|
58
|
+
match self {
|
|
59
|
+
Json::Object(pairs) => pairs.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
|
|
60
|
+
_ => None,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
pub fn as_str(&self) -> Option<&str> {
|
|
48
65
|
match self {
|
|
49
66
|
Json::String(s) => Some(s.as_str()),
|
|
@@ -58,25 +75,50 @@ impl Json {
|
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
|
|
78
|
+
pub fn as_array_mut(&mut self) -> Option<&mut Vec<Json>> {
|
|
79
|
+
match self {
|
|
80
|
+
Json::Array(items) => Some(items),
|
|
81
|
+
_ => None,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pub fn as_object(&self) -> Option<&[(String, Json)]> {
|
|
86
|
+
match self {
|
|
87
|
+
Json::Object(pairs) => Some(pairs.as_slice()),
|
|
88
|
+
_ => None,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
61
92
|
pub fn as_bool(&self) -> bool {
|
|
62
93
|
// Ruby truthiness: only `false` and `nil` are falsy, so `0` and `""` are truthy.
|
|
63
94
|
!matches!(self, Json::Bool(false) | Json::Null)
|
|
64
95
|
}
|
|
65
96
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
97
|
+
/// The number as Ruby wrote it, or `None` for any other value. An integer too large for `i64`
|
|
98
|
+
/// reads as a Float.
|
|
99
|
+
pub fn number(&self) -> Option<Number> {
|
|
100
|
+
let Json::Number(text) = self else { return None };
|
|
101
|
+
if text.contains(|c| matches!(c, '.' | 'e' | 'E')) {
|
|
102
|
+
return text.parse::<f64>().ok().map(Number::Float);
|
|
71
103
|
}
|
|
104
|
+
match text.parse::<i64>() {
|
|
105
|
+
Ok(n) => Some(Number::Int(n)),
|
|
106
|
+
Err(_) => text.parse::<f64>().ok().map(Number::Float),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pub fn as_f64(&self) -> Option<f64> {
|
|
111
|
+
self.number().map(|n| match n {
|
|
112
|
+
Number::Int(n) => n as f64,
|
|
113
|
+
Number::Float(n) => n,
|
|
114
|
+
})
|
|
72
115
|
}
|
|
73
116
|
|
|
74
117
|
pub fn as_i64(&self) -> Option<i64> {
|
|
75
|
-
match
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
118
|
+
self.number().map(|n| match n {
|
|
119
|
+
Number::Int(n) => n,
|
|
120
|
+
Number::Float(n) => n as i64,
|
|
121
|
+
})
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
/// `#to_s` on a scalar; non-scalars render as the empty string rather than panicking.
|
|
@@ -84,8 +126,11 @@ impl Json {
|
|
|
84
126
|
match self {
|
|
85
127
|
Json::Null => String::new(),
|
|
86
128
|
Json::Bool(b) => b.to_string(),
|
|
87
|
-
Json::
|
|
88
|
-
|
|
129
|
+
Json::Number(text) => match self.number() {
|
|
130
|
+
Some(Number::Int(n)) => n.to_string(),
|
|
131
|
+
Some(Number::Float(n)) => format_number(n),
|
|
132
|
+
None => text.clone(),
|
|
133
|
+
},
|
|
89
134
|
Json::String(s) => s.clone(),
|
|
90
135
|
_ => String::new(),
|
|
91
136
|
}
|
|
@@ -95,6 +140,21 @@ impl Json {
|
|
|
95
140
|
pub fn each(&self) -> &[Json] {
|
|
96
141
|
self.as_array().unwrap_or(&[])
|
|
97
142
|
}
|
|
143
|
+
|
|
144
|
+
/// Sets `key` to `value`, overwriting in place or else appending last.
|
|
145
|
+
pub fn set(&mut self, key: &str, value: Json) {
|
|
146
|
+
if let Json::Object(pairs) = self {
|
|
147
|
+
if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
|
|
148
|
+
*existing = value;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
pairs.push((key.to_string(), value));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
pub fn set_bool(&mut self, key: &str, value: bool) {
|
|
156
|
+
self.set(key, Json::Bool(value));
|
|
157
|
+
}
|
|
98
158
|
}
|
|
99
159
|
|
|
100
160
|
impl fmt::Display for Json {
|
|
@@ -301,6 +361,7 @@ impl<'a> Parser<'a> {
|
|
|
301
361
|
Ok(out)
|
|
302
362
|
}
|
|
303
363
|
|
|
364
|
+
// Keeps the raw literal text, so numbers round-trip unchanged.
|
|
304
365
|
fn parse_number(&mut self) -> Result<Json, String> {
|
|
305
366
|
let start = self.pos;
|
|
306
367
|
if self.peek() == Some(b'-') {
|
|
@@ -325,11 +386,141 @@ impl<'a> Parser<'a> {
|
|
|
325
386
|
}
|
|
326
387
|
}
|
|
327
388
|
let text = std::str::from_utf8(&self.bytes[start..self.pos]).map_err(|e| e.to_string())?;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
389
|
+
Ok(Json::Number(text.to_string()))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// Renders `value` exactly as `JSON.pretty_generate` does for the pinned json gem.
|
|
394
|
+
pub fn write(value: &Json) -> String {
|
|
395
|
+
let mut out = String::new();
|
|
396
|
+
write_value(&mut out, value, 0);
|
|
397
|
+
out
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fn indent(out: &mut String, depth: usize) {
|
|
401
|
+
for _ in 0..depth {
|
|
402
|
+
out.push_str(" ");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
fn write_value(out: &mut String, value: &Json, depth: usize) {
|
|
407
|
+
match value {
|
|
408
|
+
Json::Null => out.push_str("null"),
|
|
409
|
+
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
|
|
410
|
+
Json::Number(n) => out.push_str(n),
|
|
411
|
+
Json::String(s) => write_string(out, s),
|
|
412
|
+
Json::Array(items) => write_array(out, items, depth),
|
|
413
|
+
Json::Object(pairs) => write_object(out, pairs, depth),
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
fn write_string(out: &mut String, text: &str) {
|
|
418
|
+
out.push('"');
|
|
419
|
+
for ch in text.chars() {
|
|
420
|
+
match ch {
|
|
421
|
+
'"' => out.push_str("\\\""),
|
|
422
|
+
'\\' => out.push_str("\\\\"),
|
|
423
|
+
'\n' => out.push_str("\\n"),
|
|
424
|
+
'\t' => out.push_str("\\t"),
|
|
425
|
+
'\r' => out.push_str("\\r"),
|
|
426
|
+
c if (c as u32) < 0x20 => {
|
|
427
|
+
let _ = write!(out, "\\u{:04x}", c as u32);
|
|
428
|
+
}
|
|
429
|
+
c => out.push(c),
|
|
333
430
|
}
|
|
334
431
|
}
|
|
432
|
+
out.push('"');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
fn write_array(out: &mut String, items: &[Json], depth: usize) {
|
|
436
|
+
if items.is_empty() {
|
|
437
|
+
// Empty arrays render with a blank line, matching the pinned json gem.
|
|
438
|
+
out.push_str("[\n\n");
|
|
439
|
+
indent(out, depth);
|
|
440
|
+
out.push(']');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
out.push_str("[\n");
|
|
444
|
+
for (idx, item) in items.iter().enumerate() {
|
|
445
|
+
indent(out, depth + 1);
|
|
446
|
+
write_value(out, item, depth + 1);
|
|
447
|
+
if idx + 1 < items.len() {
|
|
448
|
+
out.push(',');
|
|
449
|
+
}
|
|
450
|
+
out.push('\n');
|
|
451
|
+
}
|
|
452
|
+
indent(out, depth);
|
|
453
|
+
out.push(']');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
fn write_object(out: &mut String, pairs: &[(String, Json)], depth: usize) {
|
|
457
|
+
// An empty object needs no special case: the loop runs zero times.
|
|
458
|
+
out.push_str("{\n");
|
|
459
|
+
for (idx, (key, val)) in pairs.iter().enumerate() {
|
|
460
|
+
indent(out, depth + 1);
|
|
461
|
+
write_string(out, key);
|
|
462
|
+
out.push_str(": ");
|
|
463
|
+
write_value(out, val, depth + 1);
|
|
464
|
+
if idx + 1 < pairs.len() {
|
|
465
|
+
out.push(',');
|
|
466
|
+
}
|
|
467
|
+
out.push('\n');
|
|
468
|
+
}
|
|
469
|
+
indent(out, depth);
|
|
470
|
+
out.push('}');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[cfg(test)]
|
|
474
|
+
mod tests {
|
|
475
|
+
use super::*;
|
|
476
|
+
|
|
477
|
+
#[test]
|
|
478
|
+
fn round_trips_an_empty_object() {
|
|
479
|
+
let text = "{\n}";
|
|
480
|
+
let value = Json::parse(text).expect("parses");
|
|
481
|
+
assert_eq!(write(&value), text);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#[test]
|
|
485
|
+
fn set_bool_overwrites_an_existing_key() {
|
|
486
|
+
let mut value = Json::parse("{\n \"optional\": false\n}").expect("parses");
|
|
487
|
+
value.set_bool("optional", true);
|
|
488
|
+
assert_eq!(write(&value), "{\n \"optional\": true\n}");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A whole-number Float default must keep its `.0`, in the text and when rendered.
|
|
492
|
+
#[test]
|
|
493
|
+
fn keeps_a_float_apart_from_an_integer() {
|
|
494
|
+
let value = Json::parse("[0.0, 0, 1e3, -7, 12345678901234567890]").expect("parses");
|
|
495
|
+
let numbers: Vec<Option<Number>> = value.each().iter().map(Json::number).collect();
|
|
496
|
+
|
|
497
|
+
assert_eq!(numbers[0], Some(Number::Float(0.0)));
|
|
498
|
+
assert_eq!(numbers[1], Some(Number::Int(0)));
|
|
499
|
+
assert_eq!(numbers[2], Some(Number::Float(1000.0)));
|
|
500
|
+
assert_eq!(numbers[3], Some(Number::Int(-7)));
|
|
501
|
+
assert!(matches!(numbers[4], Some(Number::Float(_))), "an integer past i64 reads as a Float");
|
|
502
|
+
assert_eq!(value.each()[0].to_s(), "0.0");
|
|
503
|
+
assert_eq!(value.each()[1].to_s(), "0");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#[test]
|
|
507
|
+
fn writes_numbers_as_scanned() {
|
|
508
|
+
let text = "[\n 0.0,\n 1e3,\n -7\n]";
|
|
509
|
+
assert_eq!(write(&Json::parse(text).expect("parses")), text);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn reads_as_i64_and_f64_across_both_kinds() {
|
|
514
|
+
let value = Json::parse("[3, 2.5]").expect("parses");
|
|
515
|
+
assert_eq!(value.each()[0].as_f64(), Some(3.0));
|
|
516
|
+
assert_eq!(value.each()[1].as_i64(), Some(2));
|
|
517
|
+
assert_eq!(Json::String("3".to_string()).as_i64(), None);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#[test]
|
|
521
|
+
fn get_treats_null_as_missing_and_get_raw_does_not() {
|
|
522
|
+
let value = Json::parse("{\"a\": null}").expect("parses");
|
|
523
|
+
assert!(value.get("a").is_none());
|
|
524
|
+
assert_eq!(value.get_raw("a"), Some(&Json::Null));
|
|
525
|
+
}
|
|
335
526
|
}
|
data/rust/codegen/src/literal.rs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//! Port of `Literal.read` from `lib/hecks/literal.rb`, the inverse of `Literal.render`.
|
|
2
2
|
//! `Literal::from_json` covers values embedded raw in ir.json, which were never rendered.
|
|
3
3
|
|
|
4
|
-
use crate::json::Json;
|
|
4
|
+
use crate::json::{Json, Number};
|
|
5
5
|
|
|
6
6
|
#[derive(Debug, Clone, PartialEq)]
|
|
7
7
|
pub enum Literal {
|
|
@@ -22,8 +22,11 @@ impl Literal {
|
|
|
22
22
|
match value {
|
|
23
23
|
Json::Null => Literal::Nil,
|
|
24
24
|
Json::Bool(b) => Literal::Bool(*b),
|
|
25
|
-
Json::
|
|
26
|
-
|
|
25
|
+
Json::Number(_) => match value.number() {
|
|
26
|
+
Some(Number::Int(n)) => Literal::Int(n),
|
|
27
|
+
Some(Number::Float(n)) => Literal::Float(n),
|
|
28
|
+
None => Literal::Nil,
|
|
29
|
+
},
|
|
27
30
|
Json::String(s) => Literal::Str(s.clone()),
|
|
28
31
|
Json::Array(items) => Literal::Array(items.iter().map(Literal::from_json).collect()),
|
|
29
32
|
Json::Object(pairs) => Literal::Hash(pairs.iter().map(|(k, v)| (k.clone(), Literal::from_json(v))).collect()),
|
data/rust/codegen/src/main.rs
CHANGED
|
@@ -19,6 +19,7 @@ mod literal;
|
|
|
19
19
|
mod manifest;
|
|
20
20
|
mod mutations;
|
|
21
21
|
mod naming;
|
|
22
|
+
mod optional_pass;
|
|
22
23
|
mod ports;
|
|
23
24
|
mod prelude;
|
|
24
25
|
mod queries;
|
|
@@ -28,6 +29,7 @@ mod reference_specs;
|
|
|
28
29
|
mod registry;
|
|
29
30
|
mod reserved_names;
|
|
30
31
|
mod shared;
|
|
32
|
+
mod sidecars;
|
|
31
33
|
mod skip_reason;
|
|
32
34
|
mod types;
|
|
33
35
|
|
|
@@ -85,22 +87,29 @@ fn run_prelude(args: &[String]) -> Result<(), String> {
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/// `hecks-codegen domain <ir.json> <source_label> <mod_name> <out_dir>` — writes one chapter's
|
|
88
|
-
/// `<aggregate>.rs`, `registry.rs`, `mod.rs
|
|
89
|
-
///
|
|
90
|
-
/// `metadata.rs` and `ir.json` are left to `hecks project_rust`.
|
|
90
|
+
/// `<aggregate>.rs`, `registry.rs`, `mod.rs`, `manifest.json` and its `ir.json`/`metadata.rs`
|
|
91
|
+
/// sidecars into `out_dir`.
|
|
91
92
|
fn run_domain(args: &[String]) -> Result<(), String> {
|
|
92
93
|
let [ir_path, source_label, mod_name, out_dir] = args else {
|
|
93
94
|
return Err("usage: hecks-codegen domain <ir.json> <source_label> <mod_name> <out_dir>".to_string());
|
|
94
95
|
};
|
|
95
96
|
|
|
96
|
-
let
|
|
97
|
-
let ir = Json::parse(&text).map_err(|e| format!("parsing {ir_path}: {e}"))?;
|
|
97
|
+
let ir = read_ir(ir_path)?;
|
|
98
98
|
|
|
99
99
|
let ex = exemplar::Exemplar::load();
|
|
100
100
|
write_domain(&ex, &ir, source_label, mod_name, out_dir)?;
|
|
101
101
|
Ok(())
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/// Reads one chapter's IR and prepares it for generation: marks the fields an `append` binds to an
|
|
105
|
+
/// optional argument. What it generates from and what it writes to `ir.json` are this same IR.
|
|
106
|
+
fn read_ir(path: &str) -> Result<Json, String> {
|
|
107
|
+
let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
|
|
108
|
+
let mut ir = Json::parse(&text).map_err(|e| format!("parsing {path}: {e}"))?;
|
|
109
|
+
optional_pass::run(&mut ir);
|
|
110
|
+
Ok(ir)
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
/// Writes one chapter's files into `out_dir` and returns the `GeneratedDomain`, so `run_full`
|
|
105
114
|
/// can union the registry, query and read-model definitions across chapters.
|
|
106
115
|
fn write_domain(
|
|
@@ -147,6 +156,10 @@ fn write_domain(
|
|
|
147
156
|
std::fs::write(&manifest_path, &generated.manifest_json).map_err(|e| format!("writing {manifest_path}: {e}"))?;
|
|
148
157
|
println!("wrote {manifest_path}");
|
|
149
158
|
|
|
159
|
+
let (ir_json_path, metadata_path) = sidecars::write(std::path::Path::new(out_dir), ir, source_label)?;
|
|
160
|
+
println!("wrote {}", ir_json_path.display());
|
|
161
|
+
println!("wrote {}", metadata_path.display());
|
|
162
|
+
|
|
150
163
|
Ok(generated)
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -181,8 +194,7 @@ fn run_full(args: &[String]) -> Result<(), String> {
|
|
|
181
194
|
|
|
182
195
|
let ex = exemplar::Exemplar::load();
|
|
183
196
|
|
|
184
|
-
let
|
|
185
|
-
let target_ir = Json::parse(&target_text).map_err(|e| format!("parsing {target_ir_path}: {e}"))?;
|
|
197
|
+
let target_ir = read_ir(target_ir_path)?;
|
|
186
198
|
let target_domain_name = target_ir.get("name").and_then(Json::as_str).unwrap_or("").to_string();
|
|
187
199
|
|
|
188
200
|
let target_out_dir = format!("{out_root}/{target_mod_name}");
|
|
@@ -209,8 +221,7 @@ fn run_full(args: &[String]) -> Result<(), String> {
|
|
|
209
221
|
let chapter_ir_path = &args[i + 2];
|
|
210
222
|
i += 3;
|
|
211
223
|
|
|
212
|
-
let
|
|
213
|
-
let chapter_ir = Json::parse(&text).map_err(|e| format!("parsing {chapter_ir_path}: {e}"))?;
|
|
224
|
+
let chapter_ir = read_ir(chapter_ir_path)?;
|
|
214
225
|
let chapter_domain_name = chapter_ir.get("name").and_then(Json::as_str).unwrap_or("").to_string();
|
|
215
226
|
|
|
216
227
|
let chapter_out_dir = format!("{out_root}/{chapter_mod_name}");
|
data/rust/codegen/src/naming.rs
CHANGED
|
@@ -285,8 +285,10 @@ pub fn literal_rhs(literal: &crate::json::Json) -> String {
|
|
|
285
285
|
match literal {
|
|
286
286
|
crate::json::Json::String(s) => format!("{}.to_string()", ruby_inspect_string(s)),
|
|
287
287
|
// Separate arms so a whole-number Float renders as `0.0`, not an integer-typed `0`.
|
|
288
|
-
crate::json::Json::
|
|
289
|
-
|
|
288
|
+
crate::json::Json::Number(_) => match literal.number() {
|
|
289
|
+
Some(crate::json::Number::Int(n)) => n.to_string(),
|
|
290
|
+
_ => literal.to_s(),
|
|
291
|
+
},
|
|
290
292
|
crate::json::Json::Bool(b) => b.to_string(),
|
|
291
293
|
other => panic!("unsupported literal mutation source {other:?} — not one of String/Integer/Float/Boolean"),
|
|
292
294
|
}
|