POST
Just Enough Lua to Be Productive in Hammerspoon, Part 2
In this second article of the “Just Enough Lua” series, we dive into Lua’s types and data structures.
Tables
Table are the only compound data type in Lua, and are used to implement arrays, associative arrays (commonly called “maps” or “hashes” in other languages), modules, objects and namespaces. As you can see, it is very important to understand them!
A table in Lua is a collection of values, which can be indexed either
by numbers or by arbitrary strings (the two types of indices can
coexist within the same table). Let’s go through a few examples that
will give you an overview (you can type these in the Hammerspoon
console as we go, or at the prompt of the hs
command - keep in mind
that some of the statements are broken across multiple lines here for
formatting, but each statement should be type in a single line in the
console).
Table literals are declared using curly braces:
> unicorns = {} -- empty table
> people = { "Chris", "Aaron", "Diego" } -- array
> handles = { Diego = "zzamboni",
Chris = "cmsj",
Aaron = "asmagill" } -- associative array
Indices are indicated using square brackets. Numeric indices start at
1 (not 0 as in most other languages). For identifier-like string
indices, you can use the dot shortcut. Requesting a non-existent index
returns nil
:
> unicorns[1]
nil
> people[0]
nil
> people[1]
Chris
> handles['Diego']
zzamboni
> handles.Diego
zzamboni
> handles.Michael
nil
Within the curly-brace notation, indices that are not identifier-like (letters, numbers, underscores) need to be enclosed in quotes and square brackets. Values can be tables as well:
colors = { ["U.S."] = { "red", "white", "blue" },
Mexico = { "green", "white", "red" },
Germany = { "black", "red", "yellow" } }
With non-identifier indices, you cannot use the dot-notation. Also, to
see a table within the Hammerspoon console, use hs.inspect
:
> colors["U.S."]
table: 0x618000470400
> hs.inspect(colors.Mexico)
{ "green", "white", "red" }
> hs.inspect(colors)
{
Germany = { "black", "red", "yellow" },
Mexico = { "green", "white", "red" },
["U.S."] = { "red", "white", "blue" }
}
Iteration through an array is commonly done using the ipairs()
functions. Note that it will only iterate through
contiguous numeric indices starting at 1, so that it does not work
well with “sparse” tables.
> for i,v in ipairs(people) do print(i, v) end
1 Chris
2 Aaron
3 Diego
> people[4]='John'
> for i,v in ipairs(people) do print(i, v) end
1 Chris
2 Aaron
3 Diego
4 John
> people[7]='Mike'
> for i,v in ipairs(people) do print(i, v) end
1 Chris
2 Aaron
3 Diego
4 John
> hs.inspect(people)
{ "Chris", "Aaron", "Diego", "John",
[7] = "Mike"
}
The pairs()
function, on the other hand, will iterate through all
the elements in the table (both numeric and string indices), but does
not guarantee their order. Both numeric and string indices can be
mixed in a single table (although this gets confusing quickly unless
you access everything using pairs()
).
> for i,v in pairs(people) do print(i,v) end
1 Chris
2 Aaron
3 Diego
4 John
7 Mike
> for i,v in ipairs(handles) do print(i,v) end
<no output>
> for i,v in pairs(handles) do print(i,v) end
Aaron asmagill
Diego zzamboni
Chris cmsj
> handles[1]='whoa' -- assign the first numeric index
> hs.inspect(handles)
{ "whoa",
Aaron = "asmagill",
Chris = "cmsj",
Diego = "zzamboni"
}
> for i,v in ipairs(handles) do print(i,v) end
1 whoa
The built-in table
module includes a number of
useful table-manipulation functions, including the following:
table.concat()
for joining the values of a list in a single string (equivalent tojoin
in other languages). This only joins the elements that would be returned byipairs()
.> table.concat(people, ", ") Chris, Aaron, Diego, John
table.insert()
adds an element to a list, by default adding it to the end.> hs.inspect(people) { "Chris", "Aaron", "Diego", "John", "Bill", [7] = "Mike" } > table.insert(people, "George") > hs.inspect(people) { "Chris", "Aaron", "Diego", "John", "Bill", "George", "Mike" }
Note how in the last example, the contiguous indices have finally caught up to 7, so the last element is no longer shown separately (and will now be included by
ipairs()
,table.concat()
, etc.table.remove()
removes an element from a list, by default the last one. It returns the removed element.> for i=1,4 do print(table.remove(people)) end Mike George Bill John > hs.inspect(people) { "Chris", "Aaron", "Diego" }
Notable omissions from the language and the table
module are “get keys” and “get values” functions, common in other
languages. This may be explained by the flexible nature of Lua tables,
so that those functions would need to behave differently depending on
the contents of the table. If you need them, you can easily build your
own. For example, if you want to get a sorted list of the keys in a
table, you can use this function:
function sortedkeys(tab)
local keys={}
for k,v in pairs(tab) do table.insert(keys, k) end
table.sort(keys)
return keys
end
Tables as namespaces
Functions in Lua are first-class objects, which means they can be used
like any other value. This means that functions can be stored in
tables, and this is how namespaces (or “modules”) are implemented in
Lua. We can inspect an manipulate them like any other table. Let us
look at the table
library itself. First, the
module itself is a table:
> table
table: 0x61800046f740
Second, we can inspect its contents using the functions we know:
> hs.inspect(table)
{
concat = <function 1>,
insert = <function 2>,
move = <function 3>,
pack = <function 4>,
remove = <function 5>,
sort = <function 6>,
sortedkeys = <function 7>,
unpack = <function 8>
}
The function values themselves are opaque (we cannot see their code),
but we can easily extend the module. For example, we could add our
sortedkeys()
function above to the table
module for
consistency. Lua allows us to specify the namespace of a function in
its declaration:
function table.sortedkeys(tab)
local keys={}
for k,v in pairs(tab) do table.insert(keys, k) end
table.sort(keys)
return keys
end
All the Hammerspoon modules are implemented the same way:
> type(hs)
table
> type(hs.mouse)
table
> hs.inspect(hs.mouse)
{
get = <function 1>,
getAbsolutePosition = <function 2>,
getButtons = <function 3>,
getCurrentScreen = <function 4>,
getRelativePosition = <function 5>,
set = <function 6>,
setAbsolutePosition = <function 7>,
setRelativePosition = <function 8>,
trackingSpeed = <function 9>
}
The common way of defining a new module in Lua is to create an empty
table, and populate it with functions or variables as needed. For
example, let’s put our double-click generator in a module. Create the
file ~/.hammerspoon/doubleclick.lua
with the following contents:
local mod={}
mod.default_modifiers={}
function mod.leftDoubleClick(modifiers)
modifiers = modifiers or mod.default_modifiers
local pos=hs.mouse.getAbsolutePosition()
hs.eventtap.event.newMouseEvent(
hs.eventtap.event.types.leftMouseDown, pos, modifiers)
:setProperty(hs.eventtap.event.properties.mouseEventClickState, 2)
:post()
hs.eventtap.event.newMouseEvent(
hs.eventtap.event.types.leftMouseUp, pos, modifiers):post()
end
function mod.bindto(keyspec)
hs.hotkey.bindSpec(keyspec, mod.leftDoubleClick)
end
return mod
You can then, from the console, do the following:
> doubleclick=require('doubleclick')
> doubleclick.bindto({ {"ctrl", "alt", "cmd"}, "p" })
19:53:53 hotkey: Disabled previous hotkey ⌘⌃⌥P
hotkey: Enabled hotkey ⌘⌃⌥P
You have written and loaded your first Lua module. Let’s try it out! Press Ctrl+⌘+Alt+p while your cursor is over a word in your terminal or web browser, to select it as if you had double-clicked it. You can also change the modifiers used with it. For example, did you know that Cmd-double-click can be used to open URLs from the macOS Terminal application?
> doubleclick.default_modifiers={cmd=true}
Now try pressing Ctrl+⌘+Alt+p while your pointer is over a URL displayed on your Terminal (you can just type one yourself to test), and it will open in your browser.
Note that the name doubleclick
does not have any special meaning -
it is a regular variable to which you assigned the value returned by
require('doubleclick')
, which is the value of the mod
variable
created within the module file (note that within the module file you
use the local variable name to refer to functions and variables within
itself). You could assign it to any name you want:
> a=require('doubleclick')
> a.leftDoubleClick()
The argument of the require()
function is the name of the
file to load, without the .lua
extension. Hammerspoon by default
adds your ~/.hammerspoon/
directory to its load path, along with any
other default directories in your system. You can view the places
where Hammerspoon will look for files by examining the package.path
variable. On my machine I get the following:
> package.path
/Users/zzamboni/.hammerspoon/?.lua;/Users/zzamboni/.hammerspoon/?/
init.lua;/Users/zzamboni/.hammerspoon/Spoons/?.spoon/init.lua;/usr/
local/share/lua/5.3/?.lua;/usr/local/share/lua/5.3/?/init.lua;/usr/
local/lib/lua/5.3/?.lua;/usr/local/lib/lua/5.3/?/init.lua;./?.lua;
./?/init.lua;/Users/zzamboni/Dropbox/Personal/devel/hammerspoon/
hammerspoon/build/Hammerspoon.app/Contents/Resources/extensions/?.lua;
/Users/zzamboni/Dropbox/Personal/devel/hammerspoon/hammerspoon/build/
Hammerspoon.app/Contents/Resources/extensions/?/init.lua
Patterns
If you are familiar with regular expressions, you know how powerful
they are for examining and manipulating strings in any programming
language. Lua has patterns
, which fulfill
many of the same functions but have a different syntax and some
limitations. They are used by many functions in the string library
like string.find()
and string.match()
.
The following are some differences and similarities you need to be aware of when using patterns:
The dot (
.
) represents any character, just like in regexes.The asterisk (
*
), plus sign (+
) and question mark (?
) represent “zero or more”, “one or more” and “one or none” of the previous character, just like in regexes. Unlike regexes, these characters can only be applied to a single character and not to a whole capture group (i.e. the regex(foo)+
is not possible).Alternations, represented by the vertical bar (
|
) in regexes, are not supported.The caret (
^
) and dollar sign ($
) represent “beginning of string” and “end of string”, just like in regexes.The dash (
-
) represents a non-greedy “zero or more” (i.e. match the shortest possible string instead of the longest one) of the previous character, unlike in regexes, in which it’s commonly indicate by a question mark following the corresponding*
or+
The regex.*?
is equivalent to the Lua pattern.-
.The escape character is the ampersand (
%
) instead of the backslash (\
).Most character classes are represented by the same characters, but preceded by ampersand. For example
%d
for digits,%s
for spaces,%w
for alphanumeric characters.
For most common use cases, Lua patterns are enough, you just have to be aware of their differences. If you encounter something that really cannot be done, you can always resort to libraries like Lrexlib, which provide interfaces to real regex libraries. Unfortunately these are not included in Lua, so you would need to install them on your own.
Patterns, just like regular expressions, are commonly used for string
manipulation, using primarily functions from the string
library.
String manipulation
Lua includes the string
library to implement
common string manipulation functions, including pattern matching. All
of these functions can be called either as regular functions, with the
string as the first argument, or as method calls on the string itself,
using the colon syntax (which, as we saw before, gets converted to the same call). For example, the following two
are equivalent:
string.find(a, "^foo")
a:find("^foo")
You can find the full documentation in the Lua reference manual and many other examples in the Lua-users wiki String Library Tutorial. The following is a partial list of some of the functions I have found most useful:
string.find(str, pat [, pos [, plain]])
finds the pattern within the string. By default the search starts at the beginning of the string, but can be modified with thepos
argument (index starts at 1, as with the tables). By defaultpat
is intepreted as a Lua pattern, but this can be disabled by passingplain
as a true value. If the pattern is not found, returnsnil
. If the pattern is found, the function returns the start and end position of the pattern within the string. Furthermore, if the pattern contains parenthesis capture groups, all groups are returned as well. For example:> string.find("bah", "ah") 2 3 > string.find("bah", "foo") nil > string.find("bah", "(ah)") 2 3 ah > p1, p2, g1, g2 = string.find("bah", "(b)(ah)") > p1,p2,g1,g2 1 3 b ah
Note that the return value is not a table, but rather multiple values, as shown in the last example.
string.match(str, pat [, pos])
is similar tostring.find
, but it does not return the positions, rather it returns the part of the string matched by the pattern, or if the pattern contains capture groups, returns the captured segments:> string.match("bah", "ah") ah > string.match("bah", "foo") nil > string.match("bah", "(b)(ah)") b ah
string.gmatch(str, pat)
returns a function that returns the next match ofpat
withinstr
every time it is called, returningnil
when there are no more matches. Ifpat
contains capture groups, they are returned on each iteration.> a="Hammerspoon is awesome!" > f=string.gmatch(a, "(%w+)") > f() Hammerspoon > f() is > f() awesome > f()
Most commonly, this is used inside a loop:
> for cap in string.gmatch(a, "%w+") do print(cap) end Hammerspoon is awesome
string.format(formatstring, …)
formats a sequence of values according to the given format string, following the same formatting rules as the ISO Csprintf()
function. It additionally supports a new format character%q
, which formats a string value in a way that can be read back by Lua, escaping or quoting characters as needed (for example quotes, newlines, etc.).string.len(str)
returns the length of the string.string.lower(str)
andstring.upper(str)
convert the string to lower and uppercase, respectively.string.gsub(str, pat, rep[, n])
is a very powerful string-replacement function which hides considerably more power than its simple syntax would lead you to believe. In general, it replaces all (or the firstn
) occurrences ofpat
instr
with the replacementrep
. However,rep
can take any of the following values:A string which is used for the replacement. If the string contains %m, where m is a number, the it is replaced by the m-th captured group (or the whole match if m is zero).
A table which is consulted for the replacement values, using the first capture group as a key (or the whole match if there are no captures). For example:
> a="Event type codes: leftMouseDown=$leftMouseDown, rightMouseDown=$rightMouseDown, mouseMoved=$mouseMoved" > a:gsub("%$(%w+)", hs.eventtap.event.types) Event type codes: leftMouseDown=1, rightMouseDown=3, mouseMoved=5 3
A function which is executed with the captured groups (or the whole match) as an argument, and whose return value is used as the replacement. For example, using the
os.getenv
function, we can easily replace environment variables by their values in a string:> a="Hello $USER, your home directory is $HOME" > a:gsub("%$(%w+)", os.getenv) Hello zzamboni, your home directory is /Users/zzamboni 2
Note that
gsub
returns the modified string as its first return value, and the number of replacements it made as the second (2
in the example above). If you don’t need the number, you can simply ignore it (you don’t even need to assign it). Also note thatgsub
does not modify the original string, only returns a copy with the changes:> b = a:gsub("%$(%w+)", os.getenv) > b Hello zzamboni, your home directory is /Users/zzamboni > a Hello $USER, your home directory is $HOME
Keep learning!
You know now enough Lua to start being productive with Hammerspoon. You’ll pick up more details as you play with it. If you need more information, I can recommend the following resources, which I have found useful:
The Lua 5.3 Reference Manual, available at the official Lua website.
The Lua Wiki, a community-maintained wiki with many descriptions, tips, examples and tutorials.
Have fun!
- Tags:
- hammerspoon
- lua
- howto
- mac