pycforge 0.15.2__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.
- pycforge/__init__.py +8 -0
- pycforge/__main__.py +2 -0
- pycforge/_version.py +1 -0
- pycforge/converter/__init__.py +0 -0
- pycforge/converter/analysis/__init__.py +5 -0
- pycforge/converter/analysis/functions.py +723 -0
- pycforge/converter/analysis/model.py +158 -0
- pycforge/converter/analysis/planning.py +464 -0
- pycforge/converter/analysis/stage.py +1976 -0
- pycforge/converter/analysis/symbols.py +237 -0
- pycforge/converter/analysis/validation.py +2166 -0
- pycforge/converter/c_output/__init__.py +2 -0
- pycforge/converter/c_output/conformance.py +297 -0
- pycforge/converter/c_output/renderer.py +220 -0
- pycforge/converter/conditional_regions/__init__.py +71 -0
- pycforge/converter/conditional_regions/analysis.py +481 -0
- pycforge/converter/conditional_regions/lowering.py +579 -0
- pycforge/converter/conditional_regions/model.py +243 -0
- pycforge/converter/conditional_regions/validation.py +690 -0
- pycforge/converter/containers/__init__.py +19 -0
- pycforge/converter/containers/analysis.py +542 -0
- pycforge/converter/containers/lowering.py +290 -0
- pycforge/converter/containers/model.py +146 -0
- pycforge/converter/contracts/__init__.py +0 -0
- pycforge/converter/contracts/configuration.py +178 -0
- pycforge/converter/contracts/identifiers.py +68 -0
- pycforge/converter/contracts/versions.py +71 -0
- pycforge/converter/core/__init__.py +0 -0
- pycforge/converter/core/artifact_io.py +87 -0
- pycforge/converter/core/cancellation.py +7 -0
- pycforge/converter/core/canonicalization.py +254 -0
- pycforge/converter/core/context.py +13 -0
- pycforge/converter/core/diagnostics.py +95 -0
- pycforge/converter/core/enums.py +23 -0
- pycforge/converter/core/fingerprint.py +22 -0
- pycforge/converter/core/pipeline.py +8 -0
- pycforge/converter/core/progress.py +25 -0
- pycforge/converter/core/request.py +50 -0
- pycforge/converter/core/resource_policy.py +31 -0
- pycforge/converter/core/result.py +23 -0
- pycforge/converter/core/serialization.py +36 -0
- pycforge/converter/core/stage.py +10 -0
- pycforge/converter/core/stage_artifact.py +80 -0
- pycforge/converter/core/stage_outcome.py +15 -0
- pycforge/converter/decision_trace/__init__.py +0 -0
- pycforge/converter/decision_trace/recorder.py +52 -0
- pycforge/converter/facade.py +459 -0
- pycforge/converter/frontend/__init__.py +4 -0
- pycforge/converter/frontend/normalizer.py +59 -0
- pycforge/converter/frontend/parser.py +20 -0
- pycforge/converter/frontend/source_document.py +93 -0
- pycforge/converter/frontend/stages.py +518 -0
- pycforge/converter/frontend/validation.py +76 -0
- pycforge/converter/io/__init__.py +0 -0
- pycforge/converter/io/atomic_writer.py +33 -0
- pycforge/converter/ir/__init__.py +1 -0
- pycforge/converter/ir/c_ir/__init__.py +4 -0
- pycforge/converter/ir/c_ir/builder.py +26 -0
- pycforge/converter/ir/c_ir/model.py +318 -0
- pycforge/converter/ir/c_ir/serialization.py +88 -0
- pycforge/converter/ir/c_ir/validation.py +834 -0
- pycforge/converter/ir/python_ir/__init__.py +17 -0
- pycforge/converter/ir/python_ir/nodes.py +86 -0
- pycforge/converter/keyword_calls/__init__.py +68 -0
- pycforge/converter/keyword_calls/analysis.py +458 -0
- pycforge/converter/keyword_calls/lowering.py +271 -0
- pycforge/converter/keyword_calls/model.py +304 -0
- pycforge/converter/keyword_calls/validation.py +802 -0
- pycforge/converter/keyword_only_calls/__init__.py +68 -0
- pycforge/converter/keyword_only_calls/analysis.py +640 -0
- pycforge/converter/keyword_only_calls/lowering.py +376 -0
- pycforge/converter/keyword_only_calls/model.py +328 -0
- pycforge/converter/keyword_only_calls/validation.py +1357 -0
- pycforge/converter/lowering.py +1000 -0
- pycforge/converter/modules/__init__.py +13 -0
- pycforge/converter/modules/analysis.py +1250 -0
- pycforge/converter/modules/lowering.py +200 -0
- pycforge/converter/modules/model.py +73 -0
- pycforge/converter/modules/stage.py +192 -0
- pycforge/converter/numeric_semantics/__init__.py +23 -0
- pycforge/converter/numeric_semantics/analysis.py +420 -0
- pycforge/converter/numeric_semantics/lowering.py +153 -0
- pycforge/converter/numeric_semantics/model.py +123 -0
- pycforge/converter/records/__init__.py +34 -0
- pycforge/converter/records/analysis.py +1394 -0
- pycforge/converter/records/lowering.py +393 -0
- pycforge/converter/records/model.py +319 -0
- pycforge/converter/rules/__init__.py +3 -0
- pycforge/converter/support_templates/__init__.py +49 -0
- pycforge/converter/support_templates/assembly.py +106 -0
- pycforge/converter/support_templates/factories.py +348 -0
- pycforge/converter/support_templates/model.py +159 -0
- pycforge/converter/support_templates/registry.py +388 -0
- pycforge/converter/telemetry/__init__.py +0 -0
- pycforge/converter/telemetry/sink.py +39 -0
- pycforge/ide/__init__.py +19 -0
- pycforge/ide/__main__.py +5 -0
- pycforge/ide/_worker_event_codec.py +276 -0
- pycforge/ide/_worker_protocol_json.py +197 -0
- pycforge/ide/_worker_protocol_types.py +90 -0
- pycforge/ide/_worker_request_codec.py +200 -0
- pycforge/ide/_worker_request_data.py +260 -0
- pycforge/ide/_worker_result_data.py +343 -0
- pycforge/ide/action_contract.py +469 -0
- pycforge/ide/action_validation.py +229 -0
- pycforge/ide/command_palette.py +223 -0
- pycforge/ide/controller.py +904 -0
- pycforge/ide/controller_conversion.py +546 -0
- pycforge/ide/controller_io.py +205 -0
- pycforge/ide/editor.py +951 -0
- pycforge/ide/editor_commands_qt.py +274 -0
- pycforge/ide/editor_lexical.py +43 -0
- pycforge/ide/editor_sidebars.py +236 -0
- pycforge/ide/editor_syntax.py +243 -0
- pycforge/ide/find_replace.py +535 -0
- pycforge/ide/icons.py +91 -0
- pycforge/ide/io_service.py +196 -0
- pycforge/ide/model.py +164 -0
- pycforge/ide/panels.py +903 -0
- pycforge/ide/positions.py +220 -0
- pycforge/ide/process_worker.py +130 -0
- pycforge/ide/qt.py +230 -0
- pycforge/ide/qt_actions.py +418 -0
- pycforge/ide/qt_close.py +169 -0
- pycforge/ide/qt_command_palette.py +297 -0
- pycforge/ide/qt_contract.py +220 -0
- pycforge/ide/qt_documents.py +590 -0
- pycforge/ide/qt_editor_buffers.py +113 -0
- pycforge/ide/qt_editor_surfaces.py +112 -0
- pycforge/ide/qt_menus.py +324 -0
- pycforge/ide/qt_projection.py +500 -0
- pycforge/ide/qt_shell.py +348 -0
- pycforge/ide/qt_shell_interactions.py +320 -0
- pycforge/ide/qt_state.py +393 -0
- pycforge/ide/qt_workspace_features.py +398 -0
- pycforge/ide/qt_workspace_navigation.py +208 -0
- pycforge/ide/qt_workspace_observers.py +173 -0
- pycforge/ide/qt_workspace_panels.py +545 -0
- pycforge/ide/qt_workspace_widgets.py +228 -0
- pycforge/ide/resources/icons/about.svg +1 -0
- pycforge/ide/resources/icons/add-document.svg +1 -0
- pycforge/ide/resources/icons/brand-mark.svg +1 -0
- pycforge/ide/resources/icons/bundle-search.svg +1 -0
- pycforge/ide/resources/icons/cancel.svg +1 -0
- pycforge/ide/resources/icons/check.svg +1 -0
- pycforge/ide/resources/icons/chevron-right.svg +1 -0
- pycforge/ide/resources/icons/close.svg +1 -0
- pycforge/ide/resources/icons/collapse-all.svg +1 -0
- pycforge/ide/resources/icons/command-palette.svg +1 -0
- pycforge/ide/resources/icons/convert.svg +1 -0
- pycforge/ide/resources/icons/copy.svg +1 -0
- pycforge/ide/resources/icons/cut.svg +1 -0
- pycforge/ide/resources/icons/decision-trace.svg +1 -0
- pycforge/ide/resources/icons/details.svg +1 -0
- pycforge/ide/resources/icons/diagnostics.svg +1 -0
- pycforge/ide/resources/icons/duplicate-line.svg +1 -0
- pycforge/ide/resources/icons/exit.svg +1 -0
- pycforge/ide/resources/icons/expand-all.svg +1 -0
- pycforge/ide/resources/icons/export.svg +1 -0
- pycforge/ide/resources/icons/find.svg +1 -0
- pycforge/ide/resources/icons/go-to-line.svg +1 -0
- pycforge/ide/resources/icons/go-to-output.svg +1 -0
- pycforge/ide/resources/icons/go-to-source.svg +1 -0
- pycforge/ide/resources/icons/history.svg +1 -0
- pycforge/ide/resources/icons/indent.svg +1 -0
- pycforge/ide/resources/icons/link-c.svg +1 -0
- pycforge/ide/resources/icons/mappings.svg +1 -0
- pycforge/ide/resources/icons/module.svg +1 -0
- pycforge/ide/resources/icons/move-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-up.svg +1 -0
- pycforge/ide/resources/icons/move-up.svg +1 -0
- pycforge/ide/resources/icons/next.svg +1 -0
- pycforge/ide/resources/icons/open.svg +1 -0
- pycforge/ide/resources/icons/outdent.svg +1 -0
- pycforge/ide/resources/icons/outline.svg +1 -0
- pycforge/ide/resources/icons/paste.svg +1 -0
- pycforge/ide/resources/icons/previous.svg +1 -0
- pycforge/ide/resources/icons/primary-module.svg +1 -0
- pycforge/ide/resources/icons/redo.svg +1 -0
- pycforge/ide/resources/icons/remove-document.svg +1 -0
- pycforge/ide/resources/icons/replace.svg +1 -0
- pycforge/ide/resources/icons/save-as.svg +1 -0
- pycforge/ide/resources/icons/save.svg +1 -0
- pycforge/ide/resources/icons/select-all.svg +1 -0
- pycforge/ide/resources/icons/settings.svg +1 -0
- pycforge/ide/resources/icons/show-c.svg +1 -0
- pycforge/ide/resources/icons/split-view.svg +1 -0
- pycforge/ide/resources/icons/summary.svg +1 -0
- pycforge/ide/resources/icons/telemetry.svg +1 -0
- pycforge/ide/resources/icons/toggle-comment.svg +1 -0
- pycforge/ide/resources/icons/toggle-fold.svg +1 -0
- pycforge/ide/resources/icons/undo.svg +1 -0
- pycforge/ide/resources/icons/whitespace.svg +1 -0
- pycforge/ide/revisions.py +400 -0
- pycforge/ide/search_service.py +280 -0
- pycforge/ide/session_history.py +150 -0
- pycforge/ide/source_editing.py +417 -0
- pycforge/ide/source_structure.py +558 -0
- pycforge/ide/source_structure_async.py +191 -0
- pycforge/ide/supervisor.py +503 -0
- pycforge/ide/theme.py +119 -0
- pycforge/ide/theme_stylesheet.py +598 -0
- pycforge/ide/theme_workspace_stylesheet.py +155 -0
- pycforge/ide/visual_tokens.py +142 -0
- pycforge/ide/worker_protocol.py +80 -0
- pycforge/ide/workspace_search.py +462 -0
- pycforge/ide/workspace_session.py +256 -0
- pycforge/laboratory/__init__.py +6 -0
- pycforge/laboratory/audits.py +2244 -0
- pycforge/laboratory/checkpoint_e.py +2685 -0
- pycforge/laboratory/cli.py +119 -0
- pycforge/laboratory/exit_codes.py +11 -0
- pycforge/laboratory/keyword_audit.py +661 -0
- pycforge/laboratory/keyword_only_audit.py +481 -0
- pycforge-0.15.2.dist-info/METADATA +209 -0
- pycforge-0.15.2.dist-info/RECORD +221 -0
- pycforge-0.15.2.dist-info/WHEEL +5 -0
- pycforge-0.15.2.dist-info/entry_points.txt +5 -0
- pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
- pycforge-0.15.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Bounded literal search over an explicitly captured source bundle.
|
|
2
|
+
|
|
3
|
+
This module is independent of Qt and performs no file-system access. A search
|
|
4
|
+
request owns immutable strings supplied by the caller; linked document paths
|
|
5
|
+
are deliberately absent from the input contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import re
|
|
12
|
+
from threading import Condition, Thread, current_thread
|
|
13
|
+
from time import monotonic
|
|
14
|
+
from typing import Callable, Iterable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_BUNDLE_DOCUMENTS = 64
|
|
18
|
+
MAX_BUNDLE_MATCHES = 5_000
|
|
19
|
+
MAX_QUERY_CHARS = 4_096
|
|
20
|
+
MAX_PREVIEW_CHARS = 512
|
|
21
|
+
MAX_DOCUMENT_ID_CHARS = 128
|
|
22
|
+
MAX_LOGICAL_NAME_CHARS = 4_096
|
|
23
|
+
DEFAULT_PREVIEW_CHARS = 240
|
|
24
|
+
DEFAULT_SCAN_CHUNK = 64 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class WorkspaceSearchDocument:
|
|
29
|
+
"""One already-open document captured for a search request."""
|
|
30
|
+
|
|
31
|
+
document_id: str
|
|
32
|
+
logical_name: str
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class BundleSearchMatch:
|
|
38
|
+
"""One literal match with explicit Python and Qt position contracts.
|
|
39
|
+
|
|
40
|
+
``start`` and ``end`` are Python code-point offsets. ``qt_start`` and
|
|
41
|
+
``qt_end`` are UTF-16 code-unit offsets suitable for a Qt text cursor.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
document_id: str
|
|
45
|
+
logical_name: str
|
|
46
|
+
document_ordinal: int
|
|
47
|
+
start: int
|
|
48
|
+
end: int
|
|
49
|
+
qt_start: int
|
|
50
|
+
qt_end: int
|
|
51
|
+
preview: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class BundleSearchResult:
|
|
56
|
+
"""A globally bounded search result.
|
|
57
|
+
|
|
58
|
+
When ``truncated`` is true, ``total_count`` is a lower bound of
|
|
59
|
+
``MAX_BUNDLE_MATCHES + 1``. Scanning stops at that first omitted match so
|
|
60
|
+
a broad query cannot create unbounded background work merely to count.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
generation: int
|
|
64
|
+
matches: tuple[BundleSearchMatch, ...]
|
|
65
|
+
total_count: int
|
|
66
|
+
truncated: bool
|
|
67
|
+
elapsed_seconds: float
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class _BundleSearchRequest:
|
|
72
|
+
generation: int
|
|
73
|
+
documents: tuple[WorkspaceSearchDocument, ...]
|
|
74
|
+
query: str
|
|
75
|
+
match_case: bool
|
|
76
|
+
whole_word: bool
|
|
77
|
+
callback: Callable[[BundleSearchResult], None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _captured_documents(
|
|
81
|
+
documents: Iterable[WorkspaceSearchDocument],
|
|
82
|
+
) -> tuple[WorkspaceSearchDocument, ...]:
|
|
83
|
+
records = tuple(documents)
|
|
84
|
+
if not 1 <= len(records) <= MAX_BUNDLE_DOCUMENTS:
|
|
85
|
+
raise ValueError("search requires between 1 and 64 open documents")
|
|
86
|
+
document_ids: list[str] = []
|
|
87
|
+
logical_names: list[str] = []
|
|
88
|
+
for document in records:
|
|
89
|
+
if not isinstance(document, WorkspaceSearchDocument):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
"search documents must be WorkspaceSearchDocument values"
|
|
92
|
+
)
|
|
93
|
+
if (
|
|
94
|
+
not isinstance(document.document_id, str)
|
|
95
|
+
or not document.document_id
|
|
96
|
+
or len(document.document_id) > MAX_DOCUMENT_ID_CHARS
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("search document ID must be non-empty text")
|
|
99
|
+
if (
|
|
100
|
+
not isinstance(document.logical_name, str)
|
|
101
|
+
or not document.logical_name
|
|
102
|
+
or len(document.logical_name) > MAX_LOGICAL_NAME_CHARS
|
|
103
|
+
):
|
|
104
|
+
raise ValueError("search logical name must be non-empty text")
|
|
105
|
+
if not isinstance(document.text, str):
|
|
106
|
+
raise TypeError("search document text must be a string")
|
|
107
|
+
document_ids.append(document.document_id)
|
|
108
|
+
logical_names.append(document.logical_name)
|
|
109
|
+
if len(document_ids) != len(set(document_ids)):
|
|
110
|
+
raise ValueError("search document IDs must be unique")
|
|
111
|
+
if len(logical_names) != len(set(logical_names)):
|
|
112
|
+
raise ValueError("search logical names must be unique")
|
|
113
|
+
return records
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _positive_bounded_int(
|
|
117
|
+
value: object,
|
|
118
|
+
*,
|
|
119
|
+
name: str,
|
|
120
|
+
maximum: int,
|
|
121
|
+
) -> int:
|
|
122
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
123
|
+
raise TypeError(f"{name} must be an integer")
|
|
124
|
+
if not 1 <= value <= maximum:
|
|
125
|
+
raise ValueError(f"{name} must be between 1 and {maximum}")
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _utf16_units(value: str) -> int:
|
|
130
|
+
return len(value.encode("utf-16-le", errors="surrogatepass")) // 2
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _bounded_preview(
|
|
134
|
+
text: str,
|
|
135
|
+
start: int,
|
|
136
|
+
end: int,
|
|
137
|
+
budget: int,
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Return one bounded, single-line context window."""
|
|
140
|
+
|
|
141
|
+
left = max(0, start - budget // 3)
|
|
142
|
+
right = min(len(text), left + budget)
|
|
143
|
+
if right - left < budget:
|
|
144
|
+
left = max(0, right - budget)
|
|
145
|
+
value = text[left:right].replace("\r", " ").replace("\n", " ")
|
|
146
|
+
if left:
|
|
147
|
+
value = "\N{HORIZONTAL ELLIPSIS}" + value[1:]
|
|
148
|
+
if right < len(text) and value:
|
|
149
|
+
value = value[:-1] + "\N{HORIZONTAL ELLIPSIS}"
|
|
150
|
+
# Replacements above preserve length, but retain a final fail-closed cap.
|
|
151
|
+
return value[:budget]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def search_closed_documents(
|
|
155
|
+
documents: Iterable[WorkspaceSearchDocument],
|
|
156
|
+
query: str,
|
|
157
|
+
*,
|
|
158
|
+
generation: int = 0,
|
|
159
|
+
match_case: bool = False,
|
|
160
|
+
whole_word: bool = False,
|
|
161
|
+
match_limit: int = MAX_BUNDLE_MATCHES,
|
|
162
|
+
preview_chars: int = DEFAULT_PREVIEW_CHARS,
|
|
163
|
+
scan_chunk: int = DEFAULT_SCAN_CHUNK,
|
|
164
|
+
cancelled: Callable[[], bool] | None = None,
|
|
165
|
+
) -> BundleSearchResult | None:
|
|
166
|
+
"""Synchronously search captured documents without consulting the host.
|
|
167
|
+
|
|
168
|
+
The returned match sequence is in bundle order and then source order.
|
|
169
|
+
``None`` means the caller's generation was cancelled before publication.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
started = monotonic()
|
|
173
|
+
records = _captured_documents(documents)
|
|
174
|
+
if not isinstance(query, str):
|
|
175
|
+
raise TypeError("search query must be a string")
|
|
176
|
+
if len(query) > MAX_QUERY_CHARS:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"search query exceeds the {MAX_QUERY_CHARS}-character limit"
|
|
179
|
+
)
|
|
180
|
+
if isinstance(generation, bool) or not isinstance(generation, int):
|
|
181
|
+
raise TypeError("search generation must be an integer")
|
|
182
|
+
if generation < 0:
|
|
183
|
+
raise ValueError("search generation must be non-negative")
|
|
184
|
+
match_limit = _positive_bounded_int(
|
|
185
|
+
match_limit,
|
|
186
|
+
name="match_limit",
|
|
187
|
+
maximum=MAX_BUNDLE_MATCHES,
|
|
188
|
+
)
|
|
189
|
+
preview_chars = _positive_bounded_int(
|
|
190
|
+
preview_chars,
|
|
191
|
+
name="preview_chars",
|
|
192
|
+
maximum=MAX_PREVIEW_CHARS,
|
|
193
|
+
)
|
|
194
|
+
scan_chunk = _positive_bounded_int(
|
|
195
|
+
scan_chunk,
|
|
196
|
+
name="scan_chunk",
|
|
197
|
+
maximum=4 * 1024 * 1024,
|
|
198
|
+
)
|
|
199
|
+
is_cancelled = cancelled or (lambda: False)
|
|
200
|
+
if is_cancelled():
|
|
201
|
+
return None
|
|
202
|
+
if not query:
|
|
203
|
+
return BundleSearchResult(
|
|
204
|
+
generation, (), 0, False, monotonic() - started
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
expression = re.escape(query)
|
|
208
|
+
if whole_word:
|
|
209
|
+
expression = rf"(?<!\w){expression}(?!\w)"
|
|
210
|
+
pattern = re.compile(expression, 0 if match_case else re.IGNORECASE)
|
|
211
|
+
matches: list[BundleSearchMatch] = []
|
|
212
|
+
total_count = 0
|
|
213
|
+
|
|
214
|
+
for ordinal, document in enumerate(records):
|
|
215
|
+
if is_cancelled():
|
|
216
|
+
return None
|
|
217
|
+
text = document.text
|
|
218
|
+
if len(query) > len(text):
|
|
219
|
+
continue
|
|
220
|
+
ownership_width = max(scan_chunk, len(query) + 1)
|
|
221
|
+
ownership_start = 0
|
|
222
|
+
last_match_end = 0
|
|
223
|
+
codepoint_position = 0
|
|
224
|
+
qt_position = 0
|
|
225
|
+
matches_since_check = 0
|
|
226
|
+
|
|
227
|
+
while ownership_start < len(text):
|
|
228
|
+
if is_cancelled():
|
|
229
|
+
return None
|
|
230
|
+
ownership_end = min(
|
|
231
|
+
len(text), ownership_start + ownership_width
|
|
232
|
+
)
|
|
233
|
+
search_start = max(ownership_start, last_match_end)
|
|
234
|
+
search_end = min(
|
|
235
|
+
len(text),
|
|
236
|
+
ownership_end + len(query) + (1 if whole_word else 0),
|
|
237
|
+
)
|
|
238
|
+
for found in pattern.finditer(text, search_start, search_end):
|
|
239
|
+
start, end = found.span()
|
|
240
|
+
if start >= ownership_end:
|
|
241
|
+
break
|
|
242
|
+
total_count += 1
|
|
243
|
+
if total_count > match_limit:
|
|
244
|
+
return BundleSearchResult(
|
|
245
|
+
generation,
|
|
246
|
+
tuple(matches),
|
|
247
|
+
total_count,
|
|
248
|
+
True,
|
|
249
|
+
monotonic() - started,
|
|
250
|
+
)
|
|
251
|
+
qt_position += _utf16_units(
|
|
252
|
+
text[codepoint_position:start]
|
|
253
|
+
)
|
|
254
|
+
qt_start = qt_position
|
|
255
|
+
qt_position += _utf16_units(text[start:end])
|
|
256
|
+
matches.append(
|
|
257
|
+
BundleSearchMatch(
|
|
258
|
+
document.document_id,
|
|
259
|
+
document.logical_name,
|
|
260
|
+
ordinal,
|
|
261
|
+
start,
|
|
262
|
+
end,
|
|
263
|
+
qt_start,
|
|
264
|
+
qt_position,
|
|
265
|
+
_bounded_preview(
|
|
266
|
+
text, start, end, preview_chars
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
codepoint_position = end
|
|
271
|
+
last_match_end = end
|
|
272
|
+
matches_since_check += 1
|
|
273
|
+
if matches_since_check >= 256:
|
|
274
|
+
matches_since_check = 0
|
|
275
|
+
if is_cancelled():
|
|
276
|
+
return None
|
|
277
|
+
ownership_start = ownership_end
|
|
278
|
+
|
|
279
|
+
return BundleSearchResult(
|
|
280
|
+
generation,
|
|
281
|
+
tuple(matches),
|
|
282
|
+
total_count,
|
|
283
|
+
False,
|
|
284
|
+
monotonic() - started,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class AsyncBundleSearchService:
|
|
289
|
+
"""One-active, one-latest-pending search with stale-result suppression."""
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
match_limit: int = MAX_BUNDLE_MATCHES,
|
|
295
|
+
preview_chars: int = DEFAULT_PREVIEW_CHARS,
|
|
296
|
+
scan_chunk: int = DEFAULT_SCAN_CHUNK,
|
|
297
|
+
) -> None:
|
|
298
|
+
self._match_limit = _positive_bounded_int(
|
|
299
|
+
match_limit,
|
|
300
|
+
name="match_limit",
|
|
301
|
+
maximum=MAX_BUNDLE_MATCHES,
|
|
302
|
+
)
|
|
303
|
+
self._preview_chars = _positive_bounded_int(
|
|
304
|
+
preview_chars,
|
|
305
|
+
name="preview_chars",
|
|
306
|
+
maximum=MAX_PREVIEW_CHARS,
|
|
307
|
+
)
|
|
308
|
+
self._scan_chunk = _positive_bounded_int(
|
|
309
|
+
scan_chunk,
|
|
310
|
+
name="scan_chunk",
|
|
311
|
+
maximum=4 * 1024 * 1024,
|
|
312
|
+
)
|
|
313
|
+
self._condition = Condition()
|
|
314
|
+
self._generation = 0
|
|
315
|
+
self._active_generation: int | None = None
|
|
316
|
+
self._pending: _BundleSearchRequest | None = None
|
|
317
|
+
self._closed = False
|
|
318
|
+
self._worker = Thread(
|
|
319
|
+
target=self._worker_main,
|
|
320
|
+
name="pycforge-bundle-search",
|
|
321
|
+
daemon=True,
|
|
322
|
+
)
|
|
323
|
+
self._worker.start()
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def worker_is_daemon(self) -> bool:
|
|
327
|
+
return self._worker.daemon
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def active_generation(self) -> int | None:
|
|
331
|
+
with self._condition:
|
|
332
|
+
return self._active_generation
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def pending_generation(self) -> int | None:
|
|
336
|
+
with self._condition:
|
|
337
|
+
return (
|
|
338
|
+
self._pending.generation
|
|
339
|
+
if self._pending is not None
|
|
340
|
+
else None
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def is_closed(self) -> bool:
|
|
345
|
+
with self._condition:
|
|
346
|
+
return self._closed
|
|
347
|
+
|
|
348
|
+
def submit(
|
|
349
|
+
self,
|
|
350
|
+
documents: Iterable[WorkspaceSearchDocument],
|
|
351
|
+
query: str,
|
|
352
|
+
*,
|
|
353
|
+
match_case: bool = False,
|
|
354
|
+
whole_word: bool = False,
|
|
355
|
+
callback: Callable[[BundleSearchResult], None],
|
|
356
|
+
) -> int:
|
|
357
|
+
records = _captured_documents(documents)
|
|
358
|
+
if not isinstance(query, str):
|
|
359
|
+
raise TypeError("search query must be a string")
|
|
360
|
+
if len(query) > MAX_QUERY_CHARS:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
f"search query exceeds the {MAX_QUERY_CHARS}-character limit"
|
|
363
|
+
)
|
|
364
|
+
if not callable(callback):
|
|
365
|
+
raise TypeError("search callback must be callable")
|
|
366
|
+
with self._condition:
|
|
367
|
+
if self._closed:
|
|
368
|
+
raise RuntimeError("bundle search service is closed")
|
|
369
|
+
self._generation += 1
|
|
370
|
+
generation = self._generation
|
|
371
|
+
self._pending = _BundleSearchRequest(
|
|
372
|
+
generation,
|
|
373
|
+
records,
|
|
374
|
+
query,
|
|
375
|
+
bool(match_case),
|
|
376
|
+
bool(whole_word),
|
|
377
|
+
callback,
|
|
378
|
+
)
|
|
379
|
+
self._condition.notify()
|
|
380
|
+
return generation
|
|
381
|
+
|
|
382
|
+
def cancel(self) -> int:
|
|
383
|
+
with self._condition:
|
|
384
|
+
if self._closed:
|
|
385
|
+
return self._generation
|
|
386
|
+
self._generation += 1
|
|
387
|
+
self._pending = None
|
|
388
|
+
self._condition.notify()
|
|
389
|
+
return self._generation
|
|
390
|
+
|
|
391
|
+
def close(self, *, wait_seconds: float = 0.05) -> None:
|
|
392
|
+
with self._condition:
|
|
393
|
+
if self._closed:
|
|
394
|
+
return
|
|
395
|
+
self._closed = True
|
|
396
|
+
self._generation += 1
|
|
397
|
+
self._pending = None
|
|
398
|
+
self._condition.notify_all()
|
|
399
|
+
if wait_seconds > 0 and current_thread() is not self._worker:
|
|
400
|
+
self._worker.join(timeout=wait_seconds)
|
|
401
|
+
|
|
402
|
+
def _is_cancelled(self, generation: int) -> bool:
|
|
403
|
+
with self._condition:
|
|
404
|
+
return self._closed or generation != self._generation
|
|
405
|
+
|
|
406
|
+
def _worker_main(self) -> None:
|
|
407
|
+
while True:
|
|
408
|
+
with self._condition:
|
|
409
|
+
while self._pending is None and not self._closed:
|
|
410
|
+
self._condition.wait()
|
|
411
|
+
if self._closed:
|
|
412
|
+
return
|
|
413
|
+
request = self._pending
|
|
414
|
+
self._pending = None
|
|
415
|
+
assert request is not None
|
|
416
|
+
self._active_generation = request.generation
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
result = search_closed_documents(
|
|
420
|
+
request.documents,
|
|
421
|
+
request.query,
|
|
422
|
+
generation=request.generation,
|
|
423
|
+
match_case=request.match_case,
|
|
424
|
+
whole_word=request.whole_word,
|
|
425
|
+
match_limit=self._match_limit,
|
|
426
|
+
preview_chars=self._preview_chars,
|
|
427
|
+
scan_chunk=self._scan_chunk,
|
|
428
|
+
cancelled=lambda: self._is_cancelled(
|
|
429
|
+
request.generation
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
except Exception:
|
|
433
|
+
# A non-authoritative observer defect must not retire the
|
|
434
|
+
# service or publish a plausible partial result.
|
|
435
|
+
result = None
|
|
436
|
+
|
|
437
|
+
with self._condition:
|
|
438
|
+
if self._active_generation == request.generation:
|
|
439
|
+
self._active_generation = None
|
|
440
|
+
deliver = (
|
|
441
|
+
result is not None
|
|
442
|
+
and not self._closed
|
|
443
|
+
and request.generation == self._generation
|
|
444
|
+
)
|
|
445
|
+
if deliver:
|
|
446
|
+
try:
|
|
447
|
+
request.callback(result)
|
|
448
|
+
except Exception:
|
|
449
|
+
# Presentation observers cannot retire the sole worker.
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = [
|
|
454
|
+
"AsyncBundleSearchService",
|
|
455
|
+
"BundleSearchMatch",
|
|
456
|
+
"BundleSearchResult",
|
|
457
|
+
"DEFAULT_PREVIEW_CHARS",
|
|
458
|
+
"MAX_BUNDLE_DOCUMENTS",
|
|
459
|
+
"MAX_BUNDLE_MATCHES",
|
|
460
|
+
"WorkspaceSearchDocument",
|
|
461
|
+
"search_closed_documents",
|
|
462
|
+
]
|