stl-parser 1.7.0__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.
- stl_parser/__init__.py +113 -0
- stl_parser/_utils.py +524 -0
- stl_parser/analyzer.py +309 -0
- stl_parser/builder.py +252 -0
- stl_parser/cli.py +758 -0
- stl_parser/decay.py +294 -0
- stl_parser/diff.py +414 -0
- stl_parser/emitter.py +231 -0
- stl_parser/errors.py +919 -0
- stl_parser/grammar.py +232 -0
- stl_parser/graph.py +254 -0
- stl_parser/llm.py +563 -0
- stl_parser/models.py +427 -0
- stl_parser/parser.py +607 -0
- stl_parser/query.py +340 -0
- stl_parser/reader.py +458 -0
- stl_parser/schema.py +764 -0
- stl_parser/serializer.py +225 -0
- stl_parser/validator.py +642 -0
- stl_parser-1.7.0.dist-info/METADATA +351 -0
- stl_parser-1.7.0.dist-info/RECORD +25 -0
- stl_parser-1.7.0.dist-info/WHEEL +5 -0
- stl_parser-1.7.0.dist-info/entry_points.txt +2 -0
- stl_parser-1.7.0.dist-info/licenses/LICENSE +201 -0
- stl_parser-1.7.0.dist-info/top_level.txt +1 -0
stl_parser/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
STL Parser - Semantic Tension Language Parser
|
|
3
|
+
|
|
4
|
+
A Python parser for the Semantic Tension Language (STL) specification.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .parser import parse, parse_file
|
|
8
|
+
from .models import (
|
|
9
|
+
ParseResult,
|
|
10
|
+
Statement,
|
|
11
|
+
Anchor,
|
|
12
|
+
Modifier,
|
|
13
|
+
AnchorType,
|
|
14
|
+
PathType,
|
|
15
|
+
)
|
|
16
|
+
from .validator import validate_parse_result
|
|
17
|
+
from .serializer import to_json, to_dict, from_json, from_dict, to_stl
|
|
18
|
+
from .graph import STLGraph
|
|
19
|
+
from .analyzer import STLAnalyzer
|
|
20
|
+
from .errors import STLError, STLParseError, STLWarning
|
|
21
|
+
|
|
22
|
+
# New modules (Priority 1 Tooling)
|
|
23
|
+
from .builder import stl, stl_doc, StatementBuilder
|
|
24
|
+
from .schema import load_schema, validate_against_schema, STLSchema, to_pydantic, from_pydantic
|
|
25
|
+
from .llm import clean, repair, validate_llm_output, prompt_template, LLMValidationResult
|
|
26
|
+
from .emitter import STLEmitter
|
|
27
|
+
|
|
28
|
+
# Priority 2 Tooling
|
|
29
|
+
from .decay import effective_confidence, decay_report, filter_by_confidence, DecayConfig, DecayReport
|
|
30
|
+
|
|
31
|
+
# Query
|
|
32
|
+
from .query import find, find_all, filter_statements, select, stl_pointer
|
|
33
|
+
|
|
34
|
+
# Diff/Patch
|
|
35
|
+
from .diff import stl_diff, stl_patch, diff_to_text, diff_to_dict, STLDiff
|
|
36
|
+
|
|
37
|
+
# Streaming I/O
|
|
38
|
+
from .reader import stream_parse, STLReader, ReaderStats
|
|
39
|
+
|
|
40
|
+
# Utilities (public)
|
|
41
|
+
from ._utils import sanitize_anchor_name
|
|
42
|
+
|
|
43
|
+
__version__ = "1.7.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Main parsing functions
|
|
47
|
+
"parse",
|
|
48
|
+
"parse_file",
|
|
49
|
+
# Data models
|
|
50
|
+
"ParseResult",
|
|
51
|
+
"Statement",
|
|
52
|
+
"Anchor",
|
|
53
|
+
"Modifier",
|
|
54
|
+
"AnchorType",
|
|
55
|
+
"PathType",
|
|
56
|
+
# Validation
|
|
57
|
+
"validate_parse_result",
|
|
58
|
+
# Serialization
|
|
59
|
+
"to_json",
|
|
60
|
+
"to_dict",
|
|
61
|
+
"from_json",
|
|
62
|
+
"from_dict",
|
|
63
|
+
"to_stl",
|
|
64
|
+
# Graph and analysis
|
|
65
|
+
"STLGraph",
|
|
66
|
+
"STLAnalyzer",
|
|
67
|
+
# Errors
|
|
68
|
+
"STLError",
|
|
69
|
+
"STLParseError",
|
|
70
|
+
"STLWarning",
|
|
71
|
+
# Builder (new)
|
|
72
|
+
"stl",
|
|
73
|
+
"stl_doc",
|
|
74
|
+
"StatementBuilder",
|
|
75
|
+
# Schema (new)
|
|
76
|
+
"load_schema",
|
|
77
|
+
"validate_against_schema",
|
|
78
|
+
"STLSchema",
|
|
79
|
+
"to_pydantic",
|
|
80
|
+
"from_pydantic",
|
|
81
|
+
# LLM (new)
|
|
82
|
+
"clean",
|
|
83
|
+
"repair",
|
|
84
|
+
"validate_llm_output",
|
|
85
|
+
"prompt_template",
|
|
86
|
+
"LLMValidationResult",
|
|
87
|
+
# Emitter (new)
|
|
88
|
+
"STLEmitter",
|
|
89
|
+
# Decay (P2)
|
|
90
|
+
"effective_confidence",
|
|
91
|
+
"decay_report",
|
|
92
|
+
"filter_by_confidence",
|
|
93
|
+
"DecayConfig",
|
|
94
|
+
"DecayReport",
|
|
95
|
+
# Query
|
|
96
|
+
"find",
|
|
97
|
+
"find_all",
|
|
98
|
+
"filter_statements",
|
|
99
|
+
"select",
|
|
100
|
+
"stl_pointer",
|
|
101
|
+
# Diff/Patch
|
|
102
|
+
"stl_diff",
|
|
103
|
+
"stl_patch",
|
|
104
|
+
"diff_to_text",
|
|
105
|
+
"diff_to_dict",
|
|
106
|
+
"STLDiff",
|
|
107
|
+
# Streaming I/O
|
|
108
|
+
"stream_parse",
|
|
109
|
+
"STLReader",
|
|
110
|
+
"ReaderStats",
|
|
111
|
+
# Utilities
|
|
112
|
+
"sanitize_anchor_name",
|
|
113
|
+
]
|
stl_parser/_utils.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
STL Parser Internal Utilities
|
|
4
|
+
|
|
5
|
+
Shared utility functions used by parser.py and other modules.
|
|
6
|
+
Extracted from parser.py for reuse by llm.py and other new modules.
|
|
7
|
+
|
|
8
|
+
This module is internal (prefixed with _) and should not be imported
|
|
9
|
+
directly by users. The public API is in parser.py and __init__.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import List, Dict, Any
|
|
14
|
+
|
|
15
|
+
from .models import ParseResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ========================================
|
|
19
|
+
# STL LINE DETECTION
|
|
20
|
+
# ========================================
|
|
21
|
+
|
|
22
|
+
def is_stl_line(line: str) -> Dict[str, Any]:
|
|
23
|
+
"""Detect if a line is likely an STL statement with confidence score.
|
|
24
|
+
|
|
25
|
+
Uses hierarchical detection based on STL syntax features:
|
|
26
|
+
- Brackets [] (required)
|
|
27
|
+
- Arrow -> or → (required)
|
|
28
|
+
- Modifier ::mod(...) (optional, increases confidence)
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
line: Single line of text to analyze
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Dictionary with detection results:
|
|
35
|
+
{
|
|
36
|
+
'is_stl': bool,
|
|
37
|
+
'confidence': float (0.0-1.0),
|
|
38
|
+
'type': str,
|
|
39
|
+
'features': dict
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> is_stl_line('[A] -> [B] ::mod(confidence=0.9)')
|
|
44
|
+
{'is_stl': True, 'confidence': 0.99, 'type': 'complete_stl', ...}
|
|
45
|
+
>>> is_stl_line('[A] -> [B]')
|
|
46
|
+
{'is_stl': True, 'confidence': 0.95, 'type': 'minimal_stl', ...}
|
|
47
|
+
>>> is_stl_line('[Just a note]')
|
|
48
|
+
{'is_stl': False, 'confidence': 0.0, 'type': 'not_stl', ...}
|
|
49
|
+
"""
|
|
50
|
+
stripped = line.strip()
|
|
51
|
+
|
|
52
|
+
# Check for empty line first
|
|
53
|
+
if not stripped:
|
|
54
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'empty', 'features': {}}
|
|
55
|
+
|
|
56
|
+
# Extract features
|
|
57
|
+
features = {
|
|
58
|
+
'has_brackets': '[' in stripped and ']' in stripped,
|
|
59
|
+
'bracket_count': stripped.count('['),
|
|
60
|
+
'has_arrow': '->' in stripped or '\u2192' in stripped,
|
|
61
|
+
'has_modifier': '::mod(' in stripped,
|
|
62
|
+
'is_comment': stripped.startswith('#'),
|
|
63
|
+
'is_markdown_link': bool(re.match(r'\[.+?\]\(.+?\)', stripped)),
|
|
64
|
+
'is_markdown_list': stripped.startswith(('-', '*', '+')) and not stripped.startswith('->'),
|
|
65
|
+
'is_markdown_quote': stripped.startswith('>'),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Priority 1: Exclude non-STL patterns
|
|
69
|
+
if features['is_comment']:
|
|
70
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'comment', 'features': features}
|
|
71
|
+
|
|
72
|
+
if features['is_markdown_link']:
|
|
73
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'markdown_link', 'features': features}
|
|
74
|
+
|
|
75
|
+
if features['is_markdown_list']:
|
|
76
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'markdown_list', 'features': features}
|
|
77
|
+
|
|
78
|
+
if features['is_markdown_quote']:
|
|
79
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'markdown_quote', 'features': features}
|
|
80
|
+
|
|
81
|
+
# Priority 2: Check for STL required features
|
|
82
|
+
if features['has_brackets'] and features['has_arrow']:
|
|
83
|
+
# At least 2 brackets required for valid STL: [A] -> [B]
|
|
84
|
+
if features['bracket_count'] >= 2:
|
|
85
|
+
if features['has_modifier']:
|
|
86
|
+
# Complete STL with modifier
|
|
87
|
+
return {
|
|
88
|
+
'is_stl': True,
|
|
89
|
+
'confidence': 0.99,
|
|
90
|
+
'type': 'complete_stl',
|
|
91
|
+
'features': features
|
|
92
|
+
}
|
|
93
|
+
else:
|
|
94
|
+
# Minimal STL without modifier
|
|
95
|
+
return {
|
|
96
|
+
'is_stl': True,
|
|
97
|
+
'confidence': 0.95,
|
|
98
|
+
'type': 'minimal_stl',
|
|
99
|
+
'features': features
|
|
100
|
+
}
|
|
101
|
+
else:
|
|
102
|
+
# Has arrow but not enough brackets - likely syntax error
|
|
103
|
+
return {
|
|
104
|
+
'is_stl': False,
|
|
105
|
+
'confidence': 0.2,
|
|
106
|
+
'type': 'possible_syntax_error',
|
|
107
|
+
'features': features
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Distinguish natural language from other non-STL
|
|
111
|
+
# Natural language typically has no STL markers at all
|
|
112
|
+
if not features['has_brackets'] and not features['has_arrow']:
|
|
113
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'natural_language', 'features': features}
|
|
114
|
+
|
|
115
|
+
# Other non-STL cases
|
|
116
|
+
return {'is_stl': False, 'confidence': 0.0, 'type': 'not_stl', 'features': features}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ========================================
|
|
120
|
+
# MARKDOWN CODE FENCE EXTRACTION
|
|
121
|
+
# ========================================
|
|
122
|
+
|
|
123
|
+
def extract_stl_fences(text: str) -> tuple[str, Dict[str, Any]]:
|
|
124
|
+
"""Extract STL code from Markdown code fences.
|
|
125
|
+
|
|
126
|
+
Looks for ```stl ... ``` blocks and extracts their content.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
text: Full text containing potential code fences
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Tuple of (extracted_stl_text, metadata)
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
>>> text = "# Doc\\n```stl\\n[A] -> [B]\\n```\\nMore text"
|
|
136
|
+
>>> stl, meta = extract_stl_fences(text)
|
|
137
|
+
>>> print(stl)
|
|
138
|
+
[A] -> [B]
|
|
139
|
+
"""
|
|
140
|
+
lines = text.split('\n')
|
|
141
|
+
stl_lines = []
|
|
142
|
+
line_mapping = []
|
|
143
|
+
in_fence = False
|
|
144
|
+
fence_count = 0
|
|
145
|
+
|
|
146
|
+
for i, line in enumerate(lines):
|
|
147
|
+
stripped = line.strip()
|
|
148
|
+
|
|
149
|
+
if stripped == '```stl' or stripped.startswith('```stl '):
|
|
150
|
+
in_fence = True
|
|
151
|
+
fence_count += 1
|
|
152
|
+
elif stripped == '```' and in_fence:
|
|
153
|
+
in_fence = False
|
|
154
|
+
elif in_fence:
|
|
155
|
+
stl_lines.append(line)
|
|
156
|
+
line_mapping.append(i + 1) # 1-based line numbers
|
|
157
|
+
|
|
158
|
+
metadata = {
|
|
159
|
+
'format': 'fenced',
|
|
160
|
+
'total_lines': len(lines),
|
|
161
|
+
'stl_lines': len(stl_lines),
|
|
162
|
+
'fence_count': fence_count,
|
|
163
|
+
'line_mapping': line_mapping
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return '\n'.join(stl_lines), metadata
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ========================================
|
|
170
|
+
# HEURISTIC EXTRACTION
|
|
171
|
+
# ========================================
|
|
172
|
+
|
|
173
|
+
def extract_stl_heuristic(text: str) -> tuple[str, Dict[str, Any]]:
|
|
174
|
+
"""Extract STL statements using heuristic detection.
|
|
175
|
+
|
|
176
|
+
Scans each line and uses pattern matching to identify likely STL statements.
|
|
177
|
+
Based on detection of brackets, arrows, and modifiers.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
text: Full text with mixed content
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Tuple of (extracted_stl_text, metadata)
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
>>> text = "# Intro\\n[A] -> [B]\\nSome text\\n[C] -> [D] ::mod(confidence=0.9)"
|
|
187
|
+
>>> stl, meta = extract_stl_heuristic(text)
|
|
188
|
+
>>> print(meta['stl_lines'])
|
|
189
|
+
2
|
|
190
|
+
"""
|
|
191
|
+
lines = text.split('\n')
|
|
192
|
+
stl_lines = []
|
|
193
|
+
line_mapping = []
|
|
194
|
+
detection_stats = {
|
|
195
|
+
'complete_stl': 0,
|
|
196
|
+
'minimal_stl': 0,
|
|
197
|
+
'syntax_errors': 0,
|
|
198
|
+
'not_stl': 0
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for i, line in enumerate(lines):
|
|
202
|
+
detection = is_stl_line(line)
|
|
203
|
+
|
|
204
|
+
if detection['is_stl']:
|
|
205
|
+
stl_lines.append(line)
|
|
206
|
+
line_mapping.append(i + 1)
|
|
207
|
+
detection_stats[detection['type']] += 1
|
|
208
|
+
elif detection['type'] == 'comment':
|
|
209
|
+
# Preserve comments in output but don't count as STL
|
|
210
|
+
stl_lines.append(line)
|
|
211
|
+
line_mapping.append(i + 1)
|
|
212
|
+
elif detection['type'] == 'possible_syntax_error':
|
|
213
|
+
detection_stats['syntax_errors'] += 1
|
|
214
|
+
else:
|
|
215
|
+
detection_stats['not_stl'] += 1
|
|
216
|
+
|
|
217
|
+
# Count only actual STL lines (excluding comments)
|
|
218
|
+
actual_stl_count = detection_stats['complete_stl'] + detection_stats['minimal_stl']
|
|
219
|
+
|
|
220
|
+
metadata = {
|
|
221
|
+
'format': 'heuristic',
|
|
222
|
+
'total_lines': len(lines),
|
|
223
|
+
'stl_lines': actual_stl_count,
|
|
224
|
+
'line_mapping': line_mapping,
|
|
225
|
+
'detection_stats': detection_stats
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return '\n'.join(stl_lines), metadata
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ========================================
|
|
232
|
+
# PURE STL DETECTION
|
|
233
|
+
# ========================================
|
|
234
|
+
|
|
235
|
+
def is_pure_stl(text: str) -> bool:
|
|
236
|
+
"""Check if text appears to be pure STL without mixed content.
|
|
237
|
+
|
|
238
|
+
A file is considered pure STL if all non-empty, non-comment lines
|
|
239
|
+
appear to be STL statements and there are no Markdown-specific patterns.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
text: Text to analyze
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
True if likely pure STL, False otherwise
|
|
246
|
+
"""
|
|
247
|
+
lines = text.split('\n')
|
|
248
|
+
|
|
249
|
+
# Check for any Markdown-specific patterns
|
|
250
|
+
has_stl_before_header = False
|
|
251
|
+
for i, line in enumerate(lines):
|
|
252
|
+
detection = is_stl_line(line)
|
|
253
|
+
|
|
254
|
+
# Track if we've seen STL statements
|
|
255
|
+
if detection['is_stl']:
|
|
256
|
+
has_stl_before_header = True
|
|
257
|
+
|
|
258
|
+
# If we find Markdown patterns (not plain comments), it's not pure STL
|
|
259
|
+
if detection['type'] in ('markdown_link', 'markdown_list', 'markdown_quote'):
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
# Detect Markdown headers (not STL comments)
|
|
263
|
+
stripped = line.strip()
|
|
264
|
+
if stripped.startswith('# ') and not stripped.startswith('##'):
|
|
265
|
+
after_hash = stripped[2:].strip()
|
|
266
|
+
if len(after_hash) < 50 and after_hash and after_hash[0].isupper():
|
|
267
|
+
if not has_stl_before_header:
|
|
268
|
+
if i < 3:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
# Filter to non-empty, non-comment lines
|
|
272
|
+
non_empty_lines = [l for l in lines if l.strip() and not l.strip().startswith('#')]
|
|
273
|
+
|
|
274
|
+
if not non_empty_lines:
|
|
275
|
+
return True # Empty or comment-only file
|
|
276
|
+
|
|
277
|
+
# Check first few lines to determine
|
|
278
|
+
sample_size = min(5, len(non_empty_lines))
|
|
279
|
+
stl_count = 0
|
|
280
|
+
|
|
281
|
+
for line in non_empty_lines[:sample_size]:
|
|
282
|
+
detection = is_stl_line(line)
|
|
283
|
+
if detection['is_stl']:
|
|
284
|
+
stl_count += 1
|
|
285
|
+
|
|
286
|
+
# If most sampled lines are STL, treat as pure STL
|
|
287
|
+
return stl_count >= sample_size * 0.8
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ========================================
|
|
291
|
+
# AUTO-EXTRACTION
|
|
292
|
+
# ========================================
|
|
293
|
+
|
|
294
|
+
def auto_extract_stl(text: str, mode: str = 'auto') -> tuple[str, Dict[str, Any]]:
|
|
295
|
+
"""Automatically extract STL statements from mixed content.
|
|
296
|
+
|
|
297
|
+
Supports multiple extraction modes:
|
|
298
|
+
- 'auto': Automatically detect best strategy
|
|
299
|
+
- 'fenced': Extract from ```stl code fences
|
|
300
|
+
- 'heuristic': Use pattern matching
|
|
301
|
+
- 'strict': No extraction (treat as pure STL)
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
text: Input text (may contain mixed content)
|
|
305
|
+
mode: Extraction mode
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Tuple of (extracted_stl_text, metadata)
|
|
309
|
+
|
|
310
|
+
Example:
|
|
311
|
+
>>> text = "```stl\\n[A] -> [B]\\n```"
|
|
312
|
+
>>> stl, meta = auto_extract_stl(text, mode='auto')
|
|
313
|
+
>>> print(meta['format'])
|
|
314
|
+
fenced
|
|
315
|
+
"""
|
|
316
|
+
# Auto-detect mode
|
|
317
|
+
if mode == 'auto':
|
|
318
|
+
if re.search(r'^\s*```stl', text, re.MULTILINE):
|
|
319
|
+
mode = 'fenced'
|
|
320
|
+
elif is_pure_stl(text):
|
|
321
|
+
mode = 'pure_stl'
|
|
322
|
+
else:
|
|
323
|
+
mode = 'heuristic'
|
|
324
|
+
|
|
325
|
+
# Extract based on mode
|
|
326
|
+
if mode == 'fenced':
|
|
327
|
+
return extract_stl_fences(text)
|
|
328
|
+
elif mode == 'heuristic':
|
|
329
|
+
return extract_stl_heuristic(text)
|
|
330
|
+
elif mode == 'pure_stl':
|
|
331
|
+
metadata = {
|
|
332
|
+
'format': 'pure_stl',
|
|
333
|
+
'total_lines': len(text.split('\n')),
|
|
334
|
+
'stl_lines': len(text.split('\n')),
|
|
335
|
+
'line_mapping': list(range(1, len(text.split('\n')) + 1))
|
|
336
|
+
}
|
|
337
|
+
return text, metadata
|
|
338
|
+
else: # strict mode (explicit)
|
|
339
|
+
metadata = {
|
|
340
|
+
'format': 'strict',
|
|
341
|
+
'total_lines': len(text.split('\n')),
|
|
342
|
+
'stl_lines': len(text.split('\n')),
|
|
343
|
+
'line_mapping': list(range(1, len(text.split('\n')) + 1))
|
|
344
|
+
}
|
|
345
|
+
return text, metadata
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ========================================
|
|
349
|
+
# MULTI-LINE STATEMENT MERGING
|
|
350
|
+
# ========================================
|
|
351
|
+
|
|
352
|
+
def merge_multiline_statements(text: str) -> str:
|
|
353
|
+
"""Merge multi-line STL statements into single lines.
|
|
354
|
+
|
|
355
|
+
LLMs often format STL statements across multiple lines for readability:
|
|
356
|
+
[A] -> [B] ::mod(
|
|
357
|
+
rule="empirical",
|
|
358
|
+
confidence=0.9
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
This function detects such multi-line statements (identified by unmatched
|
|
362
|
+
parentheses) and merges them into single lines for parsing.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
text: Input text that may contain multi-line statements
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Text with multi-line statements merged into single lines
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
>>> text = '[A] -> [B] ::mod(\\n rule="test"\\n)'
|
|
372
|
+
>>> merge_multiline_statements(text)
|
|
373
|
+
'[A] -> [B] ::mod( rule="test" )'
|
|
374
|
+
"""
|
|
375
|
+
lines = text.split('\n')
|
|
376
|
+
merged = []
|
|
377
|
+
buffer = []
|
|
378
|
+
paren_depth = 0
|
|
379
|
+
in_multiline = False
|
|
380
|
+
|
|
381
|
+
for line in lines:
|
|
382
|
+
stripped = line.strip()
|
|
383
|
+
|
|
384
|
+
# Skip comment lines - don't count their parentheses
|
|
385
|
+
if stripped.startswith('#'):
|
|
386
|
+
if not in_multiline:
|
|
387
|
+
merged.append(line)
|
|
388
|
+
else:
|
|
389
|
+
buffer.append(line)
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
open_parens = line.count('(')
|
|
393
|
+
close_parens = line.count(')')
|
|
394
|
+
|
|
395
|
+
if in_multiline:
|
|
396
|
+
buffer.append(line)
|
|
397
|
+
paren_depth += open_parens - close_parens
|
|
398
|
+
|
|
399
|
+
if paren_depth <= 0:
|
|
400
|
+
merged_line = ' '.join(l.strip() for l in buffer if not l.strip().startswith('#'))
|
|
401
|
+
merged.append(merged_line)
|
|
402
|
+
buffer = []
|
|
403
|
+
in_multiline = False
|
|
404
|
+
paren_depth = 0
|
|
405
|
+
else:
|
|
406
|
+
paren_depth = open_parens - close_parens
|
|
407
|
+
|
|
408
|
+
if paren_depth > 0:
|
|
409
|
+
in_multiline = True
|
|
410
|
+
buffer.append(line)
|
|
411
|
+
else:
|
|
412
|
+
merged.append(line)
|
|
413
|
+
|
|
414
|
+
# Handle any unclosed multi-line statements (malformed input)
|
|
415
|
+
if buffer:
|
|
416
|
+
merged_line = ' '.join(l.strip() for l in buffer if not l.strip().startswith('#'))
|
|
417
|
+
merged.append(merged_line)
|
|
418
|
+
|
|
419
|
+
return '\n'.join(merged)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ========================================
|
|
423
|
+
# LINE NUMBER REMAPPING
|
|
424
|
+
# ========================================
|
|
425
|
+
|
|
426
|
+
def remap_line_numbers(result: ParseResult, line_mapping: List[int]) -> ParseResult:
|
|
427
|
+
"""Remap line numbers in parse errors/warnings to original file line numbers.
|
|
428
|
+
|
|
429
|
+
When STL is extracted from mixed content, the line numbers in parse errors
|
|
430
|
+
refer to the extracted text. This function remaps them to the original file.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
result: ParseResult with line numbers from extracted text
|
|
434
|
+
line_mapping: List mapping extracted line numbers to original line numbers
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
ParseResult with remapped line numbers
|
|
438
|
+
"""
|
|
439
|
+
if not line_mapping:
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
# Remap error line numbers
|
|
443
|
+
for error in result.errors:
|
|
444
|
+
if error.line is not None and 0 < error.line <= len(line_mapping):
|
|
445
|
+
error.line = line_mapping[error.line - 1]
|
|
446
|
+
|
|
447
|
+
# Remap warning line numbers
|
|
448
|
+
for warning in result.warnings:
|
|
449
|
+
if warning.line is not None and 0 < warning.line <= len(line_mapping):
|
|
450
|
+
warning.line = line_mapping[warning.line - 1]
|
|
451
|
+
|
|
452
|
+
# Remap statement line numbers if they exist
|
|
453
|
+
for statement in result.statements:
|
|
454
|
+
if statement.line is not None and 0 < statement.line <= len(line_mapping):
|
|
455
|
+
statement.line = line_mapping[statement.line - 1]
|
|
456
|
+
|
|
457
|
+
return result
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ========================================
|
|
461
|
+
# MARKDOWN ESCAPE REMOVAL
|
|
462
|
+
# ========================================
|
|
463
|
+
|
|
464
|
+
def remove_markdown_escapes(text: str) -> str:
|
|
465
|
+
"""Remove Markdown escape characters from STL text, protecting quoted strings.
|
|
466
|
+
|
|
467
|
+
Markdown editors automatically escape special characters used in STL syntax
|
|
468
|
+
(like [, ], ->, (, ), ", ', _) with backslashes. This function removes those
|
|
469
|
+
escapes to allow proper parsing, but carefully preserves backslashes inside
|
|
470
|
+
quoted strings.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
text: Raw text that may contain Markdown escapes
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Text with Markdown escapes removed
|
|
477
|
+
|
|
478
|
+
Example:
|
|
479
|
+
>>> remove_markdown_escapes(r'\\[Coffee\\]')
|
|
480
|
+
'[Coffee]'
|
|
481
|
+
"""
|
|
482
|
+
pattern = r'("[^"\\]*(?:\\.[^"\\]*)*"|\'[^\'\\]*(?:\\.[^\'\\]*)*\')|\\([\[\]\-\>\:\(\)\"\'\._\*\#\{\}\|])'
|
|
483
|
+
|
|
484
|
+
def replace(match):
|
|
485
|
+
if match.group(1):
|
|
486
|
+
return match.group(1)
|
|
487
|
+
else:
|
|
488
|
+
return match.group(2)
|
|
489
|
+
|
|
490
|
+
return re.sub(pattern, replace, text)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ========================================
|
|
494
|
+
# ANCHOR NAME SANITIZATION
|
|
495
|
+
# ========================================
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def sanitize_anchor_name(name: str) -> str:
|
|
499
|
+
"""Sanitize a string for use as an STL anchor name.
|
|
500
|
+
|
|
501
|
+
Replaces characters invalid in STL anchors (hyphens, dots, slashes,
|
|
502
|
+
special chars) with underscores, collapses runs, and strips edges.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
name: Raw string (e.g. file path, package name, version string)
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
A valid STL anchor name, or "Unknown" if the result would be empty.
|
|
509
|
+
|
|
510
|
+
Examples:
|
|
511
|
+
>>> sanitize_anchor_name("stl-parser")
|
|
512
|
+
'stl_parser'
|
|
513
|
+
>>> sanitize_anchor_name("src/core.py")
|
|
514
|
+
'src_core_py'
|
|
515
|
+
>>> sanitize_anchor_name("黄帝内经")
|
|
516
|
+
'黄帝内经'
|
|
517
|
+
"""
|
|
518
|
+
# Keep only characters valid in STL anchors (word chars + CJK + Arabic)
|
|
519
|
+
result = re.sub(r'[^A-Za-z0-9_\u4e00-\u9fff\u0600-\u06ff]', '_', name)
|
|
520
|
+
# Collapse multiple underscores
|
|
521
|
+
result = re.sub(r'_+', '_', result)
|
|
522
|
+
# Strip leading/trailing underscores
|
|
523
|
+
result = result.strip('_')
|
|
524
|
+
return result if result else "Unknown"
|