183 lines
8.2 KiB
Python
183 lines
8.2 KiB
Python
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
|