Skip to content

Scripting with Lua ​

A script is a few lines of Lua that act on the file you are editing: tidy a selection, rewrite a block, jump to the next match of a syntax query. You keep it under Settings › Scripts, give it a name, and run it however suits you — a hardware key, the command palette, a key on the accessory bar, or :Name on vim's command line. Vim does not have to be on.

Scripts run in a sandbox: they can read and change the open file and ask the app to do what your keys can, and nothing else — no files of their own, no network, no system.

Your first script ​

  1. Settings › Scripts, tap +.

  2. Name it Shout. A name starts with a capital letter and has only letters and digits.

  3. Under Source, tap Edit. The editor opens with a starter that says how the script runs. Its toolbar's share and download buttons (Share, Import) are for a script kept as a file. Replace it with:

    lua
    local i, j = selection()
    if j < i then return notify('Select something first') end
    buf.replace(i, j, buf.text():sub(i, j):upper())

    Tap Done. Done is offered once the source compiles.

  4. Tap Save on the script's screen.

Open a file, select a word, and run it: from the command palette (Run script: Shout), or with vim on, type :Shout. To give it a key, see Running a script.

The Scripts screen ​

Each row is a script: its key on the left, its name, and the first line of its source.

  • Tap a row to open the script. Tap its key to set its mnemonic. Long-press to delete it.
  • On a script's screen:
    • Name is fixed once the script exists — it is how the script is run, and its shortcut and mnemonic are kept by it. To rename, make a new script and delete the old one.
    • Source is shown with line numbers. Edit opens it in the editor, which checks the Lua as you type and underlines a line that will not compile; Done is offered once it compiles and brings the source back here.
    • Mnemonic is the two characters on the script's key. Left empty, the key shows the first letters of the name: OI for OrganizeImports, Jo for Join. Another script or snippet already showing the same characters is pointed out; you can keep them anyway.
    • Save keeps everything at once. Until you tap it, nothing changes: a key, a shortcut or :Name runs the script as it was last saved.
    • Delete removes the script — see Deleting a script.

Running a script ​

FromHow
A hardware keySettings › Shortcuts › Scripts: each script is a shortcut, Run script: Name. Give it a key there as you would any shortcut.
The command paletteSearch for Run script: Name.
The accessory barEach script is a key in the key library and the key picker, under Scripts. Put it on one of your keyboards — see Keyboards.
Vim's command line:Name, with anything after the name handed to the script (below). :action script.Name runs it too.
Your vimrc or a command of your owncommand J Join makes :J run the script Join; a mapping can run :Join<CR>.
  • A script runs on the file you are editing. Outside the editor — on Home, in Settings — its key does nothing.
  • Arguments come only from the command line: what follows the name is the script's ..., so :Grep TODO hands the script 'TODO' (local word = ...). From a key, the palette or the accessory bar, ... is empty.
  • A key another shortcut already uses is pointed out when you set it; if you keep it, the other shortcut wins. Give the script a key of its own.
  • One script at a time. Running a script while one is still running — a held key repeats — is refused: Script Join is already running.

With vim on, and with vim off ​

Scripts run either way. What differs is where they speak:

  • With vim on, a script's printed lines and its error show on vim's message line, as :lua's do.
  • With vim off, they show on the editor's status line — an error for longer — and mode() answers off. The bar shows the last line of what was printed; when there is more, a speech-bubble button beside it opens the whole output, and the button stays after the line clears. ex() and registers are vim's, so they are refused; everything else works.

Stopping a script ​

Esc or Ctrl+C stops a running script with "Interrupted" — also one that is waiting on the app, such as a slow format. With vim on, the same Esc that leaves insert mode stops it.

The Lua you can use ​

Lua 5.3, with its string, table, math and utf8 libraries and the basics (pairs, ipairs, pcall, tostring, select, …). Nothing reaches outside the editor: no io, no os, no require; load takes Lua text only.

  • print(...) collects lines and shows them when the script ends — the last 20, with a count of any before them. notify(text) shows a line at once.
  • Strings are bytes, as in any Lua: #'中文' is 6. utf8.len, utf8.offset and utf8.codes count and step by characters.
  • An error stops the script and names it and the line: Join:3: attempt to index a nil value.
  • Each run starts fresh: nothing one run sets is there for the next.

The editor, from Lua ​

Positions are Lua's: 1-based byte positions, ranges inclusive, as string.sub counts them — so what string.find gives you goes straight back in:

lua
local i, j = buf.text():find('TODO')
if i then buf.replace(i, j, 'DONE') end
CallWhat it does
buf.text()The whole file, as a string
buf.lines(), buf.line(n)Every line as a table, or line n (1-based)
buf.replace(i, j, text)Replace bytes i to j; buf.replace(i, i - 1, text) inserts at i
buf.format(i, j)Format bytes i to j (Python, JavaScript, TypeScript, JSON, CSS, GraphQL)
cursor(), cursor(pos)The cursor's position, or move it there
selection(), selection(i, j)The selection as i, j — an empty one is i, i - 1 — or select bytes i to j
action(id, arg)Run an app action by its id — the ids are listed in Vim mode
set(name, value)Set an editor option: set('ts', 2), set('nu', false)
syntax.outline(), syntax.outline(path)The file's outline, as the structure panel shows it, or another project file's
syntax.nodeAt(pos)The syntax node at a position and the nodes around it
syntax.query(pattern), syntax.query(pattern, path)Every match of a tree-sitter query, in this file or another project file
notify(text)Show a line now
mode()Vim's mode: normal, insert, visual or replace, and line or block for a visual mode; off with vim off
ex(line)Run a vim command line, | and all (vim on only)
registers.a, registers.a = 'text'Read or set a vim register, as :let @a does; + sets the clipboard (vim on only)
  • A position inside a character is refused, never moved to the nearest one: buf.replace(3, 3, 'x') in héllo stops with "3 is inside a character" (é is bytes 2 and 3).

  • Reading and editing stop the script when refused — a read-only file, a position outside the text, a file still opening — with the reason and the line. ex, action and set answer instead, with a table to check: ok, message, and for an action applied (false when there was nothing to do) and data (what a query found):

    lua
    if not action('editor.save').ok then return end
  • An action that asks you — action('project.quickOpen') opens the file picker — waits for your pick.

  • syntax.nodeAt(pos):enclosing(...) finds the nearest node around pos whose kind matches any of the Lua patterns given. Grammars name things differently, so give the kinds your language uses: in Go a function is function_declaration, method_declaration or func_literal; in TypeScript function_declaration, method_definition, arrow_function or function_expression.

    lua
    local f = syntax.nodeAt(cursor()):enclosing('function', 'method', 'func_literal')
    if f then selection(f.from, f.to) end
  • syntax.query(pattern) runs a tree-sitter query and answers its matches, each with its pattern (1-based) and captures: the capture's name, the node's kind, from and to in bytes, its line and its text (cut at 200 characters, truncated then set). A query stops at 1,000 matches and on a file over 256 KB, and says so rather than answering part of the list.

    lua
    local q = syntax.query('(function_declaration name: (identifier) @name)')
    for _, m in ipairs(q.matches) do print(m.captures[1].text) end
  • buf.format(i, j) formats part of the file with its formatter's own range form, and answers whether anything changed and which lines: local changed, lines = buf.format(i, j). The formatter may widen the range to whole lines or a whole statement. A range ending at the start of a line leaves that line out, as a selection does. Go has no range formatting: format the whole file with action('editor.formatDocument').

  • A path asks about another file in the project without opening it — syntax.outline('src/util.ts'). The file you are editing is answered from the editor; another open file with unsaved changes is refused (save it first); a path outside the project, a binary file and a file over 1 MB are refused. source in a query's answer says where the text came from: editor or disk.

  • action, ex, set and the syntax calls cannot be used inside a gsub replacement function, a table.sort comparison or a metamethod — they wait for the app, and Lua cannot wait there.

Undo ​

A script's edits undo as one step, its ex and buf.format edits included — unless you typed while it waited, which keeps them separate. Formatting or a lint fix the script asks for through action (action('editor.formatDocument')) is the app's own edit, made while the script waits for it, so it is a step of its own.

Limits ​

Limits keep a mistake from freezing the editor. Each stops the script with its reason and line:

  • Too long: a script that computes too long stops with "instruction budget spent". A long one does not freeze the editor meanwhile: it pauses every few milliseconds to let the editor draw.
  • Too big: building a string over 16 MB stops with "result too large", and more than 256 MB of strings in all with "memory budget spent".
  • Too slow a pattern: one that backtracks too long stops with "pattern too complex".
  • Too much asked of the app: more than 100 actions (set counts as one, and so do :fmt and :lint through ex) or 2,000 requests of any kind in one run stops it, naming the count and the last action. Every action a script runs is recorded in the key event log under its name.

Recipes ​

Surround the selection — :Surround [ or a key (a parenthesis by default):

lua
local open = ... or '('
local close = ({ ['('] = ')', ['['] = ']', ['{'] = '}', ['<'] = '>' })[open] or open
local i, j = selection()
if j < i then return notify('Select something first') end
buf.replace(i, j, open .. buf.text():sub(i, j) .. close)

Trim trailing whitespace in the whole file, as one undo step:

lua
local text = buf.text()
local trimmed = text:gsub('[ \t]+\n', '\n'):gsub('[ \t]+$', '')
if trimmed ~= text then buf.replace(1, #text, trimmed) end

Count the TODOs:

lua
local n = 0
for _ in buf.text():gmatch('TODO') do n = n + 1 end
notify(n .. ' TODO' .. (n == 1 and '' or 's'))

Format, then save — stopping if the format was refused:

lua
if not action('editor.formatDocument').ok then return end
action('editor.save')

Select the function around the cursor (TypeScript and Go kinds):

lua
local f = syntax.nodeAt(cursor()):enclosing('function_declaration', 'method', 'arrow_function', 'func_literal')
if not f then return notify('Not in a function') end
selection(f.from, f.to)

One-off Lua: :lua ​

With vim on, :lua {chunk} runs the Lua you give it without keeping it: :lua print(6 * 7). Everything on this page holds for it — the same calls, limits and single undo step — and its errors name it :lua. The whole rest of the line is Lua, | included (it is Lua's bitwise or).

Names and commands ​

  • A script's name is its command, and its own: :command Join … is refused while a script holds the name, and so is a vimrc command Join … line. A :command or a vimrc line may run one: command J Join.
  • There is no rename. A new script under the new name starts with no shortcut and no mnemonic, and a vimrc line naming the old one answers No script named Join.

Deleting a script ​

  • A script on a keyboard cannot be deleted. You are told which keyboard and slot hold its key — "Script in use" — and remove it there first.
  • Otherwise you are asked, and told what goes with it: its shortcut, so a later script of the same name does not inherit your key, and any vimrc line that names it, which will then fail.

See also ​

  • Vim mode — the command line, :command and the action ids
  • Keyboards — putting a script on the accessory bar
  • vimrc — commands and mappings that run scripts at startup

Last updated: