Quantar 0.1.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,38 @@
1
+ """Quantar language systems - parsers, ASTs, and language tools.
2
+
3
+ ``lang_systems.parse`` returns pegen's raw parse tree. ``lang_systems.parse_ast``
4
+ returns a Python AST with custom :class:`QuantumOp` nodes. Both are imported
5
+ lazily so that importing this package does not require the generated parser
6
+ (which is produced at build time) to already exist.
7
+ """
8
+
9
+ import importlib
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ _EXPORTS = {
14
+ "parse": ("lang_systems.quantar", "parse"),
15
+ "parse_ast": ("lang_systems.ast_builder", "parse_ast"),
16
+ "build_ast": ("lang_systems.ast_builder", "build_ast"),
17
+ "lower_ast": ("lang_systems.ast_builder", "lower_ast"),
18
+ "compile_ast": ("lang_systems.ast_builder", "compile_ast"),
19
+ "QuantumOp": ("lang_systems.ast_builder", "QuantumOp"),
20
+ "LowerQuantumOps": ("lang_systems.ast_builder", "LowerQuantumOps"),
21
+ "load_operators": ("lang_systems.operators", "load_operators"),
22
+ "list_known_operators": ("lang_systems.operators", "list_known_operators"),
23
+ "QuantarNodeVisitor": ("lang_systems.visitors", "QuantarNodeVisitor"),
24
+ "OperatorCollector": ("lang_systems.visitors", "OperatorCollector"),
25
+ }
26
+
27
+
28
+ def __getattr__(name):
29
+ target = _EXPORTS.get(name)
30
+ if target is None:
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
32
+ module_name, attribute = target
33
+ value = getattr(importlib.import_module(module_name), attribute)
34
+ globals()[name] = value
35
+ return value
36
+
37
+
38
+ __all__ = list(_EXPORTS)
@@ -0,0 +1,272 @@
1
+ """Build a Python AST for Quantar source.
2
+
3
+ Quantar adds a handful of infix operators to Python's syntax. Reimplementing
4
+ CPython's grammar would be wasteful, so this module takes the opposite route:
5
+
6
+ 1. recursively find custom infix expressions in the pegen parse tree;
7
+ 2. replace each one with a unique sentinel call;
8
+ 3. hand the resulting text to :func:`ast.parse`;
9
+ 4. replace the sentinel calls with :class:`QuantumOp` nodes.
10
+
11
+ The result is ordinary Python AST plus custom :class:`QuantumOp` nodes, which
12
+ can then be inspected with :mod:`lang_systems.visitors` or lowered back to calls.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import token
19
+ import tokenize
20
+ from dataclasses import dataclass
21
+ from types import CodeType
22
+
23
+ from lang_systems.operators import get_known_operator_kind
24
+ from lang_systems.quantar import parse
25
+ from lang_systems.quantar_tokens import tokenize_source
26
+
27
+ _SENTINEL_PREFIX = "__quantar_operator_"
28
+ _SENTINEL_SUFFIX = "__"
29
+
30
+
31
+ class QuantumOp(ast.expr):
32
+ """A Quantar infix operation, stored as a first-class AST node."""
33
+
34
+ _fields = ("operator", "operands", "kind")
35
+
36
+ def __init__(
37
+ self,
38
+ operator: str,
39
+ operands: list[ast.expr],
40
+ kind: str = "binary",
41
+ **kwargs,
42
+ ):
43
+ super().__init__(**kwargs)
44
+ self.operator = operator
45
+ self.operands = operands
46
+ self.kind = kind
47
+
48
+
49
+ @dataclass
50
+ class _Rewrite:
51
+ name: str
52
+ operator: str
53
+ kind: str
54
+ start: tuple[int, int]
55
+ end: tuple[int, int]
56
+
57
+
58
+ class _Source:
59
+ """Map token positions (line, column) to source offsets and slices."""
60
+
61
+ def __init__(self, text: str):
62
+ self.text = text
63
+ self.lines = text.splitlines(keepends=True)
64
+ self.starts: list[int] = []
65
+ offset = 0
66
+ for line in self.lines:
67
+ self.starts.append(offset)
68
+ offset += len(line)
69
+
70
+ def offset(self, position: tuple[int, int]) -> int:
71
+ row, column = position
72
+ if row < 1 or row > len(self.starts):
73
+ return len(self.text)
74
+ return self.starts[row - 1] + column
75
+
76
+ def slice(self, start: tuple[int, int], end: tuple[int, int]) -> str:
77
+ return self.text[self.offset(start) : self.offset(end)]
78
+
79
+ def line(self, row: int) -> str:
80
+ if 1 <= row <= len(self.lines):
81
+ return self.lines[row - 1]
82
+ return ""
83
+
84
+ def byte_column(self, position: tuple[int, int]) -> int:
85
+ _, column = position
86
+ return len(self.line(position[0])[:column].encode("utf-8"))
87
+
88
+
89
+ def _first_token(node) -> tokenize.TokenInfo | None:
90
+ if isinstance(node, tokenize.TokenInfo):
91
+ return node
92
+ if isinstance(node, list):
93
+ for item in node:
94
+ found = _first_token(item)
95
+ if found is not None:
96
+ return found
97
+ return None
98
+
99
+
100
+ def _last_token(node) -> tokenize.TokenInfo | None:
101
+ if isinstance(node, tokenize.TokenInfo):
102
+ return node
103
+ if isinstance(node, list):
104
+ for item in reversed(node):
105
+ found = _last_token(item)
106
+ if found is not None:
107
+ return found
108
+ return None
109
+
110
+
111
+ def _custom_operator(node) -> tokenize.TokenInfo | None:
112
+ """Return the operator token if `node` is a custom binary expression."""
113
+ if not isinstance(node, list) or len(node) != 3:
114
+ return None
115
+ left, middle, right = node
116
+ if not isinstance(middle, tokenize.TokenInfo):
117
+ return None
118
+ if middle.type not in (token.OP, token.NAME):
119
+ return None
120
+ if get_known_operator_kind(middle.string) is None:
121
+ return None
122
+ if left is None or right is None:
123
+ return None
124
+ return middle
125
+
126
+
127
+ def _direct_custom_nodes(node) -> list:
128
+ """Return the outermost custom expressions inside a standard node."""
129
+ found = []
130
+
131
+ def visit(value):
132
+ if _custom_operator(value) is not None:
133
+ found.append(value)
134
+ return
135
+ if isinstance(value, list):
136
+ for item in value:
137
+ visit(item)
138
+
139
+ visit(node)
140
+ return found
141
+
142
+
143
+ def _node_text(node, source: _Source) -> str:
144
+ if isinstance(node, tokenize.TokenInfo):
145
+ return node.string
146
+ if not isinstance(node, list):
147
+ return ""
148
+ first = _first_token(node)
149
+ last = _last_token(node)
150
+ if first is None or last is None:
151
+ return ""
152
+ return source.slice(first.start, last.end)
153
+
154
+
155
+ def _render(node, source: _Source, rewrites: list[_Rewrite]) -> str:
156
+ operator = _custom_operator(node)
157
+ if operator is not None:
158
+ left, _, right = node
159
+ first = _first_token(node)
160
+ last = _last_token(node)
161
+ if first is None or last is None:
162
+ return _node_text(node, source)
163
+
164
+ name = f"{_SENTINEL_PREFIX}{len(rewrites)}{_SENTINEL_SUFFIX}"
165
+ kind = get_known_operator_kind(operator.string) or "binary"
166
+ rewrites.append(_Rewrite(name, operator.string, kind, first.start, last.end))
167
+
168
+ left_text = _render(left, source, rewrites)
169
+ right_text = _render(right, source, rewrites)
170
+ return f"{name}({operator.string!r}, ({left_text}), ({right_text}))"
171
+
172
+ if isinstance(node, tokenize.TokenInfo):
173
+ return node.string
174
+
175
+ text = _node_text(node, source)
176
+ if not text:
177
+ return text
178
+
179
+ first = _first_token(node)
180
+ if first is None:
181
+ return text
182
+ base = source.offset(first.start)
183
+ replacements = []
184
+ for child in _direct_custom_nodes(node):
185
+ child_first = _first_token(child)
186
+ child_last = _last_token(child)
187
+ if child_first is None or child_last is None:
188
+ continue
189
+ start = source.offset(child_first.start) - base
190
+ end = source.offset(child_last.end) - base
191
+ replacements.append((start, end, _render(child, source, rewrites)))
192
+
193
+ for start, end, replacement in sorted(replacements, reverse=True):
194
+ text = text[:start] + replacement + text[end:]
195
+ return text
196
+
197
+
198
+ class _ReplaceSentinels(ast.NodeTransformer):
199
+ def __init__(self, rewrites: list[_Rewrite], source: _Source):
200
+ self.rewrites = {rewrite.name: rewrite for rewrite in rewrites}
201
+ self.source = source
202
+
203
+ def visit_Call(self, node: ast.Call):
204
+ if not (isinstance(node.func, ast.Name) and node.func.id in self.rewrites):
205
+ return self.generic_visit(node)
206
+
207
+ node = self.generic_visit(node) #type: ignore
208
+ rewrite = self.rewrites[node.func.id] #type: ignore
209
+ if len(node.args) != 3:
210
+ return node
211
+
212
+ result = QuantumOp(rewrite.operator, [node.args[1], node.args[2]], rewrite.kind)
213
+ result.lineno = rewrite.start[0]
214
+ result.col_offset = self.source.byte_column(rewrite.start)
215
+ result.end_lineno = rewrite.end[0]
216
+ result.end_col_offset = self.source.byte_column(rewrite.end)
217
+ return result
218
+
219
+
220
+ class LowerQuantumOps(ast.NodeTransformer):
221
+ """Lower :class:`QuantumOp` nodes to ``__quantar_apply__(name, *operands)``."""
222
+
223
+ def visit_QuantumOp(self, node: QuantumOp):
224
+ node = self.generic_visit(node) #type: ignore
225
+ call = ast.Call(
226
+ func=ast.Name(id="__quantar_apply__", ctx=ast.Load()),
227
+ args=[ast.Constant(node.operator), *node.operands],
228
+ keywords=[],
229
+ )
230
+ return ast.copy_location(call, node)
231
+
232
+
233
+ def parse_ast(source: str, filename: str = "<quantar>") -> ast.Module:
234
+ """Parse Quantar source into a Python AST containing :class:`QuantumOp` nodes."""
235
+ source_map = _Source(source)
236
+ rewrites: list[_Rewrite] = []
237
+ rewritten = _render(parse(source), source_map, rewrites)
238
+ tree = ast.parse(rewritten, filename=filename)
239
+ tree = _ReplaceSentinels(rewrites, source_map).visit(tree)
240
+ ast.fix_missing_locations(tree)
241
+ return tree
242
+
243
+
244
+ def lower_ast(tree: ast.AST) -> ast.AST:
245
+ """Return `tree` with all :class:`QuantumOp` nodes lowered to calls."""
246
+ lowered = LowerQuantumOps().visit(tree)
247
+ ast.fix_missing_locations(lowered)
248
+ return lowered
249
+
250
+
251
+ def compile_ast(tree: ast.AST, filename: str = "<quantar>", mode: str = "exec") -> CodeType:
252
+ """Compile an AST produced by :func:`parse_ast`."""
253
+ return compile(lower_ast(tree), filename, mode) #type: ignore
254
+
255
+
256
+ def tokenize_ast(source: str):
257
+ """Return the token stream used while building an AST (useful for tooling)."""
258
+ return tokenize_source(source)
259
+
260
+
261
+ build_ast = parse_ast
262
+
263
+
264
+ __all__ = [
265
+ "QuantumOp",
266
+ "LowerQuantumOps",
267
+ "parse_ast",
268
+ "build_ast",
269
+ "lower_ast",
270
+ "compile_ast",
271
+ "tokenize_ast",
272
+ ]
@@ -0,0 +1 @@
1
+ """Language server support for Quantar."""
@@ -0,0 +1,3 @@
1
+ from lang_systems.lsp.server import main
2
+
3
+ main()
@@ -0,0 +1,247 @@
1
+ """Quantar language server.
2
+
3
+ The server deliberately reuses the real tokenizer and parser so editor
4
+ highlighting and diagnostics cannot drift from the language implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import keyword
10
+ import logging
11
+ import os
12
+ import sys
13
+ import token
14
+ import tokenize
15
+
16
+ from lsprotocol import types
17
+ from pygls.lsp.server import LanguageServer
18
+
19
+ from lang_systems.ast_builder import parse_ast
20
+ from lang_systems.operators import list_known_operators, load_operators
21
+ from lang_systems.quantar_tokens import tokenize_source
22
+
23
+ TOKEN_TYPES = ("quantarOperator",)
24
+ TOKEN_MODIFIERS = (
25
+ "nullary",
26
+ "unary",
27
+ "binary",
28
+ "ternary",
29
+ "callable",
30
+ "matrix",
31
+ )
32
+ MODIFIER_BITS = {
33
+ name: 1 << index for index, name in enumerate(TOKEN_MODIFIERS)
34
+ }
35
+ LEGEND = types.SemanticTokensLegend(
36
+ token_types=list(TOKEN_TYPES),
37
+ token_modifiers=list(TOKEN_MODIFIERS),
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ server = LanguageServer("quantar-lsp", "0.1.0")
43
+
44
+
45
+ def _load_default_operators() -> None:
46
+ """Register the quantum gate operators shipped with Quantar."""
47
+ try:
48
+ import quantum.gate_matrix # noqa: F401
49
+ except ImportError:
50
+ logger.debug("Quantum gate operators are not available")
51
+
52
+
53
+ def _load_configured_operators() -> None:
54
+ """Import operator modules listed in QUANTAR_OPERATOR_MODULES."""
55
+ raw_modules = os.environ.get("QUANTAR_OPERATOR_MODULES", "")
56
+ modules = [module.strip() for module in raw_modules.split(",") if module.strip()]
57
+ if not modules:
58
+ return
59
+
60
+ try:
61
+ load_operators(*modules)
62
+ except Exception:
63
+ logger.exception("Failed to load Quantar operator modules: %s", modules)
64
+
65
+
66
+ def _position(document, line: int, character: int) -> types.Position:
67
+ """Convert a Python code-point position into a client position."""
68
+ return document.position_to_client_units(
69
+ types.Position(line=max(line, 0), character=max(character, 0))
70
+ )
71
+
72
+
73
+ def _full_range(document) -> types.Range:
74
+ lines = document.lines
75
+ if not lines:
76
+ return types.Range(
77
+ start=types.Position(line=0, character=0),
78
+ end=types.Position(line=0, character=0),
79
+ )
80
+ last = len(lines) - 1
81
+ return types.Range(
82
+ start=types.Position(line=0, character=0),
83
+ end=_position(document, last, len(lines[last].rstrip("\r\n"))),
84
+ )
85
+
86
+
87
+ def _syntax_error_range(document, error: SyntaxError) -> types.Range:
88
+ lines = document.lines
89
+ if not lines:
90
+ return _full_range(document)
91
+
92
+ line = min(max((error.lineno or 1) - 1, 0), len(lines) - 1)
93
+ text = lines[line]
94
+ start = min(max((error.offset or 1) - 1, 0), len(text))
95
+
96
+ end_line = min(
97
+ max((getattr(error, "end_lineno", None) or line + 1) - 1, line),
98
+ len(lines) - 1,
99
+ )
100
+ if end_line == line:
101
+ end = max(
102
+ start + 1,
103
+ min(
104
+ max((getattr(error, "end_offset", None) or start + 2) - 1, 0),
105
+ len(text),
106
+ ),
107
+ )
108
+ else:
109
+ end = len(lines[end_line].rstrip("\r\n"))
110
+
111
+ return types.Range(
112
+ start=_position(document, line, start),
113
+ end=_position(document, end_line, end),
114
+ )
115
+
116
+
117
+ def _token_error_range(document, error: tokenize.TokenError) -> types.Range:
118
+ location = error.args[1] if len(error.args) > 1 else (1, 0)
119
+ line, character = location
120
+ return types.Range(
121
+ start=_position(document, line - 1, character),
122
+ end=_position(document, line - 1, character + 1),
123
+ )
124
+
125
+
126
+ def _publish_diagnostics(ls: LanguageServer, document) -> None:
127
+ diagnostics: list[types.Diagnostic] = []
128
+ try:
129
+ parse_ast(document.source, document.path or document.uri)
130
+ except SyntaxError as error:
131
+ diagnostics.append(
132
+ types.Diagnostic(
133
+ range=_syntax_error_range(document, error),
134
+ message=error.msg or "Invalid Quantar syntax",
135
+ severity=types.DiagnosticSeverity.Error,
136
+ source="quantar",
137
+ )
138
+ )
139
+ except tokenize.TokenError as error:
140
+ diagnostics.append(
141
+ types.Diagnostic(
142
+ range=_token_error_range(document, error),
143
+ message=error.args[0] if error.args else "Tokenizer error",
144
+ severity=types.DiagnosticSeverity.Error,
145
+ source="quantar",
146
+ )
147
+ )
148
+ except Exception as error:
149
+ diagnostics.append(
150
+ types.Diagnostic(
151
+ range=_full_range(document),
152
+ message=str(error),
153
+ severity=types.DiagnosticSeverity.Error,
154
+ source="quantar",
155
+ )
156
+ )
157
+
158
+ ls.text_document_publish_diagnostics(
159
+ types.PublishDiagnosticsParams(
160
+ uri=document.uri,
161
+ diagnostics=diagnostics,
162
+ version=document.version,
163
+ )
164
+ )
165
+
166
+
167
+ @server.feature(types.TEXT_DOCUMENT_DID_OPEN)
168
+ def did_open(ls: LanguageServer, params: types.DidOpenTextDocumentParams) -> None:
169
+ document = ls.workspace.get_text_document(params.text_document.uri)
170
+ _publish_diagnostics(ls, document)
171
+
172
+
173
+ @server.feature(types.TEXT_DOCUMENT_DID_CHANGE)
174
+ def did_change(ls: LanguageServer, params: types.DidChangeTextDocumentParams) -> None:
175
+ document = ls.workspace.get_text_document(params.text_document.uri)
176
+ _publish_diagnostics(ls, document)
177
+
178
+
179
+ @server.feature(types.TEXT_DOCUMENT_DID_SAVE)
180
+ def did_save(ls: LanguageServer, params: types.DidSaveTextDocumentParams) -> None:
181
+ document = ls.workspace.get_text_document(params.text_document.uri)
182
+ _publish_diagnostics(ls, document)
183
+
184
+
185
+ @server.feature(
186
+ types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
187
+ LEGEND,
188
+ )
189
+ def semantic_tokens_full(
190
+ ls: LanguageServer, params: types.SemanticTokensParams
191
+ ) -> types.SemanticTokens:
192
+ document = ls.workspace.get_text_document(params.text_document.uri)
193
+ operators = list_known_operators()
194
+ data: list[int] = []
195
+
196
+ try:
197
+ tokens = tokenize_source(document.source)
198
+ previous_line = 0
199
+ previous_start = 0
200
+
201
+ for current in tokens:
202
+ if current.type not in (token.OP, token.NAME):
203
+ continue
204
+ if current.string not in operators:
205
+ continue
206
+ if current.type == token.NAME and keyword.iskeyword(current.string):
207
+ continue
208
+
209
+ start = _position(document, current.start[0] - 1, current.start[1])
210
+ end = _position(document, current.end[0] - 1, current.end[1])
211
+ if start.line != end.line or end.character <= start.character:
212
+ continue
213
+
214
+ delta_line = start.line - previous_line
215
+ delta_start = (
216
+ start.character - previous_start
217
+ if delta_line == 0
218
+ else start.character
219
+ )
220
+ modifier = MODIFIER_BITS.get(operators[current.string], 0)
221
+
222
+ data.extend(
223
+ [
224
+ delta_line,
225
+ delta_start,
226
+ end.character - start.character,
227
+ 0,
228
+ modifier,
229
+ ]
230
+ )
231
+ previous_line = start.line
232
+ previous_start = start.character
233
+ except (SyntaxError, tokenize.TokenError, IndentationError):
234
+ return types.SemanticTokens(data=[])
235
+
236
+ return types.SemanticTokens(data=data)
237
+
238
+
239
+ def main() -> None:
240
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr)
241
+ _load_default_operators()
242
+ _load_configured_operators()
243
+ server.start_io()
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()