python-delphi-lsp 1.0.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.
@@ -0,0 +1,837 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Callable, Iterable, Optional
6
+
7
+ from .source_reader import read_source_text
8
+
9
+
10
+ IncludeLoader = Callable[[str, str], Optional[tuple[str, str]]]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SourceMapEntry:
15
+ file_name: str
16
+ line: int
17
+ col_offset: int = 0
18
+
19
+
20
+ @dataclass
21
+ class PreprocessorProblem:
22
+ kind: str
23
+ message: str
24
+ file_name: str
25
+ line: int
26
+ col: int
27
+
28
+
29
+ @dataclass
30
+ class CommentInfo:
31
+ kind: str
32
+ text: str
33
+ file_name: str
34
+ line: int
35
+ col: int
36
+ end_line: int
37
+ end_col: int
38
+
39
+
40
+ @dataclass
41
+ class PreprocessedSource:
42
+ text: str
43
+ source_map: list[SourceMapEntry]
44
+ defines: set[str]
45
+ scoped_enums: bool
46
+ problems: list[PreprocessorProblem]
47
+ comments: list[CommentInfo]
48
+
49
+ def map_position(self, line: int, col: int) -> tuple[str, int, int]:
50
+ if line < 1 or line > len(self.source_map):
51
+ return ('', 0, 0)
52
+ entry = self.source_map[line - 1]
53
+ return (entry.file_name, entry.line, col + entry.col_offset)
54
+
55
+
56
+ @dataclass
57
+ class PreprocessorOptions:
58
+ use_defines: bool = True
59
+ compiler_version: float = 36.0
60
+ rtl_version: float = 36.0
61
+ scoped_enums: bool = False
62
+ option_states: dict[str, bool] = field(default_factory=dict)
63
+
64
+
65
+ @dataclass
66
+ class _ConditionalFrame:
67
+ parent_active: bool
68
+ branch_taken: bool
69
+ current_active: bool
70
+
71
+
72
+ @dataclass
73
+ class _FileContext:
74
+ text: str
75
+ file_name: str
76
+ index: int = 0
77
+ line: int = 1
78
+ col: int = 1
79
+ pending_output: str = ''
80
+ pending_line: int = 1
81
+
82
+ def peek(self, offset: int = 0) -> str:
83
+ idx = self.index + offset
84
+ if idx < 0 or idx >= len(self.text):
85
+ return ''
86
+ return self.text[idx]
87
+
88
+ def advance(self, count: int = 1) -> str:
89
+ if count <= 0:
90
+ return ''
91
+ end = min(len(self.text), self.index + count)
92
+ chunk = self.text[self.index:end]
93
+ for ch in chunk:
94
+ if ch == '\n':
95
+ self.line += 1
96
+ self.col = 1
97
+ else:
98
+ self.col += 1
99
+ self.index = end
100
+ return chunk
101
+
102
+ def eof(self) -> bool:
103
+ return self.index >= len(self.text)
104
+
105
+
106
+ class _OutputBuffer:
107
+ def __init__(self) -> None:
108
+ self._lines: list[str] = ['']
109
+ self._line_map: list[SourceMapEntry] = []
110
+
111
+ @property
112
+ def text(self) -> str:
113
+ return '\n'.join(self._lines)
114
+
115
+ @property
116
+ def line_map(self) -> list[SourceMapEntry]:
117
+ return self._line_map
118
+
119
+ def append_char(self, ch: str, file_name: str, line: int) -> None:
120
+ self._ensure_mapping(file_name, line)
121
+ if ch == '\n':
122
+ self._lines.append('')
123
+ else:
124
+ self._lines[-1] += ch
125
+
126
+ def _ensure_mapping(self, file_name: str, line: int) -> None:
127
+ if len(self._line_map) < len(self._lines):
128
+ self._line_map.append(SourceMapEntry(file_name=file_name, line=line, col_offset=0))
129
+
130
+
131
+ class Preprocessor:
132
+ def __init__(
133
+ self,
134
+ *,
135
+ defines: Iterable[str] = (),
136
+ include_paths: Iterable[str] = (),
137
+ include_loader: Optional[IncludeLoader] = None,
138
+ options: Optional[PreprocessorOptions] = None,
139
+ ) -> None:
140
+ self.defines: set[str] = {self._normalize_define(d) for d in defines if d}
141
+ self.include_paths = [Path(p) for p in include_paths]
142
+ self.include_loader = include_loader or self._default_include_loader
143
+ self.options = options or PreprocessorOptions()
144
+ self._apply_default_compiler_defines()
145
+ self.scoped_enums = self.options.scoped_enums
146
+ self._option_values: dict[str, str] = {}
147
+ self._option_stack: list[tuple[dict[str, str], bool]] = []
148
+ self._problems: list[PreprocessorProblem] = []
149
+ self._comments: list[CommentInfo] = []
150
+ self._conditional_stack: list[_ConditionalFrame] = []
151
+
152
+ def process(self, text: str, file_name: str) -> PreprocessedSource:
153
+ self._problems = []
154
+ self._comments = []
155
+ self._conditional_stack = []
156
+ self._option_stack = []
157
+ self.scoped_enums = self.options.scoped_enums
158
+ self._option_values = {
159
+ self._normalize_option_name(name): 'ON' if bool(value) else 'OFF'
160
+ for name, value in self.options.option_states.items()
161
+ }
162
+ if 'SCOPEDENUMS' in self._option_values:
163
+ self.scoped_enums = self._option_values['SCOPEDENUMS'] == 'ON'
164
+ else:
165
+ self._set_option('SCOPEDENUMS', 'ON' if self.scoped_enums else 'OFF')
166
+
167
+ normalized = self._normalize_newlines(text)
168
+ contexts = [_FileContext(text=normalized, file_name=file_name)]
169
+ include_stack = [file_name]
170
+ output = _OutputBuffer()
171
+
172
+ while contexts:
173
+ ctx = contexts[-1]
174
+
175
+ if ctx.pending_output:
176
+ pending = ctx.pending_output
177
+ ctx.pending_output = ''
178
+ pending_line = ctx.pending_line
179
+ ctx.pending_line = ctx.line
180
+ self._emit_text(output, pending, ctx.file_name, pending_line, active=True)
181
+ continue
182
+
183
+ if ctx.eof():
184
+ contexts.pop()
185
+ if include_stack:
186
+ include_stack.pop()
187
+ continue
188
+
189
+ ch = ctx.peek()
190
+
191
+ if ch == "'":
192
+ start_line = ctx.line
193
+ active = self._is_active()
194
+ literal = self._consume_string(ctx)
195
+ self._emit_text(output, literal, ctx.file_name, start_line, active=active)
196
+ continue
197
+
198
+ if ch == '/' and ctx.peek(1) == '/':
199
+ start_line = ctx.line
200
+ start_col = ctx.col
201
+ active = self._is_active()
202
+ raw, content = self._consume_line_comment(ctx)
203
+ self._emit_text(output, raw, ctx.file_name, start_line, active=active)
204
+ if active:
205
+ self._record_comment('slashes', content, ctx.file_name, start_line, start_col, ctx)
206
+ continue
207
+
208
+ if ch == '{':
209
+ start_line = ctx.line
210
+ start_col = ctx.col
211
+ active = self._is_active()
212
+ if ctx.peek(1) == '$':
213
+ raw_text, content = self._consume_brace_directive(ctx)
214
+ self._handle_directive(
215
+ raw_text,
216
+ content,
217
+ ctx,
218
+ start_line,
219
+ contexts,
220
+ include_stack,
221
+ output,
222
+ )
223
+ else:
224
+ raw, content = self._consume_brace_comment(ctx)
225
+ self._emit_text(output, raw, ctx.file_name, start_line, active=active)
226
+ if active:
227
+ self._record_comment('borland', content, ctx.file_name, start_line, start_col, ctx)
228
+ continue
229
+
230
+ if ch == '(' and ctx.peek(1) == '*':
231
+ start_line = ctx.line
232
+ start_col = ctx.col
233
+ active = self._is_active()
234
+ if ctx.peek(2) == '$':
235
+ raw_text, content = self._consume_paren_directive(ctx)
236
+ self._handle_directive(
237
+ raw_text,
238
+ content,
239
+ ctx,
240
+ start_line,
241
+ contexts,
242
+ include_stack,
243
+ output,
244
+ )
245
+ else:
246
+ raw, content = self._consume_paren_comment(ctx)
247
+ self._emit_text(output, raw, ctx.file_name, start_line, active=active)
248
+ if active:
249
+ self._record_comment('ansi', content, ctx.file_name, start_line, start_col, ctx)
250
+ continue
251
+
252
+ start_line = ctx.line
253
+ ch = ctx.advance(1)
254
+ self._emit_text(output, ch, ctx.file_name, start_line, active=self._is_active())
255
+
256
+ return PreprocessedSource(
257
+ text=output.text,
258
+ source_map=output.line_map,
259
+ defines=self.defines,
260
+ scoped_enums=self.scoped_enums,
261
+ problems=self._problems,
262
+ comments=self._comments,
263
+ )
264
+
265
+ def _apply_default_compiler_defines(self) -> None:
266
+ if not self.options.use_defines:
267
+ return
268
+ if 'FPC' in self.defines:
269
+ return
270
+ if self.options.compiler_version >= 20.0:
271
+ self.defines.add('CONDITIONALEXPRESSIONS')
272
+ self.defines.add('UNICODE')
273
+ version_define = int(round(self.options.compiler_version * 10))
274
+ self.defines.add(f'VER{version_define}')
275
+
276
+ def _emit_text(
277
+ self,
278
+ output: _OutputBuffer,
279
+ text: str,
280
+ file_name: str,
281
+ line: int,
282
+ *,
283
+ active: bool,
284
+ ) -> None:
285
+ current_line = line
286
+ for ch in text:
287
+ if ch == '\n':
288
+ output.append_char('\n', file_name, current_line)
289
+ current_line += 1
290
+ else:
291
+ if active:
292
+ output.append_char(ch, file_name, current_line)
293
+ else:
294
+ output.append_char(' ', file_name, current_line)
295
+
296
+ def _is_active(self) -> bool:
297
+ if not self._conditional_stack:
298
+ return True
299
+ return self._conditional_stack[-1].current_active
300
+
301
+ def _handle_directive(
302
+ self,
303
+ directive_raw: str,
304
+ directive_content: str,
305
+ ctx: _FileContext,
306
+ start_line: int,
307
+ contexts: list[_FileContext],
308
+ include_stack: list[str],
309
+ output: _OutputBuffer,
310
+ ) -> None:
311
+ name, param = self._parse_directive(directive_content)
312
+ replacement = self._replace_with_spaces(directive_raw)
313
+
314
+ if not name:
315
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
316
+ return
317
+
318
+ if name in {'IFDEF', 'IFNDEF', 'IF', 'IFOPT'}:
319
+ condition = False
320
+ if self._is_active():
321
+ if name == 'IFDEF':
322
+ condition = self._is_defined(param)
323
+ elif name == 'IFNDEF':
324
+ condition = not self._is_defined(param)
325
+ elif name == 'IF':
326
+ condition = self._evaluate_conditional_expression(param)
327
+ elif name == 'IFOPT':
328
+ condition = self._evaluate_ifopt(param)
329
+ frame = _ConditionalFrame(
330
+ parent_active=self._is_active(),
331
+ branch_taken=condition,
332
+ current_active=self._is_active() and condition,
333
+ )
334
+ self._conditional_stack.append(frame)
335
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
336
+ return
337
+
338
+ if name in {'ELSEIF', 'ELSE', 'ENDIF', 'IFEND'}:
339
+ if not self._conditional_stack:
340
+ self._problems.append(
341
+ PreprocessorProblem(
342
+ kind='directive',
343
+ message=f'unexpected {name}',
344
+ file_name=ctx.file_name,
345
+ line=start_line,
346
+ col=ctx.col,
347
+ )
348
+ )
349
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
350
+ return
351
+
352
+ frame = self._conditional_stack[-1]
353
+ if name == 'ELSEIF':
354
+ if not frame.parent_active:
355
+ frame.current_active = False
356
+ elif frame.branch_taken:
357
+ frame.current_active = False
358
+ else:
359
+ condition = self._evaluate_conditional_expression(param)
360
+ frame.current_active = frame.parent_active and condition
361
+ if condition:
362
+ frame.branch_taken = True
363
+ elif name == 'ELSE':
364
+ if not frame.parent_active:
365
+ frame.current_active = False
366
+ else:
367
+ frame.current_active = frame.parent_active and not frame.branch_taken
368
+ frame.branch_taken = True
369
+ else:
370
+ self._conditional_stack.pop()
371
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
372
+ return
373
+
374
+ if name in {'DEFINE', 'UNDEF'}:
375
+ if self._is_active() and self.options.use_defines:
376
+ for define_name in self._parse_define_list(param):
377
+ if name == 'DEFINE':
378
+ self.defines.add(define_name)
379
+ else:
380
+ self.defines.discard(define_name)
381
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
382
+ return
383
+
384
+ if name in {'SCOPEDENUMS'}:
385
+ if self._is_active():
386
+ self.scoped_enums = self._parse_on_off(param)
387
+ self._set_option('SCOPEDENUMS', 'ON' if self.scoped_enums else 'OFF')
388
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
389
+ return
390
+
391
+ if name in {'PUSHOPT', 'POPOPT'}:
392
+ if self._is_active():
393
+ if name == 'PUSHOPT':
394
+ self._option_stack.append((dict(self._option_values), self.scoped_enums))
395
+ else:
396
+ if self._option_stack:
397
+ self._option_values, self.scoped_enums = self._option_stack.pop()
398
+ else:
399
+ self._problems.append(
400
+ PreprocessorProblem(
401
+ kind='directive',
402
+ message='POPOPT without matching PUSHOPT',
403
+ file_name=ctx.file_name,
404
+ line=start_line,
405
+ col=ctx.col,
406
+ )
407
+ )
408
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
409
+ return
410
+
411
+ if name == 'OPT':
412
+ if self._is_active():
413
+ self._apply_opt_directive(param)
414
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
415
+ return
416
+
417
+ if name in {'I', 'INCLUDE'}:
418
+ if self._is_active():
419
+ include_name = self._extract_include_name(param)
420
+ if include_name:
421
+ resolved = self.include_loader(ctx.file_name, include_name)
422
+ if resolved is None:
423
+ self._problems.append(
424
+ PreprocessorProblem(
425
+ kind='include',
426
+ message=f'include not found: {include_name}',
427
+ file_name=ctx.file_name,
428
+ line=start_line,
429
+ col=ctx.col,
430
+ )
431
+ )
432
+ else:
433
+ content, resolved_path = resolved
434
+ if resolved_path in include_stack:
435
+ self._problems.append(
436
+ PreprocessorProblem(
437
+ kind='include',
438
+ message=f'include cycle detected: {resolved_path}',
439
+ file_name=ctx.file_name,
440
+ line=start_line,
441
+ col=ctx.col,
442
+ )
443
+ )
444
+ else:
445
+ include_stack.append(resolved_path)
446
+ ctx.pending_output = replacement
447
+ ctx.pending_line = start_line
448
+ normalized = self._normalize_newlines(content)
449
+ if not normalized.endswith('\n'):
450
+ normalized += '\n'
451
+ contexts.append(_FileContext(text=normalized, file_name=resolved_path))
452
+ return
453
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
454
+ return
455
+
456
+ if self._is_active() and self._apply_named_option(name, param):
457
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
458
+ return
459
+
460
+ self._emit_text(output, replacement, ctx.file_name, start_line, active=True)
461
+
462
+ def _parse_directive(self, directive_text: str) -> tuple[str, str]:
463
+ text = directive_text.strip()
464
+ if not text:
465
+ return ('', '')
466
+ name = []
467
+ idx = 0
468
+ while idx < len(text) and (text[idx].isalpha() or text[idx] == '_'):
469
+ name.append(text[idx])
470
+ idx += 1
471
+ if not name:
472
+ return ('', '')
473
+ param = text[idx:].strip()
474
+ return (''.join(name).upper(), param)
475
+
476
+ def _parse_define_list(self, param: str) -> list[str]:
477
+ if not param:
478
+ return []
479
+ cleaned = param.replace(',', ' ')
480
+ names = [self._normalize_define(part) for part in cleaned.split() if part]
481
+ return [n for n in names if n]
482
+
483
+ def _evaluate_ifopt(self, param: str) -> bool:
484
+ if not param:
485
+ return False
486
+ token = param.strip().upper()
487
+ if not token:
488
+ return False
489
+ if token.endswith('+') and len(token) > 1:
490
+ option_name = token[:-1].strip()
491
+ return self._get_option(option_name) == 'ON'
492
+ if token.endswith('-') and len(token) > 1:
493
+ option_name = token[:-1].strip()
494
+ return self._get_option(option_name) == 'OFF'
495
+ if '=' in token:
496
+ name, raw_state = token.split('=', 1)
497
+ expected = self._normalize_option_state(raw_state)
498
+ if not name.strip() or expected is None:
499
+ return False
500
+ return self._get_option(name) == expected
501
+ parts = token.split()
502
+ if len(parts) == 2:
503
+ expected = self._normalize_option_state(parts[1])
504
+ if expected is None:
505
+ return False
506
+ return self._get_option(parts[0]) == expected
507
+ return False
508
+
509
+ def _evaluate_conditional_expression(self, param: str) -> bool:
510
+ text = param.strip().upper()
511
+ if text.startswith('COMPILERVERSION'):
512
+ return self._evaluate_version(text, 'COMPILERVERSION', self.options.compiler_version)
513
+ if text.startswith('RTLVERSION'):
514
+ return self._evaluate_version(text, 'RTLVERSION', self.options.rtl_version)
515
+ if text.startswith('DEFINED(') or text.startswith('NOT DEFINED('):
516
+ return self._evaluate_defined_chain(text)
517
+ return False
518
+
519
+ def _evaluate_version(self, text: str, label: str, value: float) -> bool:
520
+ rest = text[len(label):].strip()
521
+ if not rest:
522
+ return False
523
+ parts = rest.split()
524
+ if len(parts) < 2:
525
+ return False
526
+ oper = parts[0]
527
+ num = parts[1]
528
+ try:
529
+ right = float(num)
530
+ except ValueError:
531
+ return False
532
+ if oper == '=':
533
+ return value == right
534
+ if oper == '<>':
535
+ return value != right
536
+ if oper == '<':
537
+ return value < right
538
+ if oper == '<=':
539
+ return value <= right
540
+ if oper == '>':
541
+ return value > right
542
+ if oper == '>=':
543
+ return value >= right
544
+ return False
545
+
546
+ def _evaluate_defined_chain(self, text: str) -> bool:
547
+ remaining = text
548
+ result = True
549
+ evaluation = None
550
+ while remaining.startswith('DEFINED(') or remaining.startswith('NOT DEFINED('):
551
+ if remaining.startswith('DEFINED('):
552
+ define_name, remaining = self._consume_defined(remaining, 'DEFINED(')
553
+ cond = self._is_defined(define_name)
554
+ else:
555
+ define_name, remaining = self._consume_defined(remaining, 'NOT DEFINED(')
556
+ cond = not self._is_defined(define_name)
557
+ if evaluation is None:
558
+ result = cond
559
+ elif evaluation == 'AND':
560
+ result = result and cond
561
+ elif evaluation == 'OR':
562
+ result = result or cond
563
+ remaining = remaining.lstrip()
564
+ if remaining.startswith('AND '):
565
+ evaluation = 'AND'
566
+ remaining = remaining[4:]
567
+ elif remaining.startswith('OR '):
568
+ evaluation = 'OR'
569
+ remaining = remaining[3:]
570
+ return result
571
+
572
+ def _consume_defined(self, text: str, prefix: str) -> tuple[str, str]:
573
+ rest = text[len(prefix):]
574
+ end = rest.find(')')
575
+ if end == -1:
576
+ return ('', '')
577
+ name = rest[:end].strip()
578
+ remaining = rest[end + 1:]
579
+ return (name, remaining)
580
+
581
+ def _parse_on_off(self, param: str) -> bool:
582
+ token = param.strip().upper()
583
+ if token in {'ON', '1', 'TRUE', '+'}:
584
+ return True
585
+ if token in {'OFF', '0', 'FALSE', '-'}:
586
+ return False
587
+ return self.scoped_enums
588
+
589
+ def _is_defined(self, name: str) -> bool:
590
+ return self._normalize_define(name) in self.defines
591
+
592
+ def _normalize_define(self, name: str) -> str:
593
+ return name.strip().upper()
594
+
595
+ def _normalize_option_name(self, name: str) -> str:
596
+ return name.strip().upper()
597
+
598
+ def _normalize_option_state(self, raw_state: str) -> Optional[str]:
599
+ token = raw_state.strip().upper()
600
+ if token in {'+', 'ON', '1', 'TRUE'}:
601
+ return 'ON'
602
+ if token in {'-', 'OFF', '0', 'FALSE'}:
603
+ return 'OFF'
604
+ if token == 'AUTO':
605
+ return 'AUTO'
606
+ return None
607
+
608
+ def _set_option(self, option_name: str, state: str) -> None:
609
+ name = self._normalize_option_name(option_name)
610
+ normalized = self._normalize_option_state(state)
611
+ if not name or normalized is None:
612
+ return
613
+ self._option_values[name] = normalized
614
+ if name == 'SCOPEDENUMS':
615
+ self.scoped_enums = normalized == 'ON'
616
+
617
+ def _get_option(self, option_name: str) -> str:
618
+ return self._option_values.get(self._normalize_option_name(option_name), '')
619
+
620
+ def _apply_named_option(self, name: str, param: str) -> bool:
621
+ if not name:
622
+ return False
623
+ cleaned = param.strip()
624
+ if not cleaned:
625
+ return False
626
+ if cleaned.startswith('='):
627
+ cleaned = cleaned[1:].strip()
628
+ state = self._normalize_option_state(cleaned)
629
+ if state is not None:
630
+ self._set_option(name, state)
631
+ return True
632
+ if len(cleaned) >= 2 and cleaned[0].isalpha() and cleaned[1] in {'+', '-'} and len(cleaned) == 2:
633
+ # Handles forms like {$OPT R+} where R+ is passed as the param token.
634
+ self._set_option(cleaned[0], cleaned[1])
635
+ return True
636
+ return False
637
+
638
+ def _apply_opt_directive(self, param: str) -> None:
639
+ if not param:
640
+ return
641
+ parts = [part for part in param.replace(',', ' ').split() if part]
642
+ index = 0
643
+ while index < len(parts):
644
+ part = parts[index]
645
+ if len(part) >= 2 and part[-1] in {'+', '-'}:
646
+ self._set_option(part[:-1], part[-1])
647
+ index += 1
648
+ continue
649
+ if '=' in part:
650
+ name, raw_state = part.split('=', 1)
651
+ self._set_option(name, raw_state)
652
+ index += 1
653
+ continue
654
+ if index + 1 < len(parts):
655
+ candidate_state = self._normalize_option_state(parts[index + 1])
656
+ if candidate_state is not None:
657
+ self._set_option(part, candidate_state)
658
+ index += 2
659
+ continue
660
+ index += 1
661
+
662
+ def _consume_line_comment(self, ctx: _FileContext) -> tuple[str, str]:
663
+ start = ctx.index
664
+ while not ctx.eof() and ctx.peek() not in {'\n'}:
665
+ ctx.advance(1)
666
+ raw = ctx.text[start:ctx.index]
667
+ return raw, raw[2:] if raw.startswith('//') else raw
668
+
669
+ def _consume_brace_comment(self, ctx: _FileContext) -> tuple[str, str]:
670
+ start = ctx.index
671
+ ctx.advance(1)
672
+ while not ctx.eof():
673
+ if ctx.peek() == '}':
674
+ ctx.advance(1)
675
+ break
676
+ ctx.advance(1)
677
+ else:
678
+ self._problems.append(
679
+ PreprocessorProblem(
680
+ kind='comment',
681
+ message='unterminated comment',
682
+ file_name=ctx.file_name,
683
+ line=ctx.line,
684
+ col=ctx.col,
685
+ )
686
+ )
687
+ raw = ctx.text[start:ctx.index]
688
+ if raw.endswith('}'):
689
+ return raw, raw[1:-1]
690
+ return raw, raw[1:]
691
+
692
+ def _consume_paren_comment(self, ctx: _FileContext) -> tuple[str, str]:
693
+ start = ctx.index
694
+ ctx.advance(2)
695
+ while not ctx.eof():
696
+ if ctx.peek() == '*' and ctx.peek(1) == ')':
697
+ ctx.advance(2)
698
+ break
699
+ ctx.advance(1)
700
+ else:
701
+ self._problems.append(
702
+ PreprocessorProblem(
703
+ kind='comment',
704
+ message='unterminated comment',
705
+ file_name=ctx.file_name,
706
+ line=ctx.line,
707
+ col=ctx.col,
708
+ )
709
+ )
710
+ raw = ctx.text[start:ctx.index]
711
+ if raw.endswith('*)'):
712
+ return raw, raw[2:-2]
713
+ return raw, raw[2:]
714
+
715
+ def _consume_brace_directive(self, ctx: _FileContext) -> tuple[str, str]:
716
+ start = ctx.index
717
+ ctx.advance(2)
718
+ while not ctx.eof():
719
+ if ctx.peek() == '}':
720
+ ctx.advance(1)
721
+ break
722
+ ctx.advance(1)
723
+ else:
724
+ self._problems.append(
725
+ PreprocessorProblem(
726
+ kind='directive',
727
+ message='unterminated directive',
728
+ file_name=ctx.file_name,
729
+ line=ctx.line,
730
+ col=ctx.col,
731
+ )
732
+ )
733
+ token = ctx.text[start:ctx.index]
734
+ content = token[2:-1].strip() if token.endswith('}') else token[2:].strip()
735
+ return token, content
736
+
737
+ def _consume_paren_directive(self, ctx: _FileContext) -> tuple[str, str]:
738
+ start = ctx.index
739
+ ctx.advance(3)
740
+ while not ctx.eof():
741
+ if ctx.peek() == '*' and ctx.peek(1) == ')':
742
+ ctx.advance(2)
743
+ break
744
+ ctx.advance(1)
745
+ else:
746
+ self._problems.append(
747
+ PreprocessorProblem(
748
+ kind='directive',
749
+ message='unterminated directive',
750
+ file_name=ctx.file_name,
751
+ line=ctx.line,
752
+ col=ctx.col,
753
+ )
754
+ )
755
+ token = ctx.text[start:ctx.index]
756
+ if token.endswith('*)'):
757
+ content = token[3:-2].strip()
758
+ else:
759
+ content = token[3:].strip()
760
+ return token, content
761
+
762
+ def _consume_string(self, ctx: _FileContext) -> str:
763
+ start = ctx.index
764
+ ctx.advance(1)
765
+ while not ctx.eof():
766
+ ch = ctx.peek()
767
+ if ch == "'":
768
+ if ctx.peek(1) == "'":
769
+ ctx.advance(2)
770
+ continue
771
+ ctx.advance(1)
772
+ break
773
+ if ch == '\n':
774
+ self._problems.append(
775
+ PreprocessorProblem(
776
+ kind='string',
777
+ message='unterminated string literal',
778
+ file_name=ctx.file_name,
779
+ line=ctx.line,
780
+ col=ctx.col,
781
+ )
782
+ )
783
+ break
784
+ ctx.advance(1)
785
+ return ctx.text[start:ctx.index]
786
+
787
+ def _extract_include_name(self, param: str) -> str:
788
+ text = param.strip()
789
+ if not text:
790
+ return ''
791
+ if text[0] in {"'", '"'}:
792
+ quote = text[0]
793
+ end = text.find(quote, 1)
794
+ if end == -1:
795
+ return text[1:]
796
+ return text[1:end]
797
+ parts = text.split()
798
+ return parts[0] if parts else ''
799
+
800
+ def _replace_with_spaces(self, text: str) -> str:
801
+ return ''.join('\n' if ch == '\n' else ' ' for ch in text)
802
+
803
+ def _normalize_newlines(self, text: str) -> str:
804
+ return text.replace('\ufeff', '').replace('\r\n', '\n').replace('\r', '\n')
805
+
806
+ def _default_include_loader(self, parent_file: str, include_name: str) -> Optional[tuple[str, str]]:
807
+ parent_path = Path(parent_file)
808
+ include_path = Path(include_name.replace('\\', '/'))
809
+ search_paths = [parent_path.parent] + self.include_paths
810
+ for base in search_paths:
811
+ candidate = (base / include_path).resolve()
812
+ if candidate.exists():
813
+ return (read_source_text(candidate), str(candidate))
814
+ return None
815
+
816
+ def _record_comment(
817
+ self,
818
+ kind: str,
819
+ text: str,
820
+ file_name: str,
821
+ start_line: int,
822
+ start_col: int,
823
+ ctx: _FileContext,
824
+ ) -> None:
825
+ end_line = ctx.line
826
+ end_col = max(ctx.col - 1, 1)
827
+ self._comments.append(
828
+ CommentInfo(
829
+ kind=kind,
830
+ text=text,
831
+ file_name=file_name,
832
+ line=start_line,
833
+ col=start_col,
834
+ end_line=end_line,
835
+ end_col=end_col,
836
+ )
837
+ )