diff --git a/blark/iec.lark b/blark/iec.lark index a6a771b..1663059 100644 --- a/blark/iec.lark +++ b/blark/iec.lark @@ -33,7 +33,9 @@ _library_element_declaration: data_type_declaration | ";" // B.1.1 -IDENTIFIER: /[A-Za-z_][A-Za-z0-9_]*/i +// Identifiers need to ignore certain keywords, but NOT typenames, as those may be used +// 'as values' with SIZEOF for example +IDENTIFIER: /\b(?!(ABSTRACT|ACTION|AND|AND_THEN|AT|BY|CASE|CONTINUE|DO|ELSE|ELSIF|END_ACTION|END_CASE|END_FOR|END_FUNCTION|END_FUNCTIONBLOCK|END_FUNCTION_BLOCK|END_IF|END_INTERFACE|END_METHOD|END_PROGRAM|END_PROPERTY|END_REPEAT|END_STRUCT|END_TYPE|END_UNION|END_VAR|END_WHILE|EXIT|EXTENDS|FINAL|FOR|FUNCTION|FUNCTIONBLOCK|FUNCTION_BLOCK|IF|IMPLEMENTS|INTERFACE|INTERNAL|JMP|METHOD|MOD|NOT|OF|OR|OR_ELSE|PERSISTENT|PRIVATE|PROGRAM|PROPERTY|PROTECTED|PUBLIC|READ_ONLY|READ_WRITE|REFERENCE|REPEAT|RETURN|STRUCT|THEN|TO|TYPE|UNION|UNTIL|VAR|VAR_ACCESS|VAR_EXTERNAL|VAR_GLOBAL|VAR_INPUT|VAR_INST|VAR_IN_OUT|VAR_OUTPUT|VAR_STAT|VAR_TEMP|WHILE|XOR)\b)[A-Za-z_][A-Za-z0-9_]*\b/i // B.1.2 constant.1: time_literal @@ -346,7 +348,7 @@ _array_initial_element: expression | enumerated_value | array_initialization -structure_type_declaration: structure_type_name_declaration [ extends ] ":" [ indirection_type ] _STRUCT ( structure_element_declaration ";"+ )* _END_STRUCT +structure_type_declaration: structure_type_name_declaration [ extends ] ":" [ indirection_type ] _STRUCT ";"* ( structure_element_declaration ";"+ )* _END_STRUCT initialized_structure: structure_type_name ":=" structure_initialization @@ -445,7 +447,7 @@ fb_decl: fb_decl_name_list ":" function_block_type_name [ ":=" structure_initial fb_decl_name_list: fb_name ( "," fb_name )* -var_body: ( var_init_decl ";"+ )* +var_body: ";"* ( var_init_decl ";"+ )* array_var_declaration: var1_list ":" array_specification @@ -562,7 +564,7 @@ ACCESS_SPECIFIER: _ABSTRACT | _INTERNAL | _FINAL access_specifier: ACCESS_SPECIFIER+ -extends: _EXTENDS DOTTED_IDENTIFIER +extends: _EXTENDS DOTTED_IDENTIFIER ("," DOTTED_IDENTIFIER)* implements: _IMPLEMENTS DOTTED_IDENTIFIER ("," DOTTED_IDENTIFIER)* function_block_type_declaration: FUNCTION_BLOCK [ access_specifier ] derived_function_block_name [ extends ] [ implements ] fb_var_declaration* [ function_block_body ] END_FUNCTION_BLOCK ";"* @@ -578,6 +580,7 @@ END_FUNCTION_BLOCK: _END_FUNCTION_BLOCK | input_output_declarations | external_var_declarations | var_declarations + | var_inst_declaration | temp_var_decls | static_var_declarations | incomplete_located_var_declarations @@ -600,7 +603,7 @@ function_block_method_declaration: _METHOD [ access_specifier ] DOTTED_IDENTIFIE ?property_return_type: _located_var_spec_init -function_block_property_declaration: _PROPERTY [ access_specifier ] DOTTED_IDENTIFIER [ ":" property_return_type ] ";"* property_var_declaration* [ function_block_body ] _END_PROPERTY ";"* +function_block_property_declaration: _PROPERTY [ access_specifier ] DOTTED_IDENTIFIER [ ":" property_return_type ] [ access_specifier ] ";"* property_var_declaration* [ function_block_body ] _END_PROPERTY ";"* // B.1.5.3 ?program_type_name: IDENTIFIER @@ -638,7 +641,7 @@ program_access_decl: access_name ":" symbolic_variable ":" non_generic_type_name | external_var_declarations | var_declarations -interface_declaration: _INTERFACE IDENTIFIER [ extends ] interface_var_declaration* _END_INTERFACE ";"* +interface_declaration: _INTERFACE IDENTIFIER [ extends ] interface_var_declaration* _END_INTERFACE? ";"* // B.2.1, B.3.1 LOGICAL_OR: _OR diff --git a/blark/plain.py b/blark/plain.py index 0a14e09..911e587 100644 --- a/blark/plain.py +++ b/blark/plain.py @@ -8,7 +8,7 @@ from .input import (BlarkCompositeSourceItem, BlarkSourceItem, register_input_handler) from .output import OutputBlock, register_output_handler -from .util import AnyPath, SourceType, find_pou_type_and_identifier +from .util import AnyPath, SourceType, find_pou_type_and_identifier_plain @dataclasses.dataclass @@ -40,7 +40,7 @@ def load( with open(filename, "rt") as fp: contents = fp.read() - source_type, identifier = find_pou_type_and_identifier(contents) + source_type, identifier = find_pou_type_and_identifier_plain(contents) # if source_type is None: # return [] source_type = SourceType.general diff --git a/blark/solution.py b/blark/solution.py index 6921938..eb99256 100644 --- a/blark/solution.py +++ b/blark/solution.py @@ -351,9 +351,7 @@ def from_xml( ) -> Self: declaration = get_child_located_text(xml, "Declaration", filename=filename) if declaration is not None: - source_type, identifier = util.find_pou_type_and_identifier( - declaration.value - ) + source_type, identifier = util.find_pou_type_and_identifier_xml(xml) else: source_type, identifier = None, None @@ -366,7 +364,7 @@ def from_xml( implementation=get_child_located_text( xml, "Implementation/ST", filename=filename ), - metadata=xml.attrib, + metadata=dict(xml.attrib), source_type=source_type, filename=filename, ) @@ -948,7 +946,11 @@ def to_blark(self) -> list[Union[BlarkCompositeSourceItem, BlarkSourceItem]]: property_ident = Identifier.from_string(base_decl.identifier) parts = [] - for get_or_set, obj in (("get", self.get), ("set", self.set)): + get_set = [ + ("get", SourceType.property_get, self.get), + ("set", SourceType.property_set, self.set), + ] + for get_or_set, source_type, obj in get_set: if obj is None: continue @@ -961,7 +963,7 @@ def to_blark(self) -> list[Union[BlarkCompositeSourceItem, BlarkSourceItem]]: parts=[*property_ident.parts, get_or_set], decl_impl="declaration", ).to_string(), - type=SourceType.property, + type=source_type, lines=base_decl.lines + decl.lines, grammar_rule=SourceType.property.get_grammar_rule(), implicit_end="END_PROPERTY", @@ -977,7 +979,7 @@ def to_blark(self) -> list[Union[BlarkCompositeSourceItem, BlarkSourceItem]]: parts=[*property_ident.parts, get_or_set], decl_impl="implementation", ).to_string(), - type=SourceType.property, + type=source_type, lines=impl.lines, grammar_rule=SourceType.statement_list.get_grammar_rule(), implicit_end="", @@ -1020,7 +1022,7 @@ def from_xml( xml: lxml.etree.Element, parent: TcSource, ) -> Self: - return cls(metadata=xml.attrib, xml=xml, parent=parent) + return cls(metadata=dict(xml.attrib), xml=xml, parent=parent) @dataclasses.dataclass diff --git a/blark/summary.py b/blark/summary.py index b66dace..f947efd 100644 --- a/blark/summary.py +++ b/blark/summary.py @@ -12,7 +12,7 @@ from . import transform as tf from .parse import ParseResult from .typing import Literal -from .util import Identifier, SourceType +from .util import Identifier, SourceType, get_case_insensitive LocationType = Literal["input", "output", "memory"] @@ -166,7 +166,13 @@ def from_declaration( cls, item: Union[tf.InitDeclaration, tf.StructureElementDeclaration, tf.UnionElementDeclaration], parent: Optional[ - Union[tf.Function, tf.Method, tf.FunctionBlock, tf.StructureTypeDeclaration] + Union[ + tf.Function, + tf.Method, + tf.FunctionBlock, + tf.Property, + tf.StructureTypeDeclaration, + ] ] = None, block_header: str = "unknown", filename: Optional[pathlib.Path] = None, @@ -188,7 +194,7 @@ def from_declaration( **Summary.get_meta_kwargs(item.meta), ) elif isinstance(item, tf.UnionElementDeclaration): - result[item.name] = DeclarationSummary( + result[str(item.name)] = DeclarationSummary( name=str(item.name), item=item, location=None, @@ -239,7 +245,7 @@ def from_declaration( init = str(getattr(item.spec, "init", None)) type_ = getattr(init, "full_type_name", str(item.spec)) base_type = getattr(init, "base_type_name", str(item.spec)) - result[item.name] = DeclarationSummary( + result[str(item.name)] = DeclarationSummary( name=str(item.name), item=item, location=location, @@ -287,7 +293,7 @@ def from_global_variable( def from_block( cls, block: tf.VariableDeclarationBlock, - parent: Union[tf.Function, tf.Method, tf.FunctionBlock], + parent: Union[tf.Function, tf.Method, tf.FunctionBlock, tf.Property], filename: Optional[pathlib.Path] = None, ) -> Dict[str, DeclarationSummary]: result = {} @@ -310,6 +316,7 @@ class ActionSummary(Summary): item: tf.Action source_code: str implementation: Optional[tf.StatementList] = None + implementation_source: Optional[str] = None def __getitem__(self, key: str) -> None: raise KeyError(f"{key}: Actions do not contain declarations") @@ -365,10 +372,11 @@ class MethodSummary(Summary): return_type: Optional[str] source_code: str implementation: Optional[tf.StatementList] = None + implementation_source: Optional[str] = None declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) def __getitem__(self, key: str) -> DeclarationSummary: - return self.declarations[key] + return get_case_insensitive(self.declarations, key) @property def declarations_by_block(self) -> Dict[str, Dict[str, DeclarationSummary]]: @@ -410,9 +418,10 @@ class PropertyGetSetSummary(Summary): source_code: str declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) implementation: Optional[tf.StatementList] = None + implementation_source: Optional[str] = None def __getitem__(self, key: str) -> DeclarationSummary: - return self.declarations[key] + return get_case_insensitive(self.declarations, key) @dataclass @@ -442,7 +451,7 @@ def from_property( source_code = str(property) # TODO: this is broken at the moment - return PropertySummary( + summary = PropertySummary( name=str(property.name), getter=PropertyGetSetSummary( name=str(property.name), @@ -463,6 +472,13 @@ def from_property( **Summary.get_meta_kwargs(property.meta), ) + for decl in property.declarations: + decl_summary = DeclarationSummary.from_block(decl, parent=property, filename=filename) + summary.getter.declarations.update(decl_summary) + summary.setter.declarations.update(decl_summary) + + return summary + @dataclass class FunctionSummary(Summary): @@ -472,10 +488,11 @@ class FunctionSummary(Summary): return_type: Optional[str] source_code: str implementation: Optional[tf.StatementList] = None + implementation_source: Optional[str] = None declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) def __getitem__(self, key: str) -> DeclarationSummary: - return self.declarations[key] + return get_case_insensitive(self.declarations, key) @property def declarations_by_block(self) -> Dict[str, Dict[str, DeclarationSummary]]: @@ -517,9 +534,10 @@ class FunctionBlockSummary(Summary): name: str source_code: str item: tf.FunctionBlock - extends: Optional[str] + extends: Optional[list[str]] squashed: bool implementation: Optional[tf.StatementList] = None + implementation_source: Optional[str] = None declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) actions: List[ActionSummary] = field(default_factory=list) methods: List[MethodSummary] = field(default_factory=list) @@ -528,10 +546,11 @@ class FunctionBlockSummary(Summary): def __getitem__( self, key: str ) -> Union[DeclarationSummary, MethodSummary, PropertySummary, ActionSummary]: - if key in self.declarations: - return self.declarations[key] + decl = get_case_insensitive(self.declarations, key) + if decl is not None: + return decl for item in self.actions + self.methods + self.properties: - if item.name == key: + if item.name.lower() == key.lower(): return item raise KeyError(key) @@ -557,7 +576,7 @@ def from_function_block( item=fb, source_code=source_code, filename=filename, - extends=fb.extends.name if fb.extends else None, + extends=fb.extends.interfaces if fb.extends else None, squashed=False, **Summary.get_meta_kwargs(fb.meta), ) @@ -573,28 +592,37 @@ def squash_base_extends( self, function_blocks: Dict[str, FunctionBlockSummary] ) -> FunctionBlockSummary: """Squash the "EXTENDS" function block into this one.""" - if self.extends is None: + if not self.extends: return self - extends_from = function_blocks.get(str(self.extends), None) - if extends_from is None: + extends_from: list[FunctionBlockSummary] = [ + get_case_insensitive(function_blocks, str(ext)) + for ext in self.extends + ] + extends_from = [ + fb.squash_base_extends(function_blocks) + for fb in extends_from + if fb is not None + ] + if not extends_from: return self - if extends_from.extends: - extends_from = extends_from.squash_base_extends(function_blocks) - - declarations = dict(extends_from.declarations) + declarations = {} + for ext in extends_from: + declarations.update(ext.declarations) declarations.update(self.declarations) - actions = list(extends_from.actions) + self.actions - methods = list(extends_from.methods) + self.methods - properties = list(extends_from.properties) + self.properties + actions = [a for fb in extends_from for a in fb.actions] + self.actions + methods = [m for fb in extends_from for m in fb.methods] + self.methods + properties = [p for fb in extends_from for p in fb.properties] + self.properties return FunctionBlockSummary( name=self.name, - comments=extends_from.comments + self.comments, - pragmas=extends_from.pragmas + self.pragmas, + comments=[c for fb in extends_from for c in fb.comments] + self.comments, + pragmas=[p for fb in extends_from for p in fb.pragmas] + self.pragmas, meta=self.meta, filename=self.filename, - source_code="\n\n".join((extends_from.source_code, self.source_code)), + source_code="\n\n".join( + (*(ext.source_code for ext in extends_from), self.source_code) + ), item=self.item, extends=self.extends, declarations=declarations, @@ -612,7 +640,7 @@ class InterfaceSummary(Summary): name: str source_code: str item: tf.Interface - extends: Optional[str] + extends: Optional[list[str]] squashed: bool declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) methods: List[MethodSummary] = field(default_factory=list) @@ -624,10 +652,11 @@ class InterfaceSummary(Summary): def __getitem__( self, key: str ) -> Union[DeclarationSummary, MethodSummary, PropertySummary]: - if key in self.declarations: - return self.declarations[key] + decl = get_case_insensitive(self.declarations, key) + if decl is not None: + return decl for item in self.methods + self.properties: - if item.name == key: + if item.name.lower() == key.lower(): return item raise KeyError(key) @@ -653,7 +682,7 @@ def from_interface( item=itf, source_code=source_code, filename=filename, - extends=itf.extends.name if itf.extends else None, + extends=itf.extends.interfaces if itf.extends else None, squashed=False, **Summary.get_meta_kwargs(itf.meta), ) @@ -669,27 +698,36 @@ def squash_base_extends( self, interfaces: Dict[str, InterfaceSummary] ) -> InterfaceSummary: """Squash the "EXTENDS" INTERFACE into this one.""" - if self.extends is None: + if not self.extends: return self - extends_from = interfaces.get(str(self.extends), None) - if extends_from is None: + extends_from: list[InterfaceSummary] = [ + get_case_insensitive(interfaces, str(ext)) + for ext in self.extends + ] + extends_from = [ + fb.squash_base_extends(interfaces) + for fb in extends_from + if fb is not None + ] + if not extends_from: return self - if extends_from.extends: - extends_from = extends_from.squash_base_extends(interfaces) - - declarations = dict(extends_from.declarations) + declarations = {} + for ext in extends_from: + declarations.update(ext.declarations) declarations.update(self.declarations) - methods = list(extends_from.methods) + self.methods - properties = list(extends_from.properties) + self.properties + methods = [m for fb in extends_from for m in fb.methods] + self.methods + properties = [p for fb in extends_from for p in fb.properties] + self.properties return InterfaceSummary( name=self.name, - comments=extends_from.comments + self.comments, - pragmas=extends_from.pragmas + self.pragmas, + comments=[c for fb in extends_from for c in fb.comments] + self.comments, + pragmas=[p for fb in extends_from for p in fb.pragmas] + self.pragmas, meta=self.meta, filename=self.filename, - source_code="\n\n".join((extends_from.source_code, self.source_code)), + source_code="\n\n".join( + (*(ext.source_code for ext in extends_from), self.source_code) + ), item=self.item, extends=self.extends, declarations=declarations, @@ -707,12 +745,12 @@ class DataTypeSummary(Summary): item: tf.TypeDeclarationItem source_code: str type: str - extends: Optional[str] + extends: Optional[list[str]] squashed: bool = False declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) def __getitem__(self, key: str) -> DeclarationSummary: - return self.declarations[key] + return get_case_insensitive(self.declarations, key) @property def declarations_by_block(self) -> Dict[str, Dict[str, DeclarationSummary]]: @@ -731,7 +769,7 @@ def from_data_type( source_code = str(dtype) if isinstance(dtype, tf.StructureTypeDeclaration): - extends = dtype.extends.name if dtype.extends else None + extends = dtype.extends.interfaces if dtype.extends else None else: extends = None @@ -774,26 +812,35 @@ def squash_base_extends( self, data_types: Dict[str, DataTypeSummary] ) -> DataTypeSummary: """Squash the "EXTENDS" function block into this one.""" - if self.extends is None: + if not self.extends: return self - extends_from = data_types.get(str(self.extends), None) - if extends_from is None: + extends_from: list[DataTypeSummary] = [ + get_case_insensitive(data_types, str(ext)) + for ext in self.extends + ] + extends_from = [ + fb.squash_base_extends(data_types) + for fb in extends_from + if fb is not None + ] + if not extends_from: return self - if extends_from.extends: - extends_from = extends_from.squash_base_extends(data_types) - - declarations = dict(extends_from.declarations) + declarations = {} + for ext in extends_from: + declarations.update(ext.declarations) declarations.update(self.declarations) return DataTypeSummary( name=self.name, type=self.type, - comments=extends_from.comments + self.comments, - pragmas=extends_from.pragmas + self.pragmas, + comments=[c for fb in extends_from for c in fb.comments] + self.comments, + pragmas=[p for fb in extends_from for p in fb.pragmas] + self.pragmas, meta=self.meta, filename=self.filename, - source_code="\n\n".join((extends_from.source_code, self.source_code)), + source_code="\n\n".join( + (*(ext.source_code for ext in extends_from), self.source_code) + ), item=self.item, extends=self.extends, declarations=declarations, @@ -812,7 +859,7 @@ class GlobalVariableSummary(Summary): declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) def __getitem__(self, key: str) -> DeclarationSummary: - return self.declarations[key] + return get_case_insensitive(self.declarations, key) @property def declarations_by_block(self) -> Dict[str, Dict[str, DeclarationSummary]]: @@ -860,16 +907,18 @@ class ProgramSummary(Summary): source_code: str item: tf.Program implementation: Optional[tf.StatementList] = None + implementation_source: Optional[str] = None declarations: Dict[str, DeclarationSummary] = field(default_factory=dict) actions: List[ActionSummary] = field(default_factory=list) methods: List[MethodSummary] = field(default_factory=list) properties: List[PropertySummary] = field(default_factory=list) def __getitem__(self, key: str) -> DeclarationSummary: - if key in self.declarations: - return self.declarations[key] + decl = get_case_insensitive(self.declarations, key) + if decl is not None: + return decl for item in self.actions + self.methods + self.properties: - if item.name == key: + if item.name.lower() == key.lower(): return item raise KeyError(key) @@ -1087,10 +1136,9 @@ def get_all_items_by_name( self.data_types, ): # Very inefficient, be warned - try: - yield dct[name] - except KeyError: - ... + decl = get_case_insensitive(dct, name) + if decl is not None: + yield decl def get_item_by_name(self, name: str) -> Optional[TopLevelCodeSummaryType]: """ @@ -1186,6 +1234,9 @@ def add_implementation(parsed: ParseResult, impl: tf.StatementList): match.implementation = impl + match.meta.comments.extend(parsed.comments) + match.implementation_source = parsed.source_code + context = [] def clear_context(): @@ -1222,7 +1273,7 @@ def get_pou_context() -> Union[ source_code=get_code_by_meta(parsed, item.meta), filename=parsed.filename, ) - result.function_blocks[item.name] = summary + result.function_blocks[str(item.name)] = summary new_context(summary) elif isinstance(item, tf.Function): summary = FunctionSummary.from_function( @@ -1230,19 +1281,15 @@ def get_pou_context() -> Union[ source_code=get_code_by_meta(parsed, item.meta), filename=parsed.filename, ) - result.functions[item.name] = summary + result.functions[str(item.name)] = summary new_context(summary) elif isinstance(item, tf.DataTypeDeclaration): - if isinstance( + summary = DataTypeSummary.from_data_type( item.declaration, - (tf.StructureTypeDeclaration, tf.UnionTypeDeclaration) - ): - summary = DataTypeSummary.from_data_type( - item.declaration, - source_code=get_code_by_meta(parsed, item.declaration.meta), - filename=parsed.filename, - ) - result.data_types[item.declaration.name] = summary + source_code=get_code_by_meta(parsed, item.declaration.meta), + filename=parsed.filename, + ) + result.data_types[item.declaration.name] = summary clear_context() elif isinstance(item, tf.Method): pou = get_pou_context() @@ -1269,7 +1316,22 @@ def get_pou_context() -> Union[ source_code=get_code_by_meta(parsed, item.meta), filename=parsed.filename, ) - pou.properties.append(summary) + # property getters and setters are separate parse results, and must + # therefore be consolidated here + existing = next( + (prop for prop in pou.properties if prop.name == summary.name), + None + ) + if existing is not None: + if parsed.item.type == SourceType.property_get: + existing.getter = summary.getter + elif parsed.item.type == SourceType.property_set: + existing.setter = summary.setter + else: + raise TypeError(f"Unsupported property summary: {parsed.item.type}") + else: + # add new property + pou.properties.append(summary) push_context(summary) elif isinstance(item, tf.GlobalVariableDeclarations): clear_context() @@ -1278,7 +1340,7 @@ def get_pou_context() -> Union[ source_code=get_code_by_meta(parsed, item.meta), filename=parsed.filename, ) - result.globals[item.name] = summary + result.globals[str(item.name)] = summary # for global_var in summary.declarations.values(): # if not qualified_only: # result.globals[global_var.name] = summary @@ -1290,7 +1352,7 @@ def get_pou_context() -> Union[ source_code=get_code_by_meta(parsed, item.meta), filename=parsed.filename, ) - result.programs[item.name] = summary + result.programs[str(item.name)] = summary new_context(summary) elif isinstance(item, tf.Interface): summary = InterfaceSummary.from_interface( @@ -1298,7 +1360,7 @@ def get_pou_context() -> Union[ source_code=get_code_by_meta(parsed, item.meta), filename=parsed.filename, ) - result.interfaces[item.name] = summary + result.interfaces[str(item.name)] = summary new_context(summary) elif isinstance(item, tf.StatementList): if parsed.item.type != SourceType.action: diff --git a/blark/tests/test_summary.py b/blark/tests/test_summary.py index 4f0b849..9e9d939 100644 --- a/blark/tests/test_summary.py +++ b/blark/tests/test_summary.py @@ -7,8 +7,9 @@ from .. import transform as tf from ..dependency_store import DependencyStore, PlcProjectMetadata from ..parse import parse_source_code -from ..summary import (CodeSummary, DeclarationSummary, FunctionBlockSummary, - MethodSummary, PropertySummary) +from ..summary import (ActionSummary, CodeSummary, DeclarationSummary, + FunctionBlockSummary, FunctionSummary, MethodSummary, + PropertySummary, text_outline) @dataclass @@ -21,8 +22,7 @@ class DeclarationCheck: def check_declarations( - container: dict[str, DeclarationSummary], - checks: list[DeclarationCheck] + container: dict[str, DeclarationSummary], checks: list[DeclarationCheck] ): """ Check that ``container`` has the listed declarations - and only those. @@ -63,12 +63,13 @@ def check_declarations( def project_a(store: DependencyStore) -> PlcProjectMetadata: # This is project a from the git submodule included with the blark test # suite - proj, = store.get_dependency("project_a") + (proj,) = store.get_dependency("project_a") return proj def test_project_a_summary(project_a: PlcProjectMetadata): summary = project_a.summary + print(text_outline(summary)) assert list(summary.function_blocks) == ["FB_ProjectA"] @@ -96,12 +97,13 @@ def test_project_a_summary(project_a: PlcProjectMetadata): def project_b(store: DependencyStore) -> PlcProjectMetadata: # This is project b from the git submodule included with the blark test # suite - proj, = store.get_dependency("project_b") + (proj,) = store.get_dependency("project_b") return proj def test_project_b_summary(project_b: PlcProjectMetadata): summary = project_b.summary + print(text_outline(summary)) st_projectb = summary.data_types["ST_ProjectB"] assert list(st_projectb.declarations) == ["iProjectB"] @@ -124,12 +126,14 @@ def twincat_general_281(store: DependencyStore) -> PlcProjectMetadata: # included with the blark test suite. # This is *not* the full version from pcdshub/lcls-twincat-general, but # rather a part of it for the purposes of the blark test suite. - proj, = store.get_dependency("LCLS_General", "v2.8.1") + (proj,) = store.get_dependency("LCLS_General", "v2.8.1") return proj def test_twincat_general(twincat_general_281: PlcProjectMetadata): summary = twincat_general_281.summary + print(text_outline(summary)) + assert list(summary.function_blocks) == ["FB_LogHandler"] loghandler = summary.function_blocks["FB_LogHandler"] @@ -151,7 +155,7 @@ def test_twincat_general(twincat_general_281: PlcProjectMetadata): base_type="UINT", value="5", ), - ] + ], ) # Ensure comments transferred over assert loghandler.implementation is None @@ -179,7 +183,7 @@ def test_twincat_general(twincat_general_281: PlcProjectMetadata): value="3", comments=["// Maximum number of ports (4) on ESC"], ), - ] + ], ) defaults = summary.globals["DefaultGlobals"] @@ -196,7 +200,7 @@ def test_twincat_general(twincat_general_281: PlcProjectMetadata): name="fTimeStamp", base_type="LREAL", ), - ] + ], ) system = summary.data_types["ST_System"] @@ -240,7 +244,7 @@ def test_twincat_general(twincat_general_281: PlcProjectMetadata): location="%I*", comments=[ "{attribute 'naming' := 'omit'}", - "(* AMS Net ID used for FB_EcatDiag, among others *)" + "(* AMS Net ID used for FB_EcatDiag, among others *)", ], ), ], @@ -249,6 +253,8 @@ def test_twincat_general(twincat_general_281: PlcProjectMetadata): def test_twincat_general_interface(twincat_general_281: PlcProjectMetadata): summary = twincat_general_281.summary + print(text_outline(summary)) + assert set(str(itf) for itf in summary.interfaces) == {"I_Base", "I_Interface"} base = summary.interfaces["I_Base"] itf = summary.interfaces["I_Interface"] @@ -261,7 +267,7 @@ def test_twincat_general_interface(twincat_general_281: PlcProjectMetadata): base_type="INT", value=None, ), - ] + ], ) check_declarations( @@ -294,7 +300,7 @@ def test_twincat_general_interface(twincat_general_281: PlcProjectMetadata): base_type="INT", value=None, ), - ] + ], ) assert {prop.name for prop in base.properties} == {"BaseProperty"} @@ -320,6 +326,8 @@ def test_twincat_general_interface(twincat_general_281: PlcProjectMetadata): assert isinstance(method1, MethodSummary) assert method1.return_type == "BOOL" + assert list(method1.declarations_by_block) == [] + def test_isolated_summary(): declarations = textwrap.dedent( @@ -354,6 +362,8 @@ def test_isolated_summary(): parsed = parse_source_code(declarations) summary = CodeSummary.from_parse_results(parsed, squash=True) + print("Parsed summary:") + print(str(summary)) fb_test = summary["FB_Test"] assert isinstance(fb_test, FunctionBlockSummary) @@ -361,6 +371,8 @@ def test_isolated_summary(): assert fb_test.declarations["iValue"].base_type == "INT" st_struct = fb_test.declarations["stStruct"] + assert fb_test["stStruct"] is st_struct + assert st_struct.base_type == "StructureType" assert summary.find_code_object_by_dotted_name("FB_Test.stStruct") is st_struct @@ -402,3 +414,81 @@ def test_isolated_summary(): assert runner.base_type == "FB_Runner" assert runner.type == "ARRAY [1..2] OF FB_Runner[(name := 'one'), (name := 'two')]" assert runner.value == "None" + + +def test_function_summary(): + declarations = textwrap.dedent( + """\ + FUNCTION F_Test : INT + VAR_INPUT + iValue : INT; + END_VAR + END_FUNCTION + """ + ) + + parsed = parse_source_code(declarations) + summary = CodeSummary.from_parse_results(parsed, squash=True) + print("Parsed summary:") + print(str(summary)) + + f_test = summary["F_Test"] + assert isinstance(f_test, FunctionSummary) + assert f_test.name == "F_Test" + assert f_test.return_type == "INT" + assert f_test["iValue"].base_type == "INT" + + +def test_action_summary(): + declarations = textwrap.dedent( + """\ + FUNCTION_BLOCK fbName + END_FUNCTION_BLOCK + ACTION ActName : + iValue := iValue + 2; + END_ACTION + """ + ) + + parsed = parse_source_code(declarations) + summary = CodeSummary.from_parse_results(parsed, squash=True) + print("Parsed summary:") + print(str(summary)) + + fb = summary["fbName"] + (act,) = fb.actions + assert isinstance(act, ActionSummary) + assert act.name == "ActName" + assert str(act.item.body) == "iValue := iValue + 2;" + + +def test_fb_extends_summary(): + declarations = textwrap.dedent( + """\ + FUNCTION_BLOCK fbBase + VAR_INPUT + iBase : INT := 1; + END_VAR + END_FUNCTION_BLOCK + FUNCTION_BLOCK fbExtended EXTENDS fbBase + VAR_INPUT + iExtended : INT := 2; + END_VAR + END_FUNCTION_BLOCK + """ + ) + + parsed = parse_source_code(declarations) + summary = CodeSummary.from_parse_results(parsed, squash=True) + print("Parsed summary:") + print(str(summary)) + + base = summary["fbBase"] + assert base.declarations["iBase"].base_type == "INT" + assert base.declarations["iBase"].value == "1" + + extended = summary["fbExtended"] + assert extended.declarations["iBase"].base_type == "INT" + assert extended.declarations["iBase"].value == "1" + assert extended.declarations["iExtended"].base_type == "INT" + assert extended.declarations["iExtended"].value == "2" diff --git a/blark/tests/test_transformer.py b/blark/tests/test_transformer.py index 33ff4ee..33de637 100644 --- a/blark/tests/test_transformer.py +++ b/blark/tests/test_transformer.py @@ -2,6 +2,7 @@ import os import pathlib +import textwrap from typing import List, Optional import lark @@ -59,7 +60,7 @@ def roundtrip_rule(rule_name: str, value: str, expected: Optional[str] = None): print("\n\nTransformed:") print(repr(transformed)) - print("\n\nOr:") + print("\n\nAs source code:") print(transformed) if expected is None: expected = value @@ -394,7 +395,10 @@ def test_bool_literal_roundtrip(name, value, expected): param("var1_init_decl", "stVar1, stVar2 : (Value1 := 1, Value2 := 2) INT := Value1"), param("structure_element_declaration", "Name1, Name2 : INT"), param("structure_type_declaration", "TypeName :\nSTRUCT\nEND_STRUCT"), - param("structure_type_declaration", "TypeName EXTENDS Other.Type :\nSTRUCT\nEND_STRUCT"), + param( + "structure_type_declaration", + "TypeName EXTENDS Other.SomeType :\nSTRUCT\nEND_STRUCT", + ), param("structure_type_declaration", "TypeName : POINTER TO\nSTRUCT\nEND_STRUCT"), param("structure_type_declaration", tf.multiline_code_block( """ @@ -896,7 +900,7 @@ def test_type_name_roundtrip(rule_name, value): iValue := 1; END_IF END_IF - Method(); + MethodName(); RETURN; END_FUNCTION_BLOCK """ @@ -911,7 +915,7 @@ def test_type_name_roundtrip(rule_name, value): iValue := 1; END_IF END_IF - Method(); + MethodName(); ReturnStatus := mReturnStatus; END_FUNCTION_BLOCK """ @@ -926,7 +930,7 @@ def test_type_name_roundtrip(rule_name, value): iValue := 1; END_IF END_IF - Method(); + MethodName(); ContinueWorking := somethingElse; END_FUNCTION_BLOCK """ @@ -941,7 +945,7 @@ def test_type_name_roundtrip(rule_name, value): iValue := 1; END_IF END_IF - Method(); + MethodName(); BreakWork := somethingElse; END_FUNCTION_BLOCK """ @@ -956,7 +960,7 @@ def test_type_name_roundtrip(rule_name, value): iValue := 1; END_IF END_IF - Method(); + MethodName(); ExitWork := somethingElse; END_FUNCTION_BLOCK """ @@ -964,7 +968,7 @@ def test_type_name_roundtrip(rule_name, value): param("function_block_type_declaration", tf.multiline_code_block( """ FUNCTION_BLOCK fbName - Method(); + MethodName(); IF 1 THEN EXIT; END_IF @@ -974,7 +978,7 @@ def test_type_name_roundtrip(rule_name, value): param("function_block_type_declaration", tf.multiline_code_block( """ FUNCTION_BLOCK fbName - Method(); + MethodName(); IF 1 THEN CONTINUE; END_IF @@ -1060,6 +1064,24 @@ def test_type_name_roundtrip(rule_name, value): END_PROPERTY """ )), + param("function_block_property_declaration", tf.multiline_code_block( + """ + {attribute 'monitoring' := 'call'} + PROPERTY PRIVATE p_fActValue : LREAL PROTECTED + VAR + END_VAR + END_PROPERTY + """ + )), # PR #111 + param("function_block_property_declaration", tf.multiline_code_block( + """ + {attribute 'monitoring' := 'call'} + PROPERTY p_fActValue : LREAL PROTECTED + VAR + END_VAR + END_PROPERTY + """ + )), # PR #111 param("function_block_property_declaration", tf.multiline_code_block( """ PROPERTY PropertyName : RETURNTYPE @@ -1851,6 +1873,33 @@ def test_labeled_statements_roundtrip(statements: str, labels: List[str]): assert isinstance(transformed, tf.StatementList) +@pytest.mark.parametrize( + ("source", "expected"), + [ + pytest.param( + """\ + FUNCTION_BLOCK fbName + VAR; + END_VAR + END_FUNCTION_BLOCK + """, + """\ + FUNCTION_BLOCK fbName + VAR + END_VAR + END_FUNCTION_BLOCK + """, + id="pr111-var-semicolon" + ) + ] +) +def test_extra_semicolons(source: str, expected: str) -> None: + parsed = parse_source_code(source) + tf_source = parsed.transform() + transformed = tf_source.items[0] + assert str(transformed).strip() == textwrap.dedent(expected).strip() + + @pytest.mark.skipif( os.environ.get("BLARK_CHECK_GRAMMAR_COVERAGE", "") != "1", reason="Optional grammar coverage check not selected" diff --git a/blark/transform.py b/blark/transform.py index 8747af5..afc66de 100644 --- a/blark/transform.py +++ b/blark/transform.py @@ -2836,13 +2836,20 @@ class Extends: EXTENDS stName EXTENDS FB_Name + EXTENDS FB_Name, FB_Name2 """ - name: lark.Token + interfaces: List[lark.Token] meta: Optional[Meta] = meta_field() + @staticmethod + def from_lark( + *interfaces: lark.Token, + ) -> Extends: + return Extends(interfaces=list(interfaces)) + def __str__(self) -> str: - return f"EXTENDS {self.name}" + return "EXTENDS " + ", ".join(self.interfaces) @dataclass @@ -3219,6 +3226,7 @@ class Property: access: Optional[AccessSpecifier] name: lark.Token return_type: Optional[LocatedVariableSpecInit] + access_override: Optional[AccessSpecifier] declarations: List[VariableDeclarationBlock] body: Optional[FunctionBody] meta: Optional[Meta] = meta_field() @@ -3228,6 +3236,7 @@ def from_lark( access: Optional[AccessSpecifier], name: lark.Token, return_type: Optional[LocatedVariableSpecInit], + access_override: Optional[AccessSpecifier], *args ) -> Property: *declarations, body = args @@ -3235,6 +3244,7 @@ def from_lark( name=name, access=access, return_type=return_type, + access_override=access_override, declarations=list(declarations), body=body, ) @@ -3242,6 +3252,7 @@ def from_lark( def __str__(self) -> str: access_and_name = join_if(self.access, " ", self.name) property = join_if(access_and_name, " : ", self.return_type) + property = join_if(property, " ", self.access_override) return "\n".join( line for line in ( diff --git a/blark/util.py b/blark/util.py index f586bf4..9afc6eb 100644 --- a/blark/util.py +++ b/blark/util.py @@ -128,6 +128,17 @@ def from_string(cls: type[Self], value: str) -> Self: ) +def get_case_insensitive(dct: dict[str, Any], key: str, default=None): + """Get case-insensitive key from a dictionary, with default. Useful because TwinCAT is + case-insensitive.""" + if key in dct: + return dct[key] + for k, v in dct.items(): + if k.lower() == key.lower(): + return v + return default + + def get_source_code(fn: AnyPath, *, encoding: str = "utf-8") -> str: """ Get source code from the given file. @@ -213,11 +224,12 @@ def python_debug_session(namespace: Dict[str, Any], message: str): embed() -def find_pou_type_and_identifier(code: str) -> tuple[Optional[SourceType], Optional[str]]: +def find_pou_type_and_identifier_plain(code: str) -> tuple[Optional[SourceType], Optional[str]]: types = {source.name for source in SourceType} clean_code = remove_all_comments(code) for line in clean_code.splitlines(): - parts = line.lstrip().split() + # split line on non-word and non-dot characters + parts = re.split(r"[^\w.]+", line.lstrip()) if parts and parts[0].lower() in types: source_type = SourceType[parts[0].lower()] identifier = None @@ -236,6 +248,28 @@ def find_pou_type_and_identifier(code: str) -> tuple[Optional[SourceType], Optio return None, None +def find_pou_type_and_identifier_xml( + xml: lxml.etree.Element +) -> tuple[Optional[SourceType], Optional[str]]: + tag_source_type_map = { + "Get": SourceType.property_get, + "Set": SourceType.property_set, + "Itf": SourceType.interface, + "GVL": SourceType.var_global, + } + + if xml.tag in tag_source_type_map: + source_type = tag_source_type_map[xml.tag] + elif xml.tag == "POU": + # POU may be function block or function (or maybe others) + # so we figure it out the 'old fashioned' way + source_type, _ = find_pou_type_and_identifier_plain(xml.xpath("Declaration")[0].text) + else: + source_type = SourceType[xml.tag.lower()] + + return source_type, xml.attrib.get("Name") + + def remove_all_comments(text: str, *, replace_char: str = " ") -> str: """ Remove all comments and replace them with the provided character.