tstring-bindings 0.1.1__tar.gz → 0.2.1__tar.gz

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 (40) hide show
  1. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/Cargo.lock +79 -34
  2. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/Cargo.toml +1 -1
  3. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/PKG-INFO +1 -1
  4. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/json-tstring-rs/Cargo.toml +1 -1
  5. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/json-tstring-rs/src/lib.rs +29 -4
  6. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/json-tstring-rs/tests/parser.rs +13 -0
  7. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/pyproject.toml +1 -1
  8. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python/tstring_bindings/__init__.py +17 -13
  9. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python/tstring_bindings/__init__.pyi +11 -7
  10. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python/tstring_bindings/tstring_bindings.pyi +13 -10
  11. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python-bindings/Cargo.toml +5 -5
  12. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python-bindings/src/lib.rs +4 -4
  13. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/toml-tstring-rs/Cargo.toml +1 -1
  14. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/toml-tstring-rs/src/lib.rs +104 -54
  15. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/toml-tstring-rs/tests/parser.rs +20 -1
  16. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-pyo3-bindings/Cargo.toml +5 -5
  17. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/Cargo.toml +1 -1
  18. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/src/lib.rs +95 -2
  19. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/tests/parser.rs +68 -1
  20. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/README.md +0 -0
  21. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/json-tstring-rs/README.md +0 -0
  22. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/json-tstring-rs/tests/conformance.rs +0 -0
  23. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python/tstring_bindings/_profiles.py +0 -0
  24. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python/tstring_bindings/_types.py +0 -0
  25. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python/tstring_bindings/py.typed +0 -0
  26. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/python-bindings/README.md +0 -0
  27. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/toml-tstring-rs/README.md +0 -0
  28. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/toml-tstring-rs/tests/conformance.rs +0 -0
  29. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-core-rs/Cargo.toml +0 -0
  30. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-core-rs/README.md +0 -0
  31. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-core-rs/src/lib.rs +0 -0
  32. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-pyo3-bindings/benches/render_paths.rs +0 -0
  33. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-pyo3-bindings/src/json.rs +0 -0
  34. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-pyo3-bindings/src/lib.rs +0 -0
  35. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-pyo3-bindings/src/toml.rs +0 -0
  36. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/tstring-pyo3-bindings/src/yaml.rs +0 -0
  37. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/README.md +0 -0
  38. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/test-support/renderer_layout.rs +0 -0
  39. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/tests/conformance.rs +0 -0
  40. {tstring_bindings-0.1.1 → tstring_bindings-0.2.1}/yaml-tstring-rs/tests/normalized.rs +0 -0
@@ -11,6 +11,15 @@ dependencies = [
11
11
  "memchr",
12
12
  ]
13
13
 
14
+ [[package]]
15
+ name = "alloca"
16
+ version = "0.4.0"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
19
+ dependencies = [
20
+ "cc",
21
+ ]
22
+
14
23
  [[package]]
15
24
  name = "anes"
16
25
  version = "0.1.6"
@@ -47,6 +56,16 @@ version = "0.3.0"
47
56
  source = "registry+https://github.com/rust-lang/crates.io-index"
48
57
  checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
49
58
 
59
+ [[package]]
60
+ name = "cc"
61
+ version = "1.2.57"
62
+ source = "registry+https://github.com/rust-lang/crates.io-index"
63
+ checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
64
+ dependencies = [
65
+ "find-msvc-tools",
66
+ "shlex",
67
+ ]
68
+
50
69
  [[package]]
51
70
  name = "cfg-if"
52
71
  version = "1.0.4"
@@ -107,25 +126,24 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
107
126
 
108
127
  [[package]]
109
128
  name = "criterion"
110
- version = "0.5.1"
129
+ version = "0.8.2"
111
130
  source = "registry+https://github.com/rust-lang/crates.io-index"
112
- checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
131
+ checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
113
132
  dependencies = [
133
+ "alloca",
114
134
  "anes",
115
135
  "cast",
116
136
  "ciborium",
117
137
  "clap",
118
138
  "criterion-plot",
119
- "is-terminal",
120
139
  "itertools",
121
140
  "num-traits",
122
- "once_cell",
123
141
  "oorandom",
142
+ "page_size",
124
143
  "plotters",
125
144
  "rayon",
126
145
  "regex",
127
146
  "serde",
128
- "serde_derive",
129
147
  "serde_json",
130
148
  "tinytemplate",
131
149
  "walkdir",
@@ -133,9 +151,9 @@ dependencies = [
133
151
 
134
152
  [[package]]
135
153
  name = "criterion-plot"
136
- version = "0.5.0"
154
+ version = "0.8.2"
137
155
  source = "registry+https://github.com/rust-lang/crates.io-index"
138
- checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
156
+ checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
139
157
  dependencies = [
140
158
  "cast",
141
159
  "itertools",
@@ -193,6 +211,12 @@ version = "1.0.2"
193
211
  source = "registry+https://github.com/rust-lang/crates.io-index"
194
212
  checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
195
213
 
214
+ [[package]]
215
+ name = "find-msvc-tools"
216
+ version = "0.1.9"
217
+ source = "registry+https://github.com/rust-lang/crates.io-index"
218
+ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
219
+
196
220
  [[package]]
197
221
  name = "foldhash"
198
222
  version = "0.1.5"
@@ -240,12 +264,6 @@ version = "0.5.0"
240
264
  source = "registry+https://github.com/rust-lang/crates.io-index"
241
265
  checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
242
266
 
243
- [[package]]
244
- name = "hermit-abi"
245
- version = "0.5.2"
246
- source = "registry+https://github.com/rust-lang/crates.io-index"
247
- checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
248
-
249
267
  [[package]]
250
268
  name = "indexmap"
251
269
  version = "2.13.0"
@@ -265,22 +283,11 @@ dependencies = [
265
283
  "rustversion",
266
284
  ]
267
285
 
268
- [[package]]
269
- name = "is-terminal"
270
- version = "0.4.17"
271
- source = "registry+https://github.com/rust-lang/crates.io-index"
272
- checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
273
- dependencies = [
274
- "hermit-abi",
275
- "libc",
276
- "windows-sys",
277
- ]
278
-
279
286
  [[package]]
280
287
  name = "itertools"
281
- version = "0.10.5"
288
+ version = "0.13.0"
282
289
  source = "registry+https://github.com/rust-lang/crates.io-index"
283
- checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
290
+ checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
284
291
  dependencies = [
285
292
  "either",
286
293
  ]
@@ -371,6 +378,16 @@ dependencies = [
371
378
  "num-traits",
372
379
  ]
373
380
 
381
+ [[package]]
382
+ name = "page_size"
383
+ version = "0.6.0"
384
+ source = "registry+https://github.com/rust-lang/crates.io-index"
385
+ checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
386
+ dependencies = [
387
+ "libc",
388
+ "winapi",
389
+ ]
390
+
374
391
  [[package]]
375
392
  name = "plotters"
376
393
  version = "0.3.7"
@@ -634,6 +651,12 @@ dependencies = [
634
651
  "serde_core",
635
652
  ]
636
653
 
654
+ [[package]]
655
+ name = "shlex"
656
+ version = "1.3.0"
657
+ source = "registry+https://github.com/rust-lang/crates.io-index"
658
+ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
659
+
637
660
  [[package]]
638
661
  name = "syn"
639
662
  version = "2.0.117"
@@ -702,7 +725,7 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
702
725
 
703
726
  [[package]]
704
727
  name = "tstring-backend-e2e-tests"
705
- version = "0.1.1"
728
+ version = "0.2.1"
706
729
  dependencies = [
707
730
  "tstring-json",
708
731
  "tstring-syntax",
@@ -712,7 +735,7 @@ dependencies = [
712
735
 
713
736
  [[package]]
714
737
  name = "tstring-bindings"
715
- version = "0.1.1"
738
+ version = "0.2.1"
716
739
  dependencies = [
717
740
  "pyo3",
718
741
  "pythonize",
@@ -728,7 +751,7 @@ dependencies = [
728
751
 
729
752
  [[package]]
730
753
  name = "tstring-json"
731
- version = "0.1.1"
754
+ version = "0.2.1"
732
755
  dependencies = [
733
756
  "serde_json",
734
757
  "toml",
@@ -737,7 +760,7 @@ dependencies = [
737
760
 
738
761
  [[package]]
739
762
  name = "tstring-pyo3-bindings"
740
- version = "0.1.1"
763
+ version = "0.2.1"
741
764
  dependencies = [
742
765
  "criterion",
743
766
  "pyo3",
@@ -753,14 +776,14 @@ dependencies = [
753
776
 
754
777
  [[package]]
755
778
  name = "tstring-syntax"
756
- version = "0.1.1"
779
+ version = "0.2.1"
757
780
  dependencies = [
758
781
  "num-bigint",
759
782
  ]
760
783
 
761
784
  [[package]]
762
785
  name = "tstring-toml"
763
- version = "0.1.1"
786
+ version = "0.2.1"
764
787
  dependencies = [
765
788
  "serde_json",
766
789
  "toml",
@@ -769,7 +792,7 @@ dependencies = [
769
792
 
770
793
  [[package]]
771
794
  name = "tstring-yaml"
772
- version = "0.1.1"
795
+ version = "0.2.1"
773
796
  dependencies = [
774
797
  "saphyr",
775
798
  "saphyr-parser",
@@ -780,7 +803,7 @@ dependencies = [
780
803
 
781
804
  [[package]]
782
805
  name = "tstring-yaml-pyo3-tests"
783
- version = "0.1.1"
806
+ version = "0.2.1"
784
807
  dependencies = [
785
808
  "pyo3",
786
809
  "saphyr",
@@ -865,6 +888,22 @@ dependencies = [
865
888
  "wasm-bindgen",
866
889
  ]
867
890
 
891
+ [[package]]
892
+ name = "winapi"
893
+ version = "0.3.9"
894
+ source = "registry+https://github.com/rust-lang/crates.io-index"
895
+ checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
896
+ dependencies = [
897
+ "winapi-i686-pc-windows-gnu",
898
+ "winapi-x86_64-pc-windows-gnu",
899
+ ]
900
+
901
+ [[package]]
902
+ name = "winapi-i686-pc-windows-gnu"
903
+ version = "0.4.0"
904
+ source = "registry+https://github.com/rust-lang/crates.io-index"
905
+ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
906
+
868
907
  [[package]]
869
908
  name = "winapi-util"
870
909
  version = "0.1.11"
@@ -874,6 +913,12 @@ dependencies = [
874
913
  "windows-sys",
875
914
  ]
876
915
 
916
+ [[package]]
917
+ name = "winapi-x86_64-pc-windows-gnu"
918
+ version = "0.4.0"
919
+ source = "registry+https://github.com/rust-lang/crates.io-index"
920
+ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
921
+
877
922
  [[package]]
878
923
  name = "windows-link"
879
924
  version = "0.2.1"
@@ -9,7 +9,7 @@ homepage = "https://github.com/koxudaxi/tstring-structured-data"
9
9
  license = "MIT"
10
10
  repository = "https://github.com/koxudaxi/tstring-structured-data"
11
11
  rust-version = "1.94.0"
12
- version = "0.1.1"
12
+ version = "0.2.1"
13
13
 
14
14
  [workspace.dependencies]
15
15
  num-bigint = "0.4.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tstring-bindings
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -18,7 +18,7 @@ test = false
18
18
 
19
19
  [dependencies]
20
20
  serde_json = { workspace = true }
21
- tstring-syntax = { version = "0.1.1", path = "../tstring-core-rs" }
21
+ tstring-syntax = { version = "0.2.1", path = "../tstring-core-rs" }
22
22
 
23
23
  [dev-dependencies]
24
24
  toml = { workspace = true }
@@ -600,11 +600,36 @@ pub fn parse_template(template: &TemplateInput) -> BackendResult<JsonDocumentNod
600
600
  parse_template_with_profile(template, JsonProfile::default())
601
601
  }
602
602
 
603
+ pub fn parse_validated_template_with_profile(
604
+ template: &TemplateInput,
605
+ profile: JsonProfile,
606
+ ) -> BackendResult<JsonDocumentNode> {
607
+ // JSON does not add format-specific post-parse validation yet. Keep the
608
+ // validated entry point aligned with the other backends so callers can rely
609
+ // on one API shape as backend-specific validation rules are introduced.
610
+ parse_template_with_profile(template, profile)
611
+ }
612
+
613
+ pub fn parse_validated_template(template: &TemplateInput) -> BackendResult<JsonDocumentNode> {
614
+ parse_validated_template_with_profile(template, JsonProfile::default())
615
+ }
616
+
617
+ pub fn validate_template_with_profile(
618
+ template: &TemplateInput,
619
+ profile: JsonProfile,
620
+ ) -> BackendResult<()> {
621
+ parse_validated_template_with_profile(template, profile).map(|_| ())
622
+ }
623
+
624
+ pub fn validate_template(template: &TemplateInput) -> BackendResult<()> {
625
+ validate_template_with_profile(template, JsonProfile::default())
626
+ }
627
+
603
628
  pub fn check_template_with_profile(
604
629
  template: &TemplateInput,
605
630
  profile: JsonProfile,
606
631
  ) -> BackendResult<()> {
607
- parse_template_with_profile(template, profile).map(|_| ())
632
+ validate_template_with_profile(template, profile)
608
633
  }
609
634
 
610
635
  pub fn check_template(template: &TemplateInput) -> BackendResult<()> {
@@ -615,7 +640,7 @@ pub fn format_template_with_profile(
615
640
  template: &TemplateInput,
616
641
  profile: JsonProfile,
617
642
  ) -> BackendResult<String> {
618
- let document = parse_template_with_profile(template, profile)?;
643
+ let document = parse_validated_template_with_profile(template, profile)?;
619
644
  format_json_value(template, &document.value)
620
645
  }
621
646
 
@@ -774,9 +799,9 @@ fn normalize_number(number: &serde_json::Number) -> BackendResult<NormalizedValu
774
799
 
775
800
  #[cfg(test)]
776
801
  mod tests {
777
- use super::{parse_template, JsonKeyValue, JsonStringPart, JsonValueNode};
802
+ use super::{JsonKeyValue, JsonStringPart, JsonValueNode, parse_template};
778
803
  use pyo3::prelude::*;
779
- use serde_json::{json, Map, Number, Value};
804
+ use serde_json::{Map, Number, Value, json};
780
805
  use tstring_pyo3_bindings::{extract_template, json::render_document};
781
806
  use tstring_syntax::{BackendError, BackendResult, ErrorKind};
782
807
 
@@ -1,5 +1,6 @@
1
1
  use tstring_json::{
2
2
  JsonKeyValue, JsonStringPart, JsonValueNode, check_template, format_template, parse_template,
3
+ parse_validated_template, validate_template,
3
4
  };
4
5
  use tstring_syntax::{TemplateInput, TemplateInterpolation, TemplateSegment};
5
6
 
@@ -68,6 +69,18 @@ fn checks_valid_json_templates() {
68
69
  check_template(&template).expect("expected check success");
69
70
  }
70
71
 
72
+ #[test]
73
+ fn validates_json_templates_with_supported_interpolations() {
74
+ let template = TemplateInput::from_segments(vec![
75
+ TemplateSegment::StaticText("{\"name\": ".to_owned()),
76
+ interpolation(0, "name"),
77
+ TemplateSegment::StaticText(", \"active\": true}".to_owned()),
78
+ ]);
79
+
80
+ validate_template(&template).expect("expected validate success");
81
+ parse_validated_template(&template).expect("expected validated parse success");
82
+ }
83
+
71
84
  #[test]
72
85
  fn formats_json_templates_with_raw_interpolations() {
73
86
  let template = TemplateInput::from_segments(vec![
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tstring-bindings"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "Native Python bindings for t-string structured data backends"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from string.templatelib import Template
4
- from typing import Protocol, cast
4
+ from typing import Annotated, Protocol, cast
5
5
 
6
6
  from . import tstring_bindings as _bindings
7
7
  from ._profiles import (
@@ -14,29 +14,33 @@ from ._profiles import (
14
14
  )
15
15
  from ._types import JsonValue, TomlValue, YamlValue
16
16
 
17
+ type JsonTemplate = Annotated[Template, "json"]
18
+ type TomlTemplate = Annotated[Template, "toml"]
19
+ type YamlTemplate = Annotated[Template, "yaml"]
20
+
17
21
 
18
22
  class _RenderJson(Protocol):
19
- def __call__(self, template: Template, profile: JsonProfile) -> JsonValue: ...
23
+ def __call__(self, template: JsonTemplate, profile: JsonProfile) -> JsonValue: ...
20
24
 
21
25
 
22
26
  class _RenderJsonText(Protocol):
23
- def __call__(self, template: Template, profile: JsonProfile) -> str: ...
27
+ def __call__(self, template: JsonTemplate, profile: JsonProfile) -> str: ...
24
28
 
25
29
 
26
30
  class _RenderToml(Protocol):
27
- def __call__(self, template: Template, profile: TomlProfile) -> TomlValue: ...
31
+ def __call__(self, template: TomlTemplate, profile: TomlProfile) -> TomlValue: ...
28
32
 
29
33
 
30
34
  class _RenderTomlText(Protocol):
31
- def __call__(self, template: Template, profile: TomlProfile) -> str: ...
35
+ def __call__(self, template: TomlTemplate, profile: TomlProfile) -> str: ...
32
36
 
33
37
 
34
38
  class _RenderYaml(Protocol):
35
- def __call__(self, template: Template, profile: YamlProfile) -> YamlValue: ...
39
+ def __call__(self, template: YamlTemplate, profile: YamlProfile) -> YamlValue: ...
36
40
 
37
41
 
38
42
  class _RenderYamlText(Protocol):
39
- def __call__(self, template: Template, profile: YamlProfile) -> str: ...
43
+ def __call__(self, template: YamlTemplate, profile: YamlProfile) -> str: ...
40
44
 
41
45
 
42
46
  class _BindingsContract(Protocol):
@@ -101,37 +105,37 @@ _render_yaml_text = _EXTENSION.render_yaml_text
101
105
 
102
106
 
103
107
  def render_json(
104
- template: Template, *, profile: JsonProfile | str | None = None
108
+ template: JsonTemplate, *, profile: JsonProfile | str | None = None
105
109
  ) -> JsonValue:
106
110
  return _render_json(template, resolve_json_profile(profile))
107
111
 
108
112
 
109
113
  def render_json_text(
110
- template: Template, *, profile: JsonProfile | str | None = None
114
+ template: JsonTemplate, *, profile: JsonProfile | str | None = None
111
115
  ) -> str:
112
116
  return _render_json_text(template, resolve_json_profile(profile))
113
117
 
114
118
 
115
119
  def render_toml(
116
- template: Template, *, profile: TomlProfile | str | None = None
120
+ template: TomlTemplate, *, profile: TomlProfile | str | None = None
117
121
  ) -> TomlValue:
118
122
  return _render_toml(template, resolve_toml_profile(profile))
119
123
 
120
124
 
121
125
  def render_toml_text(
122
- template: Template, *, profile: TomlProfile | str | None = None
126
+ template: TomlTemplate, *, profile: TomlProfile | str | None = None
123
127
  ) -> str:
124
128
  return _render_toml_text(template, resolve_toml_profile(profile))
125
129
 
126
130
 
127
131
  def render_yaml(
128
- template: Template, *, profile: YamlProfile | str | None = None
132
+ template: YamlTemplate, *, profile: YamlProfile | str | None = None
129
133
  ) -> YamlValue:
130
134
  return _render_yaml(template, resolve_yaml_profile(profile))
131
135
 
132
136
 
133
137
  def render_yaml_text(
134
- template: Template, *, profile: YamlProfile | str | None = None
138
+ template: YamlTemplate, *, profile: YamlProfile | str | None = None
135
139
  ) -> str:
136
140
  return _render_yaml_text(template, resolve_yaml_profile(profile))
137
141
 
@@ -1,10 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from string.templatelib import Template
4
- from typing import Any
4
+ from typing import Annotated, Any
5
5
 
6
6
  from ._profiles import JsonProfile, TomlProfile, YamlProfile
7
7
 
8
+ type JsonTemplate = Annotated[Template, "json"]
9
+ type TomlTemplate = Annotated[Template, "toml"]
10
+ type YamlTemplate = Annotated[Template, "yaml"]
11
+
8
12
  type ExceptionSpan = tuple[tuple[int, int], tuple[int, int]]
9
13
  type ExceptionDiagnostic = dict[str, object]
10
14
 
@@ -19,20 +23,20 @@ class TemplateSemanticError(TemplateError): ...
19
23
  class UnrepresentableValueError(TemplateSemanticError): ...
20
24
 
21
25
  def render_json(
22
- template: Template, *, profile: JsonProfile | str | None = ...
26
+ template: JsonTemplate, *, profile: JsonProfile | str | None = ...
23
27
  ) -> Any: ...
24
28
  def render_json_text(
25
- template: Template, *, profile: JsonProfile | str | None = ...
29
+ template: JsonTemplate, *, profile: JsonProfile | str | None = ...
26
30
  ) -> str: ...
27
31
  def render_toml(
28
- template: Template, *, profile: TomlProfile | str | None = ...
32
+ template: TomlTemplate, *, profile: TomlProfile | str | None = ...
29
33
  ) -> Any: ...
30
34
  def render_toml_text(
31
- template: Template, *, profile: TomlProfile | str | None = ...
35
+ template: TomlTemplate, *, profile: TomlProfile | str | None = ...
32
36
  ) -> str: ...
33
37
  def render_yaml(
34
- template: Template, *, profile: YamlProfile | str | None = ...
38
+ template: YamlTemplate, *, profile: YamlProfile | str | None = ...
35
39
  ) -> Any: ...
36
40
  def render_yaml_text(
37
- template: Template, *, profile: YamlProfile | str | None = ...
41
+ template: YamlTemplate, *, profile: YamlProfile | str | None = ...
38
42
  ) -> str: ...
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from string.templatelib import Template
4
- from typing import Any
4
+ from typing import Annotated, Any
5
5
 
6
6
  # Non-public extension module retained for internal imports and packaging
7
7
  # compatibility. Public callers should use `tstring_bindings`.
8
8
 
9
+ type JsonTemplate = Annotated[Template, "json"]
10
+ type TomlTemplate = Annotated[Template, "toml"]
11
+ type YamlTemplate = Annotated[Template, "yaml"]
9
12
  type ExceptionSpan = tuple[tuple[int, int], tuple[int, int]]
10
13
  type ExceptionDiagnostic = dict[str, object]
11
14
 
@@ -22,18 +25,18 @@ class UnrepresentableValueError(TemplateSemanticError): ...
22
25
  __contract_version__: int
23
26
  __contract_symbols__: tuple[str, ...]
24
27
 
25
- def render_json(template: Template, profile: str = ...) -> Any: ...
26
- def render_json_text(template: Template, profile: str = ...) -> str: ...
28
+ def render_json(template: JsonTemplate, profile: str = ...) -> Any: ...
29
+ def render_json_text(template: JsonTemplate, profile: str = ...) -> str: ...
27
30
  def _render_json_result_payload(
28
- template: Template, profile: str = ...
31
+ template: JsonTemplate, profile: str = ...
29
32
  ) -> tuple[str, Any]: ...
30
- def render_toml(template: Template, profile: str = ...) -> Any: ...
31
- def render_toml_text(template: Template, profile: str = ...) -> str: ...
33
+ def render_toml(template: TomlTemplate, profile: str = ...) -> Any: ...
34
+ def render_toml_text(template: TomlTemplate, profile: str = ...) -> str: ...
32
35
  def _render_toml_result_payload(
33
- template: Template, profile: str = ...
36
+ template: TomlTemplate, profile: str = ...
34
37
  ) -> tuple[str, Any]: ...
35
- def render_yaml(template: Template, profile: str = ...) -> Any: ...
36
- def render_yaml_text(template: Template, profile: str = ...) -> str: ...
38
+ def render_yaml(template: YamlTemplate, profile: str = ...) -> Any: ...
39
+ def render_yaml_text(template: YamlTemplate, profile: str = ...) -> str: ...
37
40
  def _render_yaml_result_payload(
38
- template: Template, profile: str = ...
41
+ template: YamlTemplate, profile: str = ...
39
42
  ) -> tuple[str, Any]: ...
@@ -24,12 +24,12 @@ pyo3 = { workspace = true, features = ["abi3-py314"] }
24
24
  pythonize = { workspace = true }
25
25
  saphyr = { workspace = true }
26
26
  serde_json = { workspace = true }
27
- tstring-json = { version = "0.1.1", path = "../json-tstring-rs" }
28
- tstring-pyo3-bindings = { version = "0.1.1", path = "../tstring-pyo3-bindings" }
29
- tstring-syntax = { version = "0.1.1", path = "../tstring-core-rs" }
27
+ tstring-json = { version = "0.2.1", path = "../json-tstring-rs" }
28
+ tstring-pyo3-bindings = { version = "0.2.1", path = "../tstring-pyo3-bindings" }
29
+ tstring-syntax = { version = "0.2.1", path = "../tstring-core-rs" }
30
30
  toml = { workspace = true }
31
- tstring-toml = { version = "0.1.1", path = "../toml-tstring-rs" }
32
- tstring-yaml = { version = "0.1.1", path = "../yaml-tstring-rs" }
31
+ tstring-toml = { version = "0.2.1", path = "../toml-tstring-rs" }
32
+ tstring-yaml = { version = "0.2.1", path = "../yaml-tstring-rs" }
33
33
 
34
34
  [dev-dependencies]
35
35
  pyo3 = { workspace = true, features = ["auto-initialize"] }
@@ -160,7 +160,7 @@ fn parse_json_template(
160
160
  ) -> PyResult<Arc<JsonDocumentNode>> {
161
161
  json_parse_cache()
162
162
  .get_or_try_insert_with(&template_cache_key(template, profile.as_str()), || {
163
- tstring_json::parse_template_with_profile(template.input(), profile)
163
+ tstring_json::parse_validated_template_with_profile(template.input(), profile)
164
164
  })
165
165
  .map_err(backend_error_to_py)
166
166
  }
@@ -171,7 +171,7 @@ fn parse_toml_template(
171
171
  ) -> PyResult<Arc<TomlDocumentNode>> {
172
172
  toml_parse_cache()
173
173
  .get_or_try_insert_with(&template_cache_key(template, profile.as_str()), || {
174
- tstring_toml::parse_template_with_profile(template.input(), profile)
174
+ tstring_toml::parse_validated_template_with_profile(template.input(), profile)
175
175
  })
176
176
  .map_err(backend_error_to_py)
177
177
  }
@@ -182,7 +182,7 @@ fn parse_yaml_template(
182
182
  ) -> PyResult<Arc<YamlStreamNode>> {
183
183
  yaml_parse_cache()
184
184
  .get_or_try_insert_with(&template_cache_key(template, profile.as_str()), || {
185
- tstring_yaml::parse_template_with_profile(template.input(), profile)
185
+ tstring_yaml::parse_validated_template_with_profile(template.input(), profile)
186
186
  })
187
187
  .map_err(backend_error_to_py)
188
188
  }
@@ -627,7 +627,7 @@ fn normalized_offset_to_python(py: Python<'_>, offset_minutes: i16) -> PyResult<
627
627
 
628
628
  #[pymodule]
629
629
  fn tstring_bindings(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
630
- module.add("__version__", "0.1.1")?;
630
+ module.add("__version__", "0.2.1")?;
631
631
  module.add("__contract_version__", CONTRACT_VERSION)?;
632
632
  module.add("__contract_symbols__", PyTuple::new(py, CONTRACT_SYMBOLS)?)?;
633
633
  module.add("TemplateError", py.get_type::<TemplateError>())?;
@@ -19,4 +19,4 @@ test = false
19
19
  [dependencies]
20
20
  serde_json = { workspace = true }
21
21
  toml = { workspace = true }
22
- tstring-syntax = { version = "0.1.1", path = "../tstring-core-rs" }
22
+ tstring-syntax = { version = "0.2.1", path = "../tstring-core-rs" }
@@ -1110,11 +1110,36 @@ pub fn parse_template(template: &TemplateInput) -> BackendResult<TomlDocumentNod
1110
1110
  parse_template_with_profile(template, TomlProfile::default())
1111
1111
  }
1112
1112
 
1113
+ pub fn parse_validated_template_with_profile(
1114
+ template: &TemplateInput,
1115
+ profile: TomlProfile,
1116
+ ) -> BackendResult<TomlDocumentNode> {
1117
+ // TOML does not add format-specific post-parse validation yet. Keep the
1118
+ // validated entry point aligned with the other backends so callers can rely
1119
+ // on one API shape as backend-specific validation rules are introduced.
1120
+ parse_template_with_profile(template, profile)
1121
+ }
1122
+
1123
+ pub fn parse_validated_template(template: &TemplateInput) -> BackendResult<TomlDocumentNode> {
1124
+ parse_validated_template_with_profile(template, TomlProfile::default())
1125
+ }
1126
+
1127
+ pub fn validate_template_with_profile(
1128
+ template: &TemplateInput,
1129
+ profile: TomlProfile,
1130
+ ) -> BackendResult<()> {
1131
+ parse_validated_template_with_profile(template, profile).map(|_| ())
1132
+ }
1133
+
1134
+ pub fn validate_template(template: &TemplateInput) -> BackendResult<()> {
1135
+ validate_template_with_profile(template, TomlProfile::default())
1136
+ }
1137
+
1113
1138
  pub fn check_template_with_profile(
1114
1139
  template: &TemplateInput,
1115
1140
  profile: TomlProfile,
1116
1141
  ) -> BackendResult<()> {
1117
- parse_template_with_profile(template, profile).map(|_| ())
1142
+ validate_template_with_profile(template, profile)
1118
1143
  }
1119
1144
 
1120
1145
  pub fn check_template(template: &TemplateInput) -> BackendResult<()> {
@@ -1125,7 +1150,7 @@ pub fn format_template_with_profile(
1125
1150
  template: &TemplateInput,
1126
1151
  profile: TomlProfile,
1127
1152
  ) -> BackendResult<String> {
1128
- let document = parse_template_with_profile(template, profile)?;
1153
+ let document = parse_validated_template_with_profile(template, profile)?;
1129
1154
  format_toml_document(template, &document)
1130
1155
  }
1131
1156
 
@@ -1390,7 +1415,7 @@ fn normalize_time(value: toml::value::Time) -> NormalizedTime {
1390
1415
 
1391
1416
  #[cfg(test)]
1392
1417
  mod tests {
1393
- use super::{parse_template, TomlKeySegmentValue, TomlStatementNode, TomlValueNode};
1418
+ use super::{TomlKeySegmentValue, TomlStatementNode, TomlValueNode, parse_template};
1394
1419
  use pyo3::prelude::*;
1395
1420
  use tstring_pyo3_bindings::{extract_template, toml::render_document};
1396
1421
  use tstring_syntax::{BackendError, BackendResult, ErrorKind};
@@ -1901,10 +1926,12 @@ mod tests {
1901
1926
  );
1902
1927
  let special_floats = table["special_float_array"].as_array().expect("array");
1903
1928
  assert!(special_floats[0].as_float().expect("float").is_infinite());
1904
- assert!(special_floats[1]
1905
- .as_float()
1906
- .expect("float")
1907
- .is_sign_negative());
1929
+ assert!(
1930
+ special_floats[1]
1931
+ .as_float()
1932
+ .expect("float")
1933
+ .is_sign_negative()
1934
+ );
1908
1935
  assert!(special_floats[2].as_float().expect("float").is_nan());
1909
1936
  assert_eq!(
1910
1937
  table["special_float_nested_arrays"]
@@ -1916,18 +1943,24 @@ mod tests {
1916
1943
  let special_float_deeper_arrays = table["special_float_deeper_arrays"]
1917
1944
  .as_array()
1918
1945
  .expect("array");
1919
- assert!(special_float_deeper_arrays[0][0][0]
1920
- .as_float()
1921
- .expect("float")
1922
- .is_infinite());
1923
- assert!(special_float_deeper_arrays[1][0][0]
1924
- .as_float()
1925
- .expect("float")
1926
- .is_sign_negative());
1927
- assert!(special_float_deeper_arrays[2][0][0]
1928
- .as_float()
1929
- .expect("float")
1930
- .is_nan());
1946
+ assert!(
1947
+ special_float_deeper_arrays[0][0][0]
1948
+ .as_float()
1949
+ .expect("float")
1950
+ .is_infinite()
1951
+ );
1952
+ assert!(
1953
+ special_float_deeper_arrays[1][0][0]
1954
+ .as_float()
1955
+ .expect("float")
1956
+ .is_sign_negative()
1957
+ );
1958
+ assert!(
1959
+ special_float_deeper_arrays[2][0][0]
1960
+ .as_float()
1961
+ .expect("float")
1962
+ .is_nan()
1963
+ );
1931
1964
  assert_eq!(
1932
1965
  table["upper_exp_nested_mixed"]
1933
1966
  .as_array()
@@ -1935,14 +1968,18 @@ mod tests {
1935
1968
  .len(),
1936
1969
  2
1937
1970
  );
1938
- assert!(table["special_float_inline_table"]["pos"]
1939
- .as_float()
1940
- .expect("float")
1941
- .is_infinite());
1942
- assert!(table["special_float_inline_table"]["nan"]
1943
- .as_float()
1944
- .expect("float")
1945
- .is_nan());
1971
+ assert!(
1972
+ table["special_float_inline_table"]["pos"]
1973
+ .as_float()
1974
+ .expect("float")
1975
+ .is_infinite()
1976
+ );
1977
+ assert!(
1978
+ table["special_float_inline_table"]["nan"]
1979
+ .as_float()
1980
+ .expect("float")
1981
+ .is_nan()
1982
+ );
1946
1983
  assert_eq!(
1947
1984
  table["special_float_mixed_nested"]
1948
1985
  .as_array()
@@ -2026,9 +2063,10 @@ mod tests {
2026
2063
  Err(err) => err,
2027
2064
  };
2028
2065
  assert_eq!(err.kind, ErrorKind::Parse);
2029
- assert!(err
2030
- .message
2031
- .contains("single-line basic strings cannot contain newlines"));
2066
+ assert!(
2067
+ err.message
2068
+ .contains("single-line basic strings cannot contain newlines")
2069
+ );
2032
2070
  });
2033
2071
  }
2034
2072
 
@@ -2920,30 +2958,42 @@ mod tests {
2920
2958
  rendered.text,
2921
2959
  "special_float_inline_table = { pos = +inf, neg = -inf, nan = nan }\nspecial_float_mixed_nested = [[+inf, -inf], [nan]]"
2922
2960
  );
2923
- assert!(rendered.data["special_float_inline_table"]["pos"]
2924
- .as_float()
2925
- .expect("pos float")
2926
- .is_infinite());
2927
- assert!(rendered.data["special_float_inline_table"]["neg"]
2928
- .as_float()
2929
- .expect("neg float")
2930
- .is_sign_negative());
2931
- assert!(rendered.data["special_float_inline_table"]["nan"]
2932
- .as_float()
2933
- .expect("nan float")
2934
- .is_nan());
2935
- assert!(rendered.data["special_float_mixed_nested"][0][0]
2936
- .as_float()
2937
- .expect("nested pos")
2938
- .is_infinite());
2939
- assert!(rendered.data["special_float_mixed_nested"][0][1]
2940
- .as_float()
2941
- .expect("nested neg")
2942
- .is_sign_negative());
2943
- assert!(rendered.data["special_float_mixed_nested"][1][0]
2944
- .as_float()
2945
- .expect("nested nan")
2946
- .is_nan());
2961
+ assert!(
2962
+ rendered.data["special_float_inline_table"]["pos"]
2963
+ .as_float()
2964
+ .expect("pos float")
2965
+ .is_infinite()
2966
+ );
2967
+ assert!(
2968
+ rendered.data["special_float_inline_table"]["neg"]
2969
+ .as_float()
2970
+ .expect("neg float")
2971
+ .is_sign_negative()
2972
+ );
2973
+ assert!(
2974
+ rendered.data["special_float_inline_table"]["nan"]
2975
+ .as_float()
2976
+ .expect("nan float")
2977
+ .is_nan()
2978
+ );
2979
+ assert!(
2980
+ rendered.data["special_float_mixed_nested"][0][0]
2981
+ .as_float()
2982
+ .expect("nested pos")
2983
+ .is_infinite()
2984
+ );
2985
+ assert!(
2986
+ rendered.data["special_float_mixed_nested"][0][1]
2987
+ .as_float()
2988
+ .expect("nested neg")
2989
+ .is_sign_negative()
2990
+ );
2991
+ assert!(
2992
+ rendered.data["special_float_mixed_nested"][1][0]
2993
+ .as_float()
2994
+ .expect("nested nan")
2995
+ .is_nan()
2996
+ );
2947
2997
  });
2948
2998
  }
2949
2999
 
@@ -1,6 +1,7 @@
1
1
  use tstring_syntax::{TemplateInput, TemplateInterpolation, TemplateSegment};
2
2
  use tstring_toml::{
3
- check_template, format_template, parse_template, TomlStatementNode, TomlValueNode,
3
+ TomlStatementNode, TomlValueNode, check_template, format_template, parse_template,
4
+ parse_validated_template, validate_template,
4
5
  };
5
6
 
6
7
  fn interpolation(index: usize, expression: &str) -> TemplateSegment {
@@ -64,6 +65,24 @@ fn checks_valid_toml_templates() {
64
65
  check_template(&template).expect("expected check success");
65
66
  }
66
67
 
68
+ #[test]
69
+ fn validates_toml_templates_with_supported_interpolations() {
70
+ let template = TemplateInput::from_segments(vec![
71
+ TemplateSegment::StaticText("title = ".to_owned()),
72
+ TemplateSegment::Interpolation(TemplateInterpolation {
73
+ expression: "title".to_owned(),
74
+ conversion: None,
75
+ format_spec: String::new(),
76
+ interpolation_index: 0,
77
+ raw_source: Some("{title}".to_owned()),
78
+ }),
79
+ TemplateSegment::StaticText("\n".to_owned()),
80
+ ]);
81
+
82
+ validate_template(&template).expect("expected validate success");
83
+ parse_validated_template(&template).expect("expected validated parse success");
84
+ }
85
+
67
86
  #[test]
68
87
  fn formats_toml_templates_with_raw_interpolations() {
69
88
  let template = TemplateInput::from_segments(vec![
@@ -16,13 +16,13 @@ serde_json = { workspace = true }
16
16
  saphyr = { workspace = true }
17
17
  saphyr-parser = { workspace = true }
18
18
  toml = { workspace = true }
19
- tstring-json = { version = "0.1.1", path = "../json-tstring-rs" }
20
- tstring-syntax = { version = "0.1.1", path = "../tstring-core-rs" }
21
- tstring-toml = { version = "0.1.1", path = "../toml-tstring-rs" }
22
- tstring-yaml = { version = "0.1.1", path = "../yaml-tstring-rs" }
19
+ tstring-json = { version = "0.2.1", path = "../json-tstring-rs" }
20
+ tstring-syntax = { version = "0.2.1", path = "../tstring-core-rs" }
21
+ tstring-toml = { version = "0.2.1", path = "../toml-tstring-rs" }
22
+ tstring-yaml = { version = "0.2.1", path = "../yaml-tstring-rs" }
23
23
 
24
24
  [dev-dependencies]
25
- criterion = "0.5.1"
25
+ criterion = "0.8.2"
26
26
  pyo3 = { workspace = true, features = ["auto-initialize"] }
27
27
 
28
28
  [[bench]]
@@ -20,7 +20,7 @@ test = false
20
20
  saphyr = { workspace = true }
21
21
  saphyr-parser = { workspace = true }
22
22
  serde_json = { workspace = true }
23
- tstring-syntax = { version = "0.1.1", path = "../tstring-core-rs" }
23
+ tstring-syntax = { version = "0.2.1", path = "../tstring-core-rs" }
24
24
 
25
25
  [dev-dependencies]
26
26
  toml = { workspace = true }
@@ -2048,11 +2048,36 @@ pub fn parse_template(template: &TemplateInput) -> BackendResult<YamlStreamNode>
2048
2048
  parse_template_with_profile(template, YamlProfile::default())
2049
2049
  }
2050
2050
 
2051
+ pub fn parse_validated_template_with_profile(
2052
+ template: &TemplateInput,
2053
+ profile: YamlProfile,
2054
+ ) -> BackendResult<YamlStreamNode> {
2055
+ let stream = parse_template_with_profile(template, profile)?;
2056
+ validate_template_stream(&stream)?;
2057
+ Ok(stream)
2058
+ }
2059
+
2060
+ pub fn parse_validated_template(template: &TemplateInput) -> BackendResult<YamlStreamNode> {
2061
+ parse_validated_template_with_profile(template, YamlProfile::default())
2062
+ }
2063
+
2064
+ pub fn validate_template_with_profile(
2065
+ template: &TemplateInput,
2066
+ profile: YamlProfile,
2067
+ ) -> BackendResult<()> {
2068
+ let stream = parse_template_with_profile(template, profile)?;
2069
+ validate_template_stream(&stream)
2070
+ }
2071
+
2072
+ pub fn validate_template(template: &TemplateInput) -> BackendResult<()> {
2073
+ validate_template_with_profile(template, YamlProfile::default())
2074
+ }
2075
+
2051
2076
  pub fn check_template_with_profile(
2052
2077
  template: &TemplateInput,
2053
2078
  profile: YamlProfile,
2054
2079
  ) -> BackendResult<()> {
2055
- parse_template_with_profile(template, profile).map(|_| ())
2080
+ validate_template_with_profile(template, profile)
2056
2081
  }
2057
2082
 
2058
2083
  pub fn check_template(template: &TemplateInput) -> BackendResult<()> {
@@ -2063,7 +2088,7 @@ pub fn format_template_with_profile(
2063
2088
  template: &TemplateInput,
2064
2089
  profile: YamlProfile,
2065
2090
  ) -> BackendResult<String> {
2066
- let stream = parse_template_with_profile(template, profile)?;
2091
+ let stream = parse_validated_template_with_profile(template, profile)?;
2067
2092
  format_yaml_stream(template, &stream)
2068
2093
  }
2069
2094
 
@@ -2071,6 +2096,74 @@ pub fn format_template(template: &TemplateInput) -> BackendResult<String> {
2071
2096
  format_template_with_profile(template, YamlProfile::default())
2072
2097
  }
2073
2098
 
2099
+ fn validate_template_stream(stream: &YamlStreamNode) -> BackendResult<()> {
2100
+ for document in &stream.documents {
2101
+ validate_value_node(&document.value)?;
2102
+ }
2103
+ Ok(())
2104
+ }
2105
+
2106
+ fn validate_value_node(node: &YamlValueNode) -> BackendResult<()> {
2107
+ match node {
2108
+ YamlValueNode::Scalar(YamlScalarNode::Plain(node)) => validate_plain_scalar_node(node),
2109
+ YamlValueNode::Mapping(node) => {
2110
+ for entry in &node.entries {
2111
+ validate_key_node(&entry.key)?;
2112
+ validate_value_node(&entry.value)?;
2113
+ }
2114
+ Ok(())
2115
+ }
2116
+ YamlValueNode::Sequence(node) => {
2117
+ for item in &node.items {
2118
+ validate_value_node(item)?;
2119
+ }
2120
+ Ok(())
2121
+ }
2122
+ YamlValueNode::Decorated(node) => validate_value_node(&node.value),
2123
+ YamlValueNode::Interpolation(_)
2124
+ | YamlValueNode::Scalar(
2125
+ YamlScalarNode::DoubleQuoted(_)
2126
+ | YamlScalarNode::SingleQuoted(_)
2127
+ | YamlScalarNode::Block(_)
2128
+ | YamlScalarNode::Alias(_),
2129
+ ) => Ok(()),
2130
+ }
2131
+ }
2132
+
2133
+ fn validate_key_node(node: &YamlKeyNode) -> BackendResult<()> {
2134
+ match &node.value {
2135
+ YamlKeyValue::Scalar(YamlScalarNode::Plain(node)) => validate_plain_scalar_node(node),
2136
+ YamlKeyValue::Complex(node) => validate_value_node(node),
2137
+ YamlKeyValue::Interpolation(_)
2138
+ | YamlKeyValue::Scalar(
2139
+ YamlScalarNode::DoubleQuoted(_)
2140
+ | YamlScalarNode::SingleQuoted(_)
2141
+ | YamlScalarNode::Block(_)
2142
+ | YamlScalarNode::Alias(_),
2143
+ ) => Ok(()),
2144
+ }
2145
+ }
2146
+
2147
+ fn validate_plain_scalar_node(node: &YamlPlainScalarNode) -> BackendResult<()> {
2148
+ let has_interpolation = node
2149
+ .chunks
2150
+ .iter()
2151
+ .any(|chunk| matches!(chunk, YamlChunk::Interpolation(_)));
2152
+ let has_whitespace_text = node.chunks.iter().any(|chunk| {
2153
+ matches!(chunk, YamlChunk::Text(text) if text.value.chars().any(char::is_whitespace))
2154
+ });
2155
+
2156
+ if has_interpolation && has_whitespace_text {
2157
+ return Err(BackendError::parse_at(
2158
+ "yaml.parse",
2159
+ "Quote YAML plain scalars that mix whitespace and interpolations.",
2160
+ Some(node.span.clone()),
2161
+ ));
2162
+ }
2163
+
2164
+ Ok(())
2165
+ }
2166
+
2074
2167
  pub fn normalize_documents_with_profile(
2075
2168
  documents: &[YamlOwned],
2076
2169
  _profile: YamlProfile,
@@ -1,5 +1,7 @@
1
1
  use tstring_syntax::{TemplateInput, TemplateInterpolation, TemplateSegment};
2
- use tstring_yaml::{YamlValueNode, check_template, format_template, parse_template};
2
+ use tstring_yaml::{
3
+ YamlValueNode, check_template, format_template, parse_template, validate_template,
4
+ };
3
5
 
4
6
  fn interpolation(index: usize, expression: &str) -> TemplateSegment {
5
7
  TemplateSegment::Interpolation(TemplateInterpolation {
@@ -62,6 +64,71 @@ fn checks_valid_yaml_templates() {
62
64
  check_template(&template).expect("expected check success");
63
65
  }
64
66
 
67
+ #[test]
68
+ fn rejects_plain_scalars_that_mix_whitespace_and_interpolation() {
69
+ let template = TemplateInput::from_segments(vec![
70
+ TemplateSegment::StaticText("replicas: fdsa fff fds".to_owned()),
71
+ TemplateSegment::Interpolation(TemplateInterpolation {
72
+ expression: "replicas".to_owned(),
73
+ conversion: None,
74
+ format_spec: String::new(),
75
+ interpolation_index: 0,
76
+ raw_source: Some("{replicas}".to_owned()),
77
+ }),
78
+ TemplateSegment::StaticText("\n".to_owned()),
79
+ ]);
80
+
81
+ let error = check_template(&template).expect_err("expected YAML validation failure");
82
+ assert_eq!(error.diagnostics[0].code, "yaml.parse");
83
+ assert!(
84
+ error
85
+ .message
86
+ .contains("Quote YAML plain scalars that mix whitespace and interpolations")
87
+ );
88
+ }
89
+
90
+ #[test]
91
+ fn validates_token_like_plain_scalars_with_interpolation() {
92
+ let template = TemplateInput::from_segments(vec![
93
+ TemplateSegment::StaticText("plain: item-".to_owned()),
94
+ TemplateSegment::Interpolation(TemplateInterpolation {
95
+ expression: "user".to_owned(),
96
+ conversion: None,
97
+ format_spec: String::new(),
98
+ interpolation_index: 0,
99
+ raw_source: Some("{user}".to_owned()),
100
+ }),
101
+ TemplateSegment::StaticText("\n".to_owned()),
102
+ ]);
103
+
104
+ validate_template(&template).expect("expected YAML validation success");
105
+ }
106
+
107
+ #[test]
108
+ fn validates_plain_scalars_with_multiple_interpolations_and_no_whitespace() {
109
+ let template = TemplateInput::from_segments(vec![
110
+ TemplateSegment::StaticText("plain: ".to_owned()),
111
+ TemplateSegment::Interpolation(TemplateInterpolation {
112
+ expression: "foo".to_owned(),
113
+ conversion: None,
114
+ format_spec: String::new(),
115
+ interpolation_index: 0,
116
+ raw_source: Some("{foo}".to_owned()),
117
+ }),
118
+ TemplateSegment::StaticText("-".to_owned()),
119
+ TemplateSegment::Interpolation(TemplateInterpolation {
120
+ expression: "bar".to_owned(),
121
+ conversion: None,
122
+ format_spec: String::new(),
123
+ interpolation_index: 1,
124
+ raw_source: Some("{bar}".to_owned()),
125
+ }),
126
+ TemplateSegment::StaticText("\n".to_owned()),
127
+ ]);
128
+
129
+ validate_template(&template).expect("expected YAML validation success");
130
+ }
131
+
65
132
  #[test]
66
133
  fn formats_yaml_templates_with_raw_interpolations() {
67
134
  let template = TemplateInput::from_segments(vec![