diff --git a/README.md b/README.md index 10031b9..d367327 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ stackvox speak --normalize --file post.md # normalize, then synthesize stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon ``` -`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively). +`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively). + +**Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone. Bash completion: diff --git a/stackvox/text.py b/stackvox/text.py index 69b1ec0..5febc1f 100644 --- a/stackvox/text.py +++ b/stackvox/text.py @@ -20,6 +20,7 @@ "strip_thousands_separators", "versions_to_words", "decimals_to_words", + "speak_file_refs", "expand_units", "apply_pronunciations", "shape_pauses", @@ -110,6 +111,46 @@ def decimals_to_words(text: str) -> str: ) +# --------------------------------------------------------------------------- # +# File & line references # +# --------------------------------------------------------------------------- # +# `engine.py:42` reads as "…dot py colon forty two" — the dots and colon get +# voiced literally. Spoken aloud the line number is the signal and the filename +# is noise, so lead with "line N of " (how a person actually says it) and +# soften the path: drop directories to the basename, and voice the dotted name +# word by word. + +_FILE_REF = re.compile( + r"(?=1 letter-initial extension + r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column + r"(?!\w)" +) + + +def speak_file_refs(text: str) -> str: + """Turn ``path/file.ext:line`` refs into spoken "line N of file ext". + + ``engine.py:42`` -> "line 42 of engine py"; ``cli.py:100-118`` -> "lines 100 + to 118 of cli py"; ``foo.ts:666:10`` -> "line 666, column 10 of foo ts". The + ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``, + ``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched — + none of them carry a dotted-filename before the colon. + """ + + def repl(match: re.Match[str]) -> str: + basename = match.group(1).replace(".", " ") + start, end, column = match.group(2), match.group(3), match.group(4) + if end: + location = f"lines {start} to {end}" + else: + location = f"line {start}" + (f", column {column}" if column else "") + return f"{location} of {basename}" + + return _FILE_REF.sub(repl, text) + + # --------------------------------------------------------------------------- # # Pronunciations # # --------------------------------------------------------------------------- # @@ -290,11 +331,17 @@ def _shape_paragraph( text: str, *, pronunciations: dict[str, str] | None, + dev_terms_flag: bool, expand_units_flag: bool, expand_numbers_flag: bool, pauses_flag: bool, locale: str, ) -> str: + # File refs first: it consumes the ":line" digits (so the number stages see a + # plain "line 42", not a decimal) and forms the spoken basename before the + # dev-term dict runs over it. + if dev_terms_flag: + text = speak_file_refs(text) if expand_numbers_flag: text = strip_thousands_separators(text) if pauses_flag: @@ -345,6 +392,7 @@ def normalize_for_speech( shaped = _shape_paragraph( para, pronunciations=effective_pronunciations, + dev_terms_flag=dev_terms, expand_units_flag=expand_units_flag, expand_numbers_flag=expand_numbers_flag, pauses_flag=pauses, diff --git a/tests/test_text.py b/tests/test_text.py index 6e44cc2..11329df 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,6 +8,7 @@ markdown_to_paragraphs, normalize_for_speech, shape_pauses, + speak_file_refs, strip_emoji, strip_thousands_separators, versions_to_words, @@ -54,6 +55,44 @@ def test_semver_normalizes_end_to_end(): assert "0 point 7.0" not in out +# --- file & line references ------------------------------------------------ + + +def test_file_ref_leads_with_line_then_file(): + assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 service ts" + + +def test_file_ref_drops_directories_to_basename(): + assert speak_file_refs("Open /abs/path/to/module.py:7.") == "Open line 7 of module py." + + +def test_file_ref_line_range(): + assert speak_file_refs("cli.py:100-118") == "lines 100 to 118 of cli py" + + +def test_file_ref_line_and_column(): + assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo ts" + + +def test_file_ref_leaves_times_ratios_verses_untouched(): + text = "Meet at 12:30, the ratio was 3:1, see John 3:16." + assert speak_file_refs(text) == text + + +def test_file_ref_leaves_dotted_versions_untouched(): + assert speak_file_refs("bump to 1.2.3 and 0.7.0") == "bump to 1.2.3 and 0.7.0" + + +def test_file_ref_normalizes_end_to_end(): + out = normalize_for_speech("See `engine.py:42` for the fix.", markdown=True) + assert out == "See line 42 of engine py for the fix." + + +def test_file_ref_disabled_with_dev_terms(): + out = normalize_for_speech("See engine.py:42.", markdown=False, dev_terms=False) + assert out == "See engine.py:42." + + # --- units -----------------------------------------------------------------