|
8 | 8 | from io import StringIO |
9 | 9 | from typing import TextIO |
10 | 10 |
|
| 11 | +import hcl2 |
11 | 12 | from hcl2 import dump |
12 | 13 | from hcl2.deserializer import DeserializerOptions |
13 | 14 | from hcl2.formatter import FormatterOptions |
| 15 | +from hcl2.query.diff import diff_dicts, format_diff_json, format_diff_text |
| 16 | +from hcl2.utils import SerializationOptions |
14 | 17 | from hcl2.version import __version__ |
15 | | -from .helpers import ( |
| 18 | +from cli.helpers import ( |
16 | 19 | EXIT_DIFF, |
17 | 20 | EXIT_IO_ERROR, |
18 | 21 | EXIT_PARSE_ERROR, |
@@ -81,19 +84,26 @@ def _strip_block_markers(data): |
81 | 84 |
|
82 | 85 | _EXAMPLES = """\ |
83 | 86 | examples: |
84 | | - jsontohcl2 file.json # single file to stdout |
85 | | - jsontohcl2 a.json b.json -o out/ # multiple files to output dir |
86 | | - jsontohcl2 --diff original.tf modified.json # preview changes |
87 | | - jsontohcl2 --dry-run file.json # convert without writing |
88 | | - jsontohcl2 --fragment - # attribute snippet from stdin |
89 | | - echo '{"x": 1}' | jsontohcl2 # stdin (no args needed) |
| 87 | + jsontohcl2 file.json # single file to stdout |
| 88 | + jsontohcl2 a.json b.json -o out/ # multiple files to output dir |
| 89 | + jsontohcl2 --diff original.tf modified.json # preview text changes |
| 90 | + jsontohcl2 --semantic-diff original.tf modified.json # semantic-only changes |
| 91 | + jsontohcl2 --semantic-diff original.tf --diff-json m.json # semantic diff as JSON |
| 92 | + jsontohcl2 --dry-run file.json # convert without writing |
| 93 | + jsontohcl2 --fragment - # attribute snippet from stdin |
| 94 | + echo '{"x": 1}' | jsontohcl2 # stdin (no args needed) |
| 95 | +
|
| 96 | +fragment string format: |
| 97 | + Strings use python-hcl2's inner-quote convention. To produce HCL "value", |
| 98 | + the JSON string must be: "\\"value\\"". Unquoted strings become identifiers. |
| 99 | + Example: {"name": "\\"test\\"", "count": 3} => name = "test" count = 3 |
90 | 100 |
|
91 | 101 | exit codes: |
92 | 102 | 0 Success |
93 | 103 | 1 JSON parse error |
94 | 104 | 2 Valid JSON but incompatible HCL structure |
95 | 105 | 4 I/O error (file not found) |
96 | | - 5 Differences found (--diff mode only) |
| 106 | + 5 Differences found (--diff / --semantic-diff) |
97 | 107 | """ |
98 | 108 |
|
99 | 109 |
|
@@ -136,10 +146,22 @@ def main(): # pylint: disable=too-many-branches,too-many-statements,too-many-lo |
136 | 146 | action="store_true", |
137 | 147 | help="Convert and print to stdout without writing files", |
138 | 148 | ) |
| 149 | + mode_group.add_argument( |
| 150 | + "--semantic-diff", |
| 151 | + metavar="ORIGINAL", |
| 152 | + help="Show semantic-only diff against ORIGINAL (ignores formatting)", |
| 153 | + ) |
139 | 154 | mode_group.add_argument( |
140 | 155 | "--fragment", |
141 | 156 | action="store_true", |
142 | | - help="Treat input as a JSON fragment (attribute dict, not full HCL document)", |
| 157 | + help="Treat input as a JSON fragment (attribute dict, not full HCL " |
| 158 | + 'document). Strings must use inner quotes: \'"\\"value\\""\' for HCL ' |
| 159 | + '"value", bare strings become identifiers', |
| 160 | + ) |
| 161 | + parser.add_argument( |
| 162 | + "--diff-json", |
| 163 | + action="store_true", |
| 164 | + help="Output diff results as JSON (works with --diff and --semantic-diff)", |
143 | 165 | ) |
144 | 166 | parser.add_argument("--version", action="version", version=__version__) |
145 | 167 |
|
@@ -262,6 +284,47 @@ def convert(in_file, out_file): |
262 | 284 | sys.exit(EXIT_DIFF) |
263 | 285 | return |
264 | 286 |
|
| 287 | + # --semantic-diff mode: compare semantic dicts (ignores formatting) |
| 288 | + if args.semantic_diff: |
| 289 | + if len(paths) != 1: |
| 290 | + parser.error("--semantic-diff requires exactly one input file") |
| 291 | + json_path = paths[0] |
| 292 | + original_path = args.semantic_diff |
| 293 | + |
| 294 | + if not os.path.isfile(original_path): |
| 295 | + print( |
| 296 | + _error( |
| 297 | + f"File not found: {original_path}", |
| 298 | + error_type="io_error", |
| 299 | + file=original_path, |
| 300 | + ), |
| 301 | + file=sys.stderr, |
| 302 | + ) |
| 303 | + sys.exit(EXIT_IO_ERROR) |
| 304 | + |
| 305 | + # Parse original HCL → normalized dict |
| 306 | + sem_opts = SerializationOptions( |
| 307 | + with_comments=False, with_meta=False, explicit_blocks=True |
| 308 | + ) |
| 309 | + with open(original_path, "r", encoding="utf-8") as f: |
| 310 | + dict_original = hcl2.load(f, serialization_options=sem_opts) |
| 311 | + |
| 312 | + # Load modified JSON → dict |
| 313 | + if json_path == "-": |
| 314 | + dict_modified = json.load(sys.stdin) |
| 315 | + else: |
| 316 | + with open(json_path, "r", encoding="utf-8") as f: |
| 317 | + dict_modified = json.load(f) |
| 318 | + |
| 319 | + entries = diff_dicts(dict_original, dict_modified) |
| 320 | + if entries: |
| 321 | + if args.diff_json: |
| 322 | + print(format_diff_json(entries)) |
| 323 | + else: |
| 324 | + print(format_diff_text(entries)) |
| 325 | + sys.exit(EXIT_DIFF) |
| 326 | + return |
| 327 | + |
265 | 328 | # --dry-run mode: convert to stdout without writing |
266 | 329 | if args.dry_run: |
267 | 330 | if len(paths) != 1: |
@@ -381,3 +444,7 @@ def convert(in_file, out_file): |
381 | 444 | file=sys.stderr, |
382 | 445 | ) |
383 | 446 | sys.exit(EXIT_IO_ERROR) |
| 447 | + |
| 448 | + |
| 449 | +if __name__ == "__main__": |
| 450 | + main() |
0 commit comments