From 219f1250e4e292f210fcd9e4f49e2c6f2b44d9b4 Mon Sep 17 00:00:00 2001 From: "Gregory S. Tucker" Date: Sun, 18 Oct 2026 18:07:49 +0000 Subject: [PATCH] Write component definitions that read back as written (#391) `mccode-antlr extract` reconstitutes components with Comp.to_string, which wrote text the parser rejected or read differently: - DEPENDENCY came straight after DEFINE COMPONENT, but the grammar wants it after the parameters and metadata, so any component with one failed to parse (22 of the McStas components). - NOACC was never written. - `vector`, `double *` and `int *` parameters lost their type and default, initializer lists their braces; an archaic `symbol` came back as a string with an unquoted default. - Every block, and every METADATA, gained a blank line each time it was written and read, in instruments and instances (EXTEND) too, since they share the text wrapper. Every McStas and McXtrace component the reader accepts now reads back with the same parameters, metadata, flags and blocks, and writes identically a second time. Classic mcstas translates extracted bundles to the same C, apart from number formatting and a string default of 0 kept as NULL. --- src/mccode_antlr/common/textwrap.py | 18 ++++++- src/mccode_antlr/comp/comp.py | 74 ++++++++++++++-------------- tests/test_comp_to_file.py | 75 +++++++++++++++++++++++++++++ tests/test_instr_to_file.py | 23 +++++++++ 4 files changed, 149 insertions(+), 41 deletions(-) create mode 100644 tests/test_comp_to_file.py diff --git a/src/mccode_antlr/common/textwrap.py b/src/mccode_antlr/common/textwrap.py index e905fc15..204fdd55 100644 --- a/src/mccode_antlr/common/textwrap.py +++ b/src/mccode_antlr/common/textwrap.py @@ -110,6 +110,20 @@ def hide(content: str) -> str: raise NotImplementedError +def _own_lines(content: str) -> str: + """Put block content between its `%{` and `%}` lines. + + Reading a block keeps everything between the delimiters, so content that was read + keeps its newlines as they were, and writing must add only what is missing: adding + more grows every block each time it is written and read again. + """ + if not content.startswith('\n'): + content = '\n' + content + if not content.endswith('\n'): + content += '\n' + return content + + class TextWrapper(BaseWrapper): def wrap(self, text: str, pad: str = None) -> list[str]: @@ -132,7 +146,7 @@ def end_block_comment() -> str: return '*/' def block(self, name: str, content: str) -> str: - return '\n'.join([name + '\n%{\n', content + '\n%}\n']) + return f'{name}\n%{{{_own_lines(content)}%}}\n' def line(self, name: str, items: list[str], sep: str = ' ') -> str: items = [item or 'None' for item in items] @@ -147,7 +161,7 @@ def lines(self, items: list[str]) -> str: @staticmethod def metadata_group(name: str, mimetype: str, item: str, value: str) -> str: - return f'{name} "{mimetype}" {item} \n%{{\n{value}\n%}}\n' + return f'{name} "{mimetype}" {item}\n%{{{_own_lines(value)}%}}\n' @staticmethod def datatype(data_type: str) -> str: diff --git a/src/mccode_antlr/comp/comp.py b/src/mccode_antlr/comp/comp.py index 11d9aaa1..2291dd4a 100644 --- a/src/mccode_antlr/comp/comp.py +++ b/src/mccode_antlr/comp/comp.py @@ -165,33 +165,34 @@ def to_file(self, output=None, wrapper=None): def _join(raw_tuple): return '\n'.join(str(rc) for rc in raw_tuple) - # Build the parameter type prefix for define/setting/output parameters - def _param_type(p): - from mccode_antlr.common.expression import DataType, ShapeType + def _param_str(p): + # The type and default exactly as a parameter list spells them, so that + # reading the text back gives the same parameter + from mccode_antlr.common.expression import DataType, ObjectType + from mccode_antlr.common.expression.sympy_classes import UNSET_SYMPY v = p.value if v.is_str: - return 'string ' - if v.is_vector and getattr(v, 'data_type', None) == DataType.float and \ - getattr(v, '_shape', None) is not None: - return 'vector ' - if hasattr(v, 'data_type') and v.data_type == DataType.int: - return 'int ' - return '' + # an archaic `symbol` is read as a string with an identifier default + default = str(v) + s = 'string ' if default in ('None', 'NULL') or default.startswith('"') else 'symbol ' + elif v.is_vector: + s = 'int *' if v.data_type == DataType.int else 'vector ' + elif v.data_type == DataType.int: + s = 'int ' + else: + s = '' + s += p.name + if not (v.is_singular and v._exprs[0] is UNSET_SYMPY): + value = str(v) + if v.object_type == ObjectType.initializer_list: + value = '{' + value + '}' + s += '=' + value + return s def _params_str(params): - parts = [] - for p in params: - s = _param_type(p) + p.name - if p.value.has_value: - s += '=' + str(p.value) - parts.append(s) - return ', '.join(parts) - - name_line = f'DEFINE COMPONENT {self.name}' - if self.dependency: - name_line += f'\nDEPENDENCY "{self.dependency}"' - print(name_line, file=output) + return ', '.join(_param_str(p) for p in params) + print(f'DEFINE COMPONENT {self.name}', file=output) if self.define: print(f'DEFINITION PARAMETERS ({_params_str(self.define)})', file=output) if self.setting: @@ -199,25 +200,20 @@ def _params_str(params): if self.output: print(f'OUTPUT PARAMETERS ({_params_str(self.output)})', file=output) + # The grammar fixes the order: metadata, then dependency, then NOACC for metadata in self.metadata: metadata.to_file(output=output, wrapper=wrapper) - - if self.share: - print(wrapper.block('SHARE', _join(self.share)), file=output) - if self.user: - print(wrapper.block('USERVARS', _join(self.user)), file=output) - if self.declare: - print(wrapper.block('DECLARE', _join(self.declare)), file=output) - if self.initialize: - print(wrapper.block('INITIALIZE', _join(self.initialize)), file=output) - if self.trace: - print(wrapper.block('TRACE', _join(self.trace)), file=output) - if self.save: - print(wrapper.block('SAVE', _join(self.save)), file=output) - if self.final: - print(wrapper.block('FINALLY', _join(self.final)), file=output) - if self.display: - print(wrapper.block('MCDISPLAY', _join(self.display)), file=output) + if self.dependency: + print(f'DEPENDENCY "{self.dependency}"', file=output) + if not self.acc: + print('NOACC', file=output) + + for name, raw in (('SHARE', self.share), ('USERVARS', self.user), + ('DECLARE', self.declare), ('INITIALIZE', self.initialize), + ('TRACE', self.trace), ('SAVE', self.save), + ('FINALLY', self.final), ('MCDISPLAY', self.display)): + if raw: + print(wrapper.block(name, _join(raw)), file=output) print('END', file=output) diff --git a/tests/test_comp_to_file.py b/tests/test_comp_to_file.py new file mode 100644 index 00000000..0d76a25e --- /dev/null +++ b/tests/test_comp_to_file.py @@ -0,0 +1,75 @@ +"""Comp.to_string writes a definition that reads back as the same component (#391).""" +from mccode_antlr.comp import Comp +from mccode_antlr.common import DataType + +SOURCE = '''DEFINE COMPONENT Everything +DEFINITION PARAMETERS (int n=3) +SETTING PARAMETERS (a, b=1e-4, int c=2, string s="x", string none=0, char *cs=NULL, + vector v=NULL, vector w={1, 2, 3}, vector u=nm, double *d, int *i={1, 2}, int *j, + symbol big=FLT_MAX) +OUTPUT PARAMETERS (o) +METADATA "text/plain" note %{ +a note +%} +DEPENDENCY "-lm" +NOACC +SHARE %{ +double helper(double x) { return x; } +%} +USERVARS %{ int seen; %} +DECLARE %{ +double total; +%} +INITIALIZE %{ +total = 0; +%} +TRACE %{ +total += helper(a); +%} +SAVE %{ +%} +FINALLY %{ +total = 0; +%} +MCDISPLAY %{ +line(0,0,0, 1,0,0); +%} +END +''' + + +def _read(source): + import warnings + with warnings.catch_warnings(): + warnings.simplefilter('ignore') # `symbol` is archaic, and says so + return Comp.from_source(None, None, source) + + +def _parameters(comp): + return [(p.name, str(p.value), p.value.data_type, p.value.shape_type, p.value.object_type) + for p in comp.define + comp.setting + comp.output] + + +def test_a_written_component_reads_back_the_same(): + original = _read(SOURCE) + written = original.to_string() + back = _read(written) + assert _parameters(back) == _parameters(original) + assert back.dependency == original.dependency == '-lm' + assert back.acc is original.acc is False + assert [(m.name, m.mimetype, m.value) for m in back.metadata] == \ + [(m.name, m.mimetype, m.value) for m in original.metadata] + for block in ('share', 'user', 'declare', 'initialize', 'trace', 'save', 'final', 'display'): + # an inline `%{ ... %}` gains its own lines once, and keeps them thereafter + assert [str(r).strip() for r in getattr(back, block)] == \ + [str(r).strip() for r in getattr(original, block)] + assert back.to_string() == written + + +def test_vector_and_array_parameters_keep_their_type_and_default(): + written = _read(SOURCE).to_string() + for text in ('vector v=NULL', 'vector w={1,2,3}', 'vector u=nm', 'vector d,', + 'int *i={1,2}', 'int *j,', 'symbol big=FLT_MAX'): + assert text in written + i = next(p for p in _read(written).setting if p.name == 'i') + assert i.value.data_type == DataType.int and i.value.is_vector diff --git a/tests/test_instr_to_file.py b/tests/test_instr_to_file.py index 8d17aca0..a02fbd11 100644 --- a/tests/test_instr_to_file.py +++ b/tests/test_instr_to_file.py @@ -22,3 +22,26 @@ def test_to_file_with_a_file_name_or_path(tmp_path): instr.to_file(tmp_path / 'b.instr') assert (tmp_path / 'a.instr').read_text(encoding='utf-8') == expected assert (tmp_path / 'b.instr').read_text(encoding='utf-8') == expected + + +def test_written_blocks_do_not_grow_when_read_back(): + """Writing added a blank line inside every block, so each read and write grew it.""" + instr = parse_mcstas_instr(dedent("""DEFINE INSTRUMENT blocks(a=1) + METADATA "text/plain" note %{ + instrument note + %} + DECLARE %{ + double x; + %} + TRACE + COMPONENT origin = Arm() AT (0, 0, 0) ABSOLUTE + EXTEND %{ + x += a; + %} + METADATA "text/plain" inote %{ + instance note + %} + END + """)) + once = instr.to_string(None) + assert parse_mcstas_instr(once).to_string(None) == once