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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- 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
|