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