Recently Written · git

tmpl

Insanely simple templates

git clone https://github.com/equwal/tmpl

Log | Files | Refs


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