import sublime import sublime_plugin import re class SmartQuoteReplacerCommand(sublime_plugin.TextCommand): def run(self, edit): # Get the entire content or selected text # Check if there's a selection by looking at the selection regions sel = self.view.sel() if len(sel) == 1 and sel[0].empty(): # No selection, process entire document regions = [sublime.Region(0, self.view.size())] else: # There is a selection, process only selected regions regions = list(sel) for region in regions: text = self.view.substr(region) new_text = self.replace_quotes_preserve_html_and_css(text) if new_text != text: self.view.replace(edit, region, new_text) def replace_quotes_preserve_html_and_css(self, text): # First, protect style and script blocks by temporarily replacing them style_blocks = [] script_blocks = [] # Extract and store style blocks def store_style(match): style_blocks.append(match.group(0)) return f"__STYLE_BLOCK_{len(style_blocks)-1}__" def store_script(match): script_blocks.append(match.group(0)) return f"__SCRIPT_BLOCK_{len(script_blocks)-1}__" # Temporarily replace style and script blocks text = re.sub( r"]*>.*?", store_style, text, flags=re.DOTALL | re.IGNORECASE, ) text = re.sub( r"]*>.*?", store_script, text, flags=re.DOTALL | re.IGNORECASE, ) # First, convert any existing smart quotes (curly quotes) to HTML entities text = self.convert_smart_quotes_to_entities(text) # Now process straight quotes while preserving HTML tags text = self.replace_quotes_smart(text) # Restore style blocks for i, style_block in enumerate(style_blocks): text = text.replace(f"__STYLE_BLOCK_{i}__", style_block) # Restore script blocks for i, script_block in enumerate(script_blocks): text = text.replace(f"__SCRIPT_BLOCK_{i}__", script_block) return text def convert_smart_quotes_to_entities(self, text): """Convert existing curly/smart quotes to HTML entities outside of HTML tags""" result = [] i = 0 while i < len(text): if text[i] == "<": # Start of HTML tag, find the end tag_end = text.find(">", i) if tag_end != -1: # Include the entire tag without modification result.append(text[i : tag_end + 1]) i = tag_end + 1 else: result.append(text[i]) i += 1 elif text[i] == '“': # Left double quote result.append("“") i += 1 elif text[i] == '”': # Right double quote result.append("”") i += 1 elif text[i] == '‘': # Left single quote result.append('‘') i += 1 elif text[i] == '’': # Right single quote result.append("’") i += 1 else: result.append(text[i]) i += 1 return "".join(result) def replace_quotes_smart(self, text): # Handle apostrophes first (before quote processing) # Graduation years (apostrophe before 2 digits) text = re.sub(r"'(\d{2})(?=\W|$)", r"’\1", text) # Contractions (apostrophes in middle of words) text = re.sub(r"(\w)'(\w)", r"\1’\2", text) # Possessives (apostrophes at end of words) text = re.sub(r"(\w)'(s\b|\s|$|[^\w])", r"\1’\2", text) # Now handle quotes with HTML awareness # This approach tracks quote state while skipping over HTML tags # Handle double quotes text = self.replace_double_quotes_html_aware(text) # Handle single quotes (that aren't apostrophes) text = self.replace_single_quotes_html_aware(text) return text def replace_double_quotes_html_aware(self, text): result = [] in_quote = False i = 0 while i < len(text): if text[i] == '"': # Check if we're inside an HTML tag by looking backwards and forwards if self.is_inside_html_tag(text, i): # We're inside an HTML tag, don't replace result.append(text[i]) else: # We're in text content, replace the quote if in_quote: result.append("”") in_quote = False else: result.append("“") in_quote = True i += 1 elif text[i] == "<": # Start of HTML tag, find the end tag_end = text.find(">", i) if tag_end != -1: # Include the entire tag result.append(text[i : tag_end + 1]) i = tag_end + 1 else: result.append(text[i]) i += 1 else: result.append(text[i]) i += 1 return "".join(result) def replace_single_quotes_html_aware(self, text): # For single quotes, we need to be more careful since they're often apostrophes # Only replace quotes that look like actual quotation marks # Opening single quote: after whitespace, punctuation, or start of string/line text = re.sub(r"(^|\s|[(\[{—-])'(?=\w)", r"\1‘", text) # Closing single quote: after word characters, before whitespace/punctuation/end # But avoid replacing if we're inside an HTML tag def replace_closing_quote(match): full_match = match.group(0) before = match.group(1) # Simple check: if the quote is followed by common HTML attribute patterns, skip it remaining_text = text[match.end() :] if re.match(r"\s*[=>]", remaining_text): return full_match # Likely inside HTML, don't replace return before + "’" text = re.sub(r"(\w)'(?=\s|[.,;:!?)}\]—-]|$)", replace_closing_quote, text) return text def is_inside_html_tag(self, text, pos): # Look backwards for the most recent < or > last_open = text.rfind("<", 0, pos) last_close = text.rfind(">", 0, pos) # If we found a < more recently than >, we're inside a tag return last_open > last_close class SmartQuoteReplacerListener(sublime_plugin.EventListener): def on_modified_async(self, view): # Optional: Auto-replace as you type # Uncomment the following lines if you want real-time replacement pass # settings = view.settings() # if settings.get('smart_quote_auto_replace', False): # view.run_command('smart_quote_replacer')