stylefix 0.7.0__py3-none-any.whl

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 (67) hide show
  1. stylefix/__init__.py +3 -0
  2. stylefix/abbreviations.py +200 -0
  3. stylefix/accept.txt +94 -0
  4. stylefix/adjudicate.py +27 -0
  5. stylefix/apply.py +3 -0
  6. stylefix/candidates.py +105 -0
  7. stylefix/changelog.py +66 -0
  8. stylefix/cli.py +415 -0
  9. stylefix/context.py +4 -0
  10. stylefix/conventions.py +172 -0
  11. stylefix/dict/README-varcon.txt +582 -0
  12. stylefix/dict/varcon.txt.gz +0 -0
  13. stylefix/dictionary.py +3 -0
  14. stylefix/llm.py +4 -0
  15. stylefix/patterns.py +217 -0
  16. stylefix/report.py +65 -0
  17. stylefix/spellcheck.py +68 -0
  18. stylefix/styleguides.py +153 -0
  19. stylefix/styleitems.py +221 -0
  20. stylefix/styles/AP/Numbers.yml +9 -0
  21. stylefix/styles/AP/Toward.yml +13 -0
  22. stylefix/styles/APA/Numbers.yml +9 -0
  23. stylefix/styles/Chicago/Numbers.yml +9 -0
  24. stylefix/styles/Chicago/Toward.yml +13 -0
  25. stylefix/styles/GPO/Numbers.yml +9 -0
  26. stylefix/styles/GPO/Toward.yml +13 -0
  27. stylefix/styles/Guardian/Numbers.yml +9 -0
  28. stylefix/styles/Guardian/Towards.yml +10 -0
  29. stylefix/styles/Oxford/Numbers.yml +9 -0
  30. stylefix/styles/Oxford/Towards.yml +10 -0
  31. stylefix/styles/ToUK/Ambiguous.yml +7 -0
  32. stylefix/styles/ToUK/Ise1.yml +1509 -0
  33. stylefix/styles/ToUK/Ise2.yml +1509 -0
  34. stylefix/styles/ToUK/Ise3.yml +1509 -0
  35. stylefix/styles/ToUK/Ise4.yml +1094 -0
  36. stylefix/styles/ToUK/Oxford1.yml +1509 -0
  37. stylefix/styles/ToUK/Oxford2.yml +1509 -0
  38. stylefix/styles/ToUK/Oxford3.yml +1509 -0
  39. stylefix/styles/ToUK/Oxford4.yml +1108 -0
  40. stylefix/styles/ToUK/PatEr.yml +8 -0
  41. stylefix/styles/ToUK/PatIse.yml +8 -0
  42. stylefix/styles/ToUK/PatIze.yml +8 -0
  43. stylefix/styles/ToUK/PatOr.yml +8 -0
  44. stylefix/styles/ToUK/Variants1.yml +1509 -0
  45. stylefix/styles/ToUK/Variants2.yml +1509 -0
  46. stylefix/styles/ToUK/Variants3.yml +1317 -0
  47. stylefix/styles/ToUS/Ambiguous.yml +7 -0
  48. stylefix/styles/ToUS/PatIse.yml +8 -0
  49. stylefix/styles/ToUS/PatLl.yml +8 -0
  50. stylefix/styles/ToUS/PatOur.yml +8 -0
  51. stylefix/styles/ToUS/PatRe.yml +8 -0
  52. stylefix/styles/ToUS/Variants1.yml +1509 -0
  53. stylefix/styles/ToUS/Variants2.yml +1509 -0
  54. stylefix/styles/ToUS/Variants3.yml +1509 -0
  55. stylefix/styles/ToUS/Variants4.yml +1509 -0
  56. stylefix/styles/ToUS/Variants5.yml +1509 -0
  57. stylefix/styles/ToUS/Variants6.yml +1509 -0
  58. stylefix/styles/ToUS/Variants7.yml +697 -0
  59. stylefix/vale_runner.py +113 -0
  60. stylefix/varcon.py +170 -0
  61. stylefix/wordlist.py +145 -0
  62. stylefix-0.7.0.dist-info/METADATA +290 -0
  63. stylefix-0.7.0.dist-info/RECORD +67 -0
  64. stylefix-0.7.0.dist-info/WHEEL +4 -0
  65. stylefix-0.7.0.dist-info/entry_points.txt +2 -0
  66. stylefix-0.7.0.dist-info/licenses/LICENSE +21 -0
  67. stylefix-0.7.0.dist-info/licenses/stylefix/dict/README-varcon.txt +582 -0
stylefix/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """stylefix: regularize Markdown to US or UK English and a style guide."""
2
+
3
+ __version__ = "0.7.0"
@@ -0,0 +1,200 @@
1
+ """Abbreviation forms the style guides rule on as a class.
2
+
3
+ Two general rules, from the guides themselves:
4
+
5
+ - Chicago 10.4 (and Oxford, Guardian): an abbreviation in full capitals
6
+ takes no periods, whether two letters or more (US, UK, EU, NATO, PhD,
7
+ MA, DC). Applied generally: every period-group of capitals is closed up.
8
+ The one hazard is personal initials ("J.K. Rowling"), which look the
9
+ same; a group followed by a capitalised word is therefore adjudicated,
10
+ as is the sentence-end case ("in the U.S. The ...").
11
+ - AP "abbreviations and acronyms" (and GPO): periods in most two-letter
12
+ abbreviations (U.S., U.N., U.K., B.A., B.C.), with named exceptions (AP,
13
+ GI, ID, EU); none in longer ones (FBI, NATO, USA). AP applies "most" to
14
+ countries, places, degrees and eras, not to AI, IT, PR or TV, so the keep
15
+ rule is applied by category. APA keeps U.S. but writes degrees without
16
+ periods (PhD).
17
+
18
+ Also: clock times (10 a.m. / 10am), vs., and the Latin tags e.g., i.e.,
19
+ etc. And the noun rule of GPO and APA: "U.S." only as an adjective; the
20
+ noun is "the United States". That one is a warning by default and an
21
+ enforcement on request (--abbreviation-nouns enforce), decided per
22
+ occurrence because it is a part-of-speech question."""
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass
28
+
29
+ from adjudicate import Edit, Item, prose_regions, sentence_context
30
+
31
+ # Period-groups of capitals, allowing a lowercase letter after a capital
32
+ # (Ph.D., B.Sc., D.Phil.), two or more parts.
33
+ _GROUP = re.compile(r"(?<![A-Za-z.])(?:[A-Z][a-z]?\.\s?){2,}(?<=\.)(?![A-Za-z])")
34
+ # Categories AP and GPO give periods to.
35
+ COUNTRIES = {"US": "U.S.", "UK": "U.K.", "UN": "U.N.", "DC": "D.C."}
36
+ DEGREES = {"BA": "B.A.", "MA": "M.A.", "PhD": "Ph.D.", "MD": "M.D.", "JD": "J.D.", "BS": "B.S.",
37
+ "MS": "M.S.", "BSc": "B.Sc.", "MSc": "M.Sc.", "LLB": "LL.B.", "LLM": "LL.M.",
38
+ "DPhil": "D.Phil."}
39
+ ERAS = {"BC": "B.C.", "AD": "A.D."}
40
+ KEEP_EXCEPTIONS = {"EU", "AP", "GI", "ID", "USA", "OK"} # AP's named exceptions
41
+ KNOWN = set(COUNTRIES) | set(DEGREES) | set(ERAS) | KEEP_EXCEPTIONS | {
42
+ "NY", "LA", "UAE", "USSR", "NATO", "FBI", "CIA", "NASA", "CEO", "VP", "CE", "BCE", "NYC"}
43
+
44
+ _TIME = re.compile(r"(?<=\d)\s?([aApP])\.?[mM]\.?(?![A-Za-z])")
45
+ _VS = re.compile(r"\bvs\.?(?=[\s,;:)])")
46
+ _EG = re.compile(r"\b[eE]\.?[gG]\.(?=[\s,;:)])|\beg(?=[\s,])")
47
+ _IE = re.compile(r"\b[iI]\.?[eE]\.(?=[\s,;:)])|\bie(?=[\s,])")
48
+ _ETC = re.compile(r"\betc\.?(?![A-Za-z])")
49
+ _NOUN_US = re.compile(r"\b[Tt]he (U\.S\.|US)(?![A-Za-z])")
50
+ # "the US" followed by something that cannot be a modified noun: a verb,
51
+ # a preposition, a conjunction, or punctuation. Used for the warning count.
52
+ _NOUN_TAIL = re.compile(r"\s+(?:and|or|is|has|have|had|will|would|should|could|can|must|may|might|"
53
+ r"to|in|on|at|by|for|with|from|as|than|that|which|who|does|did|do|was|were|"
54
+ r"are|be|also|still|now|then|already|itself|alone|too)\b|[.,;:!?)]")
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Abbreviations:
59
+ caps: str | None # "drop" (Chicago rule) | "keep" (AP/GPO categories) | None
60
+ degrees_periods: bool | None # under keep: Ph.D. (True) or PhD (False)
61
+ time: str | None # "a.m." | "am"
62
+ vs: str | None # "vs." | "vs" | "v"
63
+ latin_periods: bool | None
64
+ noun_rule: bool = False # guide says "U.S." is an adjective only
65
+
66
+
67
+ PROFILES = {
68
+ "chicago": Abbreviations("drop", None, "a.m.", "vs.", True),
69
+ "gpo": Abbreviations("keep", True, "a.m.", "vs.", True, noun_rule=True),
70
+ "ap": Abbreviations("keep", True, "a.m.", "vs.", True),
71
+ "apa": Abbreviations("keep", False, "a.m.", "vs.", True, noun_rule=True),
72
+ "oxford": Abbreviations("drop", None, "a.m.", "vs", True),
73
+ "guardian": Abbreviations("drop", None, "am", "v", False),
74
+ "none": Abbreviations(None, None, None, None, None),
75
+ }
76
+
77
+ DROP_RULE = ("The style guide writes abbreviations in capitals without periods. This one is followed "
78
+ "by a capitalised word, so it is one of: the last word of its sentence, part of a name "
79
+ "or title ('U.S. Treasury'), or a person's initials ('J.K. Rowling'). The readings:\n"
80
+ " A (sentence ends here): {a}\n B (name or title continues): {b}\n"
81
+ "Choose '{end}' for A, '{bare}' for B. KEEP if these are a person's initials, which keep "
82
+ "their periods.")
83
+ NOUN_RULE = ("{guide} uses '{abbr}' only as an adjective ('{abbr} policy', '{abbr} exports') and writes "
84
+ "the noun out: 'the United States'. Here it follows 'the'. Choose 'United States' if the "
85
+ "abbreviation stands alone as a noun (subject or object: 'the US has', 'in the US', "
86
+ "'the US and its allies'). Choose '{abbr}' if it modifies a following noun ('the US "
87
+ "economy'). KEEP only if neither.")
88
+
89
+
90
+ def _line_col(text: str, pos: int) -> tuple[int, int]:
91
+ return text.count("\n", 0, pos) + 1, pos - (text.rfind("\n", 0, pos) + 1) + 1
92
+
93
+
94
+ def _edit(text: str, start: int, end: int, replacement: str) -> Edit:
95
+ line, col = _line_col(text, start)
96
+ return Edit(line, col, col + (end - start) - 1, text[start:end], replacement, "abbreviations")
97
+
98
+
99
+ def _item(text: str, s: int, e: int, options: tuple[str, ...], rule: str, next_id: int) -> Item:
100
+ line, col = _line_col(text, s)
101
+ return Item(next_id, text[s:e], options, rule, sentence_context(text, s, e),
102
+ line, col, col + (e - s) - 1, source="abbreviation")
103
+
104
+
105
+ def bare(group: str) -> str:
106
+ return group.replace(".", "").replace(" ", "")
107
+
108
+
109
+ def plan(text: str, guide: str, nouns: str = "default", next_id: int = 1
110
+ ) -> tuple[list[Edit], list[Item], list[tuple[int, str]]]:
111
+ """Deterministic edits, adjudication items, and noun-rule warnings
112
+ (line, phrase). `nouns`: default (warn if the guide has the rule),
113
+ warn, enforce, off."""
114
+ prof = PROFILES.get(guide, PROFILES["none"])
115
+ noun_mode = nouns if nouns != "default" else ("warn" if prof.noun_rule else "off")
116
+ edits: list[Edit] = []
117
+ items: list[Item] = []
118
+ warnings: list[tuple[int, str]] = []
119
+ claimed: set[int] = set() # start offsets already handled by a noun item
120
+
121
+ for rs, re_ in prose_regions(text):
122
+ region = text[rs:re_]
123
+
124
+ # --- the noun rule: "the U.S." / "the US"
125
+ if prof.noun_rule and noun_mode != "off":
126
+ for m in _NOUN_US.finditer(region):
127
+ s, e = rs + m.start(1), rs + m.end(1)
128
+ tail = region[m.end():m.end() + 12]
129
+ if noun_mode == "enforce":
130
+ canonical = COUNTRIES["US"] if prof.caps == "keep" else "US"
131
+ rule = NOUN_RULE.format(guide={"gpo": "GPO", "apa": "APA"}.get(guide, "The guide"),
132
+ abbr=canonical)
133
+ items.append(_item(text, s, e, ("United States", canonical, "KEEP"), rule, next_id))
134
+ next_id += 1
135
+ claimed.add(s)
136
+ elif _NOUN_TAIL.match(tail):
137
+ warnings.append((_line_col(text, s)[0], text[s - 4:e + min(12, len(tail))].strip()))
138
+
139
+ # --- capitals: drop (general) or keep (by category)
140
+ if prof.caps == "drop":
141
+ for m in _GROUP.finditer(region):
142
+ s, e = rs + m.start(), rs + m.end()
143
+ if s in claimed:
144
+ continue
145
+ group = m.group(0)
146
+ canonical = bare(group)
147
+ following = region[m.end():m.end() + 3]
148
+ if re.match(r"\s*$", following) or following.startswith("\n"):
149
+ edits.append(_edit(text, s, e, canonical + ".")) # paragraph end
150
+ elif re.match(r"\s+[A-Z“\"(]", following):
151
+ lo, hi = max(rs, s - 45), min(re_, e + 45)
152
+ before, after = text[lo:s], text[e:hi]
153
+ rule = DROP_RULE.format(a="…" + before + canonical + "." + after + "…",
154
+ b="…" + before + canonical + after + "…",
155
+ end=canonical + ".", bare=canonical)
156
+ items.append(_item(text, s, e, (canonical + ".", canonical, "KEEP"), rule, next_id))
157
+ next_id += 1
158
+ else:
159
+ edits.append(_edit(text, s, e, canonical))
160
+ elif prof.caps == "keep":
161
+ table = dict(COUNTRIES) | dict(ERAS)
162
+ if prof.degrees_periods:
163
+ table |= DEGREES
164
+ else:
165
+ # APA: degrees without periods; U.S. with.
166
+ for k, v in DEGREES.items():
167
+ table[v] = k
168
+ alts = "|".join(re.escape(k) for k in sorted(table, key=len, reverse=True))
169
+ for m in re.finditer(rf"(?<![A-Za-z.])(?:{alts})(?![A-Za-z])", region):
170
+ s, e = rs + m.start(), rs + m.end()
171
+ if s in claimed:
172
+ continue
173
+ want = table[m.group(0)]
174
+ if want.endswith(".") and text[e:e + 1] == "." and text[e:e + 2] != "..":
175
+ e += 1 # absorb the sentence period
176
+ edits.append(_edit(text, s, e, want))
177
+
178
+ # --- times
179
+ if prof.time:
180
+ for m in _TIME.finditer(region):
181
+ letter = m.group(1).lower()
182
+ want = (" " + letter + ".m.") if prof.time == "a.m." else (letter + "m")
183
+ if m.group(0) != want:
184
+ edits.append(_edit(text, rs + m.start(), rs + m.end(), want))
185
+ # --- vs
186
+ if prof.vs:
187
+ for m in _VS.finditer(region):
188
+ if m.group(0) != prof.vs:
189
+ edits.append(_edit(text, rs + m.start(), rs + m.end(), prof.vs))
190
+ # --- e.g., i.e., etc.
191
+ if prof.latin_periods is not None:
192
+ for rx, with_, without in ((_EG, "e.g.", "eg"), (_IE, "i.e.", "ie"), (_ETC, "etc.", "etc")):
193
+ want = with_ if prof.latin_periods else without
194
+ for m in rx.finditer(region):
195
+ if m.group(0) != want:
196
+ e = rs + m.end()
197
+ if want.endswith(".") and text[e:e + 1] == "." and text[e:e + 2] != "..":
198
+ e += 1
199
+ edits.append(_edit(text, rs + m.start(), e, want))
200
+ return edits, items, warnings
stylefix/accept.txt ADDED
@@ -0,0 +1,94 @@
1
+ # Terms that Vale's bundled dictionary lacks but that are correct in both
2
+ # US and UK English. Technical, policy and internet vocabulary. One per
3
+ # line; case-insensitive; Vale treats each as a regex, so keep it simple.
4
+ # Users add their own in ~/.config/stylefix/accept.txt and ./.stylefix/accept.txt.
5
+ cybersecurity
6
+ cyber
7
+ cyberattack
8
+ cyberattacks
9
+ cybercrime
10
+ cyberspace
11
+ sandboxing
12
+ sandboxed
13
+ sandbox
14
+ auditable
15
+ auditability
16
+ longterm
17
+ interoperability
18
+ interoperable
19
+ scalable
20
+ scalability
21
+ misinformation
22
+ disinformation
23
+ deepfake
24
+ deepfakes
25
+ chatbot
26
+ chatbots
27
+ blockchain
28
+ cryptocurrency
29
+ cryptocurrencies
30
+ malware
31
+ ransomware
32
+ spyware
33
+ phishing
34
+ botnet
35
+ botnets
36
+ exfiltrate
37
+ exfiltration
38
+ containerization
39
+ containerized
40
+ virtualization
41
+ hyperscaler
42
+ hyperscalers
43
+ preprint
44
+ preprints
45
+ misalignment
46
+ misaligned
47
+ superintelligence
48
+ superintelligent
49
+ multimodal
50
+ transformer
51
+ transformers
52
+ finetune
53
+ finetuned
54
+ finetuning
55
+ pretraining
56
+ pretrained
57
+ inference
58
+ agentic
59
+ autonomously
60
+ onboarding
61
+ offboarding
62
+ roadmap
63
+ roadmaps
64
+ workstream
65
+ workstreams
66
+ stakeholder
67
+ stakeholders
68
+ geopolitical
69
+ geopolitics
70
+ geo-strategic
71
+ whistleblower
72
+ whistleblowers
73
+ open-source
74
+ open-sourced
75
+ opensource
76
+ frontier
77
+ biosecurity
78
+ bioweapon
79
+ bioweapons
80
+ dual-use
81
+ zero-day
82
+ API
83
+ APIs
84
+ GPU
85
+ GPUs
86
+ TPU
87
+ TPUs
88
+ LLM
89
+ LLMs
90
+ AGI
91
+ NIST
92
+ CISA
93
+ OSTP
94
+ DARPA
stylefix/adjudicate.py ADDED
@@ -0,0 +1,27 @@
1
+ """stylefix's adjudication task on top of the adjudicate library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from adjudicate.core import (Cache, Decision, Item, decide_batch, parse_response, # noqa: F401
6
+ render_batch, system_prompt)
7
+ from adjudicate.core import adjudicate as _adjudicate
8
+ from adjudicate.llm import Endpoint
9
+
10
+ VARIETY = {"us": "American English", "uk": "British English"}
11
+
12
+ TASK = "You are a meticulous copy editor regularizing a document to {variety}."
13
+ KEEP_RULE = ("Choose KEEP when the marked text is part of a proper name, an organisation name, "
14
+ "the title of a published work, a direct quotation from a source written in another "
15
+ "variety, a URL or code identifier, or is already correct for {variety} in this "
16
+ "context. Otherwise choose the option that is correct for {variety}.")
17
+
18
+
19
+ def system_for(target: str) -> str:
20
+ v = VARIETY[target]
21
+ return system_prompt(TASK.format(variety=v), KEEP_RULE.format(variety=v))
22
+
23
+
24
+ def adjudicate(ep: Endpoint | None, target: str, items: list[Item], cache: Cache,
25
+ batch_size: int = 20, parallel: int = 4, verbose: bool = False) -> list[Decision]:
26
+ return _adjudicate(ep, target, items, cache, batch_size, parallel, verbose,
27
+ system=system_for(target), log_name="stylefix")
stylefix/apply.py ADDED
@@ -0,0 +1,3 @@
1
+ """Edit application: re-exported from the adjudicate library."""
2
+
3
+ from adjudicate.apply import Edit, apply_edits, line_offsets, match_case, to_offsets # noqa: F401
stylefix/candidates.py ADDED
@@ -0,0 +1,105 @@
1
+ """Ambiguous words: the options the adjudicator may choose between."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Candidate:
10
+ options: tuple[str, ...] # first option is the variety-specific form
11
+ rule: str
12
+
13
+
14
+ def _pair(uk_or_us: str, same: str, rule: str, plural: str | None = None,
15
+ plural_same: str | None = None) -> dict[str, Candidate]:
16
+ out = {same: Candidate((uk_or_us, same, "KEEP"), rule)}
17
+ if plural:
18
+ out[plural_same or same + "s"] = Candidate((plural, plural_same or same + "s", "KEEP"), rule)
19
+ return out
20
+
21
+
22
+ TO_UK: dict[str, Candidate] = {}
23
+ TO_UK.update(_pair("licence", "license",
24
+ "British: 'licence' is the noun (a driving licence); 'license' is the verb (to license software).",
25
+ "licences"))
26
+ TO_UK.update(_pair("practise", "practice",
27
+ "British: 'practice' is the noun (medical practice, in practice); 'practise' is the verb (to practise the piano).",
28
+ "practises"))
29
+ TO_UK.update(_pair("programme", "program",
30
+ "British: 'programme' for broadcasts, events, plans and schedules; 'program' only for computer software.",
31
+ "programmes"))
32
+ TO_UK.update(_pair("cheque", "check",
33
+ "British: 'cheque' only for the bank instrument; 'check' for verifying, restraining, chess, patterns.",
34
+ "cheques"))
35
+ TO_UK.update(_pair("storey", "story",
36
+ "British: 'storey' is a floor of a building; 'story' is a narrative.",
37
+ "storeys", "stories"))
38
+ TO_UK.update(_pair("tyre", "tire",
39
+ "British: 'tyre' is the rubber ring on a wheel; 'tire' is the verb (to grow weary).",
40
+ "tyres"))
41
+ TO_UK.update(_pair("metre", "meter",
42
+ "British: 'metre' is the unit of length (and poetic metre); 'meter' is a measuring device (gas meter).",
43
+ "metres"))
44
+ TO_UK.update(_pair("kerb", "curb",
45
+ "British: 'kerb' is the edge of a pavement; 'curb' is the verb (to restrain).",
46
+ "kerbs"))
47
+ TO_UK.update(_pair("enquiry", "inquiry",
48
+ "British: 'enquiry' is an ordinary question or request; 'inquiry' is a formal investigation.",
49
+ "enquiries", "inquiries"))
50
+ TO_UK.update(_pair("draught", "draft",
51
+ "British: 'draught' for air currents, beer, ships' depth, draught animals, and the game; 'draft' for preliminary versions, banking, and conscription.",
52
+ "draughts"))
53
+ TO_UK.update(_pair("judgement", "judgment",
54
+ "British: 'judgement' in general use; 'judgment' for a court's formal decision.",
55
+ "judgements"))
56
+ TO_UK.update(_pair("learnt", "learned",
57
+ "British: 'learnt' as the past tense/participle of learn; 'learned' (two syllables) as the adjective meaning scholarly."))
58
+ TO_UK.update(_pair("disc", "disk",
59
+ "British: 'disc' for optical discs, brake discs, anatomy and flat round objects; 'disk' for magnetic computer disks.",
60
+ "discs"))
61
+ TO_UK.update(_pair("tonne", "ton",
62
+ "British: 'tonne' is the metric unit (1000 kg); 'ton' is the imperial unit or figurative ('tons of').",
63
+ "tonnes"))
64
+ TO_UK.update(_pair("whisky", "whiskey",
65
+ "British: 'whisky' for Scotch and Canadian; 'whiskey' for Irish and American.",
66
+ "whiskies", "whiskeys"))
67
+ TO_UK.update(_pair("mum", "mom",
68
+ "British: 'mum' for mother; keep 'mom' only inside a quotation or a name.",
69
+ "mums"))
70
+
71
+ TO_US: dict[str, Candidate] = {}
72
+ TO_US.update(_pair("disk", "disc",
73
+ "American: 'disk' for computer disks and general use; 'disc' for optical discs (compact disc), brake discs and anatomy.",
74
+ "disks"))
75
+ TO_US.update(_pair("metric ton", "tonne",
76
+ "American: 'metric ton' for 1000 kg; keep 'tonne' only inside a quotation or a proper name.",
77
+ "metric tons"))
78
+ TO_US.update(_pair("whiskey", "whisky",
79
+ "American: 'whiskey' in general; 'whisky' stays for Scotch and Canadian.",
80
+ "whiskeys", "whiskys"))
81
+ TO_US.update(_pair("mom", "mum",
82
+ "American: 'mom' for mother; keep 'mum' when it means silent ('keep mum') or the flower.",
83
+ "moms"))
84
+
85
+ TABLES = {"uk": TO_UK, "us": TO_US}
86
+
87
+
88
+ VARIETY_NAME = {"us": "American", "uk": "British", "oxford": "British (Oxford)"}
89
+
90
+
91
+ def lookup(target: str, word: str, oxford: bool = False) -> Candidate | None:
92
+ """Hand-written entry first; otherwise one built from VarCon's glosses."""
93
+ hit = TABLES[target].get(word.lower())
94
+ if hit:
95
+ return hit
96
+ from . import varcon
97
+ key = "oxford" if (target == "uk" and oxford) else target
98
+ m = varcon.maps(key)
99
+ forms = m.ambiguous.get(word.lower())
100
+ if not forms:
101
+ return None
102
+ rule = m.rule(word.lower(), VARIETY_NAME[key]) or (
103
+ f"{VARIETY_NAME[key]}: both spellings exist with different meanings; choose the one "
104
+ "that fits the sentence.")
105
+ return Candidate(tuple(sorted(forms, key=lambda f: (f == word.lower(), f))) + ("KEEP",), rule)
stylefix/changelog.py ADDED
@@ -0,0 +1,66 @@
1
+ """Plain-text change log: one line per edit, for --changes and for callers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from .adjudicate import Decision
8
+ from .apply import Edit, match_case
9
+ from .conventions import Stats
10
+ from .vale_runner import Alert
11
+
12
+
13
+ def _context(text: str, edit: Edit, width: int = 28) -> str:
14
+ lines = text.split("\n")
15
+ line = lines[edit.line - 1] if edit.line - 1 < len(lines) else ""
16
+ s, e = edit.start - 1, edit.end
17
+ left = line[max(0, s - width):s]
18
+ right = line[e:e + width]
19
+ return f"{'…' if s > width else ''}{left}[{line[s:e]}]{right}{'…' if len(line) > e + width else ''}"
20
+
21
+
22
+ def render(*, text: str, target: str, applied: list[Edit], decisions: list[Decision],
23
+ stats: Stats, skipped: list[Edit] | None = None,
24
+ review: list[Alert] | None = None) -> str:
25
+ """Every change, grouped: word edits in document order, then punctuation
26
+ counts, then adjudications that were kept or left undecided."""
27
+ by_pos = {(d.item.line, d.item.start): d for d in decisions}
28
+ out = [f"stylefix changes (target {target.upper()}): {len(applied)} word edit(s)"]
29
+ for e in sorted(applied, key=lambda e: (e.line, e.start)):
30
+ d = by_pos.get((e.line, e.start))
31
+ how = "auto"
32
+ if e.source == "adjudicated" and d:
33
+ how = f"model {d.confidence:.2f}: {d.reason}"
34
+ if d.item.source == "style":
35
+ how = "style, " + how
36
+ elif e.source == "adjudicated":
37
+ how = "model"
38
+ shown = match_case(e.original, e.replacement).strip() or repr(e.replacement)
39
+ out.append(f" L{e.line:<4} {e.original.strip()} -> {shown:<14} {how}")
40
+ out.append(f" {_context(text, e)}")
41
+ kept = [d for d in decisions if d.choice == "KEEP"
42
+ or (d.choice and d.choice.lower() == d.item.word.lower())]
43
+ if kept:
44
+ out.append(f"kept after adjudication: {len(kept)}")
45
+ for d in kept:
46
+ out.append(f" L{d.item.line:<4} {d.item.word:<20} {d.confidence:.2f}: {d.reason}")
47
+ undecided = [d for d in decisions if d.choice is None]
48
+ if undecided:
49
+ out.append(f"undecided (left as is, see report): {len(undecided)}")
50
+ for d in undecided:
51
+ out.append(f" L{d.item.line:<4} {d.item.word:<20} {d.reason}")
52
+ if skipped:
53
+ out.append(f"skipped (text moved): {len(skipped)}")
54
+ punct = stats.as_dict()
55
+ if punct:
56
+ out.append("punctuation: " + ", ".join(f"{k.replace('_', ' ')} {v}" for k, v in punct.items()))
57
+ if review:
58
+ out.append(f"for review (not changed): {len(review)}")
59
+ by_check: dict[str, list[Alert]] = defaultdict(list)
60
+ for a in review:
61
+ by_check[a.check].append(a)
62
+ for check, items in sorted(by_check.items()):
63
+ out.append(f" {check}")
64
+ for a in items:
65
+ out.append(f" L{a.line:<4} {a.message}")
66
+ return "\n".join(out) + "\n"