diff --git a/src/scribe/services/snippets.py b/src/scribe/services/snippets.py index c1203f1..e1cdcac 100644 --- a/src/scribe/services/snippets.py +++ b/src/scribe/services/snippets.py @@ -76,9 +76,43 @@ def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str] return out -def _location_line(repo: str = "", path: str = "", symbol: str = "") -> str: - parts = [p.strip() for p in (repo, path, symbol) if (p or "").strip()] - return " · ".join(f"`{p}`" for p in parts) +def _normalize_locations(locations: list[dict] | None) -> list[dict]: + """Clean a list of {repo,path,symbol} locations: strip fields, drop wholly + empty entries, de-duplicate identical ones (order preserved). A merged + snippet carries several locations (one per call site); a fresh one carries + at most one.""" + out: list[dict] = [] + seen: set[tuple[str, str, str]] = set() + for loc in locations or []: + repo = (loc.get("repo") or "").strip() + path = (loc.get("path") or "").strip() + symbol = (loc.get("symbol") or "").strip() + if not (repo or path or symbol): + continue + key = (repo, path, symbol) + if key in seen: + continue + seen.add(key) + out.append({"repo": repo, "path": path, "symbol": symbol}) + return out + + +def _location_str(loc: dict) -> str: + """`repo` · `path` · `symbol` — only the non-empty parts.""" + parts = [(loc.get(k) or "").strip() for k in ("repo", "path", "symbol")] + return " · ".join(f"`{p}`" for p in parts if p) + + +def _render_location_block(locations: list[dict]) -> str | None: + """One `**Location:**` line for a single location; a `**Locations:**` bullet + list for several. None when there are none.""" + locs = [loc for loc in locations if _location_str(loc)] + if not locs: + return None + if len(locs) == 1: + return f"**Location:** {_location_str(locs[0])}" + lines = "\n".join(f"- {_location_str(loc)}" for loc in locs) + return f"**Locations:**\n{lines}" def compose_body( @@ -90,17 +124,28 @@ def compose_body( repo: str = "", path: str = "", symbol: str = "", + locations: list[dict] | None = None, ) -> str: """Render structured fields into the snippet body markdown. Empty fields are - omitted so the body stays clean.""" + omitted so the body stays clean. + + Locations: pass ``locations`` (a list of {repo,path,symbol}) for the general + multi-location case; the single ``repo``/``path``/``symbol`` params remain as + a back-compat shorthand for one location and are used only when ``locations`` + is not given. + """ + if locations is None: + locations = [{"repo": repo, "path": path, "symbol": symbol}] + locs = _normalize_locations(locations) + header: list[str] = [] if (when_to_use or "").strip(): header.append(f"**When to use:** {when_to_use.strip()}") if (signature or "").strip(): header.append(f"**Signature:** `{signature.strip()}`") - loc = _location_line(repo, path, symbol) - if loc: - header.append(f"**Location:** {loc}") + loc_block = _render_location_block(locs) + if loc_block: + header.append(loc_block) fence_lang = (language or "").strip().lower() code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" if header: @@ -113,15 +158,33 @@ def compose_body( _WHEN_RE = re.compile(r"^\*\*When to use:\*\*\s*(.+?)\s*$", re.MULTILINE) _SIG_RE = re.compile(r"^\*\*Signature:\*\*\s*(.+?)\s*$", re.MULTILINE) _LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE) +_LOCS_RE = re.compile( + r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE +) _CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) +def _parse_location_str(s: str) -> dict | None: + """Parse a `repo` · `path` · `symbol` fragment back into a location dict, or + None if it's empty.""" + raw = [p.strip().strip("`").strip() for p in (s or "").split("·")] + repo = raw[0] if len(raw) > 0 else "" + path = raw[1] if len(raw) > 1 else "" + symbol = raw[2] if len(raw) > 2 else "" + if not (repo or path or symbol): + return None + return {"repo": repo, "path": path, "symbol": symbol} + + def parse_snippet_fields( title: str, body: str, tags: list[str] | None = None ) -> dict: """Recover structured fields from a snippet note. Tolerant by design: a field that isn't present comes back empty and this never raises, so a hand-edited - body can't break the edit form.""" + body can't break the edit form. + + ``locations`` is a list of {repo,path,symbol}; ``repo``/``path``/``symbol`` + mirror the FIRST location for back-compat with the single-location callers.""" title = title or "" body = body or "" name, _, when_from_title = title.partition(" — ") @@ -133,6 +196,7 @@ def parse_snippet_fields( "repo": "", "path": "", "symbol": "", + "locations": [], "code": "", } @@ -142,11 +206,30 @@ def parse_snippet_fields( m = _SIG_RE.search(body) if m: fields["signature"] = m.group(1).strip().strip("`").strip() - m = _LOC_RE.search(body) + + # Locations: prefer the multi-location `**Locations:**` bullet list, else the + # legacy single `**Location:**` line. + locations: list[dict] = [] + m = _LOCS_RE.search(body) if m: - loc_parts = [p.strip().strip("`").strip() for p in m.group(1).split("·")] - for key, val in zip(("repo", "path", "symbol"), loc_parts): - fields[key] = val + for line in m.group(1).splitlines(): + line = line.strip() + if line.startswith("-"): + loc = _parse_location_str(line[1:]) + if loc: + locations.append(loc) + else: + m = _LOC_RE.search(body) + if m: + loc = _parse_location_str(m.group(1)) + if loc: + locations.append(loc) + fields["locations"] = locations + if locations: + fields["repo"] = locations[0]["repo"] + fields["path"] = locations[0]["path"] + fields["symbol"] = locations[0]["symbol"] + m = _CODE_RE.search(body) if m: fields["language"] = m.group(1).strip() @@ -241,29 +324,46 @@ async def update_snippet( repo: str | None = None, path: str | None = None, symbol: str | None = None, + locations: list[dict] | None = None, tags: list[str] | None = None, project_id: int | None = None, ): """Partial update: only fields passed (not None) change. Re-serializes the merged field set back into title/body/tags. Returns the Note, or None if the - id isn't a snippet.""" + id isn't a snippet. + + Locations: ``locations`` replaces the whole set; else a legacy single + ``repo``/``path``/``symbol`` overlays onto the first existing location; else + the existing locations are kept.""" note = await notes_svc.get_note(user_id, snippet_id) if note is None or note.note_type != SNIPPET_NOTE_TYPE: return None cur = parse_snippet_fields(note.title, note.body, note.tags) overlay = { - "name": name, "code": code, "language": language, "signature": signature, - "when_to_use": when_to_use, "repo": repo, "path": path, "symbol": symbol, + "name": name, "code": code, "language": language, + "signature": signature, "when_to_use": when_to_use, } merged = {**cur, **{k: v for k, v in overlay.items() if v is not None}} + if locations is not None: + merged_locations = _normalize_locations(locations) + elif repo is not None or path is not None or symbol is not None: + base = cur["locations"][0] if cur["locations"] else {"repo": "", "path": "", "symbol": ""} + merged_locations = _normalize_locations([{ + "repo": repo if repo is not None else base["repo"], + "path": path if path is not None else base["path"], + "symbol": symbol if symbol is not None else base["symbol"], + }]) + else: + merged_locations = cur["locations"] + fields: dict = { "title": compose_title(merged["name"], merged["when_to_use"]), "body": compose_body( code=merged["code"], language=merged["language"], signature=merged["signature"], when_to_use=merged["when_to_use"], - repo=merged["repo"], path=merged["path"], symbol=merged["symbol"], + locations=merged_locations, ), } # Recompute tags: keep any non-language, non-marker tags the note already had diff --git a/tests/test_services_snippets.py b/tests/test_services_snippets.py index 3371484..839a096 100644 --- a/tests/test_services_snippets.py +++ b/tests/test_services_snippets.py @@ -62,6 +62,66 @@ def test_parse_falls_back_to_tag_for_language(): assert got["language"] == "ruby" +def test_compose_body_multi_location_renders_bullet_list(): + body = s.compose_body( + code="x = 1", + locations=[ + {"repo": "scribe", "path": "a.py", "symbol": "f"}, + {"repo": "web", "path": "b.ts", "symbol": "g"}, + ], + ) + assert "**Locations:**" in body + assert "- `scribe` · `a.py` · `f`" in body + assert "- `web` · `b.ts` · `g`" in body + assert "**Location:**" not in body.replace("**Locations:**", "") + + +def test_compose_body_single_location_via_list_uses_singular_label(): + body = s.compose_body( + code="x = 1", locations=[{"repo": "scribe", "path": "a.py", "symbol": "f"}], + ) + assert "**Location:** `scribe` · `a.py` · `f`" in body + assert "**Locations:**" not in body + + +def test_normalize_locations_drops_empty_and_dedups(): + got = s._normalize_locations([ + {"repo": "scribe", "path": "a.py", "symbol": "f"}, + {"repo": "", "path": "", "symbol": ""}, # dropped (empty) + {"repo": "scribe", "path": "a.py", "symbol": "f"}, # dropped (dup) + {"repo": "web", "path": "", "symbol": ""}, + ]) + assert got == [ + {"repo": "scribe", "path": "a.py", "symbol": "f"}, + {"repo": "web", "path": "", "symbol": ""}, + ] + + +def test_parse_round_trips_multi_location(): + body = s.compose_body( + code="const x = 1", language="ts", + locations=[ + {"repo": "scribe", "path": "a.ts", "symbol": "f"}, + {"repo": "web", "path": "b.ts", "symbol": "g"}, + ], + ) + got = s.parse_snippet_fields("n — u", body, ["ts", "snippet"]) + assert got["locations"] == [ + {"repo": "scribe", "path": "a.ts", "symbol": "f"}, + {"repo": "web", "path": "b.ts", "symbol": "g"}, + ] + # repo/path/symbol mirror the first location for back-compat. + assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.ts", "f") + + +def test_parse_legacy_single_location_line_still_works(): + # A body written by the pre-multi-location serializer. + body = "**Location:** `scribe` · `a.py` · `f`\n\n```py\nx = 1\n```\n" + got = s.parse_snippet_fields("n — u", body, None) + assert got["locations"] == [{"repo": "scribe", "path": "a.py", "symbol": "f"}] + assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f") + + def test_snippet_to_dict_includes_parsed_fields(): class FakeNote: title = "debounce — rate-limit"