-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_parser.py
More file actions
194 lines (153 loc) · 5.75 KB
/
Copy path_parser.py
File metadata and controls
194 lines (153 loc) · 5.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
"""Parse taskmark content with inheritance resolution."""
from __future__ import annotations
import re
from functools import lru_cache
from importlib.resources import files
from pathlib import Path
from typing import TYPE_CHECKING
import yaml # type: ignore[import-untyped]
from lark import Lark, UnexpectedInput
from taskmark._resolver import InheritanceResolver
from taskmark._transformer import TaskmarkTransformer
from taskmark._types import Frontmatter, Task, TaskStore, Warning
if TYPE_CHECKING:
from typing import IO
def _get_grammar() -> str:
"""Load grammar from package resources."""
return files("taskmark").joinpath("grammar.lark").read_text()
@lru_cache(maxsize=1)
def _get_parser() -> Lark:
"""Get or create the parser instance (thread-safe via lru_cache)."""
return Lark(
_get_grammar(),
parser="earley",
propagate_positions=True,
)
# Pattern for list items that might be tasks but lack checkbox
_MAYBE_TASK_PATTERN = re.compile(r"^(\s*)[-*]\s+(?!\[[ xX!\-]\])")
def _detect_malformed_tasks(content: str, source_file: str | None) -> list[Warning]:
"""Detect list items that look like tasks but lack checkboxes."""
warnings: list[Warning] = []
for line_num, line in enumerate(content.split("\n"), start=1):
if _MAYBE_TASK_PATTERN.match(line):
warnings.append(
Warning(
line=line_num,
message=f"List item without checkbox: '{line.strip()}'",
source_file=source_file,
)
)
return warnings
# Pattern to detect YAML front matter
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _extract_frontmatter(
content: str, source_file: str | None
) -> tuple[str, Frontmatter | None, list[Warning]]:
"""Extract and parse YAML front matter from content.
Args:
content: Full content including potential front matter
source_file: Source file for warning reporting
Returns:
Tuple of (content_without_frontmatter, frontmatter, warnings)
"""
warnings: list[Warning] = []
match = _FRONTMATTER_PATTERN.match(content)
if not match:
return content, None, warnings
yaml_content = match.group(1)
remaining_content = content[match.end() :]
try:
data = yaml.safe_load(yaml_content)
if not isinstance(data, dict):
warnings.append(
Warning(
line=1,
message="Front matter must be a YAML mapping",
source_file=source_file,
)
)
return remaining_content, None, warnings
frontmatter = Frontmatter(
timezone=data.get("timezone"),
locale=data.get("locale"),
)
return remaining_content, frontmatter, warnings
except yaml.YAMLError as e:
warnings.append(
Warning(
line=1,
message=f"Invalid YAML in front matter: {e}",
source_file=source_file,
)
)
return remaining_content, None, warnings
def parse_string(
content: str,
source_file: str | None = None,
minutes_per_day: int = 480,
) -> TaskStore:
"""Parse markdown string into tasks with inheritance resolution.
Args:
content: Markdown content to parse
source_file: Optional source file path for error reporting
minutes_per_day: Minutes in a work day for duration conversion
Returns:
TaskStore with tasks and any warnings
"""
parser = _get_parser()
transformer = TaskmarkTransformer(minutes_per_day=minutes_per_day)
resolver = InheritanceResolver()
warnings: list[Warning] = []
tasks: list[Task] = []
# Extract front matter if present
content, frontmatter, fm_warnings = _extract_frontmatter(content, source_file)
warnings.extend(fm_warnings)
# Detect potential malformed tasks (list items without checkboxes)
warnings.extend(_detect_malformed_tasks(content, source_file))
# Ensure content ends with newline for grammar
if content and not content.endswith("\n"):
content += "\n"
try:
tree = parser.parse(content)
items = transformer.transform(tree)
# Process items through resolver
for task, line, task_warnings in resolver.process(items, source_file):
# Convert task-level warnings to Warnings
for msg in task_warnings:
warnings.append(Warning(line=line, message=msg, source_file=source_file))
tasks.append(task)
except UnexpectedInput as e:
warnings.append(
Warning(
line=e.line if hasattr(e, "line") else 0,
message=f"Parse error: {e}",
source_file=source_file,
)
)
return TaskStore(tasks=tasks, warnings=warnings, frontmatter=frontmatter)
def parse_file(
fp: str | Path | IO[str],
minutes_per_day: int = 480,
) -> TaskStore:
"""Parse a file into tasks.
Args:
fp: File path or file object
minutes_per_day: Minutes in a work day for duration conversion
Returns:
TaskStore with tasks and any warnings
Raises:
FileNotFoundError: If file doesn't exist
"""
source_file: str | None
if isinstance(fp, (str, Path)):
path = Path(fp)
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
content = path.read_text(encoding="utf-8")
source_file = str(path)
else:
# File object
content = fp.read()
name = getattr(fp, "name", None)
source_file = str(name) if name is not None else None
return parse_string(content, source_file=source_file, minutes_per_day=minutes_per_day)