Repository: leo-editor/leo-editor

Issue:
python-to-rust command
See PR #3659. Leo has several similar commands. `python-to-rust` will be built from `python-to-typescript'.

Discussion:

Patch:
diff --git a/leo/commands/convertCommands.py b/leo/commands/convertCommands.py
index e55ab5801..7d2e7bb01 100644
--- a/leo/commands/convertCommands.py
+++ b/leo/commands/convertCommands.py
@@ -120,7 +120,7 @@ class To_Python:  # pragma: no cover
     #@+node:ekr.20150514063305.133: *4* is...
     #@+node:ekr.20150514063305.134: *5* is_section_def/ref
     def is_section_def(self, s: str) -> bool:
-        return self.is_section_def(s)
+        return self.is_section_ref(s)  # 2023/11/22
 
     def is_section_ref(self, s: str) -> bool:
         n1 = s.find("<<", 0)
@@ -1523,6 +1523,689 @@ class ConvertCommandsClass(BaseEditCommandsClass):
         c = self.c
         Python_To_Coffeescript_Adapter(c).main()
         c.bodyWantsFocus()
+    #@+node:ekr.20231119103003.1: *3* ccc.python-to-rust
+    @cmd('python-to-rust')
+    def python2rust(self, event: Event) -> None:  # pragma: no cover
+        """
+        Converts Python text to Rust text. The conversion is not
+        perfect, but it eliminates a lot of tedious text manipulation.
+        """
+        c = self.c
+        self.Python_To_Rust(c).convert(c.p)
+        c.bodyWantsFocus()
+    #@+node:ekr.20231119103026.1: *4* class Python_To_Rust
+    #@@nobeautify
+    class Python_To_Rust:  # pragma: no cover
+
+        #@+<< docstring: Python_To_Rust >>
+        #@+node:ekr.20231122105133.1: *5* << docstring: Python_To_Rust >>
+        """
+        class Python_To_Rust: Convert Python text to Rust text.
+
+        The conversion is not perfect, but it eliminates lots of tedious text manipulation.
+            
+        The Rust compiler will complain about the following:
+
+        - Calls are not allowed in f-strings.
+        - This class retains `try/except` (with braces) as a guide.
+        - The code retains `self`.
+        """
+        #@-<< docstring: Python_To_Rust >>
+
+        # The handlers are clear as they are.
+        # pylint: disable=no-else-return
+
+        # Keys are argument names. Values are Rust types.
+        # Typescript can infer types of initialized kwargs.
+        types_d: dict[str, str] = {}
+        
+        #@+others
+        #@+node:ekr.20231119103026.2: *5* py2rust.ctor
+        def __init__(self, c: Cmdr, alias: str = None) -> None:
+            self.c = c
+            self.alias = alias  # For scripts. An alias for 'self'.
+            data = c.config.getData('python-to-typescript-types') or []
+            for line in data:
+                try:
+                    key, value = line.split(',')
+                    self.types_d[key.strip()] = value.strip()
+                except Exception:
+                    g.es_print('ignoring bad key/value pair in @data python-to-typescript-types')
+                    g.es_print(repr(line))
+        #@+node:ekr.20231119103026.3: *5* py2rust.convert
+        def convert(self, p: Position) -> None:
+            """
+            The main line.
+
+            Convert p and all descendants as a child of a new last top-level node.
+            """
+            c = self.c
+            # Create the parent node. It will be deleted.
+            old_p = c.p
+            parent = c.lastTopLevel().insertAfter()
+            # Convert p and all its descendants.
+            try:
+                self.convert_node(p, parent)
+                # Promote the translated node.
+                parent.promote()
+                parent.doDelete()
+                p = c.lastTopLevel()
+                p.h = p.h.replace('.py', '.rs').replace('@', '@@')
+                self.ensure_at_language(p)
+                c.redraw(p)
+                c.expandAllSubheads(p)
+                c.treeWantsFocusNow()
+            except Exception:
+                g.es_exception()
+                # Recover.
+                parent.doDelete()
+                c.redraw(old_p)
+        #@+node:ekr.20231119103026.4: *5* py2rust.convert_node
+        def convert_node(self, p: Position, parent: Position) -> None:
+            # Create a copy of p as the last child of parent.
+            target = parent.insertAsLastChild()
+            target.h = p.h  # The caller will rename this node.
+            # Convert p.b into child.b
+            self.convert_body(p, target)
+            # Recursively create all descendants.
+            for child in p.children():
+                self.convert_node(child, target)
+        #@+node:ekr.20231119103026.5: *5* py2rust.convert_body, handlers &helpers
+        def convert_body(self, p: Position, target: Position) -> None:
+            """
+            Convert p.b into target.b.
+
+            This is the heart of the algorithm.
+            """
+            # The loop may change lines, but each line is scanned only once.
+            i, lines = 0, self.pre_pass(p.b)
+            while i < len(lines):
+                progress = i
+                for (kind, pattern, handler) in self.patterns:
+                    m = pattern.match(lines[i])
+                    if m:
+                        j = handler(self, i, lines, m, p)  # May change lines.
+                        break
+                else:
+                    kind = 'code'
+                    j = i + 1
+                if kind == 'code':
+                    while i < j:
+                        self.do_operators(i, lines, p)
+                        self.do_semicolon(i, lines, p)
+                        i += 1
+                i = j
+                assert progress < i
+            # Run the post-pass
+            target.b = self.post_pass(lines)
+        #@+node:ekr.20231119103026.6: *6* handlers
+        #@+node:ekr.20231119103026.7: *7* py2rust.do_class (struct)
+        class_pat = re.compile(r'^([ \t]*)class(.*):(.*)\n')
+
+        def do_class(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, base, tail = m.group(1), m.group(2).strip(), m.group(3).strip()
+            base_s = f" {base} " if base else ''
+            tail_s = f" // {tail}" if tail else ''
+            lines[i] = f"{lws}struct{base_s}{{{tail_s}\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.8: *7* py2rust.do_comment
+        comment_pat = re.compile(r'^([ \t]*)#(.*)\n')
+
+        def do_comment(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+            """Handle a stand-alone comment line."""
+            lws, comment = m.group(1), m.group(2).strip()
+            if comment:
+                lines[i] = f"{lws}// {comment}\n"
+            else:
+                lines[i] = '\n'  # Write blank line for an empty comment.
+            return i + 1
+        #@+node:ekr.20231119103026.9: *7* py2rust.do_def & helper (fn)
+        def_pat = re.compile(r'^(\s*)def\s*([\w_]+)\s*\((.*?)\)(.*?):')
+
+        def do_def(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, name, args, tail = m.group(1), m.group(2), m.group(3).strip(), m.group(4).strip()
+            args = self.do_args(args)
+            tail_s = f"{tail.strip()} " if tail.strip() else ''  # Needed.
+            lines[i] = f"{lws}fn {name}({args}) {tail_s}{{\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.10: *8* py2rust.do_args
+        def do_args(self, args: list[str]) -> str:
+            """Add type annotations and remove the 'self' argument."""
+            result = []
+            for arg in (z.strip() for z in args.split(',')):
+                # Omit the self arg.
+                if arg != 'this':  # Already converted.
+                    val = self.types_d.get(arg)
+                    result.append(f"{arg}: {val}" if val else arg)
+            return ', '.join(result)
+        #@+node:ekr.20231119103026.11: *7* py2rust.do_docstring
+        docstring_pat = re.compile(r'^([ \t]*)r?("""|\'\'\')(.*)\n')
+
+        def do_docstring(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+            """Convert a python docstring."""
+            lws, delim, docstring = m.group(1), m.group(2), m.group(3).strip()
+            tail = docstring.replace(delim, '').strip()
+            lines[i] = ''
+            if tail:
+                lines.insert(i, f"{lws}/// {tail}\n")
+                i += 1
+            if delim in docstring:
+                return i + 2
+            i += 1
+            while i < len(lines):
+                line = lines[i]
+                # Buglet: ignores whatever might follow the end of the docstring.
+                tail = line.replace(delim, '').strip()
+                if delim in line:
+                    lines[i] = f"{lws}/// {tail}\n" if tail else ''
+                    return i + 1 if tail else i
+                else:
+                    lines[i] = f"{lws}/// {tail}\n" if tail else f"{lws}///\n"
+                i += 1
+            return i
+        #@+node:ekr.20231119103026.12: *7* py2rust.do_except
+        except_pat = re.compile(r'^([ \t]*)except(.*):(.*)\n')
+
+        def do_except(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, error, tail = m.group(1), m.group(2).strip(), m.group(3).strip()
+            tail_s = f" // {tail}" if tail else ''
+            error_s = f" ({error}) " if error else ''
+            lines[i] = f"{lws}catch{error_s}{{{tail_s}\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.13: *7* py2rust.do_for
+        for1_s = r'^([ \t]*)for[ \t]+(.*):(.*)\n'  # for (cond):
+        for2_s = r'^([ \t]*)for[ \t]*\((.*)\n'  # for (
+
+        for1_pat = re.compile(for1_s)
+        for2_pat = re.compile(for2_s)
+        for_pat = re.compile(fr"{for1_s}|{for2_s}")  # Used by main loop.
+
+        def do_for(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            line = lines[i]
+            m1 = self.for1_pat.match(line)
+            m2 = self.for2_pat.match(line)
+            if m1:
+                j = self.find_indented_block(i, lines, m, p)
+                lws, cond, tail = m.group(1), m.group(2).strip(), m.group(3).strip()
+                cond_s = cond if cond.startswith('(') else f"({cond})"
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}for {cond_s} {{{tail_s}\n"
+                self.do_operators(i, lines, p)
+                lines.insert(j, f"{lws}}}\n")
+                return i + 1
+            else:
+                j = self.find_indented_block(i, lines, m2, p)
+                # Generate the 'for' line.
+                lws, tail = m2.group(1), m2.group(2).strip()
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}for ({tail_s}\n"
+                # Tell do_semicolons that lines[i:j] are not statements.
+                self.kill_semicolons(lines, i, j)
+                # Assume line[j] closes the paren.  Insert '{'
+                lines[j] = lines[j].rstrip().replace(':', '') + ' {\n'
+                # Insert '}'
+                k = self.find_indented_block(j, lines, m2, p)
+                lines.insert(k, f"{lws}}}\n")
+                return i + 1
+        #@+node:ekr.20231119103026.14: *7* py2rust.do_import
+        import_s = r'^([ \t]*)import[ \t]+(.*)\n'
+        import_from_s = r'^([ \t]*)from[ \t]+(.*)[ \t]+import[ \t]+(.*)\n'
+        import_pat = re.compile(fr"{import_s}|{import_from_s}")  # Used by main loop.
+        import1_pat = re.compile(import_s)
+        import2_pat = re.compile(import_from_s)
+
+        def do_import(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            line = lines[i]
+            m1 = self.import1_pat.match(line)
+            m2 = self.import2_pat.match(line)
+            # Comment out all imports.
+            if m1:
+                lws, import_list = m1.group(1), m1.group(2).strip()
+                lines[i] = f'{lws}// import "{import_list}"\n'
+            else:
+                lws, module, import_list = m2.group(1), m2.group(2).strip(), m2.group(3).strip()
+                lines[i] = f'{lws}// from "{module}" import {import_list}\n'
+            return i + 1
+        #@+node:ekr.20231119103026.15: *7* py2rust.do_elif
+        elif1_s = r'^([ \t]*)elif[ \t]+(.*):(.*)\n'  # elif (cond):
+        elif2_s = r'^([ \t]*)elif[ \t]*\((.*)\n'  # elif (
+
+        elif1_pat = re.compile(elif1_s)
+        elif2_pat = re.compile(elif2_s)
+        elif_pat = re.compile(fr"{elif1_s}|{elif2_s}")  # Used by main loop.
+
+        def do_elif(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            line = lines[i]
+            m1 = self.elif1_pat.match(line)
+            m2 = self.elif2_pat.match(line)
+            if m1:
+                j = self.find_indented_block(i, lines, m, p)
+                lws, cond, tail = m.group(1), m.group(2).strip(), m.group(3).strip()
+                cond_s = cond if cond.startswith('(') else f"({cond})"
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}else if {cond_s} {{{tail_s}\n"
+                lines.insert(j, f"{lws}}}\n")
+                self.do_operators(i, lines, p)
+                return i + 1
+            else:
+                j = self.find_indented_block(i, lines, m2, p)
+                # Generate the 'else if' line.
+                lws, tail = m2.group(1), m2.group(2).strip()
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}else if ({tail_s}\n"
+                # Tell do_semicolons that lines[i:j] are not statements.
+                self.kill_semicolons(lines, i, j)
+                # Assume line[j] closes the paren.  Insert '{'
+                lines[j] = lines[j].rstrip().replace(':', '') + ' {\n'
+                # Insert '}'
+                k = self.find_indented_block(j, lines, m2, p)
+                lines.insert(k, f"{lws}}}\n")
+                return i + 1
+
+        #@+node:ekr.20231119103026.16: *7* py2rust.do_else
+        else_pat = re.compile(r'^([ \t]*)else:(.*)\n')
+
+        def do_else(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, tail = m.group(1), m.group(2).strip()
+            tail_s = f" // {tail}" if tail else ''
+            lines[i] = f"{lws}else {{{tail_s}\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.17: *7* py2rust.do_finally
+        finally_pat = re.compile(r'^([ \t]*)finally:(.*)\n')
+
+        def do_finally(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, tail = m.group(1), m.group(2).strip()
+            tail_s = f" // {tail}" if tail else ''
+            lines[i] = f"{lws}finally {{{tail_s}\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.18: *7* py2rust.do_if
+        if1_s = r'^([ \t]*)if[ \t]+(.*?):(.*?)\n'  # if cond:
+        if2_s = r'^([ \t]*)if[ \t]*\((.*?)\n'  # if (
+
+        if1_pat = re.compile(if1_s)
+        if2_pat = re.compile(if2_s)
+        if_pat = re.compile(fr"{if1_s}|{if2_s}")  # Used by main loop.
+
+        def do_if(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            # Recompute the match for better accuracy.
+            line = lines[i]
+            m1 = self.if1_pat.match(line)
+            m2 = self.if2_pat.match(line)
+            if m1:
+                j = self.find_indented_block(i, lines, m1, p)
+                lws, cond, tail = m1.group(1), m1.group(2).strip(), m1.group(3).strip()
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}if {cond} {{{tail_s}\n"
+                self.do_operators(i, lines, p)
+                lines.insert(j, f"{lws}}}\n")
+                return i + 1
+            else:
+                j = self.find_indented_block(i, lines, m2, p)
+                # Generate the 'if' line.
+                lws, tail = m2.group(1), m2.group(2).strip()
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}if ({tail_s}\n"
+                # Tell do_semicolons that lines[i:j] are not statements.
+                self.kill_semicolons(lines, i, j)
+                # Assume line[j] closes the paren.  Insert '{'
+                lines[j] = lines[j].rstrip().replace(':', '') + ' {\n'
+                # Insert '}'
+                k = self.find_indented_block(j, lines, m2, p)
+                lines.insert(k, f"{lws}}}\n")
+                return i + 1
+        #@+node:ekr.20231121113506.1: *7* py2rust.do_return
+        return_pat = re.compile(r'^([ \t]*)return\b(.*?)\n')
+
+        def do_return(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            lws, tail = m.group(1), m.group(2).strip()
+            tail_s = tail.strip() if tail.strip() else 'None'
+            lines[i] = f"{lws}{tail_s}\n"  # Any extra comment quickly becomes annoying.
+            return i + 1
+        #@+node:ekr.20231119103026.19: *7* py2rust.do_section_ref
+        section_ref_pat = re.compile(r"^([ \t]*)(\<\<.*?\>\>)\s*(.*)$")
+
+        def do_section_ref(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+            # Handle trailing code.
+            lws, section_name, tail = m.group(1), m.group(2), m.group(3).strip()
+            if tail.startswith('#'):
+                lines[i] = f"{lws}{section_name}  // {tail[1:]}\n"
+            return i + 1
+        #@+node:ekr.20231119103026.20: *7* py2rust.do_try
+        try_pat = re.compile(r'^([ \t]*)try:(.*)\n')
+
+        def do_try(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, tail = m.group(1), m.group(2).strip()
+            tail_s = f" // {tail}" if tail else ''
+            lines[i] = f"{lws}try {{{tail_s}\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.21: *7* py2rust.do_while
+        while1_s = r'^([ \t]*)while[ \t]+(.*):(.*)\n'  # while (cond):
+        while2_s = r'^([ \t]*)while[ \t]*\((.*)\n'  # while (
+
+        while1_pat = re.compile(while1_s)
+        while2_pat = re.compile(while2_s)
+        while_pat = re.compile(fr"{while1_s}|{while2_s}")  # Used by main loop.
+
+        def do_while(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            line = lines[i]
+            m1 = self.while1_pat.match(line)
+            m2 = self.while2_pat.match(line)
+            if m1:
+                j = self.find_indented_block(i, lines, m, p)
+                lws, cond, tail = m.group(1), m.group(2).strip(), m.group(3).strip()
+                cond_s = cond if cond.startswith('(') else f"({cond})"
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}while {cond_s} {{{tail_s}\n"
+                self.do_operators(i, lines, p)
+                lines.insert(j, f"{lws}}}\n")
+                return i + 1
+            else:
+                j = self.find_indented_block(i, lines, m2, p)
+                # Generate the 'while' line.
+                lws, tail = m2.group(1), m2.group(2).strip()
+                tail_s = f" // {tail}" if tail else ''
+                lines[i] = f"{lws}while ({tail_s}\n"
+                # Tell do_semicolons that lines[i:j] are not statements.
+                self.kill_semicolons(lines, i, j)
+                # Assume line[j] closes the paren.  Insert '{'
+                lines[j] = lines[j].rstrip().replace(':', '') + ' {\n'
+                # Insert '}'
+                k = self.find_indented_block(j, lines, m2, p)
+                lines.insert(k, f"{lws}}}\n")
+                return i + 1
+
+        #@+node:ekr.20231119103026.22: *7* py2rust.do_with
+        with_pat = re.compile(r'^([ \t]*)with(.*):(.*)\n')
+
+        def do_with(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+
+            j = self.find_indented_block(i, lines, m, p)
+            lws, clause, tail = m.group(1), m.group(2).strip(), m.group(3).strip()
+            tail_s = f" // {tail}" if tail else ''
+            clause_s = f" ({clause}) " if clause else ''
+            lines[i] = f"{lws}with{clause_s}{{{tail_s}\n"
+            lines.insert(j, f"{lws}}}\n")
+            return i + 1
+        #@+node:ekr.20231119103026.23: *7* py2rust.do_trailing_comment
+        trailing_comment_pat = re.compile(r'^([ \t]*)(.*)#(.*)\n')
+
+        def do_trailing_comment(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+            """
+            Handle a trailing comment line.
+
+            All other patterns have already been scanned on the line.
+            """
+            lws, statement, trailing_comment = m.group(1), m.group(2).rstrip(), m.group(3).strip()
+            statement_s = f"{statement};" if self.ends_statement(i, lines) else statement
+            lines[i] = f"{lws}{statement_s}  // {trailing_comment}\n"
+            return i + 1
+        #@+node:ekr.20231119103026.24: *6* helpers
+        #@+node:ekr.20231119103026.25: *7* py2rust.do_operators
+        def do_operators(self, i: int, lines: list[str], p: Position) -> None:
+            """Replace all operators and aliases."""
+            # Regex replacements.
+            table = (
+                # Annotations.
+                ('str', 'String:'),
+                ('int', 'i32'),
+                ('float', 'f64'),
+                # Constants.
+                ('True', 'true'),
+                ('False', 'false'),
+                ('default', 'default_val'),
+                # Operators.
+                ('and', '&&'),
+                ('or', '||'),
+                ('is not', '!='),
+                ('is', '=='),
+                ('not', '!'),
+                # Others.
+                ('assert', '// assert'),
+            )
+            for a, b in table:
+                lines[i] = re.sub(fr"\b{a}\b", b, lines[i])
+        #@+node:ekr.20231119103026.26: *7* py2rust.do_semicolon
+        def do_semicolon(self, i: int, lines: list[str], p: Position) -> None:
+            """
+            Insert a semicolon in lines[i] if appropriate.
+
+            No other handler has matched, so we know that the line:
+            - Does not end in a comment.
+            - Is not part of a docstring.
+            """
+            # Honor the flag inserted by kill_semicolons.
+            flag = self.kill_semicolons_flag
+            if lines[i].endswith(flag):
+                lines[i] = lines[i].replace(flag, '\n')
+                return
+            # For now, use a maximal policy.
+            if self.ends_statement(i, lines):
+                lines[i] = f"{lines[i].rstrip()};\n"
+        #@+node:ekr.20231119103026.27: *7* py2rust.ends_statement
+        def ends_statement(self, i: int, lines: list[str]) -> bool:
+            """
+            Return True if lines[i] ends a statement.
+
+            If so, the line should end with a semicolon,
+            before any trailing comment, that is.
+            """
+            # https://stackoverflow.com/questions/38823062/
+            s = lines[i].strip()
+            next_line = lines[i + 1] if i + 1 < len(lines) else ''
+            # Return False for blank lines.
+            if not s:
+                return False
+            # Return False for Leo directives.
+            if s.startswith('@'):
+                return False
+            # Return False for section references.
+            i = s.find('<<')
+            j = s.find('>>')
+            if -1 < i < j:
+                return False
+            # Return False if this line ends in any of the following:
+            if s.endswith(('{', '(', '[', ':', '||', '&&', '!', ',', '`')):
+                return False
+            # Return False if the next line starts with '{', '(', '['.
+            if next_line.lstrip().startswith(('[', '(', '[', '&&', '||', '!')):
+                return False
+            # Return False for '}' lines.
+            if s.startswith('}'):
+                return False
+            return True
+        #@+node:ekr.20231119103026.28: *7* py2rust.find_indented_block
+        lws_pat = re.compile(r'^([ \t]*)')
+
+        def find_indented_block(self, i: int, lines: list[str], m: Match, p: Position) -> int:
+            """Return j, the index of the line *after* the indented block."""
+            # Scan for the first non-empty line with the same or less indentation.
+            lws = m.group(1)
+            j = i + 1
+            while j < len(lines):
+                line = lines[j]
+                m2 = self.lws_pat.match(line)
+                lws2 = m2.group(1)
+                if line.strip() and len(lws2) <= len(lws):
+                    # Don't add a blank line at the end of a block.
+                    if j > 1 and not lines[j - 1].strip():
+                        j -= 1
+                    break
+                j += 1
+            return j
+        #@+node:ekr.20231119103026.29: *7* py2rust.kill_semicolons
+        kill_semicolons_flag = '  // **kill-semicolon**\n'  # Must end with a newline.
+
+        def kill_semicolons(self, lines: list[str], i: int, j: int) -> None:
+            """
+            Tell later calls to do_semicolon that lines[i : j] should *not* end with a semicolon.
+            """
+            for n in range(i, j):
+                lines[n] = lines[n].rstrip() + self.kill_semicolons_flag
+        #@+node:ekr.20231119103026.31: *7* py2rust.post_pass & helpers
+        def post_pass(self, lines: list[str]) -> str:
+
+            # Munge lines in place
+            self.do_f_strings(lines)
+            self.do_ternary(lines)
+            self.replace_single_quotes(lines)
+            self.do_assignment(lines)  # Do this last, so it doesn't add 'let' to inserted comments.
+
+            # Replace the flag with a real newline.
+            return ''.join(lines).replace(self.kill_semicolons_flag, '\n')
+
+        #@+node:ekr.20231119103026.32: *8* py2rust.do_assignment
+        assignment_pat = re.compile(r'^([ \t]*)(.*?)\s+=\s+(.*)$')  # Require whitespace around the '='
+
+        def do_assignment(self, lines: list[str]) -> None:
+            """Add let to all non-tuple assignments."""
+            # Do this late so that we can test for the ending semicolon.
+
+            # Suppression table.
+            # Missing elements are likely to cause this method to generate '= ='.
+            table = (
+                ',',  # Tuple assignment or  multi-line argument lists.
+                '*',  # A converted docstring.
+                '`',  # f-string.
+                '//',  # Comment.
+                '=',  # Condition.
+                # Keywords that might be followed by '='
+                'class', 'def', 'elif', 'for', 'if', 'print', 'public', 'return', 'with', 'while',
+            )
+            for i, s in enumerate(lines):
+                m = self.assignment_pat.match(s)
+                if m:
+                    lws, lhs, rhs = m.group(1), m.group(2), m.group(3).rstrip()
+                    if not any(z in lhs for z in table):
+                        lines[i] = f"{lws}let {lhs} = {rhs}\n"
+        #@+node:ekr.20231119103026.33: *8* py2rust.do_f_strings
+        f_string_pat = re.compile(r'([ \t]*)(.*?)f"(.*?)"(.*)$')
+
+        def do_f_strings(self, lines: list[str]) -> None:
+
+            i = 0
+            while i < len(lines):
+                progress = i
+                s = lines[i]
+                m = self.f_string_pat.match(s)
+                if not m:
+                    i += 1
+                    continue
+                lws, head, string, tail = m.group(1), m.group(2), m.group(3), m.group(4).rstrip()
+                # A hack. If the fstring is on a line by itself, remove a trailing ';'
+                tail_s = tail
+                if not head.strip() and tail.endswith(';'):
+                    tail_s = tail[:-1].strip()
+                # We'll ignore that calls are not allowed in Rust f-strings.
+                lines[i] = f'{lws}{head}f!"{string}"{tail_s}\n'
+                i += 1
+                assert i > progress
+        #@+node:ekr.20231119103026.34: *8* py2rust.do_ternary
+        ternary_pat1 = re.compile(r'^([ \t]*)(.*?)\s*=\s*(.*?) if (.*?) else (.*);$')  # assignment
+        ternary_pat2 = re.compile(r'^([ \t]*)return\s+(.*?) if (.*?) else (.*);$')  # return statement
+
+        def do_ternary(self, lines: list[str]) -> None:
+
+            i = 0
+            while i < len(lines):
+                progress = i
+                s = lines[i]
+                m1 = self.ternary_pat1.match(s)
+                m2 = self.ternary_pat2.match(s)
+                if m1:
+                    lws, target, a, cond, b = m1.group(1), m1.group(2), m1.group(3), m1.group(4), m1.group(5)
+                    lines[i] = f"{lws}// {s.strip()}\n"
+                    lines.insert(i + 1, f"{lws}{target} = if {cond} {{{a}}} else {{{b}}};\n")
+                    i += 2
+                elif m2:
+                    lws, a, cond, b = m2.group(1), m2.group(2), m2.group(3), m2.group(4)
+                    lines[i] = f"{lws}// {s.strip()}\n"
+                    lines.insert(i + 1, f"{lws}return if {cond} then {{{a}}} else {{{b}}};\n")
+                    i += 2
+                else:
+                    i += 1
+                assert progress < i
+        #@+node:ekr.20231120201711.1: *8* py2rust.replace_single_quotes
+        def replace_single_quotes(self, lines: list[str]) -> None:
+            for i, line in enumerate(lines):
+                n1 = line.find("'")
+                if n1 == -1:
+                    continue
+                n0 = line.find('//')
+                if n0 == -1 or n0 > n1:
+                    lines[i] = lines[i].replace("'", '"')
+        #@+node:ekr.20231119103026.35: *7* py2rust.pre_pass
+        def pre_pass(self, s: str) -> list[str]:
+            """
+            The pre-pass for s, a multi-line string.
+            
+            Split the lines and comment out all decorators.
+            """
+            result = []
+            for s in g.splitLines(s):
+                # Comment out decorators.
+                result.append(re.sub(r'^@(cmd|g\.command)', r'// @\1', s))
+            return result
+        #@+node:ekr.20231122020653.1: *5* py2rust.ensure_at_language
+        def ensure_at_language(self, p: Position) -> None:
+            """Ensure that the top-level node p contains @language rust"""
+            lines = g.splitLines(p.b)
+            for i, line in enumerate(lines):
+                if line.startswith('@language python'):
+                    p.b = p.b.replace('@language python', '@language rust')
+                    return
+            p.b = p.b.rstrip() + '\n@language rust\n\n'
+        #@-others
+
+        #@+<< global handler patterns >>
+        #@+node:ekr.20231122012709.1: *5* << global handler patterns >>
+        patterns = (
+            # Head: order matters.
+            ('comment', comment_pat, do_comment),
+            ('docstring', docstring_pat, do_docstring),
+            ('section-ref', section_ref_pat, do_section_ref),
+            # Middle: order doesn't matter.
+            ('code', class_pat, do_class),
+            ('code', def_pat, do_def),
+            ('code', elif_pat, do_elif),
+            ('code', else_pat, do_else),
+            ('code', except_pat, do_except),
+            ('code', finally_pat, do_finally),
+            ('code', for_pat, do_for),
+            ('code', if_pat, do_if),
+            ('code', import_pat, do_import),
+            ('return', return_pat, do_return),
+            ('code', try_pat, do_try),
+            ('code', while_pat, do_while),
+            ('code', with_pat, do_with),
+            # Tail: order matters.
+            ('trailing-comment', trailing_comment_pat, do_trailing_comment)
+        )
+        #@-<< global handler patterns >>
+
     #@+node:ekr.20211013080132.1: *3* ccc.python-to-typescript
     @cmd('python-to-typescript')
     def python_to_typescript(self, event: Event) -> None:  # pragma: no cover
@@ -2687,4 +3370,5 @@ class ConvertCommandsClass(BaseEditCommandsClass):
         c.bodyWantsFocus()
     #@-others
 #@-others
+
 #@-leo
diff --git a/leo/core/leoBeautify.rs b/leo/core/leoBeautify.rs
new file mode 100644
index 000000000..5194fd2f3
--- /dev/null
+++ b/leo/core/leoBeautify.rs
@@ -0,0 +1,31 @@
+//@+leo-ver=5-thin
+//@+node:ekr.20231119151610.1: * @file leoBeautify.rs
+# This file is part of Leo: https://leo-editor.github.io/leo-editor
+# Leo's copyright notice is based on the MIT license:
+# https://leo-editor.github.io/leo-editor/license.html
+
+//@+<< leoBeautify.rs docstring >>
+//@+node:ekr.20231119151801.1: ** << leoBeautify.rs docstring >>
+"""
+leoBeautify.rs: Transliterate Leo's beautifier (Orange) into rust.
+
+Goals:
+
+- Don't change Leo's sentinel lines.
+- Don't change line breaks.
+- Otherwise, be compatible with Black and --skip-string-normalization.
+
+"""
+//@-<< leoBeautify.rs docstring >>
+//@+<< leoBeautify.rs imports & annotations >>
+//@+node:ekr.20231119151804.1: ** << leoBeautify.rs imports & annotations >>
+//@-<< leoBeautify.rs imports & annotations >>
+//@+<< leoBeautify.rs data >>
+//@+node:ekr.20231119151807.1: ** << leoBeautify.rs data >>
+//@-<< leoBeautify.rs data >>
+
+//@+others
+//@-others
+
+//@@language rust
+//@-leo


Test patch:
diff --git a/leo/unittests/commands/test_convertCommands.py b/leo/unittests/commands/test_convertCommands.py
index c4883c5ea..cf1ac4023 100644
--- a/leo/unittests/commands/test_convertCommands.py
+++ b/leo/unittests/commands/test_convertCommands.py
@@ -10,79 +10,28 @@ from leo.commands.convertCommands import ConvertCommandsClass
 from leo.unittests.test_importers import BaseTestImporter
 
 #@+others
-#@+node:ekr.20211013081200.1: ** class TestPythonToTypeScript(LeoUnitTest):
-class TestPythonToTypeScript(LeoUnitTest):
-    """Test cases for python-to-typescript command"""
+#@+node:ekr.20220824193803.1: ** class Test_To_Python(BaseTestImporter):
+class Test_To_Python(BaseTestImporter):
+    """Test cases for commands using To_Python class."""
 
     #@+others
-    #@+node:ekr.20211013090653.1: *3*  test_py2ts.setUp
-    def setUp(self):
-        super().setUp()
-        c = self.c
-        self.x = c.convertCommands.PythonToTypescript(c)
-        self.assertTrue(hasattr(self.x, 'convert'))
-        root = self.root_p
-        # Delete all children
-        root.deleteAllChildren()
-        # Read leo.core.leoNodes into contents.
-        unittest_dir = os.path.dirname(__file__)
-        core_dir = os.path.abspath(os.path.join(unittest_dir, '..', '..', 'core'))
-        path = os.path.join(core_dir, 'leoNodes.py')
-        with open(path) as f:
-            contents = f.read()
-        # Set the gnx of the @file nodes in the contents to root.gnx.
-        # This is necessary because of a check in fast_at.scan_lines.
-        pat = re.compile(r'^\s*#\s?@\+node:([^:]+): \* @file leoNodes\.py$')
-        # line 1: #@+leo-ver=5-thin
-        # line 2: #@+node:ekr.20031218072017.3320: * @file leoNodes.py
-        line2 = g.splitLines(contents)[1]
-        m = pat.match(line2)
-        assert m, "Can not replace gnx"
-        contents = contents.replace(m.group(1), root.gnx)
-        # Replace c's outline with leoNodes.py.
-        gnx2vnode = {}
-        ok = c.atFileCommands.fast_read_into_root(c, contents, gnx2vnode, path, root)
-        self.assertTrue(ok)
-        root.h = 'leoNodes.py'
-        self.p = root
-        c.selectPosition(self.p)
-    #@+node:ekr.20211013081200.2: *3* test_py2ts.test_setup
-    def test_setup(self):
-        c = self.c
-        assert self.x
-        assert self.p
-        if 0:
-            self.dump_tree()
-        if 0:
-            for p in c.all_positions():
-                g.printObj(p.b, tag=p.h)
-    #@+node:ekr.20211013085659.1: *3* test_py2ts.test_convert_position_class
-    def test_convert_position_class(self):
-        # Convert a copy of the Position class
-        self.x.convert(self.p)
-    #@+node:ekr.20211021075411.1: *3* test_py2ts.test_do_f_strings()
-    def test_do_f_strings(self):
+    #@+node:ekr.20220824193932.1: *3* test_c_to_python
+    def test_c_to_python(self):
 
-        x = self.x
-        tests = (
-            (
-                'g.es(f"{timestamp}created: {fileName}")\n',
-                'g.es(`${timestamp}created: ${fileName}`)\n',
-            ),
-            (
-                'g.es(f"read {len(files)} files in {t2 - t1:2.2f} seconds")\n',
-                'g.es(`read ${len(files)} files in ${t2 - t1} seconds`)\n',
-            ),
-            (
-                'print(f"s: {s!r}")\n',
-                'print(`s: ${s}`)\n',
-            ),
-        )
-        for test in tests:
-            source, expected = test
-            lines = [source]
-            x.do_f_strings(lines)
-            self.assertEqual(lines[-1], expected)
+        c = self.c
+        x1 = ConvertCommandsClass(c)
+        x = x1.C_To_Python(c)
+        s = textwrap.dedent("""\
+        void hello_world()
+        {
+            print('hi')
+            if a == 2 {
+                print('2')
+            }
+        }
+        """)
+        lines = g.splitLines(s)
+        x.convertCodeList(lines)
     #@-others
 #@+node:ekr.20220108083112.1: ** class TestAddMypyAnnotations(LeoUnitTest):
 class TestAddMypyAnnotations(LeoUnitTest):
@@ -255,28 +204,197 @@ class TestAddMypyAnnotations(LeoUnitTest):
         self.x.convert_body(p)
         self.assertEqual(p.b, expected)
     #@-others
-#@+node:ekr.20220824193803.1: ** class Test_To_Python(BaseTestImporter):
-class Test_To_Python(BaseTestImporter):
-    """Test cases for commands using To_Python class."""
+#@+node:ekr.20231121061008.1: ** class TestPythonToTypeRust(LeoUnitTest):
+class TestPythonToTypeRust(LeoUnitTest):
+    """Test cases for python-to-typescript command"""
 
     #@+others
-    #@+node:ekr.20220824193932.1: *3* test_c_to_python
-    def test_c_to_python(self):
+    #@+node:ekr.20231121061008.2: *3*  test_py2rust.setUp
+    def setUp(self):
+        super().setUp()
+        c = self.c
+        self.p = c.p
+        self.x = c.convertCommands.Python_To_Rust(c)
+        self.assertTrue(hasattr(self.x, 'convert'))
+        root = self.root_p
+        root.deleteAllChildren()
+    #@+node:ekr.20231121061008.4: *3* slow_test_py2rust.test_convert_position_class
+    def slow_test_convert_position_class(self):
+        # Convert a copy of the Position class
+        c = self.c
+        root = self.root_p
+
+        # Read leo.core.leoNodes into contents.
+        unittest_dir = os.path.dirname(__file__)
+        core_dir = os.path.abspath(os.path.join(unittest_dir, '..', '..', 'core'))
+        path = os.path.join(core_dir, 'leoNodes.py')
+        with open(path) as f:
+            contents = f.read()
+        # Set the gnx of the @file nodes in the contents to root.gnx.
+        # This is necessary because of a check in fast_at.scan_lines.
+        pat = re.compile(r'^\s*#\s?@\+node:([^:]+): \* @file leoNodes\.py$')
+        # line 1: #@+leo-ver=5-thin
+        # line 2: #@+node:ekr.20031218072017.3320: * @file leoNodes.py
+        line2 = g.splitLines(contents)[1]
+        m = pat.match(line2)
+        assert m, "Can not replace gnx"
+        contents = contents.replace(m.group(1), root.gnx)
+        # Replace c's outline with leoNodes.py.
+        gnx2vnode = {}
+        ok = c.atFileCommands.fast_read_into_root(c, contents, gnx2vnode, path, root)
+        self.assertTrue(ok)
+        root.h = 'leoNodes.py'
+        self.p = root
+        c.selectPosition(self.p)
+        self.x.convert(self.p)
+    #@+node:ekr.20231121061008.5: *3* test_py2rust.test_f_strings()
+    def test_f_strings(self):
 
+        x = self.x
+        tests = (
+            (
+                'g.es(f"{timestamp}created: {fileName}")\n',
+                'g.es(f!"{timestamp}created: {fileName}")\n',
+            ),
+            (
+                'g.es(f"read {len(files)} files in {t2 - t1:2.2f} seconds")\n',
+                'g.es(f!"read {len(files)} files in {t2 - t1:2.2f} seconds")\n',
+            ),
+            (
+                'print(f!"s: {s!r}")\n',
+                'print(f!"s: {s!r}")\n',
+            ),
+        )
+        for test in tests:
+            source, expected = test
+            lines = [source]
+            x.do_f_strings(lines)
+            self.assertEqual(lines[-1], expected)
+    #@+node:ekr.20231121062934.1: *3* test_py2rust.test_if_statements()
+    def test_if_statements(self):
+
+        x = self.x
+        tests = (
+            # Test 1.
+            (
+                (
+                    'if not should_beautify(p):\n'
+                    "    return [] if toList else ''  # #2271\n"
+                ),
+                # Replace operators, but not single quotes.
+                (
+                    'if ! should_beautify(p) {\n'
+                    "    return [] if toList else ''  # #2271\n"
+                    '}\n'
+                ),
+            ),
+            # Test 2
+            (
+                (
+                    'if TYPE_CHECKING:  # pragma: no cover\n'
+                    '    from leo.core.leoCommands import Commands as Cmdr\n'
+                ),
+                (
+                    'if TYPE_CHECKING { // # pragma: no cover\n'
+                    '    from leo.core.leoCommands import Commands as Cmdr\n'
+                    '}\n'
+                ),
+            ),
+        )
+        for test in tests:
+            source_s, expected_s = test
+            lines = g.splitLines(source_s)
+            expected = g.splitLines(expected_s)
+            # g.printObj(lines, tag='lines')
+            # g.printObj(expected, tag='expected')
+            i = x.do_if(0, lines, m=None, p=None)  # Modifies lines in place.
+            assert i > 0
+            # g.printObj(lines, tag='result')
+            self.assertEqual(lines, expected)
+    #@+node:ekr.20231121061008.3: *3* test_py2rust.test_setup
+    def test_setup(self):
         c = self.c
-        x1 = ConvertCommandsClass(c)
-        x = x1.C_To_Python(c)
-        s = textwrap.dedent("""\
-        void hello_world()
-        {
-            print('hi')
-            if a == 2 {
-                print('2')
-            }
-        }
-        """)
-        lines = g.splitLines(s)
-        x.convertCodeList(lines)
+        assert self.x
+        assert self.p
+        if 0:
+            self.dump_tree()
+        if 0:
+            for p in c.all_positions():
+                g.printObj(p.b, tag=p.h)
+    #@-others
+#@+node:ekr.20211013081200.1: ** class TestPythonToTypeScript(LeoUnitTest):
+class TestPythonToTypeScript(LeoUnitTest):
+    """Test cases for python-to-typescript command"""
+
+    #@+others
+    #@+node:ekr.20211013090653.1: *3*  test_py2ts.setUp
+    def setUp(self):
+        super().setUp()
+        c = self.c
+        self.x = c.convertCommands.PythonToTypescript(c)
+        self.assertTrue(hasattr(self.x, 'convert'))
+        root = self.root_p
+        # Delete all children
+        root.deleteAllChildren()
+        # Read leo.core.leoNodes into contents.
+        unittest_dir = os.path.dirname(__file__)
+        core_dir = os.path.abspath(os.path.join(unittest_dir, '..', '..', 'core'))
+        path = os.path.join(core_dir, 'leoNodes.py')
+        with open(path) as f:
+            contents = f.read()
+        # Set the gnx of the @file nodes in the contents to root.gnx.
+        # This is necessary because of a check in fast_at.scan_lines.
+        pat = re.compile(r'^\s*#\s?@\+node:([^:]+): \* @file leoNodes\.py$')
+        # line 1: #@+leo-ver=5-thin
+        # line 2: #@+node:ekr.20031218072017.3320: * @file leoNodes.py
+        line2 = g.splitLines(contents)[1]
+        m = pat.match(line2)
+        assert m, "Can not replace gnx"
+        contents = contents.replace(m.group(1), root.gnx)
+        # Replace c's outline with leoNodes.py.
+        gnx2vnode = {}
+        ok = c.atFileCommands.fast_read_into_root(c, contents, gnx2vnode, path, root)
+        self.assertTrue(ok)
+        root.h = 'leoNodes.py'
+        self.p = root
+        c.selectPosition(self.p)
+    #@+node:ekr.20211013081200.2: *3* test_py2ts.test_setup
+    def test_setup(self):
+        c = self.c
+        assert self.x
+        assert self.p
+        if 0:
+            self.dump_tree()
+        if 0:
+            for p in c.all_positions():
+                g.printObj(p.b, tag=p.h)
+    #@+node:ekr.20211013085659.1: *3* test_py2ts.test_convert_position_class
+    def test_convert_position_class(self):
+        # Convert a copy of the Position class
+        self.x.convert(self.p)
+    #@+node:ekr.20211021075411.1: *3* test_py2ts.test_do_f_strings()
+    def test_do_f_strings(self):
+
+        x = self.x
+        tests = (
+            (
+                'g.es(f"{timestamp}created: {fileName}")\n',
+                'g.es(`${timestamp}created: ${fileName}`)\n',
+            ),
+            (
+                'g.es(f"read {len(files)} files in {t2 - t1:2.2f} seconds")\n',
+                'g.es(`read ${len(files)} files in ${t2 - t1} seconds`)\n',
+            ),
+            (
+                'print(f"s: {s!r}")\n',
+                'print(`s: ${s}`)\n',
+            ),
+        )
+        for test in tests:
+            source, expected = test
+            lines = [source]
+            x.do_f_strings(lines)
+            self.assertEqual(lines[-1], expected)
     #@-others
 #@-others
 #@-leo