raggiecode 0.2.1__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 (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. skills/tool.py +121 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CSS semantic extractor.
4
+ Extracts selectors, custom properties, keyframes, imports, and var() usages from parsed CSS.
5
+ """
6
+
7
+ import re
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from indexing.frontend.css_parser import (
11
+ CSSDocument, ParsedRuleSet, ParsedSelector, ParsedDeclaration,
12
+ ParsedKeyframes, parse_css,
13
+ )
14
+ from indexing.frontend.source_location import SourceLocation
15
+
16
+
17
+ # Pattern for var(--name) with optional fallback
18
+ VAR_PATTERN = re.compile(r"var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)")
19
+
20
+
21
+ def extract_css_semantics(source_bytes: bytes, config=None, file_path: str = "") -> dict:
22
+ """Extract all semantic entities from a CSS file.
23
+
24
+ Args:
25
+ source_bytes: Raw CSS file content as bytes.
26
+ config: Optional FrontendConfig instance for extraction limits.
27
+ file_path: Optional file path for CSS module detection (.module.css).
28
+
29
+ Returns:
30
+ dict with keys:
31
+ - style_selectors: list of selector dicts
32
+ - style_custom_properties: list of custom property definition dicts
33
+ - style_custom_property_usages: list of var() usage dicts
34
+ - style_keyframes: list of keyframes dicts
35
+ - style_imports: list of @import dicts
36
+ - frontend_diagnostics: list of diagnostic dicts
37
+ - media_queries: list of media query dicts with nested selector info
38
+ """
39
+ # Detect generated/minified CSS by size threshold — check BEFORE parsing
40
+ # to avoid the expensive tree-sitter parse for files that will be skipped.
41
+ threshold = config.generated_css_threshold if config else 100_000
42
+ file_size = len(source_bytes)
43
+
44
+ result = {
45
+ "style_selectors": [],
46
+ "style_custom_properties": [],
47
+ "style_custom_property_usages": [],
48
+ "style_keyframes": [],
49
+ "style_imports": [],
50
+ "frontend_diagnostics": [],
51
+ "media_queries": [],
52
+ }
53
+
54
+ if file_size > threshold:
55
+ result["frontend_diagnostics"].append({
56
+ "diagnostic_type": "generated_file_skipped",
57
+ "severity": "unsupported",
58
+ "message": f"CSS file is {file_size} bytes (threshold: {threshold}), skipping full extraction as generated",
59
+ })
60
+ return result
61
+
62
+ doc = parse_css(source_bytes)
63
+
64
+ is_css_module = file_path.endswith(".module.css") if file_path else False
65
+
66
+ # Detect minified CSS by high selector-to-line ratio
67
+ line_count = source_bytes.count(b"\n") + 1
68
+ all_rules = list(doc.rule_sets) + list(doc.media_rules)
69
+ total_selectors = sum(len(r.selectors) for r in all_rules)
70
+ if line_count > 0 and total_selectors > 0 and total_selectors / line_count > 10:
71
+ result["frontend_diagnostics"].append({
72
+ "diagnostic_type": "generated_file_skipped",
73
+ "severity": "unsupported",
74
+ "message": f"CSS appears minified ({total_selectors} selectors in {line_count} lines), skipping full extraction as generated",
75
+ })
76
+ return result
77
+
78
+ for rule in all_rules:
79
+ media_query = rule.media_query
80
+
81
+ for sel in rule.selectors:
82
+ sel_dict = {
83
+ "selector_text": sel.text,
84
+ "normalized_selector": _normalize_selector(sel.text),
85
+ "selector_type": sel.selector_type,
86
+ "source_range": sel.location.to_dict() if sel.location else None,
87
+ "is_scoped": is_css_module or _is_scoped_selector(sel.text),
88
+ "media_query": media_query,
89
+ }
90
+ result["style_selectors"].append(sel_dict)
91
+
92
+ # Extract custom properties and var() usages from declarations
93
+ for decl in rule.declarations:
94
+ if decl.is_custom_property:
95
+ scope = rule.selectors[0].text if rule.selectors else None
96
+ result["style_custom_properties"].append({
97
+ "name": decl.property_name,
98
+ "value": decl.value,
99
+ "scope_selector": scope,
100
+ "source_range": decl.location.to_dict(),
101
+ })
102
+
103
+ # Extract var() usages from declaration values
104
+ usages = _extract_var_usages(decl.value, decl.location)
105
+ for u in usages:
106
+ u["selector_text"] = rule.selectors[0].text if rule.selectors else None
107
+ result["style_custom_property_usages"].append(u)
108
+
109
+ # Record media query info
110
+ if media_query:
111
+ result["media_queries"].append({
112
+ "query": media_query,
113
+ "selector_count": len(rule.selectors),
114
+ "source_range": rule.location.to_dict() if rule.location else None,
115
+ })
116
+
117
+ # Process keyframes
118
+ for kf in doc.keyframes:
119
+ kf_dict = {
120
+ "name": kf.name,
121
+ "source_range": kf.location.to_dict() if kf.location else None,
122
+ "stops": [],
123
+ }
124
+ for stop in kf.keyframes:
125
+ kf_dict["stops"].append({
126
+ "stop": stop.stop,
127
+ "declaration_count": len(stop.declarations),
128
+ })
129
+ result["style_keyframes"].append(kf_dict)
130
+
131
+ # Process imports
132
+ for imp in doc.imports:
133
+ result["style_imports"].append({
134
+ "import_path": imp.import_path,
135
+ "is_external": imp.import_path.startswith(("http://", "https://", "//")),
136
+ "is_url": imp.is_url,
137
+ "source_range": imp.location.to_dict(),
138
+ })
139
+
140
+ # Diagnostics for parse errors
141
+ for err in doc.errors:
142
+ result["frontend_diagnostics"].append({
143
+ "diagnostic_type": "malformed_css",
144
+ "severity": "recoverable",
145
+ "message": f"Parse error near: {err.get('text', '')[:50]}",
146
+ "source_range": err.get("location"),
147
+ })
148
+
149
+ # Parser recovery: errors occurred but selectors were still extracted
150
+ if doc.errors and len(result["style_selectors"]) > 0:
151
+ result["frontend_diagnostics"].append({
152
+ "diagnostic_type": "parser_recovery",
153
+ "severity": "recoverable",
154
+ "message": f"CSS parser recovered from {len(doc.errors)} error(s), extracted {len(result['style_selectors'])} selector(s)",
155
+ })
156
+
157
+ # Unresolved imports: @import with empty or missing path
158
+ for imp in result["style_imports"]:
159
+ if not imp["import_path"] and not imp["is_external"]:
160
+ result["frontend_diagnostics"].append({
161
+ "diagnostic_type": "unresolved_import",
162
+ "severity": "unresolved",
163
+ "message": "CSS @import has empty path",
164
+ "source_range": imp.get("source_range"),
165
+ })
166
+
167
+ return result
168
+
169
+
170
+ def _normalize_selector(selector: str) -> str:
171
+ """Normalize a CSS selector for comparison."""
172
+ # Remove extra whitespace
173
+ normalized = re.sub(r"\s+", " ", selector.strip())
174
+ # Remove spaces around combinators
175
+ normalized = re.sub(r"\s*([>+~])\s*", r"\1", normalized)
176
+ return normalized
177
+
178
+
179
+ def _is_scoped_selector(selector: str) -> bool:
180
+ """Check if a selector is scoped (e.g., CSS modules :global or :local)."""
181
+ return ":global(" in selector or ":local(" in selector or selector.startswith(":local")
182
+
183
+
184
+ def _extract_var_usages(value: str, location: SourceLocation) -> list:
185
+ """Extract var(--name) usages from a CSS value string."""
186
+ usages = []
187
+ for match in VAR_PATTERN.finditer(value):
188
+ usage = {
189
+ "property_name": match.group(1),
190
+ "source_range": location.to_dict() if location else None,
191
+ }
192
+ if match.group(2):
193
+ usage["fallback_value"] = match.group(2).strip()
194
+ usages.append(usage)
195
+ return usages
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CSS parser using tree-sitter.
4
+ Parses CSS source and builds a document of rule sets, at-rules, and declarations.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Dict, Optional
9
+
10
+ from indexing.language_config import LANGUAGE_CONFIG
11
+ from indexing.node_utils import create_parser
12
+ from indexing.frontend.source_location import SourceLocation, node_to_location, extract_range
13
+
14
+
15
+ @dataclass
16
+ class ParsedDeclaration:
17
+ property_name: str
18
+ value: str
19
+ location: SourceLocation
20
+ is_custom_property: bool = False
21
+
22
+
23
+ @dataclass
24
+ class ParsedSelector:
25
+ text: str
26
+ selector_type: str
27
+ location: SourceLocation
28
+
29
+
30
+ @dataclass
31
+ class ParsedRuleSet:
32
+ selectors: List[ParsedSelector] = field(default_factory=list)
33
+ declarations: List[ParsedDeclaration] = field(default_factory=list)
34
+ location: SourceLocation = None
35
+ media_query: Optional[str] = None
36
+
37
+
38
+ @dataclass
39
+ class ParsedKeyframe:
40
+ stop: str
41
+ declarations: List[ParsedDeclaration] = field(default_factory=list)
42
+ location: SourceLocation = None
43
+
44
+
45
+ @dataclass
46
+ class ParsedKeyframes:
47
+ name: str
48
+ keyframes: List[ParsedKeyframe] = field(default_factory=list)
49
+ location: SourceLocation = None
50
+
51
+
52
+ @dataclass
53
+ class ParsedImport:
54
+ import_path: str
55
+ location: SourceLocation
56
+ is_url: bool = False
57
+
58
+
59
+ @dataclass
60
+ class CSSDocument:
61
+ rule_sets: List[ParsedRuleSet] = field(default_factory=list)
62
+ keyframes: List[ParsedKeyframes] = field(default_factory=list)
63
+ imports: List[ParsedImport] = field(default_factory=list)
64
+ media_rules: List[ParsedRuleSet] = field(default_factory=list)
65
+ errors: List[Dict] = field(default_factory=list)
66
+ source_bytes: bytes = b""
67
+
68
+
69
+ _css_parser = None
70
+
71
+ def _get_parser():
72
+ """Get or create the CSS parser (cached)."""
73
+ global _css_parser
74
+ if _css_parser is None:
75
+ lang_module = LANGUAGE_CONFIG.get("css", {}).get("language_module")
76
+ if lang_module is None:
77
+ return None
78
+ _css_parser = create_parser(lang_module)
79
+ return _css_parser
80
+
81
+
82
+ def _classify_selector(selector_text: str) -> str:
83
+ """Classify a CSS selector into a type."""
84
+ s = selector_text.strip()
85
+ if not s:
86
+ return "unknown"
87
+ # Check complex patterns first before simple prefix checks
88
+ if ">" in s or "+" in s or "~" in s:
89
+ return "combinator"
90
+ if " " in s:
91
+ return "descendant"
92
+ if s.startswith("["):
93
+ return "attribute"
94
+ if s.startswith(":"):
95
+ return "pseudo"
96
+ if s.startswith("@"):
97
+ return "at-rule"
98
+ # Attribute selectors like a[href=...] — tag with attribute
99
+ if "[" in s and s[0].isalpha():
100
+ return "attribute"
101
+ if "." in s and "#" in s:
102
+ return "compound"
103
+ if s.startswith("#"):
104
+ return "id"
105
+ if s.startswith("."):
106
+ return "class"
107
+ if "." in s or "#" in s or ":" in s:
108
+ return "compound"
109
+ if s and s[0].isalpha():
110
+ return "tag"
111
+ return "unknown"
112
+
113
+
114
+ def _extract_selectors(selectors_node, source_bytes) -> List[ParsedSelector]:
115
+ """Extract selectors from a selectors node."""
116
+ selectors = []
117
+ if selectors_node is None:
118
+ return selectors
119
+
120
+ # The selectors node may contain comma-separated selectors
121
+ # Each child is a selector node (class_selector, id_selector, etc.)
122
+ # Comma tokens separate them
123
+ current_text = ""
124
+ current_start = None
125
+
126
+ for i in range(selectors_node.child_count):
127
+ child = selectors_node.child(i)
128
+ child_text = extract_range(source_bytes, child.start_byte, child.end_byte)
129
+
130
+ if child.type == ",":
131
+ if current_text.strip():
132
+ selectors.append(ParsedSelector(
133
+ text=current_text.strip(),
134
+ selector_type=_classify_selector(current_text),
135
+ location=SourceLocation(
136
+ start_line=0, start_column=0, end_line=0, end_column=0,
137
+ start_byte=current_start if current_start else 0,
138
+ end_byte=child.start_byte,
139
+ ),
140
+ ))
141
+ current_text = ""
142
+ current_start = None
143
+ else:
144
+ if not current_text:
145
+ current_start = child.start_byte
146
+ current_text += child_text
147
+
148
+ # Don't forget the last selector
149
+ if current_text.strip():
150
+ selectors.append(ParsedSelector(
151
+ text=current_text.strip(),
152
+ selector_type=_classify_selector(current_text),
153
+ location=SourceLocation(
154
+ start_line=0, start_column=0, end_line=0, end_column=0,
155
+ start_byte=current_start if current_start else 0,
156
+ end_byte=selectors_node.end_byte,
157
+ ),
158
+ ))
159
+
160
+ # Fix up locations using the selectors node
161
+ # Precompute line-start offsets once to avoid quadratic per-selector scanning
162
+ line_starts = [0]
163
+ for j in range(len(source_bytes)):
164
+ if source_bytes[j] == 0x0A:
165
+ line_starts.append(j + 1)
166
+
167
+ import bisect
168
+ for sel in selectors:
169
+ if sel.location.start_byte is not None:
170
+ # Find the actual text in the source to get proper line/col
171
+ sel_text_bytes = sel.text.encode("utf-8")
172
+ offset = source_bytes.find(sel_text_bytes, sel.location.start_byte)
173
+ if offset >= 0:
174
+ # Compute line/col from byte offset using precomputed line starts
175
+ line_idx = bisect.bisect_right(line_starts, offset) - 1
176
+ line = line_idx + 1
177
+ col = offset - line_starts[line_idx]
178
+ sel.location.start_line = line
179
+ sel.location.start_column = col
180
+ sel.location.end_line = line
181
+ sel.location.end_column = col + len(sel_text_bytes)
182
+
183
+ return selectors
184
+
185
+
186
+ def _extract_declarations(block_node, source_bytes) -> List[ParsedDeclaration]:
187
+ """Extract declarations from a block node."""
188
+ declarations = []
189
+ if block_node is None:
190
+ return declarations
191
+
192
+ for i in range(block_node.child_count):
193
+ child = block_node.child(i)
194
+ if child.type == "declaration":
195
+ prop_name = None
196
+ value_parts = []
197
+ for j in range(child.child_count):
198
+ dc = child.child(j)
199
+ if dc.type == "property_name":
200
+ prop_name = extract_range(source_bytes, dc.start_byte, dc.end_byte)
201
+ elif dc.type in ("color_value", "plain_value", "integer_value", "float_value",
202
+ "string_value", "unit", "identifier", "call_expression",
203
+ "binary_expression", "grid_value", "flex_value"):
204
+ value_parts.append(extract_range(source_bytes, dc.start_byte, dc.end_byte))
205
+ elif dc.type == ";":
206
+ pass
207
+
208
+ if prop_name:
209
+ value = " ".join(value_parts) if value_parts else ""
210
+ is_custom = prop_name.startswith("--")
211
+ declarations.append(ParsedDeclaration(
212
+ property_name=prop_name,
213
+ value=value,
214
+ location=node_to_location(child),
215
+ is_custom_property=is_custom,
216
+ ))
217
+ elif child.type == "last_declaration" or (child.type == "declaration" and not child.child_by_field_name("property_name")):
218
+ # Handle declarations without trailing semicolon
219
+ pass
220
+
221
+ return declarations
222
+
223
+
224
+ def _parse_rule_set(node, source_bytes, media_query=None) -> ParsedRuleSet:
225
+ """Parse a rule_set node."""
226
+ rule = ParsedRuleSet(
227
+ location=node_to_location(node),
228
+ media_query=media_query,
229
+ )
230
+
231
+ for i in range(node.child_count):
232
+ child = node.child(i)
233
+ if child.type == "selectors":
234
+ rule.selectors = _extract_selectors(child, source_bytes)
235
+ elif child.type == "block":
236
+ rule.declarations = _extract_declarations(child, source_bytes)
237
+
238
+ return rule
239
+
240
+
241
+ def _parse_keyframes(node, source_bytes) -> ParsedKeyframes:
242
+ """Parse a keyframes_statement node."""
243
+ kf = ParsedKeyframes(location=node_to_location(node), name="")
244
+
245
+ for i in range(node.child_count):
246
+ child = node.child(i)
247
+ if child.type == "keyframes_name":
248
+ kf.name = extract_range(source_bytes, child.start_byte, child.end_byte)
249
+ elif child.type == "keyframe_block_list":
250
+ for j in range(child.child_count):
251
+ kf_child = child.child(j)
252
+ if kf_child.type == "keyframe_block":
253
+ stop = ""
254
+ block = None
255
+ for k in range(kf_child.child_count):
256
+ kc = kf_child.child(k)
257
+ if kc.type in ("integer_value", "from", "to"):
258
+ stop = extract_range(source_bytes, kc.start_byte, kc.end_byte)
259
+ elif kc.type == "block":
260
+ block = kc
261
+ decls = _extract_declarations(block, source_bytes) if block else []
262
+ kf.keyframes.append(ParsedKeyframe(
263
+ stop=stop,
264
+ declarations=decls,
265
+ location=node_to_location(kf_child),
266
+ ))
267
+
268
+ return kf
269
+
270
+
271
+ def _parse_import(node, source_bytes) -> Optional[ParsedImport]:
272
+ """Parse an import_statement node."""
273
+ for i in range(node.child_count):
274
+ child = node.child(i)
275
+ if child.type == "call_expression":
276
+ # @import url('path')
277
+ for j in range(child.child_count):
278
+ cc = child.child(j)
279
+ if cc.type == "arguments":
280
+ for k in range(cc.child_count):
281
+ arg = cc.child(k)
282
+ if arg.type == "string_value":
283
+ path = extract_range(source_bytes, arg.start_byte, arg.end_byte)
284
+ path = path.strip("'\"")
285
+ return ParsedImport(
286
+ import_path=path,
287
+ location=node_to_location(node),
288
+ is_url=True,
289
+ )
290
+ elif child.type == "string_value":
291
+ # @import "path";
292
+ path = extract_range(source_bytes, child.start_byte, child.end_byte)
293
+ path = path.strip("'\"")
294
+ return ParsedImport(
295
+ import_path=path,
296
+ location=node_to_location(node),
297
+ is_url=False,
298
+ )
299
+ return None
300
+
301
+
302
+ def _parse_media(node, source_bytes) -> List[ParsedRuleSet]:
303
+ """Parse a media_statement node, returning nested rule sets."""
304
+ media_query = ""
305
+ rules = []
306
+
307
+ for i in range(node.child_count):
308
+ child = node.child(i)
309
+ if child.type == "feature_query":
310
+ media_query = extract_range(source_bytes, child.start_byte, child.end_byte)
311
+ elif child.type == "block":
312
+ for j in range(child.child_count):
313
+ bc = child.child(j)
314
+ if bc.type == "rule_set":
315
+ rule = _parse_rule_set(bc, source_bytes, media_query=media_query)
316
+ rules.append(rule)
317
+
318
+ return rules
319
+
320
+
321
+ def _collect_errors(node, source_bytes, errors: list):
322
+ """Collect parse errors from the tree (iterative)."""
323
+ if not node.has_error and node.type != "ERROR":
324
+ return
325
+
326
+ stack = [node]
327
+ while stack:
328
+ n = stack.pop()
329
+ if n.type == "ERROR":
330
+ text = extract_range(source_bytes, n.start_byte, n.end_byte)[:80]
331
+ errors.append({
332
+ "type": "parse_error",
333
+ "location": node_to_location(n).to_dict(),
334
+ "text": text,
335
+ })
336
+ if n.has_error:
337
+ for i in range(n.child_count):
338
+ stack.append(n.child(i))
339
+
340
+
341
+ def parse_css(source_bytes: bytes) -> CSSDocument:
342
+ """Parse CSS source bytes and return a CSSDocument.
343
+
344
+ Args:
345
+ source_bytes: Raw CSS file content as bytes.
346
+
347
+ Returns:
348
+ CSSDocument with parsed rule sets, keyframes, imports, and errors.
349
+ """
350
+ parser = _get_parser()
351
+ if parser is None:
352
+ return CSSDocument(source_bytes=source_bytes, errors=[{
353
+ "type": "missing_parser",
354
+ "message": "CSS tree-sitter grammar not available",
355
+ }])
356
+
357
+ tree = parser.parse(source_bytes)
358
+ root = tree.root_node
359
+
360
+ doc = CSSDocument(source_bytes=source_bytes)
361
+
362
+ if root.has_error:
363
+ _collect_errors(root, source_bytes, doc.errors)
364
+
365
+ for i in range(root.child_count):
366
+ child = root.child(i)
367
+ if child.type == "rule_set":
368
+ doc.rule_sets.append(_parse_rule_set(child, source_bytes))
369
+ elif child.type == "keyframes_statement":
370
+ doc.keyframes.append(_parse_keyframes(child, source_bytes))
371
+ elif child.type == "import_statement":
372
+ imp = _parse_import(child, source_bytes)
373
+ if imp:
374
+ doc.imports.append(imp)
375
+ elif child.type == "media_statement":
376
+ media_rules = _parse_media(child, source_bytes)
377
+ doc.media_rules.extend(media_rules)
378
+ elif child.type == "comment":
379
+ pass
380
+ elif child.type == "ERROR":
381
+ # Try to find rule sets inside ERROR nodes for malformed CSS recovery
382
+ for j in range(child.child_count):
383
+ ec = child.child(j)
384
+ if ec.type == "rule_set":
385
+ doc.rule_sets.append(_parse_rule_set(ec, source_bytes))
386
+
387
+ return doc