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 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"