Skip to content

Commit 96987d1

Browse files
committed
fromjcl 0.4.0: parser bug fixes, Rich syntax highlighting, code quality
Bug fixes: - Scanner: false-positive ContinueComment state on lines where content reaches column 72 (long template JOB cards) caused the next statement to fail as an invalid continued comment; state is now reset and the record re-dispatched. - Scanner: lowercase characters in jobnames (template placeholders like TKTxxx1) were rejected; name-char validation now accepts any ASCII letter. - Scanner: multi-element JOB account fields like (B004273,BIN#,BLDG#) were truncated at the first comma; paren-nesting is now tracked in the keyword parse context. - CLI: piping --to json on JCL with instream data produced invalid JSON because Rich's Pygments lexer expanded \n escape sequences to literal newlines; syntax highlighting now activates only for TTY stdout. Added: - Rich dependency; colored syntax-highlighted output for json/yaml/jcl/ zoau/mvscmd targets when stdout is a terminal. Code quality: - 27 refurb modernizations across 10+ files (FURB102/108/113/115/123/ 124/138/142/143/173). - Voice audit: removed 10 comments that restated code or referenced prior state.
1 parent 93b73d0 commit 96987d1

16 files changed

Lines changed: 315 additions & 205 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Changelog
22

3+
## 0.4.0 (2026-05-23)
4+
5+
### Added
6+
7+
- Syntax-highlighted terminal output via Rich. `--to json`, `--to yaml`,
8+
`--to jcl`, `--to zoau`, and `--to mvscmd` colorize output when stdout
9+
is a TTY (monokai theme); piped output is plain text, byte-for-byte
10+
identical to file output (`-o`).
11+
12+
### Fixed
13+
14+
- **Scanner: false-positive continuation state** on lines where content
15+
reaches column 72 (e.g., template JOB cards with long `MSGCLASS=`
16+
values). The scanner incorrectly set `ContinueComment` state, causing
17+
the next statement to fail with `Invalid continued comment record`.
18+
- **Scanner: lowercase jobname rejection.** Jobnames with lowercase
19+
characters (common in template JCL, e.g., `TKTxxx1`) were rejected as
20+
invalid. Name-character validation now accepts any ASCII letter.
21+
- **Scanner: multi-element JOB account truncation.** Account fields of
22+
the form `(B004273,BIN#,BLDG#,DEPT#)` were split at the first comma
23+
inside the parentheses. Paren-nesting is now tracked in the keyword
24+
context so the full group is preserved.
25+
- **CLI: invalid JSON when piping `--to json`** on JCL with instream
26+
data. Rich's Pygments JSON lexer was expanding `\n` escape sequences
27+
to literal newlines inside string values. Syntax highlighting now
28+
activates only for interactive terminals.
29+
330
## 0.3.1 (2026-05-18)
431

532
### Fixed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fromjcl"
3-
version = "0.3.1"
3+
version = "0.4.0"
44
description = "Parse IBM z/OS JCL and serialize to JSON, YAML, CSV, or roundtrip JCL."
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -27,6 +27,7 @@ classifiers = [
2727
# z/OS as well as Linux/macOS/Windows.
2828
dependencies = [
2929
"pyyaml>=6.0",
30+
"rich>=13.0",
3031
"typer>=0.12",
3132
]
3233

@@ -66,6 +67,7 @@ dev = [
6667
"bandit>=1.7",
6768
"pip-audit>=2.7",
6869
"twine>=5.0",
70+
"refurb>=2.3.1",
6971
]
7072

7173
[build-system]

src/fromjcl/_scanner.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,9 @@ def to_dict(self) -> dict[str, Any]:
157157
"name": self.name,
158158
"lines": self.lines,
159159
"keyword_col": self.keyword_col,
160-
"record_lens": list(self.record_lens),
161-
"instream_records": list(self.instream_records),
162-
"parameters": list(self.kvps),
160+
"record_lens": self.record_lens.copy(),
161+
"instream_records": self.instream_records.copy(),
162+
"parameters": self.kvps.copy(),
163163
"scanned_lines": [
164164
{
165165
"parm_text": sl.parm_text,
@@ -201,15 +201,15 @@ def _skip_blanks(text: str, start: int, end: int) -> tuple[int, int]:
201201

202202

203203
def _is_name_char(c: str) -> bool:
204-
return c.isupper() or c.isdigit() or c in NATIONAL
204+
return (c.isascii() and c.isalpha()) or c.isdigit() or c in NATIONAL
205205

206206

207207
def _is_valid_name(buf: str) -> tuple[bool, int]:
208208
"""buf is text AFTER the // prefix. Return (is_valid, name_len)."""
209209
if not buf:
210210
return False, 0
211211
c0 = buf[0]
212-
if not (c0.isupper() or c0 in NATIONAL):
212+
if not ((c0.isascii() and c0.isalpha()) or c0 in NATIONAL):
213213
return False, 0
214214

215215
dot = 0
@@ -301,8 +301,7 @@ def _add_scanned_line(
301301
)
302302

303303
def _append_scanned_comment(self, text: str, start: int, end: int) -> None:
304-
"""C appendScannedComment: strip blanks, append to last scan line's
305-
comment_text, creating an empty comment string if needed."""
304+
"""C appendScannedComment."""
306305
start, end = _skip_blanks(text, start, end)
307306
chunk = text[start:end]
308307
tail = self._cur.scan_lines[-1]
@@ -393,8 +392,7 @@ def _scan_parameters(self, text: str, column: int) -> None:
393392
if (
394393
dlm_orig is not None
395394
and len(dlm_orig) == DELIM_LEN + 2
396-
and dlm_orig[0] == "'"
397-
and dlm_orig[-1] == "'"
395+
and dlm_orig[0] == dlm_orig[-1] == "'"
398396
):
399397
dlm = dlm_orig[1:3]
400398
else:
@@ -424,10 +422,14 @@ def _scan_sub_parameters(self) -> None:
424422
while i < end:
425423
c = text[i]
426424
if context == ParmContext.InKeyword:
427-
if c == "=":
425+
if c == "(":
426+
paren_nest += 1
427+
elif c == ")" and paren_nest > 0:
428+
paren_nest -= 1
429+
elif c == "=" and paren_nest == 0:
428430
context = ParmContext.InValue
429431
cur_value = i + 1
430-
elif c == ",":
432+
elif c == "," and paren_nest == 0:
431433
if i + 1 == end:
432434
comment = cur_line_comment
433435
hn = True
@@ -584,7 +586,7 @@ def _scan_conditional(self, text: str, column: int) -> None:
584586
self.state = ScanState.ContinueConditional
585587

586588
def _scan_comment_stmt(self, text: str) -> None:
587-
"""//* comment. Comment text is cropped to the original line length."""
589+
"""Comment text is cropped to the original line length."""
588590
self._add_stmt("//*", None)
589591
raw_len = len(self._current_raw)
590592
content_end = max(PREFIX_LEN + 1, min(raw_len, JCL_RECLEN))
@@ -757,7 +759,13 @@ def _process_inline_record(self, text: str) -> None:
757759

758760
def _process_continued_comment(self, text: str) -> None:
759761
if text[0] != "/" or text[1] != "/" or text[2] != " ":
760-
raise ValueError("Invalid continued comment record")
762+
# A new JCL statement arrived while continuation was expected
763+
# (e.g. a parameter line whose content reached col 72 set the
764+
# flag spuriously). Treat this record as the start of a fresh
765+
# statement rather than a hard error.
766+
self.state = ScanState.NotContinued
767+
self._process_jcl_record(text)
768+
return
761769
self._add_to_stmt()
762770
self._scan_parameters(text, PREFIX_LEN + 1)
763771

@@ -781,24 +789,22 @@ def _process_continued_conditional(self, text: str) -> None:
781789
def _process_continued_parameter(self, text: str) -> None:
782790
# //* inside continued params: append to the current scan line's
783791
# comment_text with a leading newline. No addToStatement.
784-
if text[0] == "/" and text[1] == "/" and text[2] == "*":
792+
if text[0] == text[1] == "/" and text[2] == "*":
785793
tail = self._cur.scan_lines[-1]
786794
existing = tail.comment_text or ""
787795
tail.comment_text = existing + "\n"
788796
self._append_scanned_comment(text, PREFIX_LEN + 1, JCL_TXTLEN)
789797
return
790798
if text[0] != "/" or text[1] != "/" or text[2] != " ":
791-
raise ValueError("Invalid continued parameter record")
799+
# Same lenient recovery as _process_continued_comment.
800+
self.state = ScanState.NotContinued
801+
self._process_jcl_record(text)
802+
return
792803
self._add_to_stmt()
793804
self._scan_parameters(text, PREFIX_LEN + 1)
794805

795806
def _process_jes3_continued_dataset(self, text: str) -> None:
796-
if (
797-
text[0] == "/"
798-
and text[1] == "/"
799-
and text[2] == "*"
800-
and _word_starts(text[3:], "ENDDATASET")
801-
):
807+
if text[0] == text[1] == "/" and text[2] == "*" and _word_starts(text[3:], "ENDDATASET"):
802808
# C: addStatement(0, JES3_KEYWORD); opens a new //* stmt.
803809
self._add_stmt("//*", None)
804810
self.state = ScanState.NotContinued
@@ -827,7 +833,6 @@ def process_record(self, text: str, raw: str | None = None) -> None:
827833
else:
828834
raise RuntimeError(f"Unreachable scan state: {current}")
829835

830-
# Inline data lines go to instream_records; JCL records to record_lens.
831836
if len(self.stmts) > before:
832837
self._cur.record_lens.append(len(self._current_raw))
833838
elif self.stmts and was_inline:

src/fromjcl/_zoau_flags.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# SPDX-License-Identifier: Apache-2.0
22
"""Frozen snapshot of ZOAU 1.x command flags (55 verbs, 493 flags).
33
4-
Originally extracted from manpages; the generator has been removed.
54
To refresh against a newer ZOAU release, run `man <verb>` on each verb
65
in FLAGS_BY_VERB below and reshape the synopsis lines into the existing
76
list structure.

src/fromjcl/cli.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from typing import Annotated
1010

1111
import typer
12+
from rich.console import Console
13+
from rich.syntax import Syntax
1214

1315
from fromjcl.models import Job
1416
from fromjcl.parser import parse, parse_bytes
@@ -41,6 +43,16 @@ class InputFormat(StrEnum):
4143

4244
_ZOAU_FORMATS = {OutputFormat.mvscmd, OutputFormat.zoau}
4345

46+
_RICH_LEXERS: dict[OutputFormat, str] = {
47+
OutputFormat.json: "json",
48+
OutputFormat.yaml: "yaml",
49+
OutputFormat.jcl: "jcl",
50+
OutputFormat.zoau: "bash",
51+
OutputFormat.mvscmd: "bash",
52+
}
53+
54+
_console = Console()
55+
4456

4557
def _require_extra(extra: str, marker_module: str) -> None:
4658
"""Exit with help if pip install fromjcl[<extra>] hasn't been run."""
@@ -55,12 +67,19 @@ def _require_extra(extra: str, marker_module: str) -> None:
5567
raise typer.Exit(code=2) from None
5668

5769

58-
def _write_output(output: str, dest: str | None) -> None:
70+
def _write_output(output: str, dest: str | None, fmt: OutputFormat | None = None) -> None:
5971
"""Write output to a file or stdout. Both paths ensure exactly one
60-
trailing newline so piping `--to jcl` matches the `-o file` form."""
72+
trailing newline so piping `--to jcl` matches the `-o file` form.
73+
When writing to a terminal, syntax-highlights via Rich."""
6174
text = output if output.endswith("\n") else output + "\n"
6275
if dest:
6376
Path(dest).write_text(text)
77+
return
78+
lexer = _RICH_LEXERS.get(fmt) if fmt else None
79+
if lexer and _console.is_terminal:
80+
_console.print(
81+
Syntax(text.rstrip("\n"), lexer, theme="monokai", background_color="default")
82+
)
6483
else:
6584
sys.stdout.write(text)
6685

@@ -121,7 +140,7 @@ def convert(
121140
except (ValueError, KeyError) as e:
122141
typer.echo(f"Error: {e}", err=True)
123142
raise typer.Exit(code=1) from e
124-
_write_output(result, output)
143+
_write_output(result, output, OutputFormat.jcl)
125144
return
126145

127146
try:
@@ -159,7 +178,7 @@ def convert(
159178
else: # pragma: no cover - Enum exhaustiveness; defensive default.
160179
result = ""
161180

162-
_write_output(result, output)
181+
_write_output(result, output, to)
163182

164183
if warnings:
165184
typer.echo(f"fromjcl: validation failed ({len(warnings)} issue(s)):", err=True)

src/fromjcl/converters/_conditions.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,5 @@ def referenced_step_names(items: list[Any]) -> set[str]:
150150
text = getattr(item, attr, None)
151151
if not text:
152152
continue
153-
for m in pattern.finditer(text):
154-
refs.add(m.group(1).split(".")[0].lower())
153+
refs.update(m.group(1).split(".")[0].lower() for m in pattern.finditer(text))
155154
return refs

src/fromjcl/converters/classify.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def _classify_iefbr14(step: Step) -> DatasetOps:
227227
# NEW+CATLG/KEEP allocates; OLD/MOD/SHR + DELETE deletes.
228228
if disp.status == "NEW" and disp.normal in ("CATLG", "KEEP"):
229229
creates.append((name, ds))
230-
elif (disp.normal == "DELETE" or disp.abnormal == "DELETE") and disp.status in (
230+
elif "DELETE" in (disp.normal, disp.abnormal) and disp.status in (
231231
"OLD",
232232
"MOD",
233233
"SHR",
@@ -277,7 +277,7 @@ def _gather_iebgener_info(step: Step) -> _IEBGenerInfo:
277277
return info
278278

279279

280-
# Matcher order matters: specific patterns first, then more general.
280+
# Matcher order matters.
281281

282282

283283
def _match_path_to_dsn(info: _IEBGenerInfo) -> CopyDataset | None:
@@ -466,8 +466,7 @@ def _classify_sort(step: Step) -> TextReplace | Fallback:
466466
find = joined[findrep.start(1) : findrep.end(1)]
467467
repl = joined[findrep.start(2) : findrep.end(2)]
468468

469-
dd_map = build_dd_map(step)
470-
sortin = dd_map.get("SORTIN")
469+
sortin = build_dd_map(step).get("SORTIN")
471470
if not (sortin and sortin.datasets):
472471
return Fallback(reason="SORT: Missing SORTIN dataset")
473472

@@ -697,7 +696,7 @@ def _join_continuations(sysin_data: str) -> list[str]:
697696
current = ""
698697
continue
699698

700-
ends_continued = line.endswith(",") or line.endswith("-")
699+
ends_continued = line.endswith((",", "-"))
701700
if current:
702701
# Trailing comma joins flush (parsers want the comma); trailing
703702
# dash inserts a space (IDCAMS treats line-break as whitespace).

src/fromjcl/converters/common.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,12 @@ def build_mvscmd_command(step: Step, force_auth: bool | None = None) -> list[str
143143
if step.program:
144144
parts.append(f"--pgm={step.program}")
145145
elif step.proc:
146-
result.append(f"# WARNING: PROC={step.proc} cannot be executed by {exe}.")
147-
result.append("# Expand the PROC or find the program it calls.")
146+
result.extend(
147+
(
148+
f"# WARNING: PROC={step.proc} cannot be executed by {exe}.",
149+
"# Expand the PROC or find the program it calls.",
150+
)
151+
)
148152
parts.append("--pgm=UNKNOWN")
149153

150154
if step.parm:

src/fromjcl/converters/shell/_scaffold.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ def emit(job: Job, render_step: Callable[[Step], list[str]], header_tag: str) ->
3333
lines.append("# WARNING: This job uses symbolic parameters:")
3434
for sym, val in job.symbols.items():
3535
lines.append(f"# {sym}={val}")
36-
lines.append("# You may need to substitute these values manually.")
37-
lines.append("")
36+
lines.extend(("# You may need to substitute these values manually.", ""))
3837

3938
referenced = _conditions.referenced_step_names(job.steps)
4039
for cond, steps in _conditions.group_consecutive_by_condition(job.steps):
@@ -67,8 +66,12 @@ def _emit_conditional(
6766
warn = _conditions._approx_warning(cond)
6867
if warn:
6968
out.append(f"# WARNING: {warn}")
70-
out.append(f"# Steps: {', '.join(s.name for s in steps)}")
71-
out.append(f"if (( {_conditions.to_shell(cond)} )); then")
69+
out.extend(
70+
(
71+
f"# Steps: {', '.join(s.name for s in steps)}",
72+
f"if (( {_conditions.to_shell(cond)} )); then",
73+
)
74+
)
7275
body_has_command = False
7376
for step in steps:
7477
out.append(f" # Step: {step.name}")
@@ -85,6 +88,5 @@ def _emit_conditional(
8588
if not body_has_command:
8689
# bash if ... fi requires at least one command between then/fi.
8790
out.append(" :")
88-
out.append("fi")
89-
out.append("")
91+
out.extend(("fi", ""))
9092
return out

src/fromjcl/converters/shell/zoau.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,10 @@ def _render_dataset_ops(intent: DatasetOps, sym: dict[str, str]) -> list[str]:
8484
result: list[str] = []
8585
for dd_name, dataset in intent.creates:
8686
dsn = resolve_symbols(dataset.dsn, sym)
87-
result.append(f"# DD {dd_name}: Allocate dataset")
88-
result.append(_build_dtouch(dataset, dsn))
87+
result.extend((f"# DD {dd_name}: Allocate dataset", _build_dtouch(dataset, dsn)))
8988
for raw_dsn in intent.deletes:
9089
dsn = resolve_symbols(raw_dsn, sym)
91-
result.append("# Delete dataset")
92-
result.append(f'drm "{dsn}"')
90+
result.extend(("# Delete dataset", f'drm "{dsn}"'))
9391
if not result:
9492
result.append("# IEFBR14 with no actionable DDs")
9593
return result
@@ -228,9 +226,6 @@ def _render_backup(intent: BackupRestore, sym: dict[str, str]) -> list[str]:
228226
return ["# ADRDSSU: Unknown operation"]
229227

230228

231-
# ZOAU-only converters (no Ansible module equivalent)
232-
233-
234229
def _convert_isrsupc(step: Step) -> list[str]:
235230
newdd = olddd = None
236231
sysin_data = get_sysin(step)
@@ -257,12 +252,10 @@ def _convert_isrsupc(step: Step) -> list[str]:
257252

258253

259254
def _render_iehlist(intent: IEHListOps) -> list[str]:
260-
result: list[str] = []
261-
for dsn in intent.pds_dsns:
262-
result.append(f'mls "{dsn}"')
263-
for vol in intent.vtoc_volumes:
264-
result.append(f"vtocls {vol}")
265-
return result
255+
return [
256+
*(f'mls "{dsn}"' for dsn in intent.pds_dsns),
257+
*(f"vtocls {vol}" for vol in intent.vtoc_volumes),
258+
]
266259

267260

268261
def _render_iehprogm(intent: IEHPROGMOps) -> list[str]:

0 commit comments

Comments
 (0)