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

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 

6 

7"""AI: Text manipulation utilities for shifting, indenting, and clipboard operations on source text.""" 

8 

9import re 

10import subprocess 

11import sys 

12import tempfile 

13from pathlib import Path 

14 

15import pyperclip 

16 

17 

18class TextUtils: 

19 """AI: Text manipulation utilities for shifting, indenting, and clipboard operations on source text.""" 

20 

21 __PRECEDING_SPACES_PATTERN = re.compile(r"([\t\s]*)") 

22 

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) 

33 

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("{") 

43 

44 return "\n".join(lines) 

45 

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. 

49 

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() 

57 

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) 

68 

69 @staticmethod 

70 def get_indent(content: bytes, offset: int) -> int: 

71 """Calculate the indentation level of a line in a byte string. 

72 

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. 

76 

77 Returns: 

78 int: The number of leading whitespace characters (tabs or spaces) from the start of the line to the given offset. 

79 

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 

92 

93 @staticmethod 

94 def get_spaces_before(content: bytes, offset: int) -> int: 

95 """Calculate the indentation level of a line in a byte string. 

96 

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. 

100 

101 Returns: 

102 int: The number of leading whitespace characters (tabs or spaces) from the start of the line to the given offset. 

103 

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 

111 

112 @staticmethod 

113 def to_clipboard(text: str) -> None: 

114 """AI: Copy the given text to the system clipboard.""" 

115 pyperclip.copy(text) 

116 

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) 

122 

123 

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 

128 

129 

130def snake_case(snippet: str) -> str: 

131 """Convert a camelCase or PascalCase string to snake_case, preserving acronyms as single words. 

132 

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() 

138 

139 

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) 

145 

146 try: 

147 if not Path(file_path).is_file(): 

148 print(f"Error: {file_path} does not exist.") 

149 return None 

150 

151 # Step 1: Run flake8 to show issues 

152 print("Running flake8...") 

153 subprocess.run([sys.executable, "-m", "flake8", file_path]) 

154 

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 ) 

168 

169 # Step 3: Run flake8 again to verify 

170 print("Re-running flake8 after fixes...") 

171 subprocess.run([sys.executable, "-m", "flake8", file_path]) 

172 

173 # Read the fixed code 

174 with Path(file_path).open() as file: 

175 fixed_code = file.read() 

176 

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()