pi-mono-coding 0.1.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.
@@ -0,0 +1,374 @@
1
+ """
2
+ Shared diff computation utilities for the edit tool.
3
+ Used by both edit.py (for execution) and tool-execution.py (for preview rendering).
4
+
5
+ Python port of edit-diff.ts — uses difflib instead of npm's 'diff' package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import os
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Literal, Optional
15
+
16
+
17
+ def detect_line_ending(content: str) -> Literal["\r\n", "\n"]:
18
+ """Detect the dominant line ending in content."""
19
+ crlf_idx = content.find("\r\n")
20
+ lf_idx = content.find("\n")
21
+ if lf_idx == -1:
22
+ return "\n"
23
+ if crlf_idx == -1:
24
+ return "\n"
25
+ return "\r\n" if crlf_idx < lf_idx else "\n"
26
+
27
+
28
+ def normalize_to_lf(text: str) -> str:
29
+ """Normalize all line endings to LF."""
30
+ return text.replace("\r\n", "\n").replace("\r", "\n")
31
+
32
+
33
+ def restore_line_endings(text: str, ending: str) -> str:
34
+ """Restore line endings to the specified type."""
35
+ return text.replace("\n", "\r\n") if ending == "\r\n" else text
36
+
37
+
38
+ def normalize_for_fuzzy_match(text: str) -> str:
39
+ """
40
+ Normalize text for fuzzy matching. Applies progressive transformations:
41
+ - Strip trailing whitespace from each line
42
+ - Normalize smart quotes to ASCII equivalents
43
+ - Normalize Unicode dashes/hyphens to ASCII hyphen
44
+ - Normalize special Unicode spaces to regular space
45
+ """
46
+ # Strip trailing whitespace per line
47
+ result = "\n".join(line.rstrip() for line in text.split("\n"))
48
+ # Smart single quotes → '
49
+ result = re.sub("[\u2018\u2019\u201a\u201b]", "'", result)
50
+ # Smart double quotes → "
51
+ result = re.sub('[\u201c\u201d\u201e\u201f]', '"', result)
52
+ # Various dashes/hyphens → -
53
+ # U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
54
+ # U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
55
+ result = re.sub("[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]", "-", result)
56
+ # Special spaces → regular space
57
+ # U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
58
+ # U+205F medium math space, U+3000 ideographic space
59
+ result = re.sub("[\u00a0\u2002-\u200a\u202f\u205f\u3000]", " ", result)
60
+ return result
61
+
62
+
63
+ @dataclass
64
+ class FuzzyMatchResult:
65
+ """Result of a fuzzy text match."""
66
+
67
+ found: bool
68
+ """Whether a match was found."""
69
+
70
+ index: int
71
+ """The index where the match starts (in the content used for replacement)."""
72
+
73
+ match_length: int
74
+ """Length of the matched text."""
75
+
76
+ used_fuzzy_match: bool
77
+ """Whether fuzzy matching was used (False = exact match)."""
78
+
79
+ content_for_replacement: str
80
+ """The content to use for replacement operations.
81
+ When exact match: original content. When fuzzy match: normalized content."""
82
+
83
+
84
+ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
85
+ """
86
+ Find *old_text* in *content*, trying exact match first, then fuzzy match.
87
+
88
+ When fuzzy matching is used, the returned ``content_for_replacement`` is the
89
+ fuzzy-normalized version of the content (trailing whitespace stripped,
90
+ Unicode quotes/dashes normalised to ASCII).
91
+ """
92
+ # Try exact match first
93
+ exact_index = content.find(old_text)
94
+ if exact_index != -1:
95
+ return FuzzyMatchResult(
96
+ found=True,
97
+ index=exact_index,
98
+ match_length=len(old_text),
99
+ used_fuzzy_match=False,
100
+ content_for_replacement=content,
101
+ )
102
+
103
+ # Try fuzzy match – work entirely in normalized space
104
+ fuzzy_content = normalize_for_fuzzy_match(content)
105
+ fuzzy_old_text = normalize_for_fuzzy_match(old_text)
106
+ fuzzy_index = fuzzy_content.find(fuzzy_old_text)
107
+
108
+ if fuzzy_index == -1:
109
+ return FuzzyMatchResult(
110
+ found=False,
111
+ index=-1,
112
+ match_length=0,
113
+ used_fuzzy_match=False,
114
+ content_for_replacement=content,
115
+ )
116
+
117
+ # When fuzzy matching, we work in the normalized space for replacement.
118
+ # This means the output will have normalized whitespace/quotes/dashes,
119
+ # which is acceptable since we're fixing minor formatting differences anyway.
120
+ return FuzzyMatchResult(
121
+ found=True,
122
+ index=fuzzy_index,
123
+ match_length=len(fuzzy_old_text),
124
+ used_fuzzy_match=True,
125
+ content_for_replacement=fuzzy_content,
126
+ )
127
+
128
+
129
+ def strip_bom(content: str) -> tuple[str, str]:
130
+ """Strip UTF-8 BOM if present.
131
+
132
+ Returns:
133
+ A ``(bom, text)`` tuple where *bom* is the BOM character (or ``""``)
134
+ and *text* is the content without it.
135
+ """
136
+ if content.startswith("\ufeff"):
137
+ return ("\ufeff", content[1:])
138
+ return ("", content)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Internal helpers for line-level diffing (replaces npm 'diff' package)
143
+ # ---------------------------------------------------------------------------
144
+
145
+ @dataclass
146
+ class _DiffPart:
147
+ """Internal representation of a diff part, analogous to Diff.diffLines output."""
148
+
149
+ value: str
150
+ added: bool = False
151
+ removed: bool = False
152
+
153
+
154
+ def _diff_lines(old_content: str, new_content: str) -> list[_DiffPart]:
155
+ """Compute line-level diffs using :mod:`difflib`, returning a list of
156
+ :class:`_DiffPart` objects compatible with the npm ``Diff.diffLines`` shape.
157
+ """
158
+ old_lines = old_content.splitlines(True)
159
+ new_lines = new_content.splitlines(True)
160
+
161
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
162
+ parts: list[_DiffPart] = []
163
+
164
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
165
+ if tag == "equal":
166
+ parts.append(_DiffPart(value="".join(old_lines[i1:i2])))
167
+ elif tag == "replace":
168
+ parts.append(_DiffPart(value="".join(old_lines[i1:i2]), removed=True))
169
+ parts.append(_DiffPart(value="".join(new_lines[j1:j2]), added=True))
170
+ elif tag == "delete":
171
+ parts.append(_DiffPart(value="".join(old_lines[i1:i2]), removed=True))
172
+ elif tag == "insert":
173
+ parts.append(_DiffPart(value="".join(new_lines[j1:j2]), added=True))
174
+
175
+ return parts
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Public diff generation
180
+ # ---------------------------------------------------------------------------
181
+
182
+ def generate_diff_string(
183
+ old_content: str,
184
+ new_content: str,
185
+ context_lines: int = 4,
186
+ ) -> dict[str, str | int | None]:
187
+ """Generate a unified diff string with line numbers and context.
188
+
189
+ Returns:
190
+ A dict with keys ``"diff"`` (str) and ``"first_changed_line"``
191
+ (int | None).
192
+ """
193
+ parts = _diff_lines(old_content, new_content)
194
+ output: list[str] = []
195
+
196
+ old_lines = old_content.split("\n")
197
+ new_lines = new_content.split("\n")
198
+ max_line_num = max(len(old_lines), len(new_lines))
199
+ line_num_width = len(str(max_line_num))
200
+
201
+ old_line_num = 1
202
+ new_line_num = 1
203
+ last_was_change = False
204
+ first_changed_line: Optional[int] = None
205
+
206
+ for i, part in enumerate(parts):
207
+ raw = part.value.split("\n")
208
+ if raw and raw[-1] == "":
209
+ raw.pop()
210
+
211
+ if part.added or part.removed:
212
+ # Capture the first changed line (in the new file)
213
+ if first_changed_line is None:
214
+ first_changed_line = new_line_num
215
+
216
+ # Show the change
217
+ for line in raw:
218
+ if part.added:
219
+ line_num_str = str(new_line_num).rjust(line_num_width)
220
+ output.append(f"+{line_num_str} {line}")
221
+ new_line_num += 1
222
+ else: # removed
223
+ line_num_str = str(old_line_num).rjust(line_num_width)
224
+ output.append(f"-{line_num_str} {line}")
225
+ old_line_num += 1
226
+ last_was_change = True
227
+ else:
228
+ # Context lines – only show a few before/after changes
229
+ next_part_is_change = (
230
+ i < len(parts) - 1
231
+ and (parts[i + 1].added or parts[i + 1].removed)
232
+ )
233
+
234
+ if last_was_change or next_part_is_change:
235
+ lines_to_show = list(raw)
236
+ skip_start = 0
237
+ skip_end = 0
238
+
239
+ if not last_was_change:
240
+ # Show only last N lines as leading context
241
+ skip_start = max(0, len(raw) - context_lines)
242
+ lines_to_show = raw[skip_start:]
243
+
244
+ if not next_part_is_change and len(lines_to_show) > context_lines:
245
+ # Show only first N lines as trailing context
246
+ skip_end = len(lines_to_show) - context_lines
247
+ lines_to_show = lines_to_show[:context_lines]
248
+
249
+ # Add ellipsis if we skipped lines at start
250
+ if skip_start > 0:
251
+ output.append(f" {''.rjust(line_num_width)} ...")
252
+ old_line_num += skip_start
253
+ new_line_num += skip_start
254
+
255
+ for line in lines_to_show:
256
+ line_num_str = str(old_line_num).rjust(line_num_width)
257
+ output.append(f" {line_num_str} {line}")
258
+ old_line_num += 1
259
+ new_line_num += 1
260
+
261
+ # Add ellipsis if we skipped lines at end
262
+ if skip_end > 0:
263
+ output.append(f" {''.rjust(line_num_width)} ...")
264
+ old_line_num += skip_end
265
+ new_line_num += skip_end
266
+ else:
267
+ # Skip these context lines entirely
268
+ old_line_num += len(raw)
269
+ new_line_num += len(raw)
270
+
271
+ last_was_change = False
272
+
273
+ return {"diff": "\n".join(output), "first_changed_line": first_changed_line}
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # High-level edit-diff entry point
278
+ # ---------------------------------------------------------------------------
279
+
280
+ @dataclass
281
+ class EditDiffResult:
282
+ """Result of a successful diff computation."""
283
+
284
+ diff: str
285
+ first_changed_line: Optional[int]
286
+
287
+
288
+ @dataclass
289
+ class EditDiffError:
290
+ """Result when diff computation encounters an error."""
291
+
292
+ error: str
293
+
294
+
295
+ def compute_edit_diff(
296
+ path: str,
297
+ old_text: str,
298
+ new_text: str,
299
+ cwd: str,
300
+ ) -> EditDiffResult | EditDiffError:
301
+ """Compute the diff for an edit operation without applying it.
302
+
303
+ Used for preview rendering in the TUI before the tool executes.
304
+ """
305
+ # Resolve path relative to cwd
306
+ absolute_path = path if os.path.isabs(path) else os.path.join(cwd, path)
307
+
308
+ try:
309
+ # Check if file exists and is readable
310
+ if not os.path.isfile(absolute_path) or not os.access(absolute_path, os.R_OK):
311
+ return EditDiffError(error=f"File not found: {path}")
312
+
313
+ # Read the file
314
+ with open(absolute_path, encoding="utf-8") as f:
315
+ raw_content = f.read()
316
+
317
+ # Strip BOM before matching (LLM won't include invisible BOM in old_text)
318
+ _, content = strip_bom(raw_content)
319
+
320
+ normalized_content = normalize_to_lf(content)
321
+ normalized_old_text = normalize_to_lf(old_text)
322
+ normalized_new_text = normalize_to_lf(new_text)
323
+
324
+ # Find the old text using fuzzy matching (tries exact first, then fuzzy)
325
+ match_result = fuzzy_find_text(normalized_content, normalized_old_text)
326
+
327
+ if not match_result.found:
328
+ return EditDiffError(
329
+ error=(
330
+ f"Could not find the exact text in {path}. "
331
+ "The old text must match exactly including all whitespace and newlines."
332
+ ),
333
+ )
334
+
335
+ # Count occurrences using fuzzy-normalized content for consistency
336
+ fuzzy_content = normalize_for_fuzzy_match(normalized_content)
337
+ fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
338
+ occurrences = fuzzy_content.count(fuzzy_old_text)
339
+
340
+ if occurrences > 1:
341
+ return EditDiffError(
342
+ error=(
343
+ f"Found {occurrences} occurrences of the text in {path}. "
344
+ "The text must be unique. Please provide more context to make it unique."
345
+ ),
346
+ )
347
+
348
+ # Compute the new content using the matched position
349
+ # When fuzzy matching was used, content_for_replacement is the normalized version
350
+ base_content = match_result.content_for_replacement
351
+ new_content = (
352
+ base_content[: match_result.index]
353
+ + normalized_new_text
354
+ + base_content[match_result.index + match_result.match_length :]
355
+ )
356
+
357
+ # Check if it would actually change anything
358
+ if base_content == new_content:
359
+ return EditDiffError(
360
+ error=(
361
+ f"No changes would be made to {path}. "
362
+ "The replacement produces identical content."
363
+ ),
364
+ )
365
+
366
+ # Generate the diff
367
+ result = generate_diff_string(base_content, new_content)
368
+ return EditDiffResult(
369
+ diff=str(result["diff"]),
370
+ first_changed_line=int(result["first_changed_line"]) if result["first_changed_line"] is not None else None,
371
+ )
372
+
373
+ except Exception as err:
374
+ return EditDiffError(error=str(err))
pi_coding/utils/git.py ADDED
@@ -0,0 +1,201 @@
1
+ from dataclasses import dataclass
2
+ import subprocess
3
+ import re
4
+ from urllib.parse import urlparse
5
+ from typing import Optional
6
+
7
+ @dataclass
8
+ class GitSource:
9
+ """Parsed git URL information."""
10
+ repo: str
11
+ host: str
12
+ path: str
13
+ type: str = "git"
14
+ ref: Optional[str] = None
15
+ pinned: bool = False
16
+
17
+ def split_ref(url: str) -> dict:
18
+ """Split a git URL into repo and ref parts."""
19
+ # Handle SCP-like URLs: git@github.com:user/repo@ref
20
+ scp_match = re.match(r"^git@([^:]+):(.+)$", url)
21
+ if scp_match:
22
+ host = scp_match.group(1)
23
+ path_with_maybe_ref = scp_match.group(2)
24
+ if "@" in path_with_maybe_ref:
25
+ repo_path, ref = path_with_maybe_ref.split("@", 1)
26
+ if repo_path and ref:
27
+ return {
28
+ "repo": f"git@{host}:{repo_path}",
29
+ "ref": ref
30
+ }
31
+ return {"repo": url}
32
+
33
+ # Handle URLs with protocols: https://github.com/user/repo@ref
34
+ if "://" in url:
35
+ try:
36
+ parsed = urlparse(url)
37
+ path_with_maybe_ref = parsed.path.lstrip("/")
38
+ if "@" in path_with_maybe_ref:
39
+ repo_path, ref = path_with_maybe_ref.split("@", 1)
40
+ if repo_path and ref:
41
+ # Reconstruct URL without ref
42
+ new_path = f"/{repo_path}"
43
+ new_url = parsed._replace(path=new_path).geturl()
44
+ return {
45
+ "repo": new_url.rstrip("/"),
46
+ "ref": ref
47
+ }
48
+ return {"repo": url}
49
+ except Exception:
50
+ return {"repo": url}
51
+
52
+ # Handle shorthand: github.com/user/repo@ref
53
+ if "/" in url:
54
+ host, path_with_maybe_ref = url.split("/", 1)
55
+ if "@" in path_with_maybe_ref:
56
+ repo_path, ref = path_with_maybe_ref.split("@", 1)
57
+ if repo_path and ref:
58
+ return {
59
+ "repo": f"{host}/{repo_path}",
60
+ "ref": ref
61
+ }
62
+
63
+ return {"repo": url}
64
+
65
+ def parse_generic_git_url(url: str) -> Optional[GitSource]:
66
+ """Parse a git URL that doesn't match hosted patterns."""
67
+ split = split_ref(url)
68
+ repo_without_ref = split["repo"]
69
+ ref = split.get("ref")
70
+
71
+ repo = repo_without_ref
72
+ host = ""
73
+ path = ""
74
+
75
+ scp_match = re.match(r"^git@([^:]+):(.+)$", repo_without_ref)
76
+ if scp_match:
77
+ host = scp_match.group(1)
78
+ path = scp_match.group(2)
79
+ elif any(repo_without_ref.startswith(p) for p in ["https://", "http://", "ssh://", "git://"]):
80
+ try:
81
+ parsed = urlparse(repo_without_ref)
82
+ host = parsed.hostname or ""
83
+ path = parsed.path.lstrip("/")
84
+ except Exception:
85
+ return None
86
+ else:
87
+ if "/" not in repo_without_ref:
88
+ return None
89
+ host, path = repo_without_ref.split("/", 1)
90
+ if "." not in host and host != "localhost":
91
+ return None
92
+ repo = f"https://{repo_without_ref}"
93
+
94
+ normalized_path = path.replace(".git", "").lstrip("/")
95
+ if not host or not normalized_path or len(normalized_path.split("/")) < 2:
96
+ return None
97
+
98
+ return GitSource(
99
+ repo=repo,
100
+ host=host,
101
+ path=normalized_path,
102
+ ref=ref,
103
+ pinned=bool(ref)
104
+ )
105
+
106
+ def parse_git_url(source: str) -> Optional[GitSource]:
107
+ """
108
+ Parse git source into a GitSource.
109
+
110
+ Supports:
111
+ - git@github.com:user/repo
112
+ - https://github.com/user/repo
113
+ - git:github.com/user/repo
114
+ - Any of the above with @ref suffix
115
+ """
116
+ trimmed = source.strip()
117
+ has_git_prefix = trimmed.startswith("git:")
118
+ url = trimmed[4:].strip() if has_git_prefix else trimmed
119
+
120
+ # Without git: prefix, only accept explicit protocol URLs or SCP-like
121
+ if not has_git_prefix and not re.match(r"^(https?|ssh|git)://", url, re.I) and not url.startswith("git@"):
122
+ return None
123
+
124
+ # The TS implementation uses hosted-git-info which handles many shorthands.
125
+ # For this Python port, we'll focus on the core requirements and generic parsing.
126
+ # Since we don't have a direct equivalent of hosted-git-info in stdlib,
127
+ # and the task asks for a port of the logic, we'll use parse_generic_git_url
128
+ # which covers the requested formats.
129
+
130
+ return parse_generic_git_url(url)
131
+
132
+ def get_current_branch(cwd: Optional[str] = None) -> Optional[str]:
133
+ """Get current git branch."""
134
+ try:
135
+ result = subprocess.run(
136
+ ["git", "symbolic-ref", "--short", "HEAD"],
137
+ cwd=cwd,
138
+ capture_output=True,
139
+ text=True
140
+ )
141
+ if result.returncode == 0:
142
+ return result.stdout.strip()
143
+
144
+ result = subprocess.run(
145
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
146
+ cwd=cwd,
147
+ capture_output=True,
148
+ text=True
149
+ )
150
+ if result.returncode == 0:
151
+ return result.stdout.strip()
152
+
153
+ return None
154
+ except (subprocess.CalledProcessError, FileNotFoundError):
155
+ return None
156
+
157
+ def get_repo_status(cwd: Optional[str] = None) -> dict[str, bool]:
158
+ """Get repo status."""
159
+ try:
160
+ result = subprocess.run(
161
+ ["git", "status", "--porcelain"],
162
+ cwd=cwd,
163
+ capture_output=True,
164
+ text=True,
165
+ check=True
166
+ )
167
+ output = result.stdout
168
+ lines = output.splitlines()
169
+
170
+ has_unstaged_changes = False
171
+ has_staged_changes = False
172
+ has_untracked_files = False
173
+
174
+ for line in lines:
175
+ if len(line) < 2:
176
+ continue
177
+ index_status = line[0]
178
+ worktree_status = line[1]
179
+
180
+ if index_status == "?":
181
+ has_untracked_files = True
182
+ if index_status not in (" ", "?"):
183
+ has_staged_changes = True
184
+ if worktree_status not in (" ", "?"):
185
+ has_unstaged_changes = True
186
+
187
+ is_clean = not (has_unstaged_changes or has_staged_changes or has_untracked_files)
188
+
189
+ return {
190
+ "has_unstaged_changes": has_unstaged_changes,
191
+ "has_staged_changes": has_staged_changes,
192
+ "has_untracked_files": has_untracked_files,
193
+ "is_clean": is_clean
194
+ }
195
+ except (subprocess.CalledProcessError, FileNotFoundError):
196
+ return {
197
+ "has_unstaged_changes": False,
198
+ "has_staged_changes": False,
199
+ "has_untracked_files": False,
200
+ "is_clean": False
201
+ }
@@ -0,0 +1,129 @@
1
+ """Path resolution utilities — Python port of path-utils.ts.
2
+
3
+ Handles macOS-specific filename quirks (NFD normalization, curly quotes,
4
+ narrow no-break spaces in screenshot timestamps, etc.).
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import unicodedata
10
+
11
+ # Matches various Unicode space characters that should be normalized to ASCII space.
12
+ UNICODE_SPACES = re.compile(
13
+ "[\u00A0\u2000-\u200A\u202F\u205F\u3000]"
14
+ )
15
+
16
+ _NARROW_NO_BREAK_SPACE = "\u202F"
17
+
18
+ # Matches " AM." or " PM." preceded by a regular space (macOS screenshot timestamps).
19
+ _AM_PM_RE = re.compile(r" (AM|PM)\.")
20
+
21
+
22
+ def normalize_unicode_spaces(s: str) -> str:
23
+ """Replace exotic Unicode whitespace characters with plain ASCII space."""
24
+ return UNICODE_SPACES.sub(" ", s)
25
+
26
+
27
+ def try_macos_screenshot_path(file_path: str) -> str:
28
+ """Return *file_path* with narrow no-break space before AM/PM.
29
+
30
+ macOS screenshot filenames use U+202F (narrow no-break space) before the
31
+ AM/PM marker, but users typically type a regular space.
32
+ """
33
+ return _AM_PM_RE.sub(f"{_NARROW_NO_BREAK_SPACE}\\1.", file_path)
34
+
35
+
36
+ def try_nfd_variant(file_path: str) -> str:
37
+ """Return the NFD (decomposed) form of *file_path*.
38
+
39
+ macOS (HFS+/APFS) stores filenames in NFD form, so converting user input
40
+ to NFD can resolve mismatches caused by composed characters.
41
+ """
42
+ return unicodedata.normalize("NFD", file_path)
43
+
44
+
45
+ def try_curly_quote_variant(file_path: str) -> str:
46
+ """Replace straight apostrophes with right single quotation marks.
47
+
48
+ macOS uses U+2019 (') in screenshot names such as ``Capture d\u2019\u00e9cran``,
49
+ but users typically type U+0027 (').
50
+ """
51
+ return file_path.replace("'", "\u2019")
52
+
53
+
54
+ def file_exists(file_path: str) -> bool:
55
+ """Return ``True`` if *file_path* exists on disk (file or directory)."""
56
+ try:
57
+ os.stat(file_path)
58
+ return True
59
+ except OSError:
60
+ return False
61
+
62
+
63
+ def normalize_at_prefix(file_path: str) -> str:
64
+ """Strip a leading ``@`` prefix from *file_path*."""
65
+ if file_path.startswith("@"):
66
+ return file_path[1:]
67
+ return file_path
68
+
69
+
70
+ def expand_path(file_path: str) -> str:
71
+ """Expand ``~`` to the user's home directory and normalize Unicode spaces."""
72
+ normalized = normalize_unicode_spaces(normalize_at_prefix(file_path))
73
+ if normalized == "~":
74
+ return os.path.expanduser("~")
75
+ if normalized.startswith("~/"):
76
+ return os.path.expanduser("~") + normalized[1:]
77
+ return normalized
78
+
79
+
80
+ def resolve_to_cwd(file_path: str, cwd: str) -> str:
81
+ """Resolve *file_path* relative to *cwd*.
82
+
83
+ Handles ``~`` expansion and absolute paths.
84
+ """
85
+ expanded = expand_path(file_path)
86
+ if os.path.isabs(expanded):
87
+ return expanded
88
+ return os.path.normpath(os.path.join(cwd, expanded))
89
+
90
+
91
+ def resolve_read_path(file_path: str, cwd: str) -> str:
92
+ """Resolve *file_path* for reading, trying macOS filename variants.
93
+
94
+ Attempts the following variants in order until one exists on disk:
95
+
96
+ 1. Direct resolved path
97
+ 2. macOS AM/PM narrow no-break space variant
98
+ 3. NFD (decomposed) variant
99
+ 4. Curly quote variant (U+2019)
100
+ 5. Combined NFD + curly quote variant
101
+
102
+ If none exist, the original resolved path is returned.
103
+ """
104
+ resolved = resolve_to_cwd(file_path, cwd)
105
+
106
+ if file_exists(resolved):
107
+ return resolved
108
+
109
+ # Try macOS AM/PM variant (narrow no-break space before AM/PM)
110
+ am_pm_variant = try_macos_screenshot_path(resolved)
111
+ if am_pm_variant != resolved and file_exists(am_pm_variant):
112
+ return am_pm_variant
113
+
114
+ # Try NFD variant (macOS stores filenames in NFD form)
115
+ nfd_variant = try_nfd_variant(resolved)
116
+ if nfd_variant != resolved and file_exists(nfd_variant):
117
+ return nfd_variant
118
+
119
+ # Try curly quote variant (macOS uses U+2019 in screenshot names)
120
+ curly_variant = try_curly_quote_variant(resolved)
121
+ if curly_variant != resolved and file_exists(curly_variant):
122
+ return curly_variant
123
+
124
+ # Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
125
+ nfd_curly_variant = try_curly_quote_variant(nfd_variant)
126
+ if nfd_curly_variant != resolved and file_exists(nfd_curly_variant):
127
+ return nfd_curly_variant
128
+
129
+ return resolved