python-delphi-lsp 3.6.0__cp310-abi3-win_amd64.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 (56) hide show
  1. delphi_lsp/__init__.py +184 -0
  2. delphi_lsp/_version.py +1 -0
  3. delphi_lsp/agent_cache.py +1574 -0
  4. delphi_lsp/agent_cli.py +902 -0
  5. delphi_lsp/agent_context.py +3907 -0
  6. delphi_lsp/agent_cpg.py +349 -0
  7. delphi_lsp/agent_cpg_builder.py +666 -0
  8. delphi_lsp/agent_layers.py +1410 -0
  9. delphi_lsp/agent_metrics.py +526 -0
  10. delphi_lsp/agent_protocol.py +445 -0
  11. delphi_lsp/agent_relations.py +1881 -0
  12. delphi_lsp/agent_templates.py +614 -0
  13. delphi_lsp/agent_wiki.py +2853 -0
  14. delphi_lsp/agent_workspace.py +860 -0
  15. delphi_lsp/ast_serialize.py +126 -0
  16. delphi_lsp/binary.py +250 -0
  17. delphi_lsp/comment_builder.py +28 -0
  18. delphi_lsp/consts.py +345 -0
  19. delphi_lsp/delphi_lsp_native.pyd +0 -0
  20. delphi_lsp/delphiast_lexer.py +399 -0
  21. delphi_lsp/delphiast_parser.py +2189 -0
  22. delphi_lsp/delphiast_tokens.py +41 -0
  23. delphi_lsp/grammar.py +459 -0
  24. delphi_lsp/incremental.py +1139 -0
  25. delphi_lsp/lark_builder.py +2684 -0
  26. delphi_lsp/lark_tokens.py +236 -0
  27. delphi_lsp/lexical_scanner.py +30 -0
  28. delphi_lsp/lsp_server.py +6698 -0
  29. delphi_lsp/metrics.py +1452 -0
  30. delphi_lsp/native_bridge.py +300 -0
  31. delphi_lsp/navigation_cache.py +189 -0
  32. delphi_lsp/nodes.py +462 -0
  33. delphi_lsp/parallel_outline.py +662 -0
  34. delphi_lsp/parse_artifacts.py +969 -0
  35. delphi_lsp/parser.py +342 -0
  36. delphi_lsp/parser_backend.py +139 -0
  37. delphi_lsp/preprocessor.py +1122 -0
  38. delphi_lsp/progress.py +26 -0
  39. delphi_lsp/project_config.py +285 -0
  40. delphi_lsp/project_discovery.py +932 -0
  41. delphi_lsp/project_indexer.py +470 -0
  42. delphi_lsp/py.typed +1 -0
  43. delphi_lsp/response_cache.py +465 -0
  44. delphi_lsp/semantic.py +394 -0
  45. delphi_lsp/semantic_builder.py +2302 -0
  46. delphi_lsp/source_reader.py +17 -0
  47. delphi_lsp/wiki_external_sort.py +392 -0
  48. delphi_lsp/wiki_memory.py +548 -0
  49. delphi_lsp/workspace.py +83 -0
  50. delphi_lsp/writer.py +73 -0
  51. python_delphi_lsp-3.6.0.dist-info/METADATA +766 -0
  52. python_delphi_lsp-3.6.0.dist-info/RECORD +56 -0
  53. python_delphi_lsp-3.6.0.dist-info/WHEEL +4 -0
  54. python_delphi_lsp-3.6.0.dist-info/entry_points.txt +3 -0
  55. python_delphi_lsp-3.6.0.dist-info/licenses/LICENSE +373 -0
  56. python_delphi_lsp-3.6.0.dist-info/sboms/python-delphi-lsp.cyclonedx.json +770 -0
@@ -0,0 +1,1122 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterable
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from .lexical_scanner import scan_brace_comment
9
+ from .source_reader import read_source_text
10
+
11
+ IncludeLoader = Callable[[str, str], tuple[str, str] | None]
12
+
13
+
14
+ _NON_NEWLINE = re.compile(r'[^\n]')
15
+ _SPECIAL_MARKER = re.compile(r"'|//|\{|\(\*")
16
+ _FPC_INSERTION_MACRO = re.compile(r'%[A-Za-z][A-Za-z0-9_]*%')
17
+
18
+
19
+ def _blank_preserving_newlines(text: str) -> str:
20
+ return _NON_NEWLINE.sub(' ', text)
21
+
22
+
23
+ def _nonblank_line_count(text: str) -> int:
24
+ return sum(1 for line in text.splitlines() if line.strip())
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SourceMapEntry:
29
+ file_name: str
30
+ line: int
31
+ col_offset: int = 0
32
+
33
+
34
+ @dataclass
35
+ class PreprocessorProblem:
36
+ kind: str
37
+ message: str
38
+ file_name: str
39
+ line: int
40
+ col: int
41
+
42
+ @property
43
+ def column(self) -> int:
44
+ return self.col
45
+
46
+
47
+ @dataclass
48
+ class CommentInfo:
49
+ kind: str
50
+ text: str
51
+ file_name: str
52
+ line: int
53
+ col: int
54
+ end_line: int
55
+ end_col: int
56
+
57
+
58
+ @dataclass
59
+ class PreprocessedSource:
60
+ text: str
61
+ source_map: list[SourceMapEntry]
62
+ defines: set[str]
63
+ scoped_enums: bool
64
+ problems: list[PreprocessorProblem]
65
+ comments: list[CommentInfo]
66
+
67
+ def map_position(self, line: int, col: int) -> tuple[str, int, int]:
68
+ if line < 1 or line > len(self.source_map):
69
+ return ('', 0, 0)
70
+ entry = self.source_map[line - 1]
71
+ return (entry.file_name, entry.line, col + entry.col_offset)
72
+
73
+
74
+ @dataclass
75
+ class PreprocessorOptions:
76
+ use_defines: bool = True
77
+ compiler_version: float = 36.0
78
+ rtl_version: float = 36.0
79
+ scoped_enums: bool = False
80
+ option_states: dict[str, bool] = field(default_factory=dict)
81
+
82
+
83
+ @dataclass
84
+ class _ConditionalFrame:
85
+ parent_active: bool
86
+ branch_taken: bool
87
+ current_active: bool
88
+ directive: str
89
+ condition: str
90
+ file_name: str
91
+ line: int
92
+
93
+
94
+ @dataclass
95
+ class _ExcludedSection:
96
+ directive: str
97
+ condition: str
98
+ file_name: str
99
+ lines: set[int] = field(default_factory=set)
100
+
101
+
102
+ @dataclass
103
+ class _FileContext:
104
+ text: str
105
+ file_name: str
106
+ index: int = 0
107
+ line: int = 1
108
+ col: int = 1
109
+ pending_output: str = ''
110
+ pending_line: int = 1
111
+
112
+ def peek(self, offset: int = 0) -> str:
113
+ idx = self.index + offset
114
+ if idx < 0 or idx >= len(self.text):
115
+ return ''
116
+ return self.text[idx]
117
+
118
+ def advance(self, count: int = 1) -> str:
119
+ if count <= 0:
120
+ return ''
121
+ start = self.index
122
+ end = min(len(self.text), start + count)
123
+ chunk = self.text[start:end]
124
+ newline_count = self.text.count('\n', start, end)
125
+ if newline_count:
126
+ self.line += newline_count
127
+ last_newline = self.text.rfind('\n', start, end)
128
+ self.col = end - last_newline
129
+ else:
130
+ self.col += end - start
131
+ self.index = end
132
+ return chunk
133
+
134
+ def eof(self) -> bool:
135
+ return self.index >= len(self.text)
136
+
137
+
138
+ class _OutputBuffer:
139
+ def __init__(self, *, track_source_map: bool = True) -> None:
140
+ self._chunks: list[str] = []
141
+ self._line_map: list[SourceMapEntry] = []
142
+ self._needs_mapping = True
143
+ self._track_source_map = track_source_map
144
+
145
+ @property
146
+ def text(self) -> str:
147
+ return ''.join(self._chunks)
148
+
149
+ @property
150
+ def line_map(self) -> list[SourceMapEntry]:
151
+ return self._line_map
152
+
153
+ def append_char(self, ch: str, file_name: str, line: int) -> None:
154
+ self.append_span(ch, file_name, line, active=True)
155
+
156
+ def append_span(
157
+ self,
158
+ text: str,
159
+ file_name: str,
160
+ line: int,
161
+ *,
162
+ active: bool,
163
+ ) -> None:
164
+ if not text:
165
+ return
166
+ emitted = text if active else _blank_preserving_newlines(text)
167
+ self._chunks.append(emitted)
168
+ if not self._track_source_map:
169
+ return
170
+ current_line = line
171
+ start = 0
172
+ if self._needs_mapping:
173
+ self._line_map.append(SourceMapEntry(file_name=file_name, line=current_line))
174
+ while True:
175
+ newline = emitted.find('\n', start)
176
+ if newline < 0:
177
+ self._needs_mapping = False
178
+ return
179
+ current_line += 1
180
+ start = newline + 1
181
+ if start < len(emitted):
182
+ self._line_map.append(SourceMapEntry(file_name=file_name, line=current_line))
183
+ else:
184
+ self._needs_mapping = True
185
+ return
186
+
187
+
188
+ class Preprocessor:
189
+ def __init__(
190
+ self,
191
+ *,
192
+ defines: Iterable[str] = (),
193
+ include_paths: Iterable[str] = (),
194
+ include_loader: IncludeLoader | None = None,
195
+ options: PreprocessorOptions | None = None,
196
+ preserve_active_directives: bool = False,
197
+ track_source_map: bool = True,
198
+ collect_comments: bool = True,
199
+ ) -> None:
200
+ self.defines: set[str] = {self._normalize_define(d) for d in defines if d}
201
+ self.include_paths = [Path(p) for p in include_paths]
202
+ self.include_loader = include_loader or self._default_include_loader
203
+ self.options = options or PreprocessorOptions()
204
+ self.preserve_active_directives = preserve_active_directives
205
+ self.track_source_map = track_source_map
206
+ self.collect_comments = collect_comments
207
+ self._apply_default_compiler_defines()
208
+ self.scoped_enums = self.options.scoped_enums
209
+ self._option_values: dict[str, str] = {}
210
+ self._option_stack: list[tuple[dict[str, str], bool]] = []
211
+ self._problems: list[PreprocessorProblem] = []
212
+ self._comments: list[CommentInfo] = []
213
+ self._conditional_stack: list[_ConditionalFrame] = []
214
+ self._excluded_sections: dict[
215
+ tuple[str, int, str, str],
216
+ _ExcludedSection,
217
+ ] = {}
218
+ self._source_nonblank_lines: dict[str, int] = {}
219
+
220
+ def process(self, text: str, file_name: str) -> PreprocessedSource:
221
+ self._problems = []
222
+ self._comments = []
223
+ self._conditional_stack = []
224
+ self._excluded_sections = {}
225
+ self._source_nonblank_lines = {}
226
+ self._option_stack = []
227
+ self.scoped_enums = self.options.scoped_enums
228
+ self._option_values = {
229
+ self._normalize_option_name(name): 'ON' if bool(value) else 'OFF'
230
+ for name, value in self.options.option_states.items()
231
+ }
232
+ if 'SCOPEDENUMS' in self._option_values:
233
+ self.scoped_enums = self._option_values['SCOPEDENUMS'] == 'ON'
234
+ else:
235
+ self._set_option('SCOPEDENUMS', 'ON' if self.scoped_enums else 'OFF')
236
+
237
+ normalized = self._normalize_newlines(text)
238
+ self._source_nonblank_lines[file_name] = _nonblank_line_count(normalized)
239
+ contexts = [_FileContext(text=normalized, file_name=file_name)]
240
+ include_stack = [file_name]
241
+ output = _OutputBuffer(track_source_map=self.track_source_map)
242
+
243
+ while contexts:
244
+ ctx = contexts[-1]
245
+
246
+ if ctx.pending_output:
247
+ pending = ctx.pending_output
248
+ ctx.pending_output = ''
249
+ pending_line = ctx.pending_line
250
+ ctx.pending_line = ctx.line
251
+ self._emit_text(output, pending, ctx.file_name, pending_line, active=True)
252
+ continue
253
+
254
+ if ctx.eof():
255
+ contexts.pop()
256
+ if include_stack:
257
+ include_stack.pop()
258
+ continue
259
+
260
+ marker_match = _SPECIAL_MARKER.search(ctx.text, ctx.index)
261
+ next_special = marker_match.start() if marker_match is not None else len(ctx.text)
262
+ if next_special > ctx.index:
263
+ start_line = ctx.line
264
+ ordinary = ctx.advance(next_special - ctx.index)
265
+ self._emit_text(
266
+ output,
267
+ ordinary,
268
+ ctx.file_name,
269
+ start_line,
270
+ active=self._is_active(),
271
+ )
272
+ continue
273
+
274
+ ch = ctx.peek()
275
+
276
+ if ch == "'":
277
+ start_line = ctx.line
278
+ active = self._is_active()
279
+ literal = self._consume_multiline_string(ctx)
280
+ if literal is None:
281
+ literal = self._consume_string(ctx)
282
+ self._emit_text(output, literal, ctx.file_name, start_line, active=active)
283
+ continue
284
+
285
+ if ch == '/' and ctx.peek(1) == '/':
286
+ start_line = ctx.line
287
+ start_col = ctx.col
288
+ active = self._is_active()
289
+ raw, content = self._consume_line_comment(ctx)
290
+ self._emit_text(output, raw, ctx.file_name, start_line, active=active)
291
+ if active and self.collect_comments:
292
+ self._record_comment('slashes', content, ctx.file_name, start_line, start_col, ctx)
293
+ continue
294
+
295
+ if ch == '{':
296
+ start_line = ctx.line
297
+ start_col = ctx.col
298
+ active = self._is_active()
299
+ if ctx.peek(1) == '$':
300
+ raw_text, content = self._consume_brace_directive(ctx)
301
+ self._handle_directive(
302
+ raw_text,
303
+ content,
304
+ ctx,
305
+ start_line,
306
+ contexts,
307
+ include_stack,
308
+ output,
309
+ )
310
+ else:
311
+ raw, content = self._consume_brace_comment(ctx, nested=active)
312
+ self._emit_text(output, raw, ctx.file_name, start_line, active=active)
313
+ if active and self.collect_comments:
314
+ self._record_comment('borland', content, ctx.file_name, start_line, start_col, ctx)
315
+ continue
316
+
317
+ if ch == '(' and ctx.peek(1) == '*':
318
+ start_line = ctx.line
319
+ start_col = ctx.col
320
+ active = self._is_active()
321
+ if ctx.peek(2) == '$':
322
+ raw_text, content = self._consume_paren_directive(ctx)
323
+ self._handle_directive(
324
+ raw_text,
325
+ content,
326
+ ctx,
327
+ start_line,
328
+ contexts,
329
+ include_stack,
330
+ output,
331
+ )
332
+ else:
333
+ raw, content = self._consume_paren_comment(ctx)
334
+ self._emit_text(output, raw, ctx.file_name, start_line, active=active)
335
+ if active and self.collect_comments:
336
+ self._record_comment('ansi', content, ctx.file_name, start_line, start_col, ctx)
337
+ continue
338
+
339
+ start_line = ctx.line
340
+ ch = ctx.advance(1)
341
+ self._emit_text(output, ch, ctx.file_name, start_line, active=self._is_active())
342
+
343
+ self._append_section_exclusion_problems()
344
+ return PreprocessedSource(
345
+ text=output.text,
346
+ source_map=output.line_map,
347
+ defines=self.defines,
348
+ scoped_enums=self.scoped_enums,
349
+ problems=self._problems,
350
+ comments=self._comments,
351
+ )
352
+
353
+ def _apply_default_compiler_defines(self) -> None:
354
+ if not self.options.use_defines:
355
+ return
356
+ if 'FPC' in self.defines:
357
+ return
358
+ platform_defines = {
359
+ 'MSWINDOWS',
360
+ 'OSWINDOWS',
361
+ 'OSPOSIX',
362
+ 'POSIX',
363
+ 'MACOS',
364
+ 'LINUX',
365
+ 'ANDROID',
366
+ 'IOS',
367
+ }
368
+ if not self.defines.intersection(platform_defines):
369
+ self.defines.add('MSWINDOWS')
370
+ if self.options.compiler_version >= 20.0:
371
+ self.defines.add('CONDITIONALEXPRESSIONS')
372
+ self.defines.add('UNICODE')
373
+ version_define = int(round(self.options.compiler_version * 10))
374
+ self.defines.add(f'VER{version_define}')
375
+
376
+ def _consume_multiline_string(self, ctx: _FileContext) -> str | None:
377
+ for quote_count in (5, 3):
378
+ delimiter = "'" * quote_count
379
+ if not ctx.text.startswith(delimiter, ctx.index):
380
+ continue
381
+ content_start = ctx.index + quote_count
382
+ if content_start >= len(ctx.text) or ctx.text[content_start] != '\n':
383
+ continue
384
+ close = ctx.text.find(delimiter, content_start + 1)
385
+ end = len(ctx.text) if close < 0 else close + quote_count
386
+ return ctx.advance(end - ctx.index)
387
+ return None
388
+
389
+ def _emit_text(
390
+ self,
391
+ output: _OutputBuffer,
392
+ text: str,
393
+ file_name: str,
394
+ line: int,
395
+ *,
396
+ active: bool,
397
+ ) -> None:
398
+ if not active:
399
+ self._record_excluded_text(text, file_name, line)
400
+ output.append_span(text, file_name, line, active=active)
401
+
402
+ def _record_excluded_text(
403
+ self,
404
+ text: str,
405
+ file_name: str,
406
+ start_line: int,
407
+ ) -> None:
408
+ controlling_frame = next(
409
+ (
410
+ frame
411
+ for frame in reversed(self._conditional_stack)
412
+ if frame.parent_active and not frame.current_active
413
+ ),
414
+ None,
415
+ )
416
+ if controlling_frame is None:
417
+ return
418
+ excluded_lines = {
419
+ start_line + offset
420
+ for offset, source_line in enumerate(text.splitlines())
421
+ if source_line.strip()
422
+ }
423
+ if not excluded_lines:
424
+ return
425
+ key = (
426
+ controlling_frame.file_name,
427
+ controlling_frame.line,
428
+ controlling_frame.directive,
429
+ controlling_frame.condition,
430
+ )
431
+ section = self._excluded_sections.setdefault(
432
+ key,
433
+ _ExcludedSection(
434
+ directive=controlling_frame.directive,
435
+ condition=controlling_frame.condition,
436
+ file_name=file_name,
437
+ ),
438
+ )
439
+ section.lines.update(excluded_lines)
440
+
441
+ def _append_section_exclusion_problems(self) -> None:
442
+ for section in self._excluded_sections.values():
443
+ total_lines = self._source_nonblank_lines.get(section.file_name, 0)
444
+ excluded_lines = sorted(section.lines)
445
+ if (
446
+ len(excluded_lines) < 20
447
+ or total_lines <= 0
448
+ or len(excluded_lines) / total_lines < 0.5
449
+ ):
450
+ continue
451
+ start_line = excluded_lines[0]
452
+ end_line = excluded_lines[-1]
453
+ condition = f" {section.condition}" if section.condition else ""
454
+ self._problems.append(
455
+ PreprocessorProblem(
456
+ kind="section_excluded",
457
+ message=(
458
+ "conditional section excluded by "
459
+ f"{section.directive}{condition}: lines {start_line}-{end_line} "
460
+ f"({len(excluded_lines)}/{total_lines} nonblank lines)"
461
+ ),
462
+ file_name=section.file_name,
463
+ line=start_line,
464
+ col=1,
465
+ )
466
+ )
467
+
468
+ def _is_active(self) -> bool:
469
+ if not self._conditional_stack:
470
+ return True
471
+ return self._conditional_stack[-1].current_active
472
+
473
+ def _handle_directive(
474
+ self,
475
+ directive_raw: str,
476
+ directive_content: str,
477
+ ctx: _FileContext,
478
+ start_line: int,
479
+ contexts: list[_FileContext],
480
+ include_stack: list[str],
481
+ output: _OutputBuffer,
482
+ ) -> None:
483
+ name, param = self._parse_directive(directive_content)
484
+ name = {
485
+ "IFC": "IF",
486
+ "ELIFC": "ELSEIF",
487
+ "ELSEC": "ELSE",
488
+ "ENDC": "ENDIF",
489
+ }.get(name, name)
490
+ replacement = self._replace_with_spaces(directive_raw)
491
+ active_replacement = (
492
+ directive_raw
493
+ if self.preserve_active_directives and self._is_active()
494
+ else replacement
495
+ )
496
+
497
+ if not name:
498
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
499
+ return
500
+
501
+ if name in {'IFDEF', 'IFNDEF', 'IF', 'IFOPT'}:
502
+ condition = False
503
+ if self._is_active():
504
+ if name == 'IFDEF':
505
+ condition = self._is_defined(param)
506
+ elif name == 'IFNDEF':
507
+ condition = not self._is_defined(param)
508
+ elif name == 'IF':
509
+ condition = self._evaluate_conditional_expression(param)
510
+ elif name == 'IFOPT':
511
+ condition = self._evaluate_ifopt(param)
512
+ frame = _ConditionalFrame(
513
+ parent_active=self._is_active(),
514
+ branch_taken=condition,
515
+ current_active=self._is_active() and condition,
516
+ directive=name,
517
+ condition=param.strip(),
518
+ file_name=ctx.file_name,
519
+ line=start_line,
520
+ )
521
+ self._conditional_stack.append(frame)
522
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
523
+ return
524
+
525
+ if name in {'ELSEIF', 'ELSE', 'ENDIF', 'IFEND'}:
526
+ if not self._conditional_stack:
527
+ self._problems.append(
528
+ PreprocessorProblem(
529
+ kind='directive',
530
+ message=f'unexpected {name}',
531
+ file_name=ctx.file_name,
532
+ line=start_line,
533
+ col=ctx.col,
534
+ )
535
+ )
536
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
537
+ return
538
+
539
+ frame = self._conditional_stack[-1]
540
+ if name == 'ELSEIF':
541
+ if not frame.parent_active or frame.branch_taken:
542
+ frame.current_active = False
543
+ else:
544
+ condition = self._evaluate_conditional_expression(param)
545
+ frame.current_active = frame.parent_active and condition
546
+ if condition:
547
+ frame.branch_taken = True
548
+ elif name == 'ELSE':
549
+ if not frame.parent_active:
550
+ frame.current_active = False
551
+ else:
552
+ frame.current_active = frame.parent_active and not frame.branch_taken
553
+ frame.branch_taken = True
554
+ else:
555
+ self._conditional_stack.pop()
556
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
557
+ return
558
+
559
+ if name in {'SETC', 'DEFINEC', 'UNDEFC'}:
560
+ if self._is_active() and self.options.use_defines:
561
+ assignment = param.replace(":=", "=", 1)
562
+ symbol_raw, _, expression = assignment.partition("=")
563
+ symbol = self._normalize_define(symbol_raw.split()[0])
564
+ if symbol:
565
+ if name == "UNDEFC":
566
+ self.defines.discard(symbol)
567
+ elif name == "DEFINEC" or self._evaluate_conditional_expression(expression):
568
+ self.defines.add(symbol)
569
+ else:
570
+ self.defines.discard(symbol)
571
+ self._emit_text(
572
+ output,
573
+ active_replacement,
574
+ ctx.file_name,
575
+ start_line,
576
+ active=True,
577
+ )
578
+ return
579
+
580
+ if name in {'DEFINE', 'UNDEF'}:
581
+ if self._is_active() and self.options.use_defines:
582
+ for define_name in self._parse_define_list(param):
583
+ if name == 'DEFINE':
584
+ self.defines.add(define_name)
585
+ else:
586
+ self.defines.discard(define_name)
587
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
588
+ return
589
+
590
+ if name in {'SCOPEDENUMS'}:
591
+ if self._is_active():
592
+ self.scoped_enums = self._parse_on_off(param)
593
+ self._set_option('SCOPEDENUMS', 'ON' if self.scoped_enums else 'OFF')
594
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
595
+ return
596
+
597
+ if name in {'PUSHOPT', 'POPOPT'}:
598
+ if self._is_active():
599
+ if name == 'PUSHOPT':
600
+ self._option_stack.append((dict(self._option_values), self.scoped_enums))
601
+ else:
602
+ if self._option_stack:
603
+ self._option_values, self.scoped_enums = self._option_stack.pop()
604
+ else:
605
+ self._problems.append(
606
+ PreprocessorProblem(
607
+ kind='directive',
608
+ message='POPOPT without matching PUSHOPT',
609
+ file_name=ctx.file_name,
610
+ line=start_line,
611
+ col=ctx.col,
612
+ )
613
+ )
614
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
615
+ return
616
+
617
+ if name == 'OPT':
618
+ if self._is_active():
619
+ self._apply_opt_directive(param)
620
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
621
+ return
622
+
623
+ if name == 'I' and param.strip() in {'+', '-'}:
624
+ if self._is_active():
625
+ self._apply_named_option(name, param)
626
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
627
+ return
628
+
629
+ if name == 'I' and _FPC_INSERTION_MACRO.fullmatch(param.strip()):
630
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
631
+ return
632
+
633
+ if name in {'I', 'INCLUDE'}:
634
+ if self._is_active():
635
+ include_name = self._extract_include_name(param)
636
+ if include_name:
637
+ resolved = self.include_loader(ctx.file_name, include_name)
638
+ if resolved is None:
639
+ self._problems.append(
640
+ PreprocessorProblem(
641
+ kind='include',
642
+ message=f'include not found: {include_name}',
643
+ file_name=ctx.file_name,
644
+ line=start_line,
645
+ col=ctx.col,
646
+ )
647
+ )
648
+ else:
649
+ content, resolved_path = resolved
650
+ if resolved_path in include_stack:
651
+ self._problems.append(
652
+ PreprocessorProblem(
653
+ kind='include',
654
+ message=f'include cycle detected: {resolved_path}',
655
+ file_name=ctx.file_name,
656
+ line=start_line,
657
+ col=ctx.col,
658
+ )
659
+ )
660
+ else:
661
+ include_stack.append(resolved_path)
662
+ ctx.pending_output = active_replacement
663
+ ctx.pending_line = start_line
664
+ normalized = self._normalize_newlines(content)
665
+ self._source_nonblank_lines.setdefault(
666
+ resolved_path,
667
+ _nonblank_line_count(normalized),
668
+ )
669
+ if not normalized.endswith('\n'):
670
+ normalized += '\n'
671
+ contexts.append(_FileContext(text=normalized, file_name=resolved_path))
672
+ return
673
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
674
+ return
675
+
676
+ if self._is_active() and self._apply_named_option(name, param):
677
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
678
+ return
679
+
680
+ self._emit_text(output, active_replacement, ctx.file_name, start_line, active=True)
681
+
682
+ def _parse_directive(self, directive_text: str) -> tuple[str, str]:
683
+ text = directive_text.strip()
684
+ if not text:
685
+ return ('', '')
686
+ name = []
687
+ idx = 0
688
+ while idx < len(text) and (text[idx].isalpha() or text[idx] == '_'):
689
+ name.append(text[idx])
690
+ idx += 1
691
+ if not name:
692
+ return ('', '')
693
+ param = text[idx:].strip()
694
+ return (''.join(name).upper(), param)
695
+
696
+ def _parse_define_list(self, param: str) -> list[str]:
697
+ if not param:
698
+ return []
699
+ cleaned = param.replace(',', ' ')
700
+ names = [self._normalize_define(part) for part in cleaned.split() if part]
701
+ return [n for n in names if n]
702
+
703
+ def _evaluate_ifopt(self, param: str) -> bool:
704
+ if not param:
705
+ return False
706
+ token = param.strip().upper()
707
+ if not token:
708
+ return False
709
+ if token.endswith('+') and len(token) > 1:
710
+ option_name = token[:-1].strip()
711
+ return self._get_option(option_name) == 'ON'
712
+ if token.endswith('-') and len(token) > 1:
713
+ option_name = token[:-1].strip()
714
+ return self._get_option(option_name) == 'OFF'
715
+ if '=' in token:
716
+ name, raw_state = token.split('=', 1)
717
+ expected = self._normalize_option_state(raw_state)
718
+ if not name.strip() or expected is None:
719
+ return False
720
+ return self._get_option(name) == expected
721
+ parts = token.split()
722
+ if len(parts) == 2:
723
+ expected = self._normalize_option_state(parts[1])
724
+ if expected is None:
725
+ return False
726
+ return self._get_option(parts[0]) == expected
727
+ return False
728
+
729
+ def _evaluate_conditional_expression(self, param: str) -> bool:
730
+ text = param.strip().upper()
731
+ if text.startswith('COMPILERVERSION'):
732
+ return self._evaluate_version(text, 'COMPILERVERSION', self.options.compiler_version)
733
+ if text.startswith('RTLVERSION'):
734
+ return self._evaluate_version(text, 'RTLVERSION', self.options.rtl_version)
735
+ return self._evaluate_boolean_expression(text)
736
+
737
+ def _evaluate_boolean_expression(self, text: str) -> bool:
738
+ tokens = re.findall(
739
+ r"DEFINED|NOT|AND|OR|TRUE|FALSE|"
740
+ r"[A-Z_][A-Z0-9_]*|\$[0-9A-F]+|\d+|[()]",
741
+ text,
742
+ )
743
+ index = 0
744
+
745
+ def atom() -> bool:
746
+ nonlocal index
747
+ if index >= len(tokens):
748
+ return False
749
+ token = tokens[index]
750
+ if token == "(":
751
+ index += 1
752
+ value = disjunction()
753
+ if index < len(tokens) and tokens[index] == ")":
754
+ index += 1
755
+ return value
756
+ if token == "DEFINED":
757
+ index += 1
758
+ parenthesized = index < len(tokens) and tokens[index] == "("
759
+ if parenthesized:
760
+ index += 1
761
+ symbol = tokens[index] if index < len(tokens) else ""
762
+ if symbol:
763
+ index += 1
764
+ if parenthesized and index < len(tokens) and tokens[index] == ")":
765
+ index += 1
766
+ return self._is_defined(symbol)
767
+ index += 1
768
+ if token == "TRUE":
769
+ return True
770
+ if token == "FALSE":
771
+ return False
772
+ if token.startswith("$"):
773
+ return int(token[1:] or "0", 16) != 0
774
+ if token.isdigit():
775
+ return int(token) != 0
776
+ return self._is_defined(token)
777
+
778
+ def negation() -> bool:
779
+ nonlocal index
780
+ if index < len(tokens) and tokens[index] == "NOT":
781
+ index += 1
782
+ return not negation()
783
+ return atom()
784
+
785
+ def conjunction() -> bool:
786
+ nonlocal index
787
+ value = negation()
788
+ while index < len(tokens) and tokens[index] == "AND":
789
+ index += 1
790
+ value = negation() and value
791
+ return value
792
+
793
+ def disjunction() -> bool:
794
+ nonlocal index
795
+ value = conjunction()
796
+ while index < len(tokens) and tokens[index] == "OR":
797
+ index += 1
798
+ value = conjunction() or value
799
+ return value
800
+
801
+ return disjunction() if tokens else False
802
+
803
+ def _evaluate_version(self, text: str, label: str, value: float) -> bool:
804
+ rest = text[len(label):].strip()
805
+ if not rest:
806
+ return False
807
+ parts = rest.split()
808
+ if len(parts) < 2:
809
+ return False
810
+ oper = parts[0]
811
+ num = parts[1]
812
+ try:
813
+ right = float(num)
814
+ except ValueError:
815
+ return False
816
+ if oper == '=':
817
+ return value == right
818
+ if oper == '<>':
819
+ return value != right
820
+ if oper == '<':
821
+ return value < right
822
+ if oper == '<=':
823
+ return value <= right
824
+ if oper == '>':
825
+ return value > right
826
+ if oper == '>=':
827
+ return value >= right
828
+ return False
829
+
830
+ def _evaluate_defined_chain(self, text: str) -> bool:
831
+ remaining = text
832
+ result = True
833
+ evaluation = None
834
+ while remaining.startswith('DEFINED(') or remaining.startswith('NOT DEFINED('):
835
+ if remaining.startswith('DEFINED('):
836
+ define_name, remaining = self._consume_defined(remaining, 'DEFINED(')
837
+ cond = self._is_defined(define_name)
838
+ else:
839
+ define_name, remaining = self._consume_defined(remaining, 'NOT DEFINED(')
840
+ cond = not self._is_defined(define_name)
841
+ if evaluation is None:
842
+ result = cond
843
+ elif evaluation == 'AND':
844
+ result = result and cond
845
+ elif evaluation == 'OR':
846
+ result = result or cond
847
+ remaining = remaining.lstrip()
848
+ if remaining.startswith('AND '):
849
+ evaluation = 'AND'
850
+ remaining = remaining[4:]
851
+ elif remaining.startswith('OR '):
852
+ evaluation = 'OR'
853
+ remaining = remaining[3:]
854
+ return result
855
+
856
+ def _consume_defined(self, text: str, prefix: str) -> tuple[str, str]:
857
+ rest = text[len(prefix):]
858
+ end = rest.find(')')
859
+ if end == -1:
860
+ return ('', '')
861
+ name = rest[:end].strip()
862
+ remaining = rest[end + 1:]
863
+ return (name, remaining)
864
+
865
+ def _parse_on_off(self, param: str) -> bool:
866
+ token = param.strip().upper()
867
+ if token in {'ON', '1', 'TRUE', '+'}:
868
+ return True
869
+ if token in {'OFF', '0', 'FALSE', '-'}:
870
+ return False
871
+ return self.scoped_enums
872
+
873
+ def _is_defined(self, name: str) -> bool:
874
+ return self._normalize_define(name) in self.defines
875
+
876
+ def _normalize_define(self, name: str) -> str:
877
+ return name.strip().upper()
878
+
879
+ def _normalize_option_name(self, name: str) -> str:
880
+ return name.strip().upper()
881
+
882
+ def _normalize_option_state(self, raw_state: str) -> str | None:
883
+ token = raw_state.strip().upper()
884
+ if token in {'+', 'ON', '1', 'TRUE'}:
885
+ return 'ON'
886
+ if token in {'-', 'OFF', '0', 'FALSE'}:
887
+ return 'OFF'
888
+ if token == 'AUTO':
889
+ return 'AUTO'
890
+ return None
891
+
892
+ def _set_option(self, option_name: str, state: str) -> None:
893
+ name = self._normalize_option_name(option_name)
894
+ normalized = self._normalize_option_state(state)
895
+ if not name or normalized is None:
896
+ return
897
+ self._option_values[name] = normalized
898
+ if name == 'SCOPEDENUMS':
899
+ self.scoped_enums = normalized == 'ON'
900
+
901
+ def _get_option(self, option_name: str) -> str:
902
+ return self._option_values.get(self._normalize_option_name(option_name), '')
903
+
904
+ def _apply_named_option(self, name: str, param: str) -> bool:
905
+ if not name:
906
+ return False
907
+ cleaned = param.strip()
908
+ if not cleaned:
909
+ return False
910
+ if cleaned.startswith('='):
911
+ cleaned = cleaned[1:].strip()
912
+ state = self._normalize_option_state(cleaned)
913
+ if state is not None:
914
+ self._set_option(name, state)
915
+ return True
916
+ if len(cleaned) >= 2 and cleaned[0].isalpha() and cleaned[1] in {'+', '-'} and len(cleaned) == 2:
917
+ # Handles forms like {$OPT R+} where R+ is passed as the param token.
918
+ self._set_option(cleaned[0], cleaned[1])
919
+ return True
920
+ return False
921
+
922
+ def _apply_opt_directive(self, param: str) -> None:
923
+ if not param:
924
+ return
925
+ parts = [part for part in param.replace(',', ' ').split() if part]
926
+ index = 0
927
+ while index < len(parts):
928
+ part = parts[index]
929
+ if len(part) >= 2 and part[-1] in {'+', '-'}:
930
+ self._set_option(part[:-1], part[-1])
931
+ index += 1
932
+ continue
933
+ if '=' in part:
934
+ name, raw_state = part.split('=', 1)
935
+ self._set_option(name, raw_state)
936
+ index += 1
937
+ continue
938
+ if index + 1 < len(parts):
939
+ candidate_state = self._normalize_option_state(parts[index + 1])
940
+ if candidate_state is not None:
941
+ self._set_option(part, candidate_state)
942
+ index += 2
943
+ continue
944
+ index += 1
945
+
946
+ def _consume_line_comment(self, ctx: _FileContext) -> tuple[str, str]:
947
+ start = ctx.index
948
+ while not ctx.eof() and ctx.peek() not in {'\n'}:
949
+ ctx.advance(1)
950
+ raw = ctx.text[start:ctx.index]
951
+ return raw, raw.removeprefix('//')
952
+
953
+ def _consume_brace_comment(
954
+ self,
955
+ ctx: _FileContext,
956
+ *,
957
+ nested: bool,
958
+ ) -> tuple[str, str]:
959
+ start = ctx.index
960
+ end, closed = scan_brace_comment(ctx.text, start, nested=nested)
961
+ ctx.advance(end - start)
962
+ if not closed:
963
+ self._problems.append(
964
+ PreprocessorProblem(
965
+ kind='comment',
966
+ message='unterminated comment',
967
+ file_name=ctx.file_name,
968
+ line=ctx.line,
969
+ col=ctx.col,
970
+ )
971
+ )
972
+ raw = ctx.text[start:ctx.index]
973
+ if closed:
974
+ return raw, raw[1:-1]
975
+ return raw, raw[1:]
976
+
977
+ def _consume_paren_comment(self, ctx: _FileContext) -> tuple[str, str]:
978
+ start = ctx.index
979
+ ctx.advance(2)
980
+ while not ctx.eof():
981
+ if ctx.peek() == '*' and ctx.peek(1) == ')':
982
+ ctx.advance(2)
983
+ break
984
+ ctx.advance(1)
985
+ else:
986
+ self._problems.append(
987
+ PreprocessorProblem(
988
+ kind='comment',
989
+ message='unterminated comment',
990
+ file_name=ctx.file_name,
991
+ line=ctx.line,
992
+ col=ctx.col,
993
+ )
994
+ )
995
+ raw = ctx.text[start:ctx.index]
996
+ if raw.endswith('*)'):
997
+ return raw, raw[2:-2]
998
+ return raw, raw[2:]
999
+
1000
+ def _consume_brace_directive(self, ctx: _FileContext) -> tuple[str, str]:
1001
+ start = ctx.index
1002
+ ctx.advance(2)
1003
+ while not ctx.eof():
1004
+ if ctx.peek() == '}':
1005
+ ctx.advance(1)
1006
+ break
1007
+ ctx.advance(1)
1008
+ else:
1009
+ self._problems.append(
1010
+ PreprocessorProblem(
1011
+ kind='directive',
1012
+ message='unterminated directive',
1013
+ file_name=ctx.file_name,
1014
+ line=ctx.line,
1015
+ col=ctx.col,
1016
+ )
1017
+ )
1018
+ token = ctx.text[start:ctx.index]
1019
+ content = token[2:-1].strip() if token.endswith('}') else token[2:].strip()
1020
+ return token, content
1021
+
1022
+ def _consume_paren_directive(self, ctx: _FileContext) -> tuple[str, str]:
1023
+ start = ctx.index
1024
+ ctx.advance(3)
1025
+ while not ctx.eof():
1026
+ if ctx.peek() == '*' and ctx.peek(1) == ')':
1027
+ ctx.advance(2)
1028
+ break
1029
+ ctx.advance(1)
1030
+ else:
1031
+ self._problems.append(
1032
+ PreprocessorProblem(
1033
+ kind='directive',
1034
+ message='unterminated directive',
1035
+ file_name=ctx.file_name,
1036
+ line=ctx.line,
1037
+ col=ctx.col,
1038
+ )
1039
+ )
1040
+ token = ctx.text[start:ctx.index]
1041
+ if token.endswith('*)'):
1042
+ content = token[3:-2].strip()
1043
+ else:
1044
+ content = token[3:].strip()
1045
+ return token, content
1046
+
1047
+ def _consume_string(self, ctx: _FileContext) -> str:
1048
+ start = ctx.index
1049
+ ctx.advance(1)
1050
+ while not ctx.eof():
1051
+ ch = ctx.peek()
1052
+ if ch == "'":
1053
+ if ctx.peek(1) == "'":
1054
+ ctx.advance(2)
1055
+ continue
1056
+ ctx.advance(1)
1057
+ break
1058
+ if ch == '\n':
1059
+ self._problems.append(
1060
+ PreprocessorProblem(
1061
+ kind='string',
1062
+ message='unterminated string literal',
1063
+ file_name=ctx.file_name,
1064
+ line=ctx.line,
1065
+ col=ctx.col,
1066
+ )
1067
+ )
1068
+ break
1069
+ ctx.advance(1)
1070
+ return ctx.text[start:ctx.index]
1071
+
1072
+ def _extract_include_name(self, param: str) -> str:
1073
+ text = param.strip()
1074
+ if not text:
1075
+ return ''
1076
+ if text[0] in {"'", '"'}:
1077
+ quote = text[0]
1078
+ end = text.find(quote, 1)
1079
+ if end == -1:
1080
+ return text[1:]
1081
+ return text[1:end]
1082
+ parts = text.split()
1083
+ return parts[0] if parts else ''
1084
+
1085
+ def _replace_with_spaces(self, text: str) -> str:
1086
+ return ''.join('\n' if ch == '\n' else ' ' for ch in text)
1087
+
1088
+ def _normalize_newlines(self, text: str) -> str:
1089
+ return text.replace('\ufeff', '').replace('\r\n', '\n').replace('\r', '\n')
1090
+
1091
+ def _default_include_loader(self, parent_file: str, include_name: str) -> tuple[str, str] | None:
1092
+ parent_path = Path(parent_file)
1093
+ include_path = Path(include_name.replace('\\', '/'))
1094
+ search_paths = [parent_path.parent] + self.include_paths
1095
+ for base in search_paths:
1096
+ candidate = (base / include_path).resolve()
1097
+ if candidate.exists():
1098
+ return (read_source_text(candidate), str(candidate))
1099
+ return None
1100
+
1101
+ def _record_comment(
1102
+ self,
1103
+ kind: str,
1104
+ text: str,
1105
+ file_name: str,
1106
+ start_line: int,
1107
+ start_col: int,
1108
+ ctx: _FileContext,
1109
+ ) -> None:
1110
+ end_line = ctx.line
1111
+ end_col = max(ctx.col - 1, 1)
1112
+ self._comments.append(
1113
+ CommentInfo(
1114
+ kind=kind,
1115
+ text=text,
1116
+ file_name=file_name,
1117
+ line=start_line,
1118
+ col=start_col,
1119
+ end_line=end_line,
1120
+ end_col=end_col,
1121
+ )
1122
+ )