1"""Frontmatter read/write utilities for little-loops.

2

3 Provides shared YAML-subset frontmatter parsing, stripping, and updating

4 used by issue_parser, sync, and issue_history modules.

5"""

6

7 from __future__ import annotations

8

9 import logging

10 import re

11 import textwrap

12 from typing import Any

13

14 import yaml

15

16 logger = logging.getLogger(__name__)

17

18 STATUS_SYNONYMS: dict[str, str] = {

19 "complete": "done",

20 "completed": "done",

21 "finished": "done",

22 "closed": "done",

23 "in-progress": "in_progress",

24 "in progress": "in_progress",

25 "wip": "in_progress",

26 "pending": "open",

27}

28

29

30 def parse_frontmatter(content: str, *, coerce_types: bool = False) -> dict[str, Any]:

31 """Extract YAML frontmatter from content.

32

33 Looks for content between opening and closing '---' markers.

34 Parses a subset of YAML: simple ``key: value`` pairs, YAML block

35 sequences (``key:`` followed by ``- item`` lines), and block scalars

36 (``key: |`` or ``key: >`` followed by indented lines). Nested

37 structures are not supported and will emit a ``logging.WARNING``.

38 Returns empty dict if no frontmatter found.

39

40 Args:

41 content: File content to parse

42 coerce_types: If True, coerce digit strings to int

43

44 Returns:

45 Dictionary of frontmatter fields, or empty dict

46 """

47 if not content or not content.startswith("---"):

48 return {}

49

50 end_match = re.search(r"\n---\s*\n", content[3:])

51 if not end_match:

52 return {}

53

54 frontmatter_text = content[4 : 3 + end_match.start()]

55

56 result: dict[str, Any] = {}

57 current_list_key: str | None = None

58 lines = frontmatter_text.split("\n")

59 i = 0

60 while i < len(lines):

61 line = lines[i].strip()

62 i += 1

63 if not line or line.startswith("#"):

64 continue

65 if line.startswith("- "):

66 if current_list_key is not None:

67 result[current_list_key].append(line[2:].strip())

68 else:

69 logger.warning("Unsupported YAML list syntax in frontmatter: %r", line)

70 continue

71 # Non-list line: finalize any in-progress empty list, then reset

72 if current_list_key is not None and result[current_list_key] == []:

73 result[current_list_key] = None

74 current_list_key = None

75 if ":" in line:

76 key, value = line.split(":", 1)

77 key = key.strip()

78 value = value.strip()

79 if value.startswith("|") or value.startswith(">"):

80 # Block scalar: collect indented continuation lines

81 block_type = value[0]

82 block_lines: list[str] = []

83 while i < len(lines):

84 next_line = lines[i]

85 if next_line and (next_line[0] == " " or next_line[0] == "\t"):

86 block_lines.append(next_line)

87 i += 1

88 else:

89 break

90 if block_lines:

91 dedented = textwrap.dedent("\n".join(block_lines))

92 if block_type == ">":

93 dedented = re.sub(r"\s+", " ", dedented).strip()

94 result[key] = dedented

95 else:

96 result[key] = ""

97 continue

98 if value.startswith("[") and value.endswith("]"):

99 inner = value[1:-1].strip()

100 result[key] = [item.strip() for item in inner.split(",")] if inner else []

101 continue

102 if value.lower() in ("null", "~", ""):

103 if value == "":

104 result[key] = []

105 current_list_key = key

106 else:

107 result[key] = None

108 elif coerce_types and value.isdigit():

109 result[key] = int(value)

110 else:

111 # Strip surrounding YAML string quotes (single or double)

112 if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):

113 value = value[1:-1]

114 result[key] = value

115 # Finalize any trailing empty list key

116 if current_list_key is not None and result[current_list_key] == []:

117 result[current_list_key] = None

118 if "status" in result and isinstance(result["status"], str):

119 result["status"] = STATUS_SYNONYMS.get(result["status"], result["status"])

120 return result

121

122

123 def parse_skill_frontmatter(text: str) -> dict[str, str]:

124 """Extract flat key/value pairs from SKILL.md frontmatter.

125

126 Uses ``yaml.safe_load`` so YAML block scalars (e.g. ``description: |``)

127 are resolved to their string content instead of the indicator literal.

128 Non-string scalar values are stringified; nested structures are dropped.

129

130 If the frontmatter is not valid YAML (e.g. unquoted colons in values),

131 falls back to a permissive line-based scan — top-level ``key: value``

132 pairs only, block scalars are not resolved in that path.

133

134 This is the canonical SKILL.md frontmatter parser. Prefer it over the

135 general ``parse_frontmatter`` for SKILL.md files: ``parse_frontmatter``

136 deliberately drops block scalars (logs a warning, sets value to

137 ``None``) which loses the description body for skills that use

138 ``description: |``.

139 """

140 if not text.startswith("---"):

141 return {}

142 end = text.find("---", 3)

143 if end == -1:

144 return {}

145 fm_text = text[3:end]

146 try:

147 loaded = yaml.safe_load(fm_text)

148 except yaml.YAMLError:

149 loaded = None

150 if isinstance(loaded, dict):

151 fm: dict[str, str] = {}

152 for key, value in loaded.items():

153 if value is None:

154 fm[str(key)] = ""

155 elif isinstance(value, str):

156 fm[str(key)] = value

157 elif isinstance(value, bool | int | float):

158 fm[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)

159 return fm

160 fm = {}

161 for line in fm_text.splitlines():

162 if line and not line.startswith(" ") and not line.startswith("\t") and ":" in line:

163 key, _, val = line.partition(":")

164 fm[key.strip()] = val.strip()

165 return fm

166

167

168 def strip_frontmatter(content: str) -> str:

169 """Remove YAML frontmatter from content, returning the body.

170

171 Strips the ``---`` delimited frontmatter block (if present) and

172 returns everything after the closing delimiter.

173

174 Args:

175 content: File content possibly starting with frontmatter

176

177 Returns:

178 Content with frontmatter removed. Returns original content

179 unchanged if no valid frontmatter block is found.

180 """

181 if not content or not content.startswith("---"):

182 return content

183

184 end_match = re.search(r"\n---\s*\n", content[3:])

185 if not end_match:

186 return content

187

188 return content[3 + end_match.end() :]

189

190

191 def update_frontmatter(content: str, updates: dict[str, Any]) -> str:

192 """Update or add frontmatter fields in content.

193

194 Merges ``updates`` into an existing ``---`` delimited YAML frontmatter

195 block, preserving other fields and their order. If no frontmatter block

196 exists, a new one is prepended. Existing keys are overwritten with the

197 new values.

198

199 Args:

200 content: Full file content, possibly with existing frontmatter

201 updates: Fields to add/update in frontmatter; values may be nested dicts

202

203 Returns:

204 Content with updated frontmatter block

205 """

206 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)

207 if not fm_match:

208 fm_text = yaml.dump(dict(updates), default_flow_style=False, sort_keys=False).strip()

209 return f"---\n{fm_text}\n---\n{content}"

210

211 existing: dict[str, Any] = yaml.safe_load(fm_match.group(1)) or {}

212 existing.update(updates)

213 fm_text = yaml.dump(existing, default_flow_style=False, sort_keys=False).strip()

214 return f"---\n{fm_text}\n---{content[fm_match.end() :]}"