Coverage for src/renaissance/utils/text_utils.py: 53%

98 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-02 13:12 +0000

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

2 

3import re 

4import subprocess 

5import sys 

6import tempfile 

7from pathlib import Path 

8 

9import pyperclip 

10 

11 

12class TextUtils: 

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

14 

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

16 

17 @staticmethod 

18 def shift_left(text: str, shift: int, start_line: int = 0) -> str: 

19 """Shifts each line of the given text to the left by the specified number of spaces. Only spaces are shifted.""" 

20 if shift == 0: 

21 return text 

22 pattern = re.compile(r"\s{0," + str(shift) + "}(.*)") 

23 lines = text.split("\n") 

24 for idx, line in enumerate(lines[start_line:]): 

25 lines[idx + start_line] = pattern.sub(r"\1", line) 

26 return "\n".join(lines) 

27 

28 @staticmethod 

29 def correct_indent(text: str, indent: int, depth: int = 0) -> str: 

30 """Shifts each line of the given text to the left by the specified number of spaces. Only spaces are shifted.""" 

31 lines = text.split("\n") 

32 for idx, line in enumerate(lines): 

33 depth -= line.count("}") 

34 stripped = re.sub(r"^\s*", "", line) 

35 lines[idx] = " " * depth * indent + stripped if stripped else stripped 

36 depth += line.count("{") 

37 

38 return "\n".join(lines) 

39 

40 @staticmethod 

41 def strip_indent(text: str, start_line: int = 0) -> str: 

42 """Shift the text left so the first line has no leading spaces. 

43 

44 All other lines are shifted left by the same amount. 

45 """ 

46 matcher = TextUtils.__PRECEDING_SPACES_PATTERN.search(text) 

47 if matcher: 

48 spaces = matcher[1] 

49 text = TextUtils.shift_left(text, len(spaces), start_line) 

50 return text.strip() 

51 

52 @staticmethod 

53 def shift_right(text: str, shift: int, start_line: int = 0) -> str: 

54 """Shifts each line of the given text to the left by the specified number of spaces. Only spaces are shifted.""" 

55 if shift == 0: 

56 return text 

57 lines = text.split("\n") 

58 spaces = " " * shift 

59 for idx, line in enumerate(lines[start_line:]): 

60 lines[idx + start_line] = spaces + line if line else line 

61 return "\n".join(lines) 

62 

63 @staticmethod 

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

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

66 

67 Args: 

68 content (bytes): The byte string containing the text. 

69 offset (int): The position within the byte string to start calculating the indentation from. 

70 

71 Returns: 

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

73 

74 """ 

75 indent = offset 

76 while indent > 1: 

77 if content[indent - 1] in b"\n\r": 

78 break 

79 indent -= 1 

80 start_of_line = indent 

81 while indent < offset: 

82 if content[indent] not in b"\t ": 

83 break 

84 indent += 1 

85 return indent - start_of_line 

86 

87 @staticmethod 

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

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

90 

91 Args: 

92 content (bytes): The byte string containing the text. 

93 offset (int): The position within the byte string to start calculating the indentation from. 

94 

95 Returns: 

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

97 

98 """ 

99 indent = offset - 1 

100 while indent > 0: 

101 if content[indent] not in b" \t": 

102 break 

103 indent -= 1 

104 return offset - indent - 1 

105 

106 @staticmethod 

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

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

109 pyperclip.copy(text) 

110 

111 @staticmethod 

112 def to_file(filename: str, text: str) -> None: 

113 """Write the given text to a file with the specified filename.""" 

114 with Path(filename).open("w") as f: 

115 f.write(text) 

116 

117 

118def signature_to_id(signature: str) -> str: 

119 """AI: Derive a short, filesystem/identifier-safe id from a node signature.""" 

120 text = signature.replace("\n", " ") 

121 return re.sub(r"[^\w\s]", "", text)[:30] # Remove punctuation, limit length 

122 

123 

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

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

126 

127 Leaves a string already in snake_case unchanged. 

128 """ 

129 snippet = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", snippet) 

130 snippet = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", snippet) 

131 return snippet.lower() 

132 

133 

134def fix_indent(code_string: str) -> str | None: 

135 """AI: Reformat code_string's indentation by round-tripping it through a temporary file and an external formatter.""" 

136 with tempfile.NamedTemporaryFile(suffix=".py", mode="w+", delete=False) as temp_file: 

137 file_path = temp_file.name 

138 temp_file.write(code_string) 

139 

140 try: 

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

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

143 return None 

144 

145 # Step 1: Run flake8 to show issues 

146 print("Running flake8...") 

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

148 

149 # Step 2: Auto-fix with autopep8 

150 print("Auto-fixing with autopep8...") 

151 subprocess.run( 

152 [ 

153 sys.executable, 

154 "-m", 

155 "autopep8", 

156 "--in-place", 

157 "--aggressive", 

158 "--aggressive", 

159 file_path, 

160 ], 

161 ) 

162 

163 # Step 3: Run flake8 again to verify 

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

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

166 

167 # Read the fixed code 

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

169 fixed_code = file.read() 

170 

171 # black format 

172 # return format_str(fixed_code, mode=FileMode()) 

173 return fixed_code 

174 except Exception as e: 

175 print(f"Error formatting code: {e}") 

176 finally: 

177 # Clean up the temporary file 

178 if Path(file_path).exists(): 

179 Path(file_path).unlink()