convert_to_luasnip.py (6548 bytes)
1 #!/usr/bin/env python3 2 """Convert template files with <++> placeholders to LuaSnip VSCode-format snippets. 3 4 Each language directory becomes one <lang>.json file. A package.json lists 5 them so the LuaSnip from_vscode loader can find them. 6 """ 7 8 import argparse 9 import json 10 import re 11 from pathlib import Path 12 from typing import Any, Dict, List, Optional, Tuple 13 14 # Map from template directory name to Neovim filetype. 15 LANGUAGE_MAP = { 16 "c": "c", 17 "cl": "lisp", 18 "sh": "sh", 19 "html": "html", 20 "make": "make", 21 "awk": "awk", 22 "vim": "vim", 23 "sed": "sed", 24 } 25 26 # Map from root-level template file name to Neovim filetype. 27 ROOT_FILE_MAP = { 28 "openrc.init": "sh", 29 "git-config-branch": "gitconfig", 30 "uad.json": "json", 31 } 32 33 # <++> is an unnamed placeholder. <+name+> is a named placeholder. 34 PLACEHOLDER_RE = re.compile(r"<\+([^+>]*)\+>") 35 36 # A summary comment on the first line of a template, in any supported comment syntax. 37 HEAD_COMMENT_RE = re.compile(r"^\s*(?:#+|;+|//|<!--)\s+(.*?)\s*(?:-->)?\s*$") 38 39 40 def escape_snippet_text(text: str) -> str: 41 """Escape the characters that VSCode snippet syntax treats as special.""" 42 return re.sub(r"([\\$}])", r"\\\1", text) 43 44 45 def convert_placeholders(content: str) -> Tuple[str, int]: 46 """Replace each placeholder with a numbered tab stop. Escape all other text. 47 48 <++> becomes ${n}. <+name+> becomes ${n:name}. 49 """ 50 parts = PLACEHOLDER_RE.split(content) 51 body = escape_snippet_text(parts[0]) 52 count = 0 53 for name, text in zip(parts[1::2], parts[2::2]): 54 count += 1 55 stop = f"${{{count}:{escape_snippet_text(name)}}}" if name else f"${{{count}}}" 56 body += stop + escape_snippet_text(text) 57 return body, count 58 59 60 def generate_snippet_name(filepath: Path, template_dir: Path) -> str: 61 """Join the path parts with dashes: sh/trap/cleanup-temp -> sh-trap-cleanup-temp.""" 62 rel_path = filepath.relative_to(template_dir).with_suffix("") 63 return "-".join(rel_path.parts) 64 65 66 def get_description(filepath: Path, template_dir: Path) -> str: 67 """Use the summary comment on the first line. Fall back to the path.""" 68 lines = filepath.read_text(encoding="utf-8").splitlines() 69 if lines and lines[0].startswith("#!"): 70 lines = lines[1:] 71 match = HEAD_COMMENT_RE.match(lines[0]) if lines else None 72 if match and match.group(1): 73 return match.group(1) 74 rel_path = filepath.relative_to(template_dir) 75 return f"{rel_path} template" 76 77 78 def process_template_file(filepath: Path, template_dir: Path) -> Optional[Dict[str, Any]]: 79 """Read one template file. Return None when it is not a template.""" 80 try: 81 content = filepath.read_text(encoding="utf-8").strip() 82 except (UnicodeDecodeError, PermissionError): 83 return None 84 85 if not content or not PLACEHOLDER_RE.search(content): 86 return None 87 88 body, _ = convert_placeholders(content) 89 return { 90 "prefix": generate_snippet_name(filepath, template_dir), 91 "body": body.split("\n") + ["$0"], 92 "description": get_description(filepath, template_dir), 93 } 94 95 96 def language_for(filepath: Path, template_dir: Path) -> Optional[str]: 97 """Return the Neovim filetype for a template path, or None when unknown.""" 98 rel_path = filepath.relative_to(template_dir) 99 if len(rel_path.parts) > 1: 100 return LANGUAGE_MAP.get(rel_path.parts[0]) 101 return ROOT_FILE_MAP.get(rel_path.name) 102 103 104 def collect_templates_by_language(template_dir: Path) -> Dict[str, List[Dict[str, Any]]]: 105 """Collect all templates under template_dir, grouped by filetype.""" 106 templates_by_lang: Dict[str, List[Dict[str, Any]]] = {} 107 108 for filepath in sorted(template_dir.rglob("*")): 109 if not filepath.is_file(): 110 continue 111 rel_path = filepath.relative_to(template_dir) 112 if any(part.startswith(".") for part in rel_path.parts): 113 continue 114 115 lang = language_for(filepath, template_dir) 116 if lang is None: 117 continue 118 119 snippet = process_template_file(filepath, template_dir) 120 if snippet is not None: 121 templates_by_lang.setdefault(lang, []).append(snippet) 122 123 return templates_by_lang 124 125 126 def write_snippet_files(templates_by_lang: Dict[str, List[Dict[str, Any]]], output_dir: Path) -> None: 127 """Write one <lang>.json per language and a package.json that lists them.""" 128 output_dir.mkdir(parents=True, exist_ok=True) 129 contributed = [] 130 131 for lang, snippets in sorted(templates_by_lang.items()): 132 snippet_obj = {snippet["prefix"]: snippet for snippet in snippets} 133 output_file = output_dir / f"{lang}.json" 134 output_file.write_text( 135 json.dumps(snippet_obj, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" 136 ) 137 contributed.append({"language": lang, "path": f"./{lang}.json"}) 138 print(f"Created {output_file} with {len(snippet_obj)} snippets") 139 140 package = { 141 "name": "tmpl-snippets", 142 "contributes": {"snippets": contributed}, 143 } 144 (output_dir / "package.json").write_text( 145 json.dumps(package, indent=2) + "\n", encoding="utf-8" 146 ) 147 148 149 def parse_args() -> argparse.Namespace: 150 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 151 parser.add_argument( 152 "template_dir", 153 nargs="?", 154 type=Path, 155 default=Path(__file__).resolve().parent, 156 help="directory that holds the templates (default: this script's directory)", 157 ) 158 parser.add_argument( 159 "--output", 160 type=Path, 161 default=Path.home() / ".config/nvim/snippets", 162 help="directory to write snippet files into (default: ~/.config/nvim/snippets)", 163 ) 164 parser.add_argument( 165 "--dry-run", 166 action="store_true", 167 help="list the snippets that would be written, write nothing", 168 ) 169 return parser.parse_args() 170 171 172 def main() -> None: 173 args = parse_args() 174 templates_by_lang = collect_templates_by_language(args.template_dir) 175 176 if not templates_by_lang: 177 print("No templates with <++> placeholders found.") 178 return 179 180 if args.dry_run: 181 for lang, snippets in sorted(templates_by_lang.items()): 182 for snippet in snippets: 183 print(f"{lang}\t{snippet['prefix']}") 184 return 185 186 write_snippet_files(templates_by_lang, args.output) 187 print("\nDone. Load the snippets in Neovim with:") 188 print(f' require("luasnip.loaders.from_vscode").lazy_load({{ paths = {{ "{args.output}" }} }})') 189 190 191 if __name__ == "__main__": 192 main()