Coverage for src/renaissance/utils/text_utils.py: 53%
98 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
1# Copyright (C) 2024-2026 TNO
2#
3# Licensed under the terms specified in the LICENSE file in the root directory
4# of this repository, also available at:
5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE
7"""AI: Text manipulation utilities for shifting, indenting, and clipboard operations on source text."""
9import re
10import subprocess
11import sys
12import tempfile
13from pathlib import Path
15import pyperclip
18class TextUtils:
19 """AI: Text manipulation utilities for shifting, indenting, and clipboard operations on source text."""
21 __PRECEDING_SPACES_PATTERN = re.compile(r"([\t\s]*)")
23 @staticmethod
24 def shift_left(text: str, shift: int, start_line: int = 0) -> str:
25 """Shifts each line of the given text to the left by the specified number of spaces. Only spaces are shifted."""
26 if shift == 0:
27 return text
28 pattern = re.compile(r"\s{0," + str(shift) + "}(.*)")
29 lines = text.split("\n")
30 for idx, line in enumerate(lines[start_line:]):
31 lines[idx + start_line] = pattern.sub(r"\1", line)
32 return "\n".join(lines)
34 @staticmethod
35 def correct_indent(text: str, indent: int, depth: int = 0) -> str:
36 """Shifts each line of the given text to the left by the specified number of spaces. Only spaces are shifted."""
37 lines = text.split("\n")
38 for idx, line in enumerate(lines):
39 depth -= line.count("}")
40 stripped = re.sub(r"^\s*", "", line)
41 lines[idx] = " " * depth * indent + stripped if stripped else stripped
42 depth += line.count("{")
44 return "\n".join(lines)
46 @staticmethod
47 def strip_indent(text: str, start_line: int = 0) -> str:
48 """Shift the text left so the first line has no leading spaces.
50 All other lines are shifted left by the same amount.
51 """
52 matcher = TextUtils.__PRECEDING_SPACES_PATTERN.search(text)
53 if matcher:
54 spaces = matcher[1]
55 text = TextUtils.shift_left(text, len(spaces), start_line)
56 return text.strip()
58 @staticmethod
59 def shift_right(text: str, shift: int, start_line: int = 0) -> str:
60 """Shifts each line of the given text to the left by the specified number of spaces. Only spaces are shifted."""
61 if shift == 0:
62 return text
63 lines = text.split("\n")
64 spaces = " " * shift
65 for idx, line in enumerate(lines[start_line:]):
66 lines[idx + start_line] = spaces + line if line else line
67 return "\n".join(lines)
69 @staticmethod
70 def get_indent(content: bytes, offset: int) -> int:
71 """Calculate the indentation level of a line in a byte string.
73 Args:
74 content (bytes): The byte string containing the text.
75 offset (int): The position within the byte string to start calculating the indentation from.
77 Returns:
78 int: The number of leading whitespace characters (tabs or spaces) from the start of the line to the given offset.
80 """
81 indent = offset
82 while indent > 1:
83 if content[indent - 1] in b"\n\r":
84 break
85 indent -= 1
86 start_of_line = indent
87 while indent < offset:
88 if content[indent] not in b"\t ":
89 break
90 indent += 1
91 return indent - start_of_line
93 @staticmethod
94 def get_spaces_before(content: bytes, offset: int) -> int:
95 """Calculate the indentation level of a line in a byte string.
97 Args:
98 content (bytes): The byte string containing the text.
99 offset (int): The position within the byte string to start calculating the indentation from.
101 Returns:
102 int: The number of leading whitespace characters (tabs or spaces) from the start of the line to the given offset.
104 """
105 indent = offset - 1
106 while indent > 0:
107 if content[indent] not in b" \t":
108 break
109 indent -= 1
110 return offset - indent - 1
112 @staticmethod
113 def to_clipboard(text: str) -> None:
114 """AI: Copy the given text to the system clipboard."""
115 pyperclip.copy(text)
117 @staticmethod
118 def to_file(filename: str, text: str) -> None:
119 """Write the given text to a file with the specified filename."""
120 with Path(filename).open("w") as f:
121 f.write(text)
124def signature_to_id(signature: str) -> str:
125 """AI: Derive a short, filesystem/identifier-safe id from a node signature."""
126 text = signature.replace("\n", " ")
127 return re.sub(r"[^\w\s]", "", text)[:30] # Remove punctuation, limit length
130def snake_case(snippet: str) -> str:
131 """Convert a camelCase or PascalCase string to snake_case, preserving acronyms as single words.
133 Leaves a string already in snake_case unchanged.
134 """
135 snippet = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", snippet)
136 snippet = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", snippet)
137 return snippet.lower()
140def fix_indent(code_string: str) -> str | None:
141 """AI: Reformat code_string's indentation by round-tripping it through a temporary file and an external formatter."""
142 with tempfile.NamedTemporaryFile(suffix=".py", mode="w+", delete=False) as temp_file:
143 file_path = temp_file.name
144 temp_file.write(code_string)
146 try:
147 if not Path(file_path).is_file():
148 print(f"Error: {file_path} does not exist.")
149 return None
151 # Step 1: Run flake8 to show issues
152 print("Running flake8...")
153 subprocess.run([sys.executable, "-m", "flake8", file_path])
155 # Step 2: Auto-fix with autopep8
156 print("Auto-fixing with autopep8...")
157 subprocess.run(
158 [
159 sys.executable,
160 "-m",
161 "autopep8",
162 "--in-place",
163 "--aggressive",
164 "--aggressive",
165 file_path,
166 ],
167 )
169 # Step 3: Run flake8 again to verify
170 print("Re-running flake8 after fixes...")
171 subprocess.run([sys.executable, "-m", "flake8", file_path])
173 # Read the fixed code
174 with Path(file_path).open() as file:
175 fixed_code = file.read()
177 # black format
178 # return format_str(fixed_code, mode=FileMode())
179 return fixed_code
180 except Exception as e:
181 print(f"Error formatting code: {e}")
182 finally:
183 # Clean up the temporary file
184 if Path(file_path).exists():
185 Path(file_path).unlink()