Python Parser

import os
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import threading

class FileSearcher:
    def __init__(self, root):
        self.root = root
        self.root.title("File String Searcher")
        self.root.geometry("900x650")
        self._stop_event = threading.Event()
        self._search_thread = None
        self._terms_list = None   # list[str] when a .txt terms file is loaded
        
        main_frame = ttk.Frame(root, padding="10")
        main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        
        root.columnconfigure(0, weight=1)
        root.rowconfigure(0, weight=1)
        main_frame.columnconfigure(1, weight=1)
        main_frame.rowconfigure(5, weight=1)
        
        # ---- Path row ----
        ttk.Label(main_frame, text="Search Path:").grid(row=0, column=0, sticky=tk.W, pady=5)
        self.path_var = tk.StringVar()
        ttk.Entry(main_frame, textvariable=self.path_var, width=50).grid(
            row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
        ttk.Button(main_frame, text="Browse", command=self.browse_folder).grid(
            row=0, column=2, pady=5)
        
        # ---- Search string row ----
        ttk.Label(main_frame, text="Search String:").grid(row=1, column=0, sticky=tk.W, pady=5)
        self.search_var = tk.StringVar()
        self.search_entry = ttk.Entry(main_frame, textvariable=self.search_var, width=50)
        self.search_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
        self.search_entry.bind('<Return>', lambda e: self.start_search())
        
        # ---- Terms file row ----
        ttk.Label(main_frame, text="Terms File (.txt):").grid(row=2, column=0, sticky=tk.W, pady=5)
        self.terms_var = tk.StringVar()
        self.terms_entry = ttk.Entry(main_frame, textvariable=self.terms_var, width=50)
        self.terms_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
        
        terms_btn_frame = ttk.Frame(main_frame)
        terms_btn_frame.grid(row=2, column=2, sticky=tk.W)
        ttk.Button(terms_btn_frame, text="Load",
                   command=self.load_terms_file).pack(side=tk.LEFT)
        ttk.Button(terms_btn_frame, text="X",
                   command=self.clear_terms_file, width=2).pack(side=tk.LEFT, padx=(2, 0))
        
        # ---- Options + buttons row ----
        options_frame = ttk.Frame(main_frame)
        options_frame.grid(row=3, column=0, columnspan=3, pady=8, sticky=tk.W)
        
        self.case_sensitive_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(options_frame, text="Case sensitive",
                        variable=self.case_sensitive_var).pack(side=tk.LEFT, padx=(0, 20))
        
        self.search_button = ttk.Button(options_frame, text="Search", command=self.start_search)
        self.search_button.pack(side=tk.LEFT, padx=5)
        
        self.stop_button = ttk.Button(options_frame, text="Stop",
                                      command=self.stop_search, state='disabled')
        self.stop_button.pack(side=tk.LEFT, padx=5)
        
        self.clear_button = ttk.Button(options_frame, text="Clear Results",
                                       command=self.clear_results)
        self.clear_button.pack(side=tk.LEFT, padx=5)
        
        # ---- Progress ----
        self.progress = ttk.Progressbar(main_frame, mode='indeterminate')
        self.progress.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
        
        # ---- Results ----
        result_frame = ttk.Frame(main_frame)
        result_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
        result_frame.columnconfigure(0, weight=1)
        result_frame.rowconfigure(0, weight=1)
        
        columns = ("Term", "Path", "File", "Line", "Match")
        self.tree = ttk.Treeview(result_frame, columns=columns, show="headings", height=15)
        self.tree.heading("Term", text="Matched Term")
        self.tree.heading("Path", text="Full Path")
        self.tree.heading("File", text="File Name")
        self.tree.heading("Line", text="Line #")
        self.tree.heading("Match", text="Matched Line")
        self.tree.column("Term", width=130)
        self.tree.column("Path", width=280)
        self.tree.column("File", width=160)
        self.tree.column("Line", width=60)
        self.tree.column("Match", width=280)
        
        vsb = ttk.Scrollbar(result_frame, orient="vertical", command=self.tree.yview)
        hsb = ttk.Scrollbar(result_frame, orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
        self.tree.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        vsb.grid(row=0, column=1, sticky=(tk.N, tk.S))
        hsb.grid(row=1, column=0, sticky=(tk.W, tk.E))
        
        # ---- Status ----
        self.status_var = tk.StringVar(value="Ready to search")
        ttk.Label(main_frame, textvariable=self.status_var).grid(
            row=6, column=0, columnspan=3, sticky=tk.W, pady=5)
    
    # ---------- Callbacks ----------
    def browse_folder(self):
        folder = filedialog.askdirectory()
        if folder:
            self.path_var.set(folder)
    
    def load_terms_file(self):
        fp = filedialog.askopenfilename(
            title="Select terms file",
            filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
        if not fp:
            return
        try:
            with open(fp, 'r', encoding='utf-8', errors='replace') as f:
                terms = [line.strip() for line in f if line.strip()]
        except OSError as e:
            messagebox.showerror("Error", f"Could not read terms file:\n{e}")
            return
        
        if not terms:
            messagebox.showwarning("Empty file", "The selected file has no non-empty lines.")
            return
        
        self._terms_list = terms
        self.terms_var.set(fp)
        self.status_var.set(f"Loaded {len(terms)} search term(s) from {os.path.basename(fp)}")
    
    def clear_terms_file(self):
        self._terms_list = None
        self.terms_var.set("")
        self.status_var.set("Cleared terms file.")
    
    def clear_results(self):
        for item in self.tree.get_children():
            self.tree.delete(item)
        self.status_var.set("Results cleared.")
    
    def start_search(self):
        path = self.path_var.get().strip()
        
        # Determine search terms
        if self._terms_list:
            search_terms = self._terms_list
        else:
            single = self.search_var.get().strip()
            if not single:
                messagebox.showwarning("Warning",
                    "Enter a search string or load a terms .txt file.")
                return
            search_terms = [single]
        
        if not path:
            messagebox.showwarning("Warning", "Please select a search path")
            return
        if not os.path.exists(path):
            messagebox.showerror("Error", f"Path does not exist: {path}")
            return
        
        for item in self.tree.get_children():
            self.tree.delete(item)
        
        self._stop_event.clear()
        self.search_button.config(state='disabled')
        self.stop_button.config(state='normal')
        self.progress.start(10)
        self.status_var.set(f"Searching for {len(search_terms)} term(s)...")
        
        self._search_thread = threading.Thread(
            target=self.search_files,
            args=(path, search_terms, self.case_sensitive_var.get()),
            daemon=True)
        self._search_thread.start()
    
    def stop_search(self):
        self._stop_event.set()
        self.status_var.set("Stopping...")
    
    # ---------- Search core ----------
    def search_files(self, path, search_terms, case_sensitive):
        matched_files = 0
        total_files = 0
        
        # Prepare (term_bytes, term_display) pairs once.
        terms = [(t.encode('utf-8', errors='ignore'), t) for t in search_terms]
        # overlap = longest needle minus 1, so cross-chunk matches aren't missed
        overlap = max(len(tb) for tb, _ in terms) - 1 if terms else 0
        
        try:
            for root_dir, dirs, files in os.walk(path):
                if self._stop_event.is_set():
                    break
                
                dirs[:] = [d for d in dirs if d not in (
                    '.git', 'node_modules', '__pycache__', '.venv', 'venv')]
                
                for file in files:
                    if self._stop_event.is_set():
                        break
                    
                    total_files += 1
                    file_path = os.path.join(root_dir, file)
                    
                    try:
                        if os.path.getsize(file_path) == 0:
                            continue
                        if self._scan_file(file_path, terms, overlap, case_sensitive):
                            matched_files += 1
                    except (PermissionError, OSError, FileNotFoundError):
                        continue
                    
                    if total_files % 500 == 0:
                        msg = f"Scanning... {total_files} files, {matched_files} matches"
                        self.root.after(0, self.status_var.set, msg)
            
            self.root.after(0, self.search_complete,
                            matched_files, total_files,
                            self._stop_event.is_set())
        except Exception as e:
            self.root.after(0, self.search_error, str(e))
    
    def _scan_file(self, file_path, terms, overlap, case_sensitive):
        """
        Stream file in 64KB chunks. All terms are searched in one pass.
        `overlap` bytes from the previous chunk are retained so matches
        spanning a chunk boundary aren't missed.
        """
        chunk_size = 64 * 1024
        prev_tail = b''
        line_number = 1
        found_any = False
        first_chunk = True
        
        try:
            with open(file_path, 'rb') as f:
                while True:
                    if self._stop_event.is_set():
                        break
                    
                    chunk = f.read(chunk_size)
                    if not chunk:
                        break
                    
                    if first_chunk:
                        first_chunk = False
                        if b'\x00' in chunk:
                            return False  # binary
                    
                    combined = prev_tail + chunk
                    
                    # Search every term in this combined slice.
                    # For case-insensitive mode, lower once per chunk.
                    haystack = combined if case_sensitive else combined.lower()
                    
                    for term_bytes, term_display in terms:
                        needle = term_bytes if case_sensitive else term_bytes.lower()
                        positions = []
                        idx = 0
                        while True:
                            p = haystack.find(needle, idx)
                            if p == -1:
                                break
                            positions.append(p)
                            idx = p + 1
                        
                        for pos in positions:
                            line_start = combined.rfind(b'\n', 0, pos) + 1
                            line_end = combined.find(b'\n', pos)
                            if line_end == -1:
                                line_end = len(combined)
                            
                            lines_before = combined.count(b'\n', 0, pos)
                            line_num = line_number + lines_before
                            
                            snippet = combined[line_start:line_end].decode(
                                'utf-8', errors='replace')
                            if len(snippet) > 200:
                                snippet = snippet[:200] + "..."
                            
                            self.root.after(0, self.add_result,
                                            term_display, file_path,
                                            os.path.basename(file_path),
                                            line_num, snippet)
                            found_any = True
                    
                    consumed = len(combined) - overlap
                    if consumed > 0:
                        line_number += combined.count(b'\n', 0, consumed)
                        prev_tail = combined[consumed:]
                    else:
                        prev_tail = combined
        
        except (UnicodeDecodeError, PermissionError, OSError):
            return False
        
        return found_any
    
    def add_result(self, term, file_path, file_name, line_num, match_context):
        self.tree.insert("", "end",
                         values=(term, file_path, file_name, line_num, match_context))
    
    def search_complete(self, matched_files, total_files, was_stopped):
        self.progress.stop()
        self.search_button.config(state='normal')
        self.stop_button.config(state='disabled')
        prefix = "Search stopped" if was_stopped else "Search complete"
        self.status_var.set(
            f"{prefix}. {matched_files} match(es) in {total_files} files.")
    
    def search_error(self, error_message):
        self.progress.stop()
        self.search_button.config(state='normal')
        self.stop_button.config(state='disabled')
        self.status_var.set("Search failed")
        messagebox.showerror("Search Error", f"An error occurred: {error_message}")


def main():
    root = tk.Tk()
    FileSearcher(root)
    root.mainloop()


if __name__ == "__main__":
    main()