hecks 3.2.1 → 3.4.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +21 -23
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +88 -33
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +198 -0
  12. data/lib/hecks/corpus.rb +3 -5
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  16. data/lib/hecks/doors.rb +0 -17
  17. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  18. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  19. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  20. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  21. data/lib/hecks/fuzzing/isolated_boot.rb +1 -4
  22. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  23. data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
  24. data/lib/hecks/gate/stages.yml +113 -8
  25. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  26. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  27. data/lib/hecks/hecks/adapters/git.rb +59 -0
  28. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  29. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  30. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  31. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  32. data/lib/hecks/hecks/codebase.behaviors +6 -0
  33. data/lib/hecks/hecks/codebase.bluebook +21 -0
  34. data/lib/hecks/hecks/custodian.behaviors +6 -0
  35. data/lib/hecks/hecks/custodian.bluebook +217 -0
  36. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  37. data/lib/hecks/hecks/hecks.world +8 -3
  38. data/lib/hecks/hecks/site.world +5 -2
  39. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  40. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  41. data/lib/hecks/language/hecksagon/hecksagon.bluebook +0 -5
  42. data/lib/hecks/language/oidc.json +5 -0
  43. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  44. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  45. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  47. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  48. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  49. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  50. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  51. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  52. data/lib/hecks/projections/deploy/box.rb +15 -2
  53. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  54. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  55. data/lib/hecks/projections/model/deviations.rb +1 -1
  56. data/lib/hecks/projections/site/live_edge.rb +162 -0
  57. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  58. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  59. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  60. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  61. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  62. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  63. data/lib/hecks/runtime/interpreting.rb +3 -22
  64. data/lib/hecks/runtime/loader.rb +6 -7
  65. data/lib/hecks/runtime/needs.rb +48 -0
  66. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  67. data/lib/hecks/runtime/registry/verification.rb +3 -3
  68. data/lib/hecks/runtime.rb +6 -11
  69. data/lib/hecks/rust_build/host.rb +147 -0
  70. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  71. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  72. data/lib/hecks/rust_build/project_rust.rb +3 -10
  73. data/lib/hecks/rust_build.rb +1 -0
  74. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  75. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  76. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  77. data/lib/hecks/tools/gate.rb +1 -1
  78. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  79. data/lib/hecks/tools/tools_doc.rb +204 -0
  80. data/lib/hecks/tools.rb +1 -0
  81. data/lib/hecks/version.rb +1 -1
  82. data/lib/hecks/vocabulary.rb +151 -0
  83. data/lib/hecks.rb +6 -12
  84. data/qa/settings.yml +1 -2
  85. data/rust/build/src/lineage_pass.rs +30 -15
  86. data/rust/build/src/main.rs +4 -2
  87. data/rust/build/src/pipeline.rs +8 -19
  88. data/rust/codegen/src/bridging.rs +7 -7
  89. data/rust/codegen/src/json.rs +214 -23
  90. data/rust/codegen/src/literal.rs +6 -3
  91. data/rust/codegen/src/main.rs +20 -9
  92. data/rust/codegen/src/naming.rs +4 -2
  93. data/rust/codegen/src/optional_pass.rs +226 -0
  94. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  95. data/rust/host/HECKS_RELEASE +1 -1
  96. data/rust/host/src/api.rs +1 -1
  97. data/rust/host/src/dispatch.rs +5 -0
  98. data/rust/host/src/needs.rs +99 -2
  99. data/rust/host/src/resend.rs +4 -4
  100. data/rust/host/src/ui_schema.rs +1 -1
  101. data/rust/parser/src/emit.rs +12 -0
  102. data/rust/parser/src/ir.rs +2 -0
  103. data/rust/parser/src/keywords.rs +2 -4
  104. data/rust/parser/src/lex.rs +1 -1
  105. data/rust/parser/src/main.rs +1 -0
  106. data/rust/parser/src/parse/command.rs +2 -44
  107. data/rust/parser/src/parse/hecksagon.rs +15 -24
  108. data/rust/parser/src/parse/mod.rs +1 -0
  109. data/rust/parser/src/parse/needs.rs +60 -0
  110. data/rust/parser/src/parse/query.rs +3 -0
  111. data/rust/src/kernel/cli.rs +16 -6
  112. data/rust/src/kernel/needs.rs +104 -9
  113. metadata +22 -9
  114. data/lib/hecks/facade.rb +0 -31
  115. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  116. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  117. data/lib/hecks/three_zero/forms.yml +0 -127
  118. data/lib/hecks/three_zero.rb +0 -12
  119. data/rust/build/src/json.rs +0 -421
  120. data/rust/build/src/optional_pass.rs +0 -150
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Hecks
4
- module RustBuild
5
- # Makes an appended element's field `Option<T>` in the IR when any command sources it from an
6
- # optional argument, since Ruby stores the omitted nil without complaint. `ir.json` records
7
- # the marking, so it runs before the IR is written or generated from.
8
- #
9
- # `rust/build/src/optional_pass.rs` is the Ruby-free build's port of this; it looks only at an
10
- # aggregate's own value objects, where this reads the domain-wide set.
11
- module AppendOptionals
12
- # Marks every aggregate in the IR, in place.
13
- #
14
- # @param tree [Hash] a domain's IR, symbol-keyed
15
- # @return [Hash] the same IR
16
- def self.mark(tree)
17
- domain_wide = by_name(tree[:aggregates].flat_map { |aggregate| aggregate[:value_objects] })
18
- tree[:aggregates].each do |aggregate|
19
- mark_aggregate(aggregate, domain_wide.merge(by_name(aggregate[:value_objects])))
20
- end
21
- tree
22
- end
23
-
24
- # Resolves an `append` target to its entity or value object, or nil if it is neither. A
25
- # local entity wins over a domain-wide value object of the same name: Syntax's local
26
- # `Argument` entity shares a name with Command's `Argument` value object.
27
- #
28
- # @param aggregate [Hash] the aggregate owning the command
29
- # @param target_type [String] the appended-to attribute's element type
30
- # @param value_objects_by_name [Hash{String => Hash}] the value objects in scope
31
- # @return [Hash, nil] the element, or nil
32
- def self.element(aggregate, target_type, value_objects_by_name)
33
- aggregate[:entities].find { |entity| entity[:name] == target_type } ||
34
- value_objects_by_name[target_type]
35
- end
36
-
37
- # @param aggregate [Hash] one aggregate of the IR
38
- # @param value_objects_by_name [Hash{String => Hash}] the value objects in scope
39
- # @return [void]
40
- def self.mark_aggregate(aggregate, value_objects_by_name)
41
- aggregate[:attributes].each do |target_attr|
42
- target = element(aggregate, target_attr[:type], value_objects_by_name)
43
- next unless target
44
-
45
- aggregate[:commands].each do |command|
46
- appends = command[:mutations].select { |m| append_to?(m, target_attr) }
47
- appends.each { |mutation| mark_fields(mutation, command, target) }
48
- end
49
- end
50
- end
51
-
52
- def self.append_to?(mutation, target_attr)
53
- mutation[:op].to_s == "append" && mutation[:target].to_s == target_attr[:name].to_s
54
- end
55
- private_class_method :append_to?
56
-
57
- # A Symbol is an argument name, and an optional one is what this pass wants; anything
58
- # else is a literal.
59
- def self.mark_fields(mutation, command, target)
60
- mutation[:fields].each do |field_name, source|
61
- parsed = Hecks::Bluebook::Assembly::Marks.read(source)
62
- next unless parsed.is_a?(Symbol)
63
-
64
- source_attr = command[:attributes].find { |attr| attr[:name].to_s == parsed.to_s }
65
- next unless source_attr && source_attr[:optional]
66
-
67
- field_attr = target[:attributes].find { |attr| attr[:name].to_s == field_name.to_s }
68
- field_attr[:optional] = true if field_attr
69
- end
70
- end
71
- private_class_method :mark_fields
72
-
73
- def self.by_name(value_objects)
74
- value_objects.to_h { |value_object| [value_object[:name], value_object] }
75
- end
76
- private_class_method :by_name
77
- end
78
- end
79
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Hecks
4
- module RustBuild
5
- # Rust string literals. Ruby's `String#inspect` escapes for Ruby's own read-back (a
6
- # brace-less `\uXXXX`, for one), which is not valid Rust syntax.
7
- module RustLiteral
8
- # Escapes only what Rust's string-literal grammar needs: backslash, double quote and
9
- # control characters.
10
- #
11
- # @param text [#to_s] the text to embed
12
- # @return [String] the quoted literal
13
- def self.string(text)
14
- escaped = text.to_s.each_char.map { |char| escape(char) }.join
15
- "\"#{escaped}\""
16
- end
17
-
18
- def self.escape(char)
19
- case char
20
- when "\\" then "\\\\"
21
- when "\"" then "\\\""
22
- when "\n" then "\\n"
23
- when "\r" then "\\r"
24
- when "\t" then "\\t"
25
- else
26
- code = char.ord
27
- code < 0x20 || code == 0x7F ? format("\\u{%x}", code) : char
28
- end
29
- end
30
- private_class_method :escape
31
- end
32
- end
33
- end
@@ -1,127 +0,0 @@
1
- # Each bin/ script and exe/hecks route, and the hecks 3.0 form that replaces it: the verb spelling the
2
- # 3.0 launcher answers to (ADR 0080, section 7), with QualityControl verbs after `hecks quality_control`.
3
- ---
4
- argument_gate_matrix: hecks conformance_run.argument_gate_matrix [--confirm] (writes only with --confirm)
5
- backfill_era_projections: hecks era.backfill_projections <domain>
6
- behaviors: hecks operation.run_behaviors subject=<path>
7
- bench: hecks fuzz_run.bench [domains=pizzas,banking] [targets=] [iterations=1000] [warmup=200]
8
- [runs=3] [rust_binary=] [format=markdown] [output=]
9
- canonicalise: hecks style_run.canonicalise <file.json>
10
- check_engine_agreement: hecks conformance_run.check_engine_agreement
11
- check_era: hecks host.check_era <url> expected=era-file [timeout=]
12
- codemod_hoist_local_givens: hecks codemod_run.hoist_local_givens --confirm
13
- codemod_implicit_append_fields: hecks codemod_run.drop_implicit_append_fields --confirm
14
- compact: hecks era.compact <domain> [aggregates=A,B] --confirm
15
- console: hecks console [subject=<domain>]
16
- corpus: hecks corpus_run.rust_domains; hecks corpus_run.regen_order; hecks corpus_run.corpus_rust_coverage
17
- deploy_template_diff: hecks deploy template_comparison.diff before=a.yaml after=b.yaml [--json] [--strict]
18
- doc_coverage: hecks conformance_run.measure_doc_coverage
19
- docs: hecks docs [domain-path] [aggregate]
20
- evolve: hecks language_run.word_status; hecks language_run.propose <word> context=X [body=none] [inner=] [opens=]
21
- [fills=]; hecks language_run.admit|deprecate|retire <word> context=X; hecks language_run.rename <word> context=X
22
- new_name=Y; hecks language_run.propose_argument <word> context=X kind=K [required=false] [at=N]
23
- [named=] [fills=] [pairs_shape=]; hecks language_run.admit_argument|deprecate_argument|retire_argument
24
- <word> context=X [at=N] [named=]
25
- expression_projection: hecks language_run.project_expression_tables [--stdout]
26
- follow: hecks operation.follow <domain> [aggregate=Name] [since=N] [interval=0.5] [wait=N] [--from-now] [--stream]
27
- fuzz: hecks fuzz_run.fuzz [domain] [seeds=20] [steps=30] [workers=] [adapter=memory]
28
- generate: hecks fuzz_run.generate_sequence <domain> [seed=1] [steps=30] [adversarial=0.0]
29
- hecks_mcp_door: hecks mcp [--stdio]
30
- hecks_query_ir_mcp: hecks corpus_run.serve_query_ir_mcp
31
- heki_compact: hecks era.compact_heki <domain> [aggregates=A,B] --confirm
32
- history: hecks introspection.history <domain>
33
- ir: hecks ir <domain> [--translations] | hecks ir --meta
34
- lint_deploy_recipes: hecks deploy makefile_check.lint [makefiles=a,b]
35
- merge_tail: hecks era.merge_tail <domain> winners=id:old,id:new --confirm
36
- model_check: hecks model_check [--strict] [--profile client] [<domain> …]
37
- narrate: hecks narrate [domain-path] [aggregate]
38
- pattern-cases: hecks test_suite_run.record_pattern_cases
39
- present: hecks corpus_run.present [port=4567]
40
- project: hecks operation.refresh_projections subject=<domain>
41
- project_bootstrap_table: hecks language_run.project_bootstrap_table
42
- project_cli: hecks project_cli [domain-path …]
43
- project_deploy: hecks deploy recipe.project <domain> [tenant=] [schema=] [out=] [environment=]
44
- project_diagrams: hecks project_diagrams <domain-path> <ChapterName>
45
- project_field_hints: hecks language_run.project_field_hints
46
- project_glossary: hecks introspection.glossary <domain> chapter=Name
47
- project_kernel_capabilities: hecks kernel_run.project_kernel_capabilities
48
- project_model: hecks language_run.project_model
49
- project_oidc: hecks deploy oidc_manifest.project_oidc [domains=a,b]
50
- project_parser_table: hecks language_run.project_parser_table
51
- project_refusal_wording: hecks language_run.project_refusal_wording
52
- project_reserved_names: hecks language_run.project_reserved_names
53
- project_rust: hecks build.project_rust <domain>
54
- project_rust_vocabulary: hecks language_run.project_rust_vocabulary
55
- project_tenant: hecks deploy tenant.provision <domain_dir> slug=s domain= realm= schema=
56
- database= [adapter=PostgresEra]; hecks deploy tenant.reprovision <tenant> directory= database=
57
- [adapter=PostgresEra]
58
- project_vocabulary: hecks language_run.project_vocabulary
59
- project_wasm: hecks build.build_wasm <domain>
60
- project_wasm_browser: hecks build.build_browser_wasm <domain>
61
- qa_concurrency_racer: not user-facing; ProcessPool starts it as hecks quality_control
62
- sweep.race domain= database= schema= verb= step_arguments=
63
- qa_discover_external_domains: hecks quality_control target.discover_external_domains [arguments="--projects-dir
64
- ~/Projects --max-depth 3 --known-path PATH …"]
65
- qa_domain_novelty: hecks quality_control target.judge_novelty <domain> [arguments="--against
66
- PATH …"]
67
- qa_generated_domains: hecks quality_control target.check_generated_domains [arguments="--domains
68
- 3 --start N --forms a,b --seeds 5 --steps 25 --adversarial 0.3 --rust --shrink-budget
69
- 200 --domain-shrink-budget 40 --promote dir --name N --from-dials --blueprint F
70
- --source F"]
71
- qa_log_bug: hecks quality_control bug.log sweep=<sweep id> reference= sequence=N title=
72
- demonstration= symptom= expectation= submitter= [tags=a,b] [name=] [reproduced=yes|no]
73
- qa_mine_combinations: hecks quality_control target.mine_combinations [arguments="--candidates
74
- 3 --rust --seeds 5 --steps 25 --adversarial 0.3 --repair-rounds 1 --agent CMD --from
75
- DIR --against PATH --brief"]
76
- qa_open_pr: hecks quality_control patch.open bug=BUG#n number=N url=… branch=… commit=SHA
77
- title=… [now=]; hecks quality_control improvement.open [angle=ANGLE-n] number=N
78
- url=… branch=… title=… [now=]
79
- qa_postgres_migrate: hecks quality_control sweep.migrate_ledger_from_heki domain=<domain_dir>
80
- data=<heki_dir> [arguments="[aggregate …] --force"] (a dry run without --force)
81
- qa_postgres_role: hecks quality_control sweep.create_ledger_role <database> [role=hecks_qa]
82
- qa_pr_check: hecks quality_control clearance.check_pull_requests
83
- qa_seed_angles: hecks quality_control angle.seed
84
- qa_seed_targets: hecks quality_control target.seed
85
- qa_sweep: hecks quality_control query sweep.run [target=] [arguments="--all --seeds N --steps
86
- N --adversarial F --role-draw F --dry-run F --self-consistency BOOL --modes a,b
87
- --persistence-parity --no-parity"]; hecks quality_control target.release <target> [now=]
88
- [next_streak=] [capabilities=] [yield_score=]
89
- qa_tick: hecks quality_control sweep.tick
90
- query_ir: hecks corpus_run.ir_constructs [names=a,b]; hecks corpus_run.ir_duplicates [domains=a,b] [--meta];
91
- hecks corpus_run.ir_impact name=N field=F
92
- reattest_era: hecks era.reattest <domain> era=N --confirm
93
- reference: hecks language_run.project_reference
94
- refresh_rspec_runtime_baseline: hecks test_suite_run.refresh_runtime_baseline [workers=] [from_run=ID]
95
- regen_codegen_domains: hecks regeneration_run.regenerate_corpus [--check]
96
- regenerate_persistence_legacy_fixtures: hecks test_suite_run.regenerate_legacy_fixtures --confirm
97
- release: hecks publishing_run.publish [--gem-only] [--npm-only] [--npm-local] [--no-wait] --confirm
98
- (without --confirm, the old --dry-run)
99
- release_gem: hecks publishing_run.publish_gem --confirm
100
- rspec_io_parallel_files: hecks test_suite_run.list_io_parallel_specs exclude=REGEX [tags=] [check=file];
101
- hecks test_suite_run.write_io_parallel_spec_list exclude=REGEX [tags=] write=file --confirm
102
- rspec_shard_files: hecks test_suite_run.shard_specs group=1 groups=N [runtime_log=]
103
- run: hecks run [domain] <verb [name=value …] | script.json | - | '{"steps":[…]}'>
104
- rust_conformance: hecks build.check_conformance <domain> script=steps.json [artifact=native]
105
- rust_conformance_fuzz: hecks build.fuzz_conformance <domain> artifact=native [seeds=10]
106
- [steps=25]
107
- rust_coverage: hecks build.rust_coverage <module> [codegen=ruby]; hecks build.check_coverage_allowlist
108
- rust_kernel_coverage: hecks kernel_run.measure_kernel_coverage
109
- scaffold_translation: hecks era.scaffold_translation <domain>
110
- seed_semantics_corpus: hecks test_suite_run.seed_semantics_corpus [fixture=name]
111
- shape: hecks introspection.shape <domain>
112
- smoke_http: hecks operation.smoke_http path=/p [url=] [header=] [scheme=timestamped]
113
- [payload=] [payload_file=] [health_path=] [state_path=]
114
- smoke_test: hecks smoke_test [domain]
115
- spec_example: hecks test_suite_run.run_spec_example file=path example=text
116
- standardize_comments: hecks style_run.report_comments paths=a,b [only=] [--json] [top=20]; hecks
117
- style_run.check_comments paths=…; hecks style_run.fix_comments paths=… --confirm; hecks style_run.write_comment_baseline
118
- --confirm; hecks style_run.check_comments_unchanged ref=REF
119
- standardize_comments_rust: hecks style_run.report_rust_comments paths=a,b [only=] [--json] [top=N];
120
- hecks style_run.check_rust_comments paths=… [only=]; hecks style_run.fix_rust_comments paths=… [only=]
121
- --confirm
122
- statements: hecks introspection.statements <domain> chapter=Name
123
- stores: hecks stores <domain>
124
- stress_concurrency_specs: hecks test_suite_run.stress_concurrency [runs=] [parallel=] [seed_start=]
125
- translation_audit: hecks era.audit_translation <domain>; hecks era.approve_translation <domain>
126
- [snapshot=] [host_version=] [rehearsal=pass|fail] [rehearsed_at=] --confirm
127
- vendor_bluebook: hecks package.vendor <package[@version]> [from=path] [root=path]
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Hecks
6
- # The table of what each retired `bin/` script became in hecks 3.0.0 (ADR 0080): a command on
7
- # the Hecks domain, run through `exe/hecks`. `docs/tools.md` is pinned to it.
8
- module ThreeZero
9
- # Each retired script's name mapped to the 3.0 form that replaced it.
10
- FORMS = YAML.safe_load_file(File.join(__dir__, "three_zero/forms.yml")).freeze
11
- end
12
- end
@@ -1,421 +0,0 @@
1
- //! A minimal, dependency-free, mutable JSON value with a reader and a pretty writer.
2
- //! The writer matches `JSON.pretty_generate` byte-for-byte, including its empty-array quirk.
3
-
4
- use std::fmt::Write as _;
5
-
6
- #[derive(Debug, Clone, PartialEq)]
7
- pub enum Json {
8
- Null,
9
- Bool(bool),
10
- /// Raw literal text as scanned, so numbers round-trip unchanged.
11
- Number(String),
12
- String(String),
13
- Array(Vec<Json>),
14
- /// Insertion-ordered pairs; byte-exact re-emission depends on key order.
15
- Object(Vec<(String, Json)>),
16
- }
17
-
18
- impl Json {
19
- pub fn parse(text: &str) -> Result<Json, String> {
20
- let mut p = Parser { bytes: text.as_bytes(), pos: 0 };
21
- p.skip_ws();
22
- let value = p.parse_value()?;
23
- p.skip_ws();
24
- if p.pos != p.bytes.len() {
25
- return Err(format!("trailing content at byte {}", p.pos));
26
- }
27
- Ok(value)
28
- }
29
-
30
- /// Looks up `key`, treating a `null` value as missing.
31
- pub fn get(&self, key: &str) -> Option<&Json> {
32
- match self {
33
- Json::Object(pairs) => pairs.iter().find(|(k, v)| k == key && !matches!(v, Json::Null)).map(|(_, v)| v),
34
- _ => None,
35
- }
36
- }
37
-
38
- pub fn get_mut(&mut self, key: &str) -> Option<&mut Json> {
39
- match self {
40
- Json::Object(pairs) => pairs.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
41
- _ => None,
42
- }
43
- }
44
-
45
- pub fn as_str(&self) -> Option<&str> {
46
- match self {
47
- Json::String(s) => Some(s.as_str()),
48
- _ => None,
49
- }
50
- }
51
-
52
- pub fn as_array(&self) -> Option<&[Json]> {
53
- match self {
54
- Json::Array(items) => Some(items.as_slice()),
55
- _ => None,
56
- }
57
- }
58
-
59
- pub fn as_array_mut(&mut self) -> Option<&mut Vec<Json>> {
60
- match self {
61
- Json::Array(items) => Some(items),
62
- _ => None,
63
- }
64
- }
65
-
66
- pub fn as_object(&self) -> Option<&[(String, Json)]> {
67
- match self {
68
- Json::Object(pairs) => Some(pairs.as_slice()),
69
- _ => None,
70
- }
71
- }
72
-
73
- /// Ruby truthiness: everything except `false` and `null` is true.
74
- pub fn as_bool(&self) -> bool {
75
- !matches!(self, Json::Bool(false) | Json::Null)
76
- }
77
-
78
- /// Iterates an array field; empty for any other value.
79
- pub fn each(&self) -> &[Json] {
80
- self.as_array().unwrap_or(&[])
81
- }
82
-
83
- /// Sets `key` to `value`, the only mutation the optional-marking pass performs.
84
- /// Overwrites the existing pair, or appends one if absent.
85
- pub fn set_bool(&mut self, key: &str, value: bool) {
86
- if let Json::Object(pairs) = self {
87
- if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
88
- *existing = Json::Bool(value);
89
- return;
90
- }
91
- pairs.push((key.to_string(), Json::Bool(value)));
92
- }
93
- }
94
-
95
- /// Sets `key` to `value`, overwriting in place or else appending last.
96
- pub fn set(&mut self, key: &str, value: Json) {
97
- if let Json::Object(pairs) = self {
98
- if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
99
- *existing = value;
100
- return;
101
- }
102
- pairs.push((key.to_string(), value));
103
- }
104
- }
105
- }
106
-
107
- struct Parser<'a> {
108
- bytes: &'a [u8],
109
- pos: usize,
110
- }
111
-
112
- impl<'a> Parser<'a> {
113
- fn peek(&self) -> Option<u8> {
114
- self.bytes.get(self.pos).copied()
115
- }
116
-
117
- fn skip_ws(&mut self) {
118
- while let Some(b) = self.peek() {
119
- if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
120
- self.pos += 1;
121
- } else {
122
- break;
123
- }
124
- }
125
- }
126
-
127
- fn expect(&mut self, b: u8) -> Result<(), String> {
128
- if self.peek() == Some(b) {
129
- self.pos += 1;
130
- Ok(())
131
- } else {
132
- Err(format!("expected {:?} at byte {}, found {:?}", b as char, self.pos, self.peek().map(|c| c as char)))
133
- }
134
- }
135
-
136
- fn parse_value(&mut self) -> Result<Json, String> {
137
- self.skip_ws();
138
- match self.peek() {
139
- Some(b'{') => self.parse_object(),
140
- Some(b'[') => self.parse_array(),
141
- Some(b'"') => Ok(Json::String(self.parse_string()?)),
142
- Some(b't') => {
143
- self.expect_literal("true")?;
144
- Ok(Json::Bool(true))
145
- }
146
- Some(b'f') => {
147
- self.expect_literal("false")?;
148
- Ok(Json::Bool(false))
149
- }
150
- Some(b'n') => {
151
- self.expect_literal("null")?;
152
- Ok(Json::Null)
153
- }
154
- Some(c) if c == b'-' || c.is_ascii_digit() => self.parse_number(),
155
- other => Err(format!("unexpected byte {:?} at {}", other.map(|c| c as char), self.pos)),
156
- }
157
- }
158
-
159
- fn expect_literal(&mut self, lit: &str) -> Result<(), String> {
160
- let end = self.pos + lit.len();
161
- if end <= self.bytes.len() && &self.bytes[self.pos..end] == lit.as_bytes() {
162
- self.pos = end;
163
- Ok(())
164
- } else {
165
- Err(format!("expected literal {lit:?} at byte {}", self.pos))
166
- }
167
- }
168
-
169
- fn parse_object(&mut self) -> Result<Json, String> {
170
- self.expect(b'{')?;
171
- let mut pairs = Vec::new();
172
- self.skip_ws();
173
- if self.peek() == Some(b'}') {
174
- self.pos += 1;
175
- return Ok(Json::Object(pairs));
176
- }
177
- loop {
178
- self.skip_ws();
179
- let key = self.parse_string()?;
180
- self.skip_ws();
181
- self.expect(b':')?;
182
- let value = self.parse_value()?;
183
- pairs.push((key, value));
184
- self.skip_ws();
185
- match self.peek() {
186
- Some(b',') => {
187
- self.pos += 1;
188
- }
189
- Some(b'}') => {
190
- self.pos += 1;
191
- break;
192
- }
193
- other => return Err(format!("expected ',' or '}}' at byte {}, found {:?}", self.pos, other.map(|c| c as char))),
194
- }
195
- }
196
- Ok(Json::Object(pairs))
197
- }
198
-
199
- fn parse_array(&mut self) -> Result<Json, String> {
200
- self.expect(b'[')?;
201
- let mut items = Vec::new();
202
- self.skip_ws();
203
- if self.peek() == Some(b']') {
204
- self.pos += 1;
205
- return Ok(Json::Array(items));
206
- }
207
- loop {
208
- let value = self.parse_value()?;
209
- items.push(value);
210
- self.skip_ws();
211
- match self.peek() {
212
- Some(b',') => {
213
- self.pos += 1;
214
- }
215
- Some(b']') => {
216
- self.pos += 1;
217
- break;
218
- }
219
- other => return Err(format!("expected ',' or ']' at byte {}, found {:?}", self.pos, other.map(|c| c as char))),
220
- }
221
- }
222
- Ok(Json::Array(items))
223
- }
224
-
225
- fn parse_string(&mut self) -> Result<String, String> {
226
- self.expect(b'"')?;
227
- let mut out = String::new();
228
- loop {
229
- match self.peek() {
230
- None => return Err("unterminated string".to_string()),
231
- Some(b'"') => {
232
- self.pos += 1;
233
- break;
234
- }
235
- Some(b'\\') => {
236
- self.pos += 1;
237
- match self.peek() {
238
- Some(b'"') => {
239
- out.push('"');
240
- self.pos += 1;
241
- }
242
- Some(b'\\') => {
243
- out.push('\\');
244
- self.pos += 1;
245
- }
246
- Some(b'/') => {
247
- out.push('/');
248
- self.pos += 1;
249
- }
250
- Some(b'n') => {
251
- out.push('\n');
252
- self.pos += 1;
253
- }
254
- Some(b't') => {
255
- out.push('\t');
256
- self.pos += 1;
257
- }
258
- Some(b'r') => {
259
- out.push('\r');
260
- self.pos += 1;
261
- }
262
- Some(b'b') => {
263
- out.push('\u{8}');
264
- self.pos += 1;
265
- }
266
- Some(b'f') => {
267
- out.push('\u{c}');
268
- self.pos += 1;
269
- }
270
- Some(b'u') => {
271
- self.pos += 1;
272
- let hex = std::str::from_utf8(&self.bytes[self.pos..self.pos + 4]).map_err(|e| e.to_string())?;
273
- let code = u32::from_str_radix(hex, 16).map_err(|e| e.to_string())?;
274
- self.pos += 4;
275
- if let Some(c) = char::from_u32(code) {
276
- out.push(c);
277
- }
278
- }
279
- other => return Err(format!("bad escape {:?}", other.map(|c| c as char))),
280
- }
281
- }
282
- Some(_) => {
283
- // One UTF-8 char at a time so multi-byte text survives.
284
- let start = self.pos;
285
- let rest = std::str::from_utf8(&self.bytes[start..]).map_err(|e| e.to_string())?;
286
- let ch = rest.chars().next().ok_or("empty remainder")?;
287
- out.push(ch);
288
- self.pos += ch.len_utf8();
289
- }
290
- }
291
- }
292
- Ok(out)
293
- }
294
-
295
- // Keeps the raw literal text, so numbers round-trip unchanged.
296
- fn parse_number(&mut self) -> Result<Json, String> {
297
- let start = self.pos;
298
- if self.peek() == Some(b'-') {
299
- self.pos += 1;
300
- }
301
- while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
302
- self.pos += 1;
303
- }
304
- if self.peek() == Some(b'.') {
305
- self.pos += 1;
306
- while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
307
- self.pos += 1;
308
- }
309
- }
310
- if matches!(self.peek(), Some(b'e') | Some(b'E')) {
311
- self.pos += 1;
312
- if matches!(self.peek(), Some(b'+') | Some(b'-')) {
313
- self.pos += 1;
314
- }
315
- while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
316
- self.pos += 1;
317
- }
318
- }
319
- let text = std::str::from_utf8(&self.bytes[start..self.pos]).map_err(|e| e.to_string())?;
320
- Ok(Json::Number(text.to_string()))
321
- }
322
- }
323
-
324
- /// Renders `value` exactly as `JSON.pretty_generate` does for the pinned json gem.
325
- pub fn write(value: &Json) -> String {
326
- let mut out = String::new();
327
- write_value(&mut out, value, 0);
328
- out
329
- }
330
-
331
- fn indent(out: &mut String, depth: usize) {
332
- for _ in 0..depth {
333
- out.push_str(" ");
334
- }
335
- }
336
-
337
- fn write_value(out: &mut String, value: &Json, depth: usize) {
338
- match value {
339
- Json::Null => out.push_str("null"),
340
- Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
341
- Json::Number(n) => out.push_str(n),
342
- Json::String(s) => write_string(out, s),
343
- Json::Array(items) => write_array(out, items, depth),
344
- Json::Object(pairs) => write_object(out, pairs, depth),
345
- }
346
- }
347
-
348
- fn write_string(out: &mut String, text: &str) {
349
- out.push('"');
350
- for ch in text.chars() {
351
- match ch {
352
- '"' => out.push_str("\\\""),
353
- '\\' => out.push_str("\\\\"),
354
- '\n' => out.push_str("\\n"),
355
- '\t' => out.push_str("\\t"),
356
- '\r' => out.push_str("\\r"),
357
- c if (c as u32) < 0x20 => {
358
- let _ = write!(out, "\\u{:04x}", c as u32);
359
- }
360
- c => out.push(c),
361
- }
362
- }
363
- out.push('"');
364
- }
365
-
366
- fn write_array(out: &mut String, items: &[Json], depth: usize) {
367
- if items.is_empty() {
368
- // Empty arrays render with a blank line, matching the pinned json gem.
369
- out.push_str("[\n\n");
370
- indent(out, depth);
371
- out.push(']');
372
- return;
373
- }
374
- out.push_str("[\n");
375
- for (idx, item) in items.iter().enumerate() {
376
- indent(out, depth + 1);
377
- write_value(out, item, depth + 1);
378
- if idx + 1 < items.len() {
379
- out.push(',');
380
- }
381
- out.push('\n');
382
- }
383
- indent(out, depth);
384
- out.push(']');
385
- }
386
-
387
- fn write_object(out: &mut String, pairs: &[(String, Json)], depth: usize) {
388
- // An empty object needs no special case: the loop runs zero times.
389
- out.push_str("{\n");
390
- for (idx, (key, val)) in pairs.iter().enumerate() {
391
- indent(out, depth + 1);
392
- write_string(out, key);
393
- out.push_str(": ");
394
- write_value(out, val, depth + 1);
395
- if idx + 1 < pairs.len() {
396
- out.push(',');
397
- }
398
- out.push('\n');
399
- }
400
- indent(out, depth);
401
- out.push('}');
402
- }
403
-
404
- #[cfg(test)]
405
- mod tests {
406
- use super::*;
407
-
408
- #[test]
409
- fn round_trips_an_empty_object() {
410
- let text = "{\n}";
411
- let value = Json::parse(text).expect("parses");
412
- assert_eq!(write(&value), text);
413
- }
414
-
415
- #[test]
416
- fn set_bool_overwrites_an_existing_key() {
417
- let mut value = Json::parse("{\n \"optional\": false\n}").expect("parses");
418
- value.set_bool("optional", true);
419
- assert_eq!(write(&value), "{\n \"optional\": true\n}");
420
- }
421
- }