from __future__ import annotations from dataclasses import asdict, dataclass, field from typing import Any from .featurescript_parser import symbolic_string from .ir import Call @dataclass class QueryInfo: kind: str | None = None owner_feature: str | None = None topology_type: str | None = None source_sketch: str | None = None source_entity: str | None = None is_start: bool | None = None calls: list[str] = field(default_factory=list) # The AST is intentionally lossless for the parser's value model. Query # aliases may be resolved for lowering, but their nested query semantics # must remain auditable and cannot be collapsed into a geometry hint. ast: dict[str, Any] | list[Any] | str | float | bool | None = None query_combinators: list[str] = field(default_factory=list) filters: list[str] = field(default_factory=list) body_scope: list[str] = field(default_factory=list) disambiguation: list[str] = field(default_factory=list) def __post_init__(self) -> None: if self.ast is None: self.ast = {} def as_dict(self) -> dict[str, Any]: return asdict(self) def walk_calls(value: Any): if isinstance(value, Call): yield value for arg in value.args: yield from walk_calls(arg) elif isinstance(value, list): for item in value: yield from walk_calls(item) elif isinstance(value, dict): for item in value.values(): yield from walk_calls(item) def query_ast(value: Any) -> dict[str, Any] | list[Any] | str | float | bool | None: """Serialize parsed FeatureScript query syntax without evaluating it.""" if isinstance(value, Call): return {"call": value.name, "args": [query_ast(arg) for arg in value.args], "line": value.line} if isinstance(value, list): return [query_ast(item) for item in value] if isinstance(value, dict): return {str(key): query_ast(item) for key, item in value.items()} if value is None or isinstance(value, (str, float, bool, int)): return value return str(value) _SET_COMBINATORS = { "qUnion": "union", "qIntersection": "intersection", "qSubtraction": "subtraction", } _FILTERS = { "qAdjacent": "adjacent", "qOwnerBody": "owner_body", "qBodyType": "body_type", "qConstructionFilter": "construction", } def _query_expression_node(value: Any) -> dict[str, Any]: """Return a typed, lossless-enough representation of a source query. ``source_query.ast`` remains the source-level record, including parser line numbers. This second representation makes set boundaries and filters explicit so a later resolver can interpret them without re-parsing FeatureScript text. Unknown calls stay opaque instead of being flattened into a nearby supported topology query. """ if isinstance(value, Call): name = value.name args = value.args if name in _SET_COMBINATORS: operands = args[0] if len(args) == 1 and isinstance(args[0], list) else args return { "node": "set", "operator": _SET_COMBINATORS[name], "operands": [_query_expression_node(item) for item in operands], } if name in _FILTERS: return { "node": "filter", "filter": _FILTERS[name], "input": _query_expression_node(args[0]) if args else {"node": "literal", "value": None}, "arguments": [_query_expression_node(item) for item in args[1:]], } if name == "makeQuery": return { "node": "topology_query", "owner": _query_expression_node(args[0]) if len(args) > 0 else {"node": "literal", "value": None}, "topology_type": _query_expression_node(args[1]) if len(args) > 1 else {"node": "literal", "value": None}, "entity_type": _query_expression_node(args[2]) if len(args) > 2 else {"node": "literal", "value": None}, "arguments": [_query_expression_node(item) for item in args[3:]], } if name == "qCreatedBy": return { "node": "created_by", "owner": _query_expression_node(args[0]) if args else {"node": "literal", "value": None}, "arguments": [_query_expression_node(item) for item in args[1:]], } if name in {"sQuery", "sketchEntityQuery"}: return { "node": "source_entity", "sketch": _query_expression_node(args[0]) if len(args) > 0 else {"node": "literal", "value": None}, "entity_type": _query_expression_node(args[1]) if len(args) > 1 else {"node": "literal", "value": None}, "entity": _query_expression_node(args[2]) if len(args) > 2 else {"node": "literal", "value": None}, "arguments": [_query_expression_node(item) for item in args[3:]], } if name == "qSketchRegion": return { "node": "sketch_region", "sketch": _query_expression_node(args[0]) if args else {"node": "literal", "value": None}, "arguments": [_query_expression_node(item) for item in args[1:]], } return { "node": "opaque_call", "name": name, "arguments": [_query_expression_node(item) for item in args], } if isinstance(value, list): return {"node": "list", "items": [_query_expression_node(item) for item in value]} if isinstance(value, dict): return { "node": "map", "entries": [ {"key": str(key), "value": _query_expression_node(item)} for key, item in value.items() ], } if value is None or isinstance(value, (str, float, bool, int)): return {"node": "literal", "value": value} return {"node": "literal", "value": str(value)} def query_expr(value: Any) -> dict[str, Any]: """Produce the versioned CDSL query-expression contract for one query.""" return {"version": "1.0", "root": _query_expression_node(value)} def parse_query(value: Any) -> QueryInfo: info = QueryInfo(ast=query_ast(value)) for call in walk_calls(value): info.calls.append(call.name) if call.name in {"qUnion", "qIntersection", "qSubtraction"}: info.query_combinators.append(call.name) if call.name in {"qBodyType", "qOwnerBody"}: info.body_scope.append(call.name) if call.name in {"TDD", "trueDependencyDisambiguation"}: info.disambiguation.append(call.name) if call.name in {"qBodyType", "qOwnerBody", "qAdjacent", "qConstructionFilter"}: info.filters.append(call.name) if call.name in {"makeQuery", "qCreatedBy"} and call.args: owner = symbolic_string(call.args[0]) if "F" in owner: tail = owner[owner.find("F"):].split(".", 1)[0] info.owner_feature = tail if call.name == "makeQuery" and len(call.args) > 2: info.topology_type = str(call.args[1]); info.kind = str(call.args[2]).lower() definition = next((arg for arg in call.args if isinstance(arg, dict)), {}) if isinstance(definition.get("isStart"), str): info.is_start = definition["isStart"].lower() == "true" elif "isStart" in definition: info.is_start = bool(definition["isStart"]) elif call.name == "qCreatedBy" and len(call.args) > 1 and info.topology_type is None: # qCreatedBy is itself a typed FeatureScript query. Keep its # requested kind so consumers can distinguish a datum plane # from a point without inspecting feature IDs or geometry. info.kind = str(call.args[1]).lower() if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0] if info.topology_type is None: info.kind = str(call.args[1]).lower() info.source_entity = str(call.args[2]) if call.name == "qSketchRegion" and call.args: info.source_sketch = symbolic_string(call.args[0]); info.kind = "face" return info