@dialecte/create 0.0.1

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 (52) hide show
  1. package/README.md +88 -0
  2. package/dist/cli/index.js +281 -0
  3. package/package.json +43 -0
  4. package/python/generate/__init__.py +1 -0
  5. package/python/generate/__main__.py +103 -0
  6. package/python/generate/collector.py +128 -0
  7. package/python/generate/deriver.py +117 -0
  8. package/python/generate/emitters/__init__.py +1 -0
  9. package/python/generate/emitters/constants.py +69 -0
  10. package/python/generate/emitters/definition.py +49 -0
  11. package/python/generate/emitters/ts_helpers.py +283 -0
  12. package/python/generate/emitters/types.py +67 -0
  13. package/python/generate/extractors/__init__.py +1 -0
  14. package/python/generate/extractors/attributes.py +83 -0
  15. package/python/generate/extractors/children.py +175 -0
  16. package/python/generate/extractors/constraints.py +154 -0
  17. package/python/generate/extractors/docs.py +32 -0
  18. package/python/generate/extractors/facets.py +168 -0
  19. package/python/generate/extractors/namespace.py +59 -0
  20. package/python/generate/globals.py +99 -0
  21. package/python/generate/helpers.py +143 -0
  22. package/python/generate/ir.py +81 -0
  23. package/python/generate/orphans.py +69 -0
  24. package/python/generate/xpath_parser.py +167 -0
  25. package/python/generate/xsi_type.py +150 -0
  26. package/python/pyproject.toml +15 -0
  27. package/templates/dialecte/README.md +39 -0
  28. package/templates/dialecte/_gitignore +4 -0
  29. package/templates/dialecte/docs/.vitepress/config.ts +15 -0
  30. package/templates/dialecte/docs/index.md +18 -0
  31. package/templates/dialecte/env.d.ts +1 -0
  32. package/templates/dialecte/package.json +45 -0
  33. package/templates/dialecte/src/__version__/config/dialecte.config.ts +48 -0
  34. package/templates/dialecte/src/__version__/config/hydrated.types.ts +78 -0
  35. package/templates/dialecte/src/__version__/config/index.ts +2 -0
  36. package/templates/dialecte/src/__version__/config/namespaces.ts +6 -0
  37. package/templates/dialecte/src/__version__/definition/.gitkeep +2 -0
  38. package/templates/dialecte/src/__version__/definition/index.ts +4 -0
  39. package/templates/dialecte/src/__version__/dialecte.ts +30 -0
  40. package/templates/dialecte/src/__version__/extensions/index.ts +3 -0
  41. package/templates/dialecte/src/__version__/index.ts +2 -0
  42. package/templates/dialecte/src/__version__/test/hydrated-test.ts +53 -0
  43. package/templates/dialecte/src/__version__/test/index.ts +1 -0
  44. package/templates/dialecte/src/index.ts +1 -0
  45. package/templates/dialecte/tsconfig.build.json +24 -0
  46. package/templates/dialecte/tsconfig.json +8 -0
  47. package/templates/dialecte/tsconfig.node.json +13 -0
  48. package/templates/dialecte/tsconfig.vitest.json +10 -0
  49. package/templates/dialecte/vite.config.ts +36 -0
  50. package/templates/dialecte/vitest.config.ts +35 -0
  51. package/vendor/elementpath-5.1.1-py3-none-any.whl +0 -0
  52. package/vendor/xmlschema-4.3.1-py3-none-any.whl +0 -0
@@ -0,0 +1,117 @@
1
+ """Deriver — compute transitive graphs and derived constants from collected IR.
2
+
3
+ Phase 3 of the pipeline: Parse → Collect → **Derive** → Emit.
4
+ """
5
+ from generate.ir import ElementDef, IdentityConstraint
6
+ def derive_graph(
7
+ elements: dict[str, ElementDef],
8
+ ) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
9
+ """Compute transitive DESCENDANTS and ANCESTORS from CHILDREN/PARENTS.
10
+
11
+ Returns:
12
+ (descendants, ancestors) — each is a dict mapping element name to sorted list.
13
+ """
14
+ children_map = {name: list(e.children.keys()) for name, e in elements.items()}
15
+ parents_map = {name: list(e.parents) for name, e in elements.items()}
16
+
17
+ def transitive(graph: dict[str, list[str]], start: str) -> list[str]:
18
+ result: list[str] = []
19
+ visited: set[str] = set()
20
+ queue = list(graph.get(start, []))
21
+ while queue:
22
+ node = queue.pop(0)
23
+ if node in visited:
24
+ continue
25
+ visited.add(node)
26
+ result.append(node)
27
+ queue.extend(graph.get(node, []))
28
+ return sorted(result)
29
+
30
+ descendants = {name: transitive(children_map, name) for name in elements}
31
+ ancestors = {name: transitive(parents_map, name) for name in elements}
32
+ return descendants, ancestors
33
+ def derive_root_element(elements: dict[str, ElementDef], override: str | None = None) -> str:
34
+ """Find the document root element.
35
+
36
+ If *override* is provided, use it directly (raising if absent).
37
+ Otherwise expect exactly one parentless element.
38
+ """
39
+ if override:
40
+ if override not in elements:
41
+ raise ValueError(f'Root override {override!r} not found in elements')
42
+ return override
43
+
44
+ roots = sorted(name for name, e in elements.items() if not e.parents)
45
+ if len(roots) != 1:
46
+ raise ValueError(f'Expected exactly 1 root element, found: {roots}')
47
+ return roots[0]
48
+ def derive_singleton_elements(elements: dict[str, ElementDef], root_name: str) -> list[str]:
49
+ """Find elements that can appear at most once in the entire document.
50
+
51
+ An element is a document singleton if:
52
+ - It has maxOccurs <= 1 in every parent context (local singleton), AND
53
+ - All of its parents are also document singletons (transitive).
54
+
55
+ Computed via fixpoint: start from root, propagate singleton status downward.
56
+ """
57
+ # Step 1: local singletons — maxOccurs <= 1 everywhere
58
+ local_singleton: set[str] = set(elements.keys())
59
+ for elem in elements.values():
60
+ for child_name, child_def in elem.children.items():
61
+ if child_def.max_occurs is None or child_def.max_occurs > 1:
62
+ local_singleton.discard(child_name)
63
+
64
+ # Step 2: fixpoint — only keep elements whose entire parent chain is singleton
65
+ doc_singletons: set[str] = {root_name}
66
+ changed = True
67
+ while changed:
68
+ changed = False
69
+ for name, elem in elements.items():
70
+ if name in doc_singletons or name not in local_singleton:
71
+ continue
72
+ if elem.parents and all(p in doc_singletons for p in elem.parents):
73
+ doc_singletons.add(name)
74
+ changed = True
75
+
76
+ return sorted(doc_singletons)
77
+
78
+
79
+ def derive_identity_fields(elements: dict[str, ElementDef]) -> dict[str, list[str]]:
80
+ """Precompute which attributes participate in identity constraints per element.
81
+
82
+ Scans all element-level constraints, resolves selector targets,
83
+ and assigns field attributes to targeted elements.
84
+ Returns element name -> sorted list of attribute names used in unique/key fields.
85
+ """
86
+ result: dict[str, set[str]] = {}
87
+ for element in elements.values():
88
+ for constraint in element.constraints:
89
+ for target in _resolve_constraint_targets(constraint, elements):
90
+ result.setdefault(target, set())
91
+ result[target] |= _extract_attribute_fields(constraint)
92
+ return {name: sorted(fields) for name, fields in result.items() if fields}
93
+
94
+
95
+ def _resolve_constraint_targets(
96
+ constraint: IdentityConstraint, elements: dict[str, ElementDef]
97
+ ) -> set[str]:
98
+ """Find which element names a constraint's selector targets."""
99
+ if constraint.kind == 'keyref':
100
+ return set()
101
+ targets: set[str] = set()
102
+ for path in constraint.selector:
103
+ for step in path.steps:
104
+ if step.kind == 'name' and step.value in elements:
105
+ targets.add(step.value)
106
+ return targets
107
+
108
+
109
+ def _extract_attribute_fields(constraint: IdentityConstraint) -> set[str]:
110
+ """Extract attribute names from a constraint's field targets."""
111
+ if constraint.kind == 'keyref':
112
+ return set()
113
+ return {
114
+ f.target.value
115
+ for f in constraint.fields
116
+ if f.target.is_attribute and f.target.value
117
+ }
@@ -0,0 +1 @@
1
+ # Emitters — one per output file
@@ -0,0 +1,69 @@
1
+ """Emit constants.generated.ts — lookup tables and derived constants."""
2
+ from pathlib import Path
3
+
4
+ from generate.emitters.ts_helpers import ts_key, ts_record, ts_string_array
5
+ from generate.ir import ElementDef
6
+ def emit_constants(
7
+ elements: dict[str, ElementDef],
8
+ descendants: dict[str, list[str]],
9
+ ancestors: dict[str, list[str]],
10
+ root_element: str,
11
+ singleton_elements: list[str],
12
+ out: Path,
13
+ ) -> None:
14
+ """Write constants.generated.ts with all lookup tables."""
15
+ names = sorted(elements.keys())
16
+
17
+ lines = [
18
+ '// Auto-generated by generate.py — do not edit',
19
+ '',
20
+ "import type { AvailableElement, AttributesOf } from './types.generated'",
21
+ '',
22
+ f'export const ELEMENT_NAMES = {ts_string_array(names)} as const',
23
+ '',
24
+ f'export const CHILDREN = {ts_record(_child_map(elements))} as const',
25
+ '',
26
+ f'export const PARENTS = {ts_record(_parent_map(elements))} as const',
27
+ '',
28
+ f'export const DESCENDANTS = {ts_record(descendants)} as const',
29
+ '',
30
+ f'export const ANCESTORS = {ts_record(ancestors)} as const',
31
+ '',
32
+ f"export const ROOT_ELEMENT = '{root_element}' as const",
33
+ '',
34
+ f'export const SINGLETON_ELEMENTS = {ts_string_array(singleton_elements)} as const',
35
+ '',
36
+ f'export const ATTRIBUTES = {_attr_obj_ts(elements)} as const',
37
+ '',
38
+ f'export const REQUIRED_ATTRIBUTES = {ts_record(_required_attr_map(elements))} as const satisfies Record<AvailableElement, readonly string[]>',
39
+ '',
40
+ ]
41
+
42
+ out.write_text('\n'.join(lines), encoding='utf-8')
43
+ def _child_map(elements: dict[str, ElementDef]) -> dict[str, list[str]]:
44
+ """Element name → sorted child element names."""
45
+ return {name: list(e.child_sequence) for name, e in elements.items()}
46
+ def _parent_map(elements: dict[str, ElementDef]) -> dict[str, list[str]]:
47
+ """Element name → sorted parent element names."""
48
+ return {name: sorted(e.parents) for name, e in elements.items()}
49
+ def _attr_obj_ts(elements: dict[str, ElementDef]) -> str:
50
+ """Generate ATTRIBUTES runtime object: each element → keyed record with '' values cast via AttributesOf."""
51
+ lines = ['{']
52
+ for name in sorted(elements.keys()):
53
+ elem = elements[name]
54
+ if not elem.attr_sequence:
55
+ lines.append(f"\t{name}: {{}} as AttributesOf<'{name}'>,")
56
+ else:
57
+ lines.append(f'\t{name}: {{')
58
+ for attr_name in elem.attr_sequence:
59
+ key = ts_key(attr_name)
60
+ lines.append(f"\t\t{key}: '' as string,")
61
+ lines.append(f"\t}} as AttributesOf<'{name}'>,")
62
+ lines.append('}')
63
+ return '\n'.join(lines)
64
+ def _required_attr_map(elements: dict[str, ElementDef]) -> dict[str, list[str]]:
65
+ """Element name → required attribute names (sorted)."""
66
+ return {
67
+ name: sorted(aname for aname, adef in e.attributes.items() if adef.required)
68
+ for name, e in elements.items()
69
+ }
@@ -0,0 +1,49 @@
1
+ """Emit definition.generated.ts — the DEFINITION constant."""
2
+ from pathlib import Path
3
+
4
+ from generate.emitters.ts_helpers import (
5
+ ts_attr_block,
6
+ ts_child_block,
7
+ ts_constraints,
8
+ ts_namespace,
9
+ ts_string,
10
+ ts_text_content,
11
+ )
12
+ from generate.ir import ElementDef
13
+ def emit_definition(elements: dict[str, ElementDef], out: Path) -> None:
14
+ """Write the DEFINITION constant to definition.generated.ts.
15
+
16
+ Sparse — only non-default fields emitted per element.
17
+ """
18
+ lines = [
19
+ '// Auto-generated by generate.py — do not edit',
20
+ '',
21
+ 'export const DEFINITION = {',
22
+ ]
23
+
24
+ for name in sorted(elements):
25
+ elem = elements[name]
26
+ lines.append(f'\t{name}: {{')
27
+ lines.append(f"\t\ttag: '{name}',")
28
+ lines.append(f'\t\tnamespace: {ts_namespace(elem.namespace)},')
29
+ if elem.documentation:
30
+ lines.append(f'\t\tdocumentation: {ts_string(elem.documentation)},')
31
+ lines.append(f'\t\tparents: {_inline_string_array(elem.parents)},')
32
+ lines.append(f'\t\tattributes: {ts_attr_block(elem, "\t\t\t")},')
33
+ lines.append(f'\t\tchildren: {ts_child_block(elem, "\t\t\t")},')
34
+ if elem.constraints:
35
+ lines.append(f'\t\tconstraints: {ts_constraints(elem.constraints, "\t\t\t")},')
36
+ if elem.text_content:
37
+ lines.append(f'\t\ttextContent: {ts_text_content(elem.text_content, "\t\t\t")},')
38
+ lines.append('\t},')
39
+
40
+ lines.append('} as const')
41
+ lines.append('')
42
+
43
+ out.write_text('\n'.join(lines), encoding='utf-8')
44
+ def _inline_string_array(items: list[str]) -> str:
45
+ """Short array on one line."""
46
+ if not items:
47
+ return '[]'
48
+ inner = ', '.join(f"'{i}'" for i in items)
49
+ return f'[{inner}]'
@@ -0,0 +1,283 @@
1
+ """Shared emission helpers for converting IR to TypeScript literals."""
2
+ import json
3
+ from typing import Any
4
+
5
+ from generate.ir import (
6
+ AttributeDef,
7
+ ChildDef,
8
+ ChoiceGroup,
9
+ ElementDef,
10
+ Facets,
11
+ IdentityConstraint,
12
+ Namespace,
13
+ TextContent,
14
+ )
15
+ from generate.xpath_parser import FieldPath, FieldTarget, SelectorPath, XPathStep
16
+ def sparse(d: dict[str, Any]) -> dict[str, Any]:
17
+ """Drop keys whose values are default (None, False, [], 0, {})."""
18
+ return {
19
+ k: v
20
+ for k, v in d.items()
21
+ if v is not None and v is not False and v != [] and v != 0 and v != {}
22
+ }
23
+ def ts_string(s: str) -> str:
24
+ """Emit a TS string literal, escaping quotes and backslashes."""
25
+ return json.dumps(s, ensure_ascii=False)
26
+ def ts_string_array(items: list[str], indent: str = '') -> str:
27
+ """Emit a TS readonly string array literal."""
28
+ if not items:
29
+ return '[]'
30
+ inner = ', '.join(ts_string(i) for i in items)
31
+ return f'[{inner}]'
32
+ def ts_record(data: dict[str, list[str]], indent: str = '\t') -> str:
33
+ """Emit a TS Record<string, string[]> literal."""
34
+ if not data:
35
+ return '{}'
36
+ lines = ['{']
37
+ for key in sorted(data.keys()):
38
+ arr = ts_string_array(data[key])
39
+ lines.append(f'{indent}{ts_key(key)}: {arr},')
40
+ lines.append('}')
41
+ return '\n'.join(lines)
42
+ def ts_key(name: str) -> str:
43
+ """Quote a TS object key if it contains special characters."""
44
+ if ':' in name or '-' in name or ' ' in name or not name.isidentifier():
45
+ return ts_string(name)
46
+ return name
47
+ def ts_namespace(ns: Namespace) -> str:
48
+ """Emit a Namespace object literal."""
49
+ return f"{{ prefix: {ts_string(ns.prefix)}, uri: {ts_string(ns.uri)} }}"
50
+ def ts_facets(facets: Facets | None, indent: str = '\t\t\t') -> str:
51
+ """Emit a sparse Facets literal."""
52
+ if facets is None or facets.is_empty():
53
+ return 'undefined'
54
+
55
+ fields: dict[str, Any] = {}
56
+ if facets.enumeration is not None:
57
+ fields['enumeration'] = facets.enumeration
58
+ if facets.pattern is not None:
59
+ fields['pattern'] = facets.pattern
60
+ if facets.min_length is not None:
61
+ fields['minLength'] = facets.min_length
62
+ if facets.max_length is not None:
63
+ fields['maxLength'] = facets.max_length
64
+ if facets.length is not None:
65
+ fields['length'] = facets.length
66
+ if facets.min_inclusive is not None:
67
+ fields['minInclusive'] = facets.min_inclusive
68
+ if facets.max_inclusive is not None:
69
+ fields['maxInclusive'] = facets.max_inclusive
70
+ if facets.min_exclusive is not None:
71
+ fields['minExclusive'] = facets.min_exclusive
72
+ if facets.max_exclusive is not None:
73
+ fields['maxExclusive'] = facets.max_exclusive
74
+ if facets.total_digits is not None:
75
+ fields['totalDigits'] = facets.total_digits
76
+ if facets.fraction_digits is not None:
77
+ fields['fractionDigits'] = facets.fraction_digits
78
+ if facets.white_space is not None:
79
+ fields['whiteSpace'] = facets.white_space
80
+
81
+ if not fields:
82
+ return 'undefined'
83
+
84
+ return _emit_object(fields, indent)
85
+ def ts_attr_block(elem: ElementDef, indent: str = '\t\t') -> str:
86
+ """Emit the attributes block for an element."""
87
+ lines = ['{']
88
+ lines.append(f'{indent}sequence: {ts_string_array(elem.attr_sequence)},')
89
+ if elem.attr_any:
90
+ lines.append(f'{indent}any: true,')
91
+ lines.append(f'{indent}details: {{')
92
+ for key in elem.attr_sequence:
93
+ attr = elem.attributes[key]
94
+ lines.append(f'{indent}\t{ts_key(key)}: {_ts_attr_def(attr, indent + "\t\t")},')
95
+ lines.append(f'{indent}}},')
96
+ if elem.identity_fields:
97
+ lines.append(f'{indent}identityFields: {ts_string_array(elem.identity_fields)},')
98
+ lines.append(f'{indent[:-1]}}}')
99
+ return '\n'.join(lines)
100
+ def ts_child_block(elem: ElementDef, indent: str = '\t\t') -> str:
101
+ """Emit the children block for an element."""
102
+ lines = ['{']
103
+ lines.append(f'{indent}sequence: {ts_string_array(elem.child_sequence)},')
104
+ if elem.child_any:
105
+ lines.append(f'{indent}any: true,')
106
+ lines.append(f'{indent}details: {{')
107
+ for key in elem.child_sequence:
108
+ child = elem.children[key]
109
+ lines.append(f'{indent}\t{ts_key(key)}: {_ts_child_def(child, indent + "\t\t")},')
110
+ lines.append(f'{indent}}},')
111
+ if elem.choices:
112
+ lines.append(f'{indent}choices: {_ts_choices(elem.choices, indent + "\t")},')
113
+ lines.append(f'{indent[:-1]}}}')
114
+ return '\n'.join(lines)
115
+ def ts_constraints(constraints: list[IdentityConstraint], indent: str = '\t\t') -> str:
116
+ """Emit an array of constraints."""
117
+ if not constraints:
118
+ return '[]'
119
+ lines = ['[']
120
+ for c in constraints:
121
+ lines.append(f'{indent}{_ts_constraint(c, indent + "\t")},')
122
+ lines.append(f'{indent[:-1]}]')
123
+ return '\n'.join(lines)
124
+ def ts_text_content(tc: TextContent, indent: str = '\t\t') -> str:
125
+ """Emit a TextContent literal."""
126
+ f = ts_facets(tc.facets, indent + '\t')
127
+ if f == 'undefined':
128
+ return '{}'
129
+ return f'{{ facets: {f} }}'
130
+ # --- Private helpers ---
131
+ def _ts_attr_def(attr: AttributeDef, indent: str) -> str:
132
+ """Emit a sparse AttributeDefinition literal."""
133
+ fields: dict[str, Any] = {}
134
+ if attr.required:
135
+ fields['required'] = True
136
+ if attr.default is not None:
137
+ fields['default'] = attr.default
138
+ if attr.fixed is not None:
139
+ fields['fixed'] = attr.fixed
140
+ if attr.namespace is not None:
141
+ fields['namespace'] = attr.namespace
142
+ if attr.facets is not None and not attr.facets.is_empty():
143
+ fields['facets'] = attr.facets
144
+
145
+ if not fields:
146
+ return '{}'
147
+ return _emit_object(fields, indent)
148
+ def _ts_child_def(child: ChildDef, indent: str) -> str:
149
+ """Emit a sparse ChildDefinition literal."""
150
+ fields: dict[str, Any] = {}
151
+ if child.required:
152
+ fields['required'] = True
153
+ if child.min_occurs != 0:
154
+ fields['minOccurs'] = child.min_occurs
155
+ if child.max_occurs is not None:
156
+ fields['maxOccurs'] = child.max_occurs
157
+ if child.constraints:
158
+ fields['constraints'] = child.constraints
159
+
160
+ if not fields:
161
+ return '{}'
162
+ return _emit_object(fields, indent)
163
+ def _ts_constraint(c: IdentityConstraint, indent: str) -> str:
164
+ """Emit a single IdentityConstraint literal."""
165
+ fields: dict[str, Any] = {'kind': c.kind, 'name': c.name}
166
+ if c.refer:
167
+ fields['refer'] = c.refer
168
+ if c.deep:
169
+ fields['deep'] = True
170
+ # Emit as raw TS — handled below for structured types
171
+ parts: list[str] = []
172
+ for key, value in fields.items():
173
+ parts.append(f'{ts_key(key)}: {_emit_value(value, indent)}')
174
+ parts.append(f'selector: {_ts_selector_paths(c.selector)}')
175
+ parts.append(f'fields: {_ts_field_paths(c.fields)}')
176
+ inner = ', '.join(parts)
177
+ return '{ ' + inner + ' }'
178
+
179
+
180
+ def _ts_selector_paths(paths: list[SelectorPath]) -> str:
181
+ """Emit selector paths array."""
182
+ if not paths:
183
+ return '[]'
184
+ items = [_ts_selector_path(p) for p in paths]
185
+ return '[' + ', '.join(items) + ']'
186
+
187
+
188
+ def _ts_selector_path(p: SelectorPath) -> str:
189
+ """Emit a single SelectorPath literal."""
190
+ parts: list[str] = []
191
+ if p.deep:
192
+ parts.append('deep: true')
193
+ parts.append(f'steps: {_ts_xpath_steps(p.steps)}')
194
+ return '{ ' + ', '.join(parts) + ' }'
195
+
196
+
197
+ def _ts_field_paths(fields: list[FieldPath]) -> str:
198
+ """Emit field paths array."""
199
+ if not fields:
200
+ return '[]'
201
+ items = [_ts_field_path(f) for f in fields]
202
+ return '[' + ', '.join(items) + ']'
203
+
204
+
205
+ def _ts_field_path(f: FieldPath) -> str:
206
+ """Emit a single FieldPath literal."""
207
+ parts: list[str] = []
208
+ if f.deep:
209
+ parts.append('deep: true')
210
+ if f.steps:
211
+ parts.append(f'steps: {_ts_xpath_steps(f.steps)}')
212
+ parts.append(f'target: {_ts_field_target(f.target)}')
213
+ return '{ ' + ', '.join(parts) + ' }'
214
+
215
+
216
+ def _ts_xpath_steps(steps: tuple[XPathStep, ...]) -> str:
217
+ """Emit an array of XPathStep literals."""
218
+ if not steps:
219
+ return '[]'
220
+ items = [_ts_xpath_step(s) for s in steps]
221
+ return '[' + ', '.join(items) + ']'
222
+
223
+
224
+ def _ts_xpath_step(s: XPathStep) -> str:
225
+ """Emit a single XPathStep."""
226
+ if s.value is not None:
227
+ return '{ ' + f"kind: '{s.kind}', value: {ts_string(s.value)}" + ' }'
228
+ return '{ ' + f"kind: '{s.kind}'" + ' }'
229
+
230
+
231
+ def _ts_field_target(t: FieldTarget) -> str:
232
+ """Emit a FieldTarget literal."""
233
+ parts = [f"kind: '{t.kind}'"]
234
+ if t.value is not None:
235
+ parts.append(f'value: {ts_string(t.value)}')
236
+ if t.is_attribute:
237
+ parts.append('isAttribute: true')
238
+ return '{ ' + ', '.join(parts) + ' }'
239
+ def _ts_choices(choices: list[ChoiceGroup], indent: str) -> str:
240
+ """Emit ChoiceGroup array."""
241
+ lines = ['[']
242
+ for cg in choices:
243
+ fields: dict[str, Any] = {'options': cg.options}
244
+ if cg.min_occurs != 0:
245
+ fields['minOccurs'] = cg.min_occurs
246
+ if cg.max_occurs is not None:
247
+ fields['maxOccurs'] = cg.max_occurs
248
+ lines.append(f'{indent}{_emit_object(fields, indent + "\t")},')
249
+ lines.append(f'{indent[:-1]}]')
250
+ return '\n'.join(lines)
251
+ def _emit_object(fields: dict[str, Any], indent: str) -> str:
252
+ """Emit a TS object literal from a dict, handling typed values."""
253
+ if not fields:
254
+ return '{}'
255
+
256
+ parts: list[str] = []
257
+ for key, value in fields.items():
258
+ ts_val = _emit_value(value, indent)
259
+ parts.append(f'{ts_key(key)}: {ts_val}')
260
+
261
+ if len(parts) == 1:
262
+ return '{ ' + parts[0] + ' }'
263
+ inner = ', '.join(parts)
264
+ return '{ ' + inner + ' }'
265
+ def _emit_value(value: Any, indent: str) -> str:
266
+ """Emit a TS value from a Python value."""
267
+ if isinstance(value, bool):
268
+ return 'true' if value else 'false'
269
+ if isinstance(value, int):
270
+ return str(value)
271
+ if isinstance(value, str):
272
+ return ts_string(value)
273
+ if isinstance(value, list):
274
+ if all(isinstance(v, str) for v in value):
275
+ return ts_string_array(value)
276
+ if all(isinstance(v, IdentityConstraint) for v in value):
277
+ return ts_constraints(value, indent)
278
+ return ts_string_array([str(v) for v in value])
279
+ if isinstance(value, Namespace):
280
+ return ts_namespace(value)
281
+ if isinstance(value, Facets):
282
+ return ts_facets(value, indent)
283
+ return str(value)
@@ -0,0 +1,67 @@
1
+ """Emit types.generated.ts — per-element attribute type interfaces."""
2
+ from pathlib import Path
3
+
4
+ from generate.emitters.ts_helpers import ts_key
5
+ from generate.ir import AttributeDef, ElementDef
6
+ def emit_types(elements: dict[str, ElementDef], out: Path) -> None:
7
+ """Write types.generated.ts with attribute type interfaces.
8
+
9
+ Namespace-qualified keys (e.g. 'eIEC61850-6-100:version') are quoted in the TS interface.
10
+ Enumeration facets generate union types.
11
+ """
12
+ lines = [
13
+ '// Auto-generated by generate.py — do not edit',
14
+ '',
15
+ "import type { ELEMENT_NAMES, REQUIRED_ATTRIBUTES } from './constants.generated'",
16
+ '',
17
+ 'export type AvailableElement = (typeof ELEMENT_NAMES)[number]',
18
+ '',
19
+ ]
20
+
21
+ sorted_names = sorted(elements)
22
+
23
+ for name in sorted_names:
24
+ elem = elements[name]
25
+ lines.append(f'export type Attributes{name} = {{')
26
+ for attr_name in elem.attr_sequence:
27
+ attr = elem.attributes[attr_name]
28
+ ts_type = _attr_to_ts_type(attr)
29
+ optional = '?' if not attr.required else ''
30
+ key = ts_key(attr_name)
31
+ lines.append(f'\t{key}{optional}: {ts_type}')
32
+ lines.append('}')
33
+ lines.append('')
34
+
35
+ lines.append('export type AttributesMap = {')
36
+ for name in sorted_names:
37
+ lines.append(f'\t{name}: Attributes{name}')
38
+ lines.append('}')
39
+ lines.append('')
40
+ lines.append('export type AttributesOf<T extends AvailableElement> = AttributesMap[T]')
41
+ lines.append('')
42
+ lines.append('export type RequiredAttributeNames<T extends AvailableElement> =')
43
+ lines.append('\t(typeof REQUIRED_ATTRIBUTES)[T][number]')
44
+ lines.append('export type OptionalAttributeNames<T extends AvailableElement> = Exclude<')
45
+ lines.append('\tkeyof AttributesOf<T>,')
46
+ lines.append('\tRequiredAttributeNames<T>')
47
+ lines.append('>')
48
+ lines.append('')
49
+
50
+ out.write_text('\n'.join(lines), encoding='utf-8')
51
+ def _attr_to_ts_type(attr: AttributeDef) -> str:
52
+ """Generate a TS type annotation from an attribute definition.
53
+
54
+ - Enumerations → union of literal strings + (string & {}) for extensibility
55
+ - Fixed values → literal string type
56
+ - Default → string
57
+ """
58
+ facets = attr.facets
59
+
60
+ if facets and facets.enumeration:
61
+ literals = ' | '.join(f"'{v}'" for v in facets.enumeration)
62
+ return f'{literals} | (string & {{}})'
63
+
64
+ if attr.fixed:
65
+ return f"'{attr.fixed}'"
66
+
67
+ return 'string'
@@ -0,0 +1 @@
1
+ # Extractors — one per XSD concern
@@ -0,0 +1,83 @@
1
+ """Extract attributes from an XSD element."""
2
+ from typing import Any
3
+
4
+ from generate.extractors.facets import extract_facets
5
+ from generate.extractors.namespace import extract_attr_namespace
6
+ from generate.helpers import local_name
7
+ from generate.ir import AttributeDef, Namespace
8
+
9
+ # W3C XML namespace attrs (xml:base, xml:lang, xml:space, xml:id, etc.) are
10
+ # implicitly valid on every XML element and have no meaning in IEC schemas.
11
+ _XML_NS_URI = 'http://www.w3.org/XML/1998/namespace'
12
+
13
+
14
+ def extract_attributes(xsd_elem: Any) -> tuple[list[str], bool, dict[str, AttributeDef]]:
15
+ """Extract attributes from an XSD element.
16
+
17
+ Uses qualify-on-collision keying: bare local name by default; a namespace-prefixed
18
+ key (``prefix:local``) only when two or more attributes on the same element share
19
+ the same local name (e.g. SCL ``version`` and 6-100 ``version``).
20
+
21
+ Returns:
22
+ (attr_sequence, has_any_attribute, attribute_details)
23
+
24
+ xmlschema API:
25
+ XsdElement.attributes: XsdAttributeGroup (dict-like, name → XsdAttribute)
26
+ XsdAttribute.use: str ('optional' | 'required' | 'prohibited')
27
+ XsdAttribute.default: str | None
28
+ XsdAttribute.fixed: str | None
29
+ XsdAttribute.type: XsdSimpleType
30
+ XsdAttributeGroup[attr_name]: XsdAttribute
31
+
32
+ Wildcards:
33
+ XsdElement.attributes.wildcard → XsdAnyAttribute | None (xs:anyAttribute)
34
+ """
35
+ sequence: list[str] = []
36
+ details: dict[str, AttributeDef] = {}
37
+ any_attr = False
38
+
39
+ attributes = getattr(xsd_elem, 'attributes', None)
40
+ if attributes is None:
41
+ return sequence, any_attr, details
42
+
43
+ # Pass 1: collect raw (local_name, namespace, xsd_attr) for collision detection
44
+ raw: list[tuple[str, Namespace | None, Any]] = []
45
+ for attr_name, xsd_attr in attributes.items():
46
+ if attr_name is None:
47
+ continue
48
+ ns = extract_attr_namespace(xsd_attr)
49
+ if ns and ns.uri == _XML_NS_URI:
50
+ continue # skip W3C XML namespace attrs (xml:base, xml:lang, xml:space, xml:id)
51
+ raw.append((local_name(attr_name), ns, xsd_attr))
52
+
53
+ # Detect local-name collisions
54
+ local_count: dict[str, int] = {}
55
+ for ln, _, _ in raw:
56
+ local_count[ln] = local_count.get(ln, 0) + 1
57
+ collision_locals = {ln for ln, cnt in local_count.items() if cnt > 1}
58
+
59
+ # Pass 2: build keys and AttributeDefs
60
+ for ln, ns, xsd_attr in raw:
61
+ key = f'{ns.prefix}:{ln}' if (ln in collision_locals and ns and ns.prefix) else ln
62
+
63
+ fixed = getattr(xsd_attr, 'fixed', None)
64
+ default = getattr(xsd_attr, 'default', None) if fixed is None else None
65
+ use = getattr(xsd_attr, 'use', 'optional')
66
+ attr_type = getattr(xsd_attr, 'type', None)
67
+
68
+ details[key] = AttributeDef(
69
+ required=use == 'required',
70
+ default=default,
71
+ fixed=fixed,
72
+ namespace=ns,
73
+ facets=extract_facets(attr_type),
74
+ )
75
+ sequence.append(key)
76
+
77
+ sequence.sort()
78
+
79
+ # Check xs:anyAttribute
80
+ wildcard = getattr(attributes, 'wildcard', None)
81
+ any_attr = wildcard is not None
82
+
83
+ return sequence, any_attr, details