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