Repository: lbl-srg/BuildingsPy

Issue:
Bug in development.refactor.move_class when updating class path
The function `_getShortName` converts the class reference `A.B.C.X.Y` in the file `A/B/C/D/E.mo` into `X.Y`.
This is incorrect in the case where a subpackage `X` also exists under `D` (typically `BaseClasses`).
In that case the function should return `C.X.Y`.
However, the class reference `A.B.C.D.X.Y` should indeed be converted into `X.Y`.
So the logic is as follows.

1. Find the first element of the path that differs between the class and the file (`X` in the above example).
2. Test if a class with that name (`X`) exists at any level below, in reference to the file path (under `A/B/C/D/.` in the above example).

    - `false`: the short class name should start with that element (`X`).
    - `true`: the short class name should start with the prior element (`C`).

In addition, in the function `_updateFile` 
```
        if "." in shortSource:
            replace_text_in_file(srcFil, shortSource, shortTarget, isRegExp=False)
        else:
            regExp = r"(?!\w)" + shortTarget
            replace_text_in_file(srcFil, regExp, shortTarget, isRegExp=True)
```
`regExp = r"(?!\w)" + shortTarget` should be `regExp = r"(?!\w)" + shortSource`.

EDIT: Also the _negative lookahead assertion_ `(?!\w)` has no effect if not preceded with `^` for instance. So
```
re.sub(r'(?!\w) Class', ' Test', 'model Class')
```
returns `model Test` which is the undesired behavior. Better use the _negative lookbehind assertion_ `(?<!\w)`.

Comments:

Patch:
diff --git a/buildingspy/development/refactor.py b/buildingspy/development/refactor.py
index a4cb721..216d16c 100644
--- a/buildingspy/development/refactor.py
+++ b/buildingspy/development/refactor.py
@@ -16,6 +16,7 @@
 
 """
 import os
+import re
 
 __all__ = ["create_modelica_package", "move_class", "write_package_order"]
 
@@ -266,20 +267,22 @@ def replace_text_in_file(file_name, old, new, isRegExp=False):
         If `isRegExp==True`, then old must be a regular expression, and
         `re.sub(old, new, ...)` is called where `...` is each line of the file.
     """
-    import re
     # Read source file, store the lines and update the content of the lines
+    modified = False
     with open(file_name, mode="r", encoding="utf-8-sig") as f_sou:
         lines = list()
-        for _, lin in enumerate(f_sou):
+        for lin in f_sou:
             if isRegExp:
-                lin = re.sub(old, new, lin)
+                lin1 = re.sub(old, new, lin)
             else:
-                lin = lin.replace(old, new)
-            lines.append(lin)
-
+                lin1 = lin.replace(old, new)
+            lines.append(lin1)
+            if lin1 != lin:
+                modified = True
     # Write the lines to the new file
-    with open(file_name, mode="w", encoding="utf-8") as f_des:
-        f_des.writelines(lines)
+    if modified:
+        with open(file_name, mode="w", encoding="utf-8") as f_des:
+            f_des.writelines(lines)
 
 
 def _move_mo_file(source, target):
@@ -491,7 +494,6 @@ def write_package_order(directory=".", recursive=False):
 def _get_package_list_for_file(directory, file_name):
     """ Gets the package list for the file `directory/file_name`
     """
-    import re
 
     pacLis = list()
 
@@ -739,13 +741,48 @@ def _update_all_references(source, target):
         _updateFile(ele)
 
 
+def _getShortName(filePath, classPath):
+    """Returns the shortest reference to a class within a file.
+
+    Args:
+        filePath: file path relative to the library root path (e.g., `Buildings/package.mo`).
+        classPath: full library path of the class to be shortened (e.g., `Buildings.Class`).
+    """
+
+    pos = re.search(r'\w', filePath).start()
+    splFil = filePath[pos:].split(os.path.sep)
+    splCla = classPath.split(".")
+    shortSource = None
+    for i in range(min(len(splFil), len(splCla))):
+        if splFil[i] != splCla[i]:
+            # See https://github.com/lbl-srg/BuildingsPy/issues/382 for the rationale
+            # behind the code below.
+            idx_start = i
+            if i > 0:
+                for k in range(i + 1, len(splFil)):
+                    lookup_path = os.path.sep.join(splFil[:k])
+                    if splCla[i] in [re.sub(r'\.mo', '', el) for el in os.listdir(lookup_path)]:
+                        idx_start = i - 1
+                        break
+            shortSource = '.'.join(splCla[idx_start:len(splCla)])
+            # shortSource starts with a space as instance names are
+            # preceded with a space.
+            shortSource = ' ' + shortSource
+            break
+    return shortSource
+
+
 def _updateFile(arg):
     """ Update all `.mo`, `package.order` and reference result file
 
-        The argument `arg` is a list where the first item is
-        the relative file name (e.g., `./Buildings/package.mo`),
-        the second element is the class name of the source and
-        the third element is the class name of the target.
+        The argument `arg` is a list providing
+        [
+            the path of the package directory where the file is located, relative
+                to the current working directory (e.g., `./Buildings` when working from `~/modelica-buildings/.`),
+            the file name (e.g., `package.mo`),
+            the full library path of the source class (e.g., `Buildings.SourceClass`),
+            the full library path of the target class (e.g., `Buildings.TargetClass`),
+        ]
 
         This function has been implemented as doing the text replace is time
         consuming and hence this is done in parallel.
@@ -753,25 +790,6 @@ def _updateFile(arg):
         :param arg: A list with the arguments.
     """
 
-    def _getShortName(fileName, className):
-        import re
-
-        pos = re.search(r'\w', fileName).start()
-        splFil = fileName[pos:].split(os.path.sep)
-        splCla = className.split(".")
-        shortSource = None
-        for i in range(min(len(splFil), len(splCla))):
-            if splFil[i] != splCla[i]:
-                # shortSource starts with a space as instance names are
-                # preceeded with a space
-                shortSource = " "
-                for j in range(i, len(splCla)):
-                    shortSource += splCla[j] + "."
-                # Remove last dot
-                shortSource = shortSource[:-1]
-                break
-        return shortSource
-
     root = arg[0]
     fil = arg[1]
     source = arg[2]
@@ -803,6 +821,7 @@ def _updateFile(arg):
         # with the new name.
         # The same is done with the target name so that short instance names
         # remain short instance names.
+
         shortSource = _getShortName(srcFil, source)
         shortTarget = _getShortName(srcFil, target)
         if shortSource is None or shortTarget is None:
@@ -810,13 +829,16 @@ def _updateFile(arg):
 
         # If shortSource is only one class (e.g., "xx" and not "xx.yy",
         # then this is also used in constructs such as "model xx" and "end xx;"
-        # Hence, we only replace it if it is proceeded only by empty characters, and nothing else.
+        # Hence, we only replace it if it is
+        #   . preceded by empty characters, and
+        #   . followed by some optional empty characters and \s or [ or , or ;.
+        # (We use a "negative lookbehind assertion" to do so.)
         if "." in shortSource:
             replace_text_in_file(srcFil, shortSource, shortTarget, isRegExp=False)
         else:
-            regExp = r"(?!\w)" + shortTarget
-            replace_text_in_file(srcFil, regExp, shortTarget, isRegExp=True)
-
+            regExpSource = r'(?<!\w)' + shortSource + r'(\s*(\s|\[|,|;))'
+            regExpTarget = shortTarget + r'\1'
+            replace_text_in_file(srcFil, regExpSource, regExpTarget, isRegExp=True)
         # Replace the hyperlinks, without the top-level library name.
         # This updates for example the RunScript command that points to
         # "....Dymola/Fluid/..."


Test patch:
diff --git a/buildingspy/development/regressiontest.py b/buildingspy/development/regressiontest.py
index 171ee2e..dbc0a12 100644
--- a/buildingspy/development/regressiontest.py
+++ b/buildingspy/development/regressiontest.py
@@ -1409,7 +1409,8 @@ class Tester(object):
             errAbs[i] = abs(yOld[i] - yInt[i])
             if np.isnan(errAbs[i]):
                 raise ValueError('NaN in errAbs ' + varNam + " " + str(yOld[i])
-                                 + "  " + str(yInt[i]) + " i, N " + str(i) + " --:" + str(yInt[i - 1])
+                                 + "  " + str(yInt[i]) + " i, N " + str(i) +
+                                 " --:" + str(yInt[i - 1])
                                  + " ++:", str(yInt[i + 1]))
             if (abs(yOld[i]) > 10 * tol):
                 errRel[i] = errAbs[i] / abs(yOld[i])
diff --git a/buildingspy/tests/test_development_refactor.py b/buildingspy/tests/test_development_refactor.py
index 11c13c4..7e15a5b 100644
--- a/buildingspy/tests/test_development_refactor.py
+++ b/buildingspy/tests/test_development_refactor.py
@@ -75,6 +75,36 @@ class Test_development_refactor(unittest.TestCase):
         self.assertEqual(r.get_modelica_file_name("Buildings.Rooms.MixedAir"),
                          os.path.join("Buildings", "Rooms", "MixedAir.mo"))
 
+    def test_getShortName(self):
+        import os
+        import buildingspy.development.refactor as r
+
+        workdir = os.getcwd()
+        os.chdir(os.path.join("buildingspy", "tests"))
+        filePath = 'MyModelicaLibrary/Examples/FMUs/Gain.mo'
+        self.assertEqual(
+            r._getShortName(
+                filePath,
+                'MyModelicaLibrary.Examples.IntegratorGain'
+            ),
+            ' Examples.IntegratorGain'
+        )
+        self.assertEqual(
+            r._getShortName(
+                filePath,
+                'MyModelicaLibrary.Examples.Test'
+            ),
+            ' Test'
+        )
+        self.assertEqual(
+            r._getShortName(
+                filePath,
+                'MyModelicaLibrary.Examples.FMUs.IntegratorGain'
+            ),
+            ' IntegratorGain'
+        )
+        os.chdir(workdir)
+
 
 if __name__ == '__main__':
     unittest.main()