sp-keshavarz
A code-generation extension for people who write the same shapes every day.
It scaffolds whole folders from a template, completes single lines from a prefix
and adds the imports they need, builds a form out of a list of field names,
turns a sample API response into a TypeScript type, and copies a folder you
already wrote under a new name — rewriting every casing of it on the way.
Everything it emits comes from a template you can read and replace. It is fully
offline: no network calls, no AI, no runtime dependencies.
It adapts to the project it is in. Import paths are resolved against that
project's own tsconfig aliases, so it writes @/... in one repo and
#shared/... in another without being configured. Anything that would not make
sense in your project is not offered at all.
At a glance
|
Key |
What it does |
| Generate |
Ctrl+Alt+N |
A folder of files from a recipe, imports wired up, cursor on the first thing to type |
| Snips |
Ctrl+Space |
A prefix and Tab that inserts a line and its import |
| Form fields |
Ctrl+Alt+F |
A list of names becomes a form's inputs, schema, empty values and type |
| Type from JSON |
Ctrl+Alt+J |
A sample response becomes an export type |
| Duplicate As... |
right-click a folder |
A copy under a new name, every casing rewritten |
On macOS all of these are Cmd instead of Ctrl.
Install
From the marketplace, or with a .vsix:
code --install-extension sp-keshavarz-0.8.0.vsix
Reload the window and it is on. Nothing needs configuring — but see
Making it yours, because the templates that ship with it are
shaped around one set of conventions and yours are probably different.
Generating
Press Ctrl+Alt+N, or right-click a folder in the explorer and choose
My Workflow. Pick a generator, type a camelCase name, and the files appear.
The main one opens with its placeholders live: Tab moves between the things
you actually have to fill in.
What ships is four generators shaped around a React/React-Query codebase — a
component, an icon, a query and a mutation. The point is not that you use those;
it is that adding your own means adding a folder.
It is a single WorkspaceEdit, so Ctrl+Z undoes a whole generation.
It works out where the files go. A recipe can name a destination relative to
the package root and read part of it from the project — so generating a query
from anywhere inside modules/finance writes to modules/finance/queries
whether or not you navigated there, and without asking, because the module is
implied by where you invoked it. A folder you actually clicked in the explorer is
always obeyed instead.
To bind a key straight to one generator, skipping the picker:
{ "key": "ctrl+alt+q", "command": "sp-keshavarz.generate", "args": { "recipe": "query" } }
The argument is the recipe's folder name, including recipes you add yourself.
Snips
Type a prefix, press Tab, and the code appears — the way a normal VS Code
snippet works, except the import it needs is added at the top of the file in the
same keystroke:
qr⇥ const { data, isPending } = useQuery(getUserQueryOptions());
+ import { useQuery } from "@tanstack/react-query";
Nothing is added twice: a file that already imports useQuery gets no new line,
and a file that imports something else from @tanstack/react-query has that
import widened in place rather than imported again. The edit rides along as a
completion's additionalTextEdits — the same mechanism TypeScript's own
auto-import uses — so it never disturbs the snippet you are tabbing through.
A snip is only offered where it applies
This is what makes it safe to leave switched on in every project you open:
- The library has to be installed.
qr needs @tanstack/react-query in the
package's dependencies; bx needs @mui/material; frm needs
react-hook-form. In a project without them they are not in the list, and in
a plain Node project you are left with the handful that are true anywhere.
- The file has to exist. A snip that imports something from inside the
project — a
Text component, a useModal hook — is offered only where that
file is actually there. Inserting a reference to something a project does not
have is worse than offering nothing.
Two snips can also share a prefix and take turns, so one keystroke means the
same thing in projects that do it differently: nv is useNavigate in a
react-router project and the navigate hook in a Next one; frm brings a yup
resolver where one is installed and plain useForm where it is not.
What ships
|
|
| Anywhere |
clg console.log · tc try/catch · ty export type · enm enum · af async arrow fn |
| React |
us useState · uef useEffect · memo useMemo · ucb useCallback · uref useRef · red useReducer · uid useId · ctx createContext + hook · frag fragment |
| React Query |
qr useQuery · qo queryOptions · iq useInfiniteQuery · mut useMutation · qc useQueryClient · inv invalidateQueries · sqd setQueryData |
| Forms |
frm useForm · sub handleSubmit · watch useWatch · ctrl Controller · fa useFieldArray · fctx useFormContext · sch yup schema |
| Intl |
intl useIntl · fm a formatMessage call · fmsg <FormattedMessage /> · fnum <FormattedNumber /> |
| MUI |
bx Box · bxf flex row Box · bxc flex column Box · stk Stack · ib IconButton · cp CircularProgress · thm useTheme · sxp an sx prop |
Styling (sx) |
fx flex row · fcol column · fbtw space-between · fctr centered · fwrap wrap · grid grid columns · abs absolute · absc absolute centered · sq square · full fill parent · cover image cover · ell ellipsis · ptr pointer · card bordered · bgx filled |
| Routing |
nv navigate · prm useParams · srch useSearchParams · pth usePathname · rtr useRouter |
| State |
zst a zustand store |
The styling ones insert just the attribute, so they work on anything that takes
an sx prop:
fx⇥ sx={{ display: "flex", alignItems: "center", gap: ".5rem" }}
card⇥ sx={{ p: "1rem", borderRadius: ".75rem", border: "1px solid", borderColor: "grey.300" }}
A few more are shaped around components a particular codebase has (txt, fb,
fbi, mdl, tst, hn). They appear only where those files exist, which in
your project probably means never — write your own instead.
My Workflow: Insert Snip in the command palette lists the ones that apply
where you are, with what each would import.
A fifteen-field form is not one template, it is the same few lines fifteen
times, and the only thing that changes is a name you already know. Press
Ctrl+Alt+F and type the names (or select them in the editor first):
firstName, lastName, nationalCode, birthDate:date, gender:select:12, note?
Then pick which blocks to write — the inputs, a yup schema, an empty-values
object, the FormValues type.
name:kind:size is the whole syntax. The size is grid columns and defaults to
6; a trailing ? makes a field optional. The kind is whatever your input
component calls it and is passed through verbatim — what it means is inferred
from the word, so priceInput is a number, uploadFile is a file and
multiSelect is an array without any of them being configured. Commas, spaces
and newlines all separate, so a column of names pasted out of a design or an API
payload works as it is.
Which blocks you are offered follows the same rule as the snips: the yup schema
where yup is installed, the inputs where a matching component exists. They are
templates you can replace.
Type from JSON
Writing the response type is the expensive half of adding an endpoint: the call
is four lines, the type is forty. Copy a sample response, put the cursor where
the type belongs, and press Ctrl+Alt+J.
It reads the JSON from your selection (so JSON already pasted into the file is
replaced by its type in place), otherwise from the clipboard, otherwise it asks.
Inference is structural and merges every sample it can see: a list where one row
carries an extra field makes that field optional, and a value that is sometimes
null becomes string | null. Keys are emitted exactly as the API sends them,
quoted only when they are not valid identifiers. Empty arrays become unknown[]
and an always-null field becomes null — both are signs the sample did not say
enough, and are yours to fix.
You do not have to tidy the JSON first: trailing commas and comments are
tolerated, and so is a fragment copied out of a larger body ("response": {…}),
which is retried as the body of an object.
A { response: … } or { results: … } wrapper is unwrapped, so what you get is
the type that goes inside your envelope rather than the envelope itself.
Duplicate As...
For anything more specific than a generator, the most accurate template is a
folder you already wrote. Right-click one and choose Duplicate As...
It works out what to rename by scoring every plausible name against the folder's
own file names and keeping the one that accounts for most of them — a
getWalletQuery folder holding getWallet.* is renaming getWallet, and an
activateUserMutation folder holding activateUser.* plus useActivateUser.ts
is renaming activateUser, though those files share no common prefix. The
prompt tells you which name it settled on before anything happens.
Type the new name and the copy appears beside it with every casing rewritten —
getWallet, GetWallet, GET_WALLET, get-wallet, get_wallet — through file
contents, file names, nested folders and import paths alike. Binary files are
copied untouched. In a folder whose files have nothing in common (utils,
locales, assets/icons) only the folder itself is renamed.
There is no dry-run mode because the whole duplicate is one Ctrl+Z.
Two things it deliberately leaves alone, because it cannot know the answer: URLs
and query keys inside the copied code. Renaming is also substring-based, so an
identifier that merely contains the stem is rewritten too — which is what makes
the surrounding type and folder names come along.
Making it yours
The templates that ship are examples. Anything in a project's own
.vscode/snips/ folder is read first, so a file with the same name replaces
the bundled one, and anything new is simply added:
.vscode/
snips/
qr.snip a prefix of your own, or a replacement for one of ours
forms/
inputs.snip the shape your form inputs actually have
recipes/
endpoint/
recipe.json a generator of your own
function.txt
Nothing has to be rebuilt or reloaded: files are re-read when they change, so
editing a template takes effect on the next keystroke.
| Setting |
Default |
|
sp-keshavarz.snips.enabled |
true |
Offer snips as completions at all |
sp-keshavarz.snips.bundled |
true |
Include the ones that ship. Turn off to use only your own |
sp-keshavarz.snips.folders |
[".vscode/snips"] |
Workspace-relative folders to read from. Recipes come from a sibling recipes/, form blocks from a forms/ subfolder |
sp-keshavarz.apiClient |
[] |
Package-root-relative paths to your API client, for <<import apiClient>>. Empty means try the built-in guesses |
sp-keshavarz.responseTypes |
[] |
The same, for your shared response types |
Template syntax
Recipes, snips and form blocks all use one marker syntax, <<...>>:
| Marker |
Meaning |
<<name>> |
The name you typed, verbatim |
<<pascal name>> |
The name through a case filter |
<<1>> |
A tab stop — where the cursor lands |
<<1:text>> |
A tab stop with a default. Repeating an index mirrors it |
<<0>> |
The final cursor position |
<<#if flag>> … <<#else>> … <</if>> |
A conditional block |
<<import types>> |
The import specifier for another file in the same recipe |
Filters are camel, pascal, kebab, snake, constant, lower and upper.
They work on any input casing, so <<kebab name>> turns getUserProfile into
get-user-profile, and they work in file and folder paths too.
<<>> was chosen over {{}} because {{ collides with real code — JSX spreads
and sx={{ … }} — which these templates are full of.
An unknown <<marker>> is left in the output verbatim rather than dropped, so a
typo is visible immediately.
Writing a recipe
A recipe is a folder containing recipe.json and its templates. There is no
TypeScript to change and no list to register it in.
{
"label": "$(symbol-method) Component",
"detail": "component.tsx + .types.ts",
"noun": "Component",
"placeholder": "myComponent",
"example": "myComponent, userCard",
"order": 10,
"hints": ["components"],
"folder": "<<name>>",
"files": [
{ "id": "component", "path": "<<name>>.tsx", "template": "component.tsx.txt", "primary": true },
{ "id": "types", "path": "<<name>>.types.ts", "template": "types.txt" }
]
}
| Field |
Meaning |
label / detail |
How it reads in the picker ($(icon) codicons work) |
noun |
Used in prompts and messages |
placeholder / example |
Shown while you type the name |
order |
Position in the picker; lower is higher up |
hints |
Folder names that float this recipe to the top when you right-click one |
anchor |
Folder names (or paths) searched upwards when there is no clicked folder; defaults to hints |
prompts |
Values chosen before generating, read out of the project |
target |
Package-root-relative folder to generate into, instead of the clicked one |
folder |
Templated subfolder to create, or "." to write in place |
options |
Yes/no toggles, asked before generating, readable as <<#if flag>> |
files[].id |
How other templates refer to this file: <<import id>> |
files[].path |
Templated file name, relative to folder |
files[].template |
Template file, resolved inside the recipe's own folder |
files[].primary |
The file that opens with its tab stops live |
files[].when |
Only write this file when the named option is on |
Choosing where it goes. A recipe can find its own destination instead of
making you navigate there first:
"hints": ["queries"],
"prompts": [{ "name": "module", "label": "Which module?", "dirs": "src/modules" }],
"target": "src/modules/<<module>>/queries"
A prompt's choices are read out of the project as it is right now, never from a
list kept in sync by hand:
| Source |
Choices |
"dirs": "src/modules" |
Subdirectory names of that package-root-relative folder |
"exports": "src/types/api.ts" |
Names exported from that file |
"values": ["a", "b"] |
A fixed list |
A dirs prompt answers itself when the folder you invoked from is already
inside one of the choices — generating from anywhere under modules/finance
means the module is finance, with nothing to pick. "alwaysAsk": true opts
out. When a recipe would redirect you somewhere else and the value could not be
inferred, the chooser offers Here first, so it can never trap you into
generating away from the folder you clicked.
Keyboard invocation. A folder you picked in the explorer is an instruction
and is always obeyed. A folder that merely happens to contain the file you have
open is not — so from a keybinding, a recipe without a target searches upwards
for the folder its output belongs in, using anchor. Generating a component
while editing pages/dashboard/dashboard.tsx puts it in the module's
components folder, not inside the page. The nearest match wins.
A recipe that fails to parse is skipped with an explanation in the My
Workflow output channel, rather than breaking the others.
Writing a snip
One file per prefix, in .vscode/snips/. A short header, ---, then the code
exactly as it should appear:
prefix: qr
label: useQuery with an options builder
needs: package @tanstack/react-query
import: useQuery from @tanstack/react-query
---
const { data<<1>>, isPending } = useQuery(<<2>>QueryOptions(<<3>>));
| Header |
Meaning |
prefix |
What you type. The only required field |
label / detail |
How it reads in the completion list |
order |
Position wherever it is listed; lower comes first |
import |
useState, useEffect from react — added when the snip is inserted |
import (project file) |
~components/kit/text/text finds that file in this project and writes whatever alias covers it. a \| b lists fallbacks. A snip whose project import is missing is not offered at all |
import? |
The same, but optional: if it is missing the snip still appears without that import, and <<#if name>> is false |
import (auto) |
auto Text from ~... reads the file and decides whether to import it named or default |
import (default / type) |
default FormBuilder from ~..., type FieldValues from react-hook-form |
needs |
package react-router-dom, or a ~path. Prefix with ! to invert it, which is how two snips share a prefix |
have |
have: intl = package react-intl — not a gate, just a flag the body reads with <<#if intl>> |
lang |
Any of ts, tsx, js, jsx. Defaults to all four |
<<name>> in a snip body is the open file's own name, so <<pascal name>>
gives you its component name.
The blocks Ctrl+Alt+F writes are templates in a forms/ subfolder, in the
same format plus a <<#each>> that repeats over the fields:
prefix: schema
label: yup schema
order: 30
needs: package yup
import?: requiredText from ~helpers/requiredText
---
const schema = object().shape({
<<#each>>
<<key>>: <<yup>><<#if optional>>.optional()<<#else>>.required(<<#if requiredText>>requiredText<</if>>)<</if>>,
<</each>>
});
Inside <<#each>> each field offers <<name>>, <<key>> (quoted if it has to
be), <<kind>>, <<size>>, <<yup>>, <<empty>>, <<type>> and <<group>>,
with <<#if optional>> and <<#if requireable>>. Outside it there is
<<typeName>> and <<count>>. The yup import is worked out from the factories
your rendered block actually uses.
How imports are resolved
When the extension writes an import, it walks up from the target folder to the
first tsconfig.json whose paths actually cover it, and writes the specifier
against that alias — @/... in one project, #shared/... in another. It
follows extends chains and references, so a solution-style tsconfig.json
that only points at tsconfig.app.json resolves correctly, and in a monorepo it
lands on the owning app or package rather than the repo root.
If no alias covers the folder, imports fall back to relative paths and a warning
says why. Specifiers never carry a .ts/.tsx extension, so the output is
valid whether or not a project enables allowImportingTsExtensions.
Whether a name arrives as { Text } or Text is read off the file it resolved
to, so the same snip is correct in a project that exports it either way.
When something looks wrong
Open the My Workflow output channel. It logs which tsconfig and alias each
run used, which prompt values were inferred, which snips were skipped and why,
and any template that failed to parse. It is the first place to look when an
import comes out in a shape you did not expect.
Development
pnpm install
pnpm run compile # or: pnpm run watch
pnpm run lint
Press F5 for an Extension Development Host with the extension loaded. To run it
against your day-to-day editor instead, symlink the repo into your extensions
folder and reload:
ln -s "$PWD" ~/.vscode/extensions/noctyra.sp-keshavarz-dev
Requires VS Code 1.74+.
License
MIT.