• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lunarmodules / Penlight / 42184447582

06 Nov 2026 07:58AM UTC coverage: 90.092% (+0.02%) from 90.07%
42184447582

push

github

web-flow
Merge 533d69043 into e04a2a1a9

15 of 15 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

5556 of 6167 relevant lines covered (90.09%)

861.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.45
/lua/pl/stringx.lua
1
--- Python-style extended string library.
2
--
3
-- see 3.6.1 of the Python reference.
4
-- If you want to make these available as string methods, then say
5
-- `stringx.import()` to bring them into the standard `string` table.
6
--
7
-- See @{03-strings.md|the Guide}
8
--
9
-- Dependencies: `pl.utils`, `pl.types`
10
-- @module pl.stringx
11
local utils = require 'pl.utils'
684 ✔
12
local is_callable = require 'pl.types'.is_callable
684 ✔
13
local string = string
684 ✔
14
local find = string.find
684 ✔
15
local type,setmetatable,ipairs = type,setmetatable,ipairs
684 ✔
16
local error = error
684 ✔
17
local gsub = string.gsub
684 ✔
18
local rep = string.rep
684 ✔
19
local sub = string.sub
684 ✔
20
local reverse = string.reverse
684 ✔
21
local concat = table.concat
684 ✔
22
local append = table.insert
684 ✔
23
local escape = utils.escape
684 ✔
24
local ceil, max = math.ceil, math.max
684 ✔
25
local assert_arg,usplit = utils.assert_arg,utils.split
684 ✔
26
local lstrip
27
local unpack = utils.unpack
684 ✔
28
local pack = utils.pack
684 ✔
29

30
local function assert_string (n,s)
31
    assert_arg(n,s,'string')
9,020 ✔
32
end
33

34
local function non_empty(s)
35
    return #s > 0
228 ✔
36
end
37

38
local function assert_nonempty_string(n,s)
39
    assert_arg(n,s,'string',non_empty,'must be a non-empty string')
228 ✔
40
end
41

42
local function makelist(l)
43
    return setmetatable(l, require('pl.List'))
950 ✔
44
end
45

46
local stringx = {}
684 ✔
47

48
------------------
49
-- String Predicates
50
-- @section predicates
51

52
--- does s only contain alphabetic characters?
53
-- @string s a string
54
function stringx.isalpha(s)
684 ✔
55
    assert_string(1,s)
114 ✔
56
    return find(s,'^%a+$') == 1
114 ✔
57
end
58

59
--- does s only contain digits?
60
-- @string s a string
61
function stringx.isdigit(s)
684 ✔
62
    assert_string(1,s)
304 ✔
63
    return find(s,'^%d+$') == 1
304 ✔
64
end
65

66
--- does s only contain alphanumeric characters?
67
-- @string s a string
68
function stringx.isalnum(s)
684 ✔
69
    assert_string(1,s)
57 ✔
70
    return find(s,'^%w+$') == 1
57 ✔
71
end
72

73
--- does s only contain whitespace?
74
-- Matches on pattern '%s' so matches space, newline, tabs, etc.
75
-- @string s a string
76
function stringx.isspace(s)
684 ✔
77
    assert_string(1,s)
76 ✔
78
    return find(s,'^%s+$') == 1
76 ✔
79
end
80

81
--- does s only contain lower case characters?
82
-- @string s a string
83
function stringx.islower(s)
684 ✔
84
    assert_string(1,s)
76 ✔
85
    return find(s,'^[%l%s]+$') == 1
76 ✔
86
end
87

88
--- does s only contain upper case characters?
89
-- @string s a string
90
function stringx.isupper(s)
684 ✔
91
    assert_string(1,s)
76 ✔
92
    return find(s,'^[%u%s]+$') == 1
76 ✔
93
end
94

95
local function raw_startswith(s, prefix)
96
    return find(s,prefix,1,true) == 1
285 ✔
97
end
98

99
local function raw_endswith(s, suffix)
100
    return #s >= #suffix and find(s, suffix, #s-#suffix+1, true) and true or false
399 ✔
101
end
102

103
local function test_affixes(s, affixes, fn)
104
    if type(affixes) == 'string' then
608 ✔
105
        return fn(s,affixes)
494 ✔
106
    elseif type(affixes) == 'table' then
114 ✔
107
        for _,affix in ipairs(affixes) do
228 ✔
108
            if fn(s,affix) then return true end
240 ✔
109
        end
110
        return false
38 ✔
111
    else
UNCOV
112
        error(("argument #2 expected a 'string' or a 'table', got a '%s'"):format(type(affixes)))
×
113
    end
114
end
115

116
--- does s start with prefix or one of prefixes?
117
-- @string s a string
118
-- @param prefix a string or an array of strings
119
function stringx.startswith(s,prefix)
684 ✔
120
    assert_string(1,s)
247 ✔
121
    return test_affixes(s,prefix,raw_startswith)
247 ✔
122
end
123

124
--- does s end with suffix or one of suffixes?
125
-- @string s a string
126
-- @param suffix a string or an array of strings
127
function stringx.endswith(s,suffix)
684 ✔
128
    assert_string(1,s)
361 ✔
129
    return test_affixes(s,suffix,raw_endswith)
361 ✔
130
end
131

132
--- Strings and Lists
133
-- @section lists
134

135
--- concatenate the strings using this string as a delimiter.
136
-- Note that the arguments are reversed from `string.concat`.
137
-- @string s the string
138
-- @param seq a table of strings or numbers
139
-- @usage stringx.join(' ', {1,2,3}) == '1 2 3'
140
function stringx.join(s,seq)
684 ✔
141
    assert_string(1,s)
19 ✔
142
    return concat(seq,s)
19 ✔
143
end
144

145
--- Split a string into a list of lines.
146
-- `"\r"`, `"\n"`, and `"\r\n"` are considered line ends.
147
-- They are not included in the lines unless `keepends` is passed.
148
-- Terminal line end does not produce an extra line.
149
-- Splitting an empty string results in an empty list.
150
-- @string s the string.
151
-- @bool[opt] keep_ends include line ends.
152
-- @return List of lines
153
function stringx.splitlines(s, keep_ends)
684 ✔
154
    assert_string(1, s)
171 ✔
155
    local res = {}
171 ✔
156
    local pos = 1
171 ✔
157
    while true do
158
        local line_end_pos = find(s, '[\r\n]', pos)
437 ✔
159
        if not line_end_pos then
437 ✔
160
            break
90 ✔
161
        end
162

163
        local line_end = sub(s, line_end_pos, line_end_pos)
266 ✔
164
        if line_end == '\r' and sub(s, line_end_pos + 1, line_end_pos + 1) == '\n' then
289 ✔
165
            line_end = '\r\n'
38 ✔
166
        end
167

168
        local line = sub(s, pos, line_end_pos - 1)
266 ✔
169
        if keep_ends then
266 ✔
170
            line = line .. line_end
114 ✔
171
        end
172
        append(res, line)
266 ✔
173

174
        pos = line_end_pos + #line_end
266 ✔
175
    end
176

177
    if pos <= #s then
171 ✔
178
        append(res, sub(s, pos))
24 ✔
179
    end
180
    return makelist(res)
171 ✔
181
end
182

183
--- split a string into a list of strings using a delimiter.
184
-- @function split
185
-- @string s the string
186
-- @string[opt] re a delimiter (defaults to whitespace)
187
-- @int[opt] n maximum number of results
188
-- @return List
189
-- @usage #(stringx.split('one two')) == 2
190
-- @usage stringx.split('one,two,three', ',') == List{'one','two','three'}
191
-- @usage stringx.split('one,two,three', ',', 2) == List{'one','two,three'}
192
function stringx.split(s,re,n)
684 ✔
193
    assert_string(1,s)
532 ✔
194
    local plain = true
532 ✔
195
    if not re then -- default spaces
532 ✔
196
        s = lstrip(s)
480 ✔
197
        plain = false
380 ✔
198
    end
199
    local res = usplit(s,re,plain,n)
532 ✔
200
    if re and re ~= '' and
532 ✔
201
       find(s,re,-#re,true) and
114 ✔
202
       (n or math.huge) > #res then
57 ✔
203
        res[#res+1] = ""
38 ✔
204
    end
205
    return makelist(res)
532 ✔
206
end
207

208
--- replace all tabs in s with tabsize spaces. If not specified, tabsize defaults to 8.
209
-- Tab stops will be honored.
210
-- @string s the string (multi-line strings are supported)
211
-- @int tabsize[opt=8] number of spaces to expand each tab
212
-- @return expanded string
213
-- @usage stringx.expandtabs('\tone,two,three', 4)   == '    one,two,three'
214
-- @usage stringx.expandtabs('  \tone,two,three', 4) == '    one,two,three'
215
function stringx.expandtabs(s,tabsize)
684 ✔
216
  assert_string(1,s)
152 ✔
217
  tabsize = tabsize or 8
152 ✔
218
  return (s:gsub("([^\t\r\n]*)\t", function(before_tab)
304 ✔
219
      if tabsize == 0 then
171 ✔
220
        return before_tab
19 ✔
221
      else
222
        return before_tab .. (" "):rep(tabsize - #before_tab % tabsize)
152 ✔
223
      end
224
    end))
225
end
226

227
--- Finding and Replacing
228
-- @section find
229

230
local function _find_all(s,sub,first,last,allow_overlap)
231
    first = first or 1
418 ✔
232
    last = last or #s
418 ✔
233
    if sub == '' then return last+1,last-first+1 end
418 ✔
234
    local i1,i2 = find(s,sub,first,true)
342 ✔
235
    local res
236
    local k = 0
342 ✔
237
    while i1 do
817 ✔
238
        if last and i2 > last then break end
532 ✔
239
        res = i1
475 ✔
240
        k = k + 1
475 ✔
241
        if allow_overlap then
475 ✔
242
            i1,i2 = find(s,sub,i1+1,true)
361 ✔
243
        else
244
            i1,i2 = find(s,sub,i2+1,true)
114 ✔
245
        end
246
    end
247
    return res,k
342 ✔
248
end
249

250
--- find index of first instance of sub in s from the left.
251
-- @string s the string
252
-- @string sub substring
253
-- @int[opt] first first index
254
-- @int[opt] last last index
255
-- @return start index, or nil if not found
256
function stringx.lfind(s,sub,first,last)
684 ✔
257
    assert_string(1,s)
304 ✔
258
    assert_string(2,sub)
304 ✔
259
    local i1, i2 = find(s,sub,first,true)
304 ✔
260

261
    if i1 and (not last or i2 <= last) then
304 ✔
262
        return i1
228 ✔
263
    else
264
        return nil
76 ✔
265
    end
266
end
267

268
--- find index of first instance of sub in s from the right.
269
-- @string s the string
270
-- @string sub substring
271
-- @int[opt] first first index
272
-- @int[opt] last last index
273
-- @return start index, or nil if not found
274
function stringx.rfind(s,sub,first,last)
684 ✔
275
    assert_string(1,s)
285 ✔
276
    assert_string(2,sub)
285 ✔
277
    return (_find_all(s,sub,first,last,true))
360 ✔
278
end
279

280
--- replace up to n instances of old by new in the string s.
281
-- If n is not present, replace all instances.
282
-- @string s the string
283
-- @string old the target substring
284
-- @string new the substitution
285
-- @int[opt] n optional maximum number of substitutions
286
-- @return result string
287
function stringx.replace(s,old,new,n)
684 ✔
288
    assert_string(1,s)
171 ✔
289
    assert_string(2,old)
171 ✔
290
    assert_string(3,new)
171 ✔
291
    return (gsub(s,escape(old),new:gsub('%%','%%%%'),n))
216 ✔
292
end
293

294
--- count all instances of substring in string.
295
-- @string s the string
296
-- @string sub substring
297
-- @bool[opt] allow_overlap allow matches to overlap
298
-- @usage
299
-- assert(stringx.count('banana', 'ana') == 1)
300
-- assert(stringx.count('banana', 'ana', true) == 2)
301
function stringx.count(s,sub,allow_overlap)
684 ✔
302
    assert_string(1,s)
133 ✔
303
    local _,k = _find_all(s,sub,1,false,allow_overlap)
133 ✔
304
    return k
133 ✔
305
end
306

307
--- Stripping and Justifying
308
-- @section strip
309

310
local function _just(s,w,ch,left,right)
311
    local n = #s
304 ✔
312
    if w > n then
304 ✔
313
        if not ch then ch = ' ' end
190 ✔
314
        local f1,f2
315
        if left and right then
190 ✔
316
            local rn = ceil((w-n)/2)
76 ✔
317
            local ln = w - n - rn
76 ✔
318
            f1 = rep(ch,ln)
76 ✔
319
            f2 = rep(ch,rn)
76 ✔
320
        elseif right then
114 ✔
321
            f1 = rep(ch,w-n)
57 ✔
322
            f2 = ''
57 ✔
323
        else
324
            f2 = rep(ch,w-n)
57 ✔
325
            f1 = ''
57 ✔
326
        end
327
        return f1..s..f2
190 ✔
328
    else
329
        return s
114 ✔
330
    end
331
end
332

333
--- left-justify s with width w.
334
-- @string s the string
335
-- @int w width of justification
336
-- @string[opt=' '] ch padding character
337
-- @usage stringx.ljust('hello', 10, '*') == '*****hello'
338
function stringx.ljust(s,w,ch)
684 ✔
339
    assert_string(1,s)
95 ✔
340
    assert_arg(2,w,'number')
95 ✔
341
    return _just(s,w,ch,true,false)
95 ✔
342
end
343

344
--- right-justify s with width w.
345
-- @string s the string
346
-- @int w width of justification
347
-- @string[opt=' '] ch padding character
348
-- @usage stringx.rjust('hello', 10, '*') == 'hello*****'
349
function stringx.rjust(s,w,ch)
684 ✔
350
    assert_string(1,s)
95 ✔
351
    assert_arg(2,w,'number')
95 ✔
352
    return _just(s,w,ch,false,true)
95 ✔
353
end
354

355
--- center-justify s with width w.
356
-- @string s the string
357
-- @int w width of justification
358
-- @string[opt=' '] ch padding character
359
-- @usage stringx.center('hello', 10, '*') == '**hello***'
360
function stringx.center(s,w,ch)
684 ✔
361
    assert_string(1,s)
114 ✔
362
    assert_arg(2,w,'number')
114 ✔
363
    return _just(s,w,ch,true,true)
114 ✔
364
end
365

366
local function _strip(s,left,right,chrs)
367
    if not chrs then
3,021 ✔
368
        chrs = '%s'
2,964 ✔
369
    else
370
        chrs = '['..escape(chrs)..']'
72 ✔
371
    end
372
    local f = 1
3,021 ✔
373
    local t
374
    if left then
3,021 ✔
375
        local i1,i2 = find(s,'^'..chrs..'*')
2,812 ✔
376
        if i2 >= i1 then
2,812 ✔
377
            f = i2+1
418 ✔
378
        end
379
    end
380
    if right then
3,021 ✔
381
        if #s < 200 then
2,432 ✔
382
            local i1,i2 = find(s,chrs..'*$',f)
2,394 ✔
383
            if i2 >= i1 then
2,394 ✔
384
                t = i1-1
361 ✔
385
            end
386
        else
387
            local rs = reverse(s)
38 ✔
388
            local i1,i2 = find(rs, '^'..chrs..'*')
38 ✔
389
            if i2 >= i1 then
38 ✔
UNCOV
390
                t = -i2-1
×
391
            end
392
        end
393
    end
394
    return sub(s,f,t)
3,021 ✔
395
end
396

397
--- trim any characters on the left of s.
398
-- @string s the string
399
-- @string[opt='%s'] chrs default any whitespace character,
400
-- but can be a string of characters to be trimmed
401
function stringx.lstrip(s,chrs)
684 ✔
402
    assert_string(1,s)
589 ✔
403
    return _strip(s,true,false,chrs)
589 ✔
404
end
405
lstrip = stringx.lstrip
684 ✔
406

407
--- trim any characters on the right of s.
408
-- @string s the string
409
-- @string[opt='%s'] chrs default any whitespace character,
410
-- but can be a string of characters to be trimmed
411
function stringx.rstrip(s,chrs)
684 ✔
412
    assert_string(1,s)
209 ✔
413
    return _strip(s,false,true,chrs)
209 ✔
414
end
415

416
--- trim any characters on both left and right of s.
417
-- @string s the string
418
-- @string[opt='%s'] chrs default any whitespace character,
419
-- but can be a string of characters to be trimmed
420
-- @usage stringx.strip('  --== Hello ==--  ', "- =")  --> 'Hello'
421
function stringx.strip(s,chrs)
684 ✔
422
    assert_string(1,s)
2,223 ✔
423
    return _strip(s,true,true,chrs)
2,223 ✔
424
end
425

426
--- Partitioning Strings
427
-- @section partitioning
428

429
--- split a string using a pattern. Note that at least one value will be returned!
430
-- @string s the string
431
-- @string[opt='%s'] re a Lua string pattern (defaults to whitespace)
432
-- @return the parts of the string
433
-- @usage  a,b = line:splitv('=')
434
-- @see utils.splitv
435
function stringx.splitv(s,re)
684 ✔
436
    assert_string(1,s)
19 ✔
437
    return utils.splitv(s,re)
19 ✔
438
end
439

440
-- The partition functions split a string using a delimiter into three parts:
441
-- the part before, the delimiter itself, and the part afterwards
442
local function _partition(p,delim,fn)
443
    local i1,i2 = fn(p,delim)
190 ✔
444
    if not i1 or i1 == -1 then
190 ✔
445
        return p,'',''
57 ✔
446
    else
447
        if not i2 then i2 = i1 end
133 ✔
448
        return sub(p,1,i1-1),sub(p,i1,i2),sub(p,i2+1)
230 ✔
449
    end
450
end
451

452
--- partition the string using first occurrence of a delimiter
453
-- @string s the string
454
-- @string ch delimiter (match as plain string, no patterns)
455
-- @return part before ch
456
-- @return ch
457
-- @return part after ch
458
-- @usage {stringx.partition('a,b,c', ','))} == {'a', ',', 'b,c'}
459
-- @usage {stringx.partition('abc', 'x'))} == {'abc', '', ''}
460
function stringx.partition(s,ch)
684 ✔
461
    assert_string(1,s)
133 ✔
462
    assert_nonempty_string(2,ch)
133 ✔
463
    return _partition(s,ch,stringx.lfind)
114 ✔
464
end
465

466
--- partition the string p using last occurrence of a delimiter
467
-- @string s the string
468
-- @string ch delimiter (match as plain string, no patterns)
469
-- @return part before ch
470
-- @return ch
471
-- @return part after ch
472
-- @usage {stringx.rpartition('a,b,c', ','))} == {'a,b', ',', 'c'}
473
-- @usage {stringx.rpartition('abc', 'x'))} == {'', '', 'abc'}
474
function stringx.rpartition(s,ch)
684 ✔
475
    assert_string(1,s)
95 ✔
476
    assert_nonempty_string(2,ch)
95 ✔
477
    local a,b,c = _partition(s,ch,stringx.rfind)
76 ✔
478
    if a == s then -- no match found
76 ✔
479
        return c,b,a
19 ✔
480
    end
481
    return a,b,c
57 ✔
482
end
483

484
--- return the 'character' at the index.
485
-- @string s the string
486
-- @int idx an index (can be negative)
487
-- @return a substring of length 1 if successful, empty string otherwise.
488
function stringx.at(s,idx)
684 ✔
489
    assert_string(1,s)
76 ✔
490
    assert_arg(2,idx,'number')
76 ✔
491
    return sub(s,idx,idx)
76 ✔
492
end
493

494

495
--- Text handling
496
-- @section text
497

498

499
--- indent a multiline string.
500
-- @tparam string s the (multiline) string
501
-- @tparam integer n the size of the indent
502
-- @tparam[opt=' '] string ch the character to use when indenting
503
-- @return indented string
504
function stringx.indent (s,n,ch)
684 ✔
505
  assert_arg(1,s,'string')
76 ✔
506
  assert_arg(2,n,'number')
76 ✔
507
  local lines = usplit(s ,'\n')
76 ✔
508
  local prefix = string.rep(ch or ' ',n)
76 ✔
509
  for i, line in ipairs(lines) do
247 ✔
510
    lines[i] = prefix..line
171 ✔
511
  end
512
  return concat(lines,'\n')..'\n'
76 ✔
513
end
514

515

516
--- dedent a multiline string by removing any initial indent.
517
-- useful when working with [[..]] strings.
518
-- Empty lines are ignored.
519
-- @tparam string s the (multiline) string
520
-- @return a string with initial indent zero.
521
-- @usage
522
-- local s = dedent [[
523
--          One
524
--
525
--        Two
526
--
527
--      Three
528
-- ]]
529
-- assert(s == [[
530
--     One
531
--
532
--   Two
533
--
534
-- Three
535
-- ]])
536
function stringx.dedent (s)
684 ✔
537
  assert_arg(1,s,'string')
76 ✔
538
  local lst = usplit(s,'\n')
76 ✔
539
  if #lst>0 then
76 ✔
540
    local ind_size = math.huge
76 ✔
541
    for i, line in ipairs(lst) do
304 ✔
542
      local i1, i2 = lst[i]:find('^%s*[^%s]')
228 ✔
543
      if i1 and i2 < ind_size then
228 ✔
544
        ind_size = i2
114 ✔
545
      end
546
    end
547
    for i, line in ipairs(lst) do
304 ✔
548
      lst[i] = lst[i]:sub(ind_size, -1)
284 ✔
549
    end
550
  end
551
  return concat(lst,'\n')..'\n'
76 ✔
552
end
553

554

555

556
do
557
  local buildline = function(words, i, size, breaklong)
558
    -- if overflow is set, a word longer than size, will overflow the size
559
    -- otherwise it will be chopped in line-length pieces
560
    local line = {}
1,710 ✔
561
    if #words[i] > size then
1,710 ✔
562
      -- word longer than line
563
      if not breaklong then
665 ✔
564
        line[1] = words[i]
247 ✔
565
        i = i + 1
247 ✔
566
      else
567
        line[1] = words[i]:sub(1, size)
528 ✔
568
        words[i] = words[i]:sub(size + 1, -1)
528 ✔
569
      end
570
    else
571
      local len = 0
1,045 ✔
572
      while words[i] and (len + #words[i] <= size) or
3,420 ✔
573
            (len == 0 and #words[i] == size) do
1,670 ✔
574
        if words[i] ~= "" then
2,375 ✔
575
          line[#line+1] = words[i]
2,185 ✔
576
          len = len + #words[i] + 1
2,185 ✔
577
        end
578
        i = i + 1
2,375 ✔
579
      end
580
    end
581
    return stringx.strip(concat(line, " ")), i
2,160 ✔
582
  end
583

584
  --- format a paragraph into lines so that they fit into a line width.
585
  -- It will not break long words by default, so lines can be over the length
586
  -- to that extent.
587
  -- @tparam string s the string to format
588
  -- @tparam[opt=70] integer width the margin width
589
  -- @tparam[opt=false] boolean breaklong if truthy, words longer than the width given will be forced split.
590
  -- @return a list of lines (List object), use `fill` to return a string instead of a `List`.
591
  -- @see pl.List
592
  -- @see fill
593
  stringx.wrap = function(s, width, breaklong)
594
    s = s:gsub('\n',' ') -- remove line breaks
285 ✔
595
    s = stringx.strip(s) -- remove leading/trailing whitespace
356 ✔
596
    if s == "" then
285 ✔
597
      return { "" }
38 ✔
598
    end
599
    width = width or 70
247 ✔
600
    local out = {}
247 ✔
601
    local words = usplit(s, "%s")
247 ✔
602
    local i = 1
247 ✔
603
    while words[i] do
1,957 ✔
604
      out[#out+1], i = buildline(words, i, width, breaklong)
2,160 ✔
605
    end
606
    return makelist(out)
247 ✔
607
  end
608
end
609

610
--- format a paragraph so that it fits into a line width.
611
-- @tparam string s the string to format
612
-- @tparam[opt=70] integer width the margin width
613
-- @tparam[opt=false] boolean breaklong if truthy, words longer than the width given will be forced split.
614
-- @return a string, use `wrap` to return a list of lines instead of a string.
615
-- @see wrap
616
function stringx.fill (s,width,breaklong)
684 ✔
617
  return concat(stringx.wrap(s,width,breaklong),'\n') .. '\n'
24 ✔
618
end
619

620
--- Template
621
-- @section Template
622

623

624
local function _substitute(s,tbl,safe)
625
  local subst
626
  if is_callable(tbl) then
72 ✔
627
    subst = tbl
×
628
  else
629
    function subst(f)
48 ✔
630
      local s = tbl[f]
76 ✔
631
      if not s then
76 ✔
632
        if safe then
×
633
          return f
×
634
        else
635
          error("not present in table "..f)
×
636
        end
637
      else
638
        return s
76 ✔
639
      end
640
    end
641
  end
642
  local res = gsub(s,'%${([%w_]+)}',subst)
57 ✔
643
  return (gsub(res,'%$([%w_]+)',subst))
57 ✔
644
end
645

646

647

648
local Template = {}
684 ✔
649
stringx.Template = Template
684 ✔
650
Template.__index = Template
684 ✔
651
setmetatable(Template, {
1,368 ✔
652
  __call = function(obj,tmpl)
653
    return Template.new(tmpl)
57 ✔
654
  end
655
})
656

657
--- Creates a new Template class.
658
-- This is a shortcut to `Template.new(tmpl)`.
659
-- @tparam string tmpl the template string
660
-- @function Template
661
-- @treturn Template
662
function Template.new(tmpl)
684 ✔
663
  assert_arg(1,tmpl,'string')
57 ✔
664
  local res = {}
57 ✔
665
  res.tmpl = tmpl
57 ✔
666
  setmetatable(res,Template)
57 ✔
667
  return res
57 ✔
668
end
669

670
--- substitute values into a template, throwing an error.
671
-- This will throw an error if no name is found.
672
-- @tparam table tbl a table of name-value pairs.
673
-- @return string with place holders substituted
674
function Template:substitute(tbl)
684 ✔
675
  assert_arg(1,tbl,'table')
38 ✔
676
  return _substitute(self.tmpl,tbl,false)
38 ✔
677
end
678

679
--- substitute values into a template.
680
-- This version just passes unknown names through.
681
-- @tparam table tbl a table of name-value pairs.
682
-- @return string with place holders substituted
683
function Template:safe_substitute(tbl)
684 ✔
684
  assert_arg(1,tbl,'table')
×
685
  return _substitute(self.tmpl,tbl,true)
×
686
end
687

688
--- substitute values into a template, preserving indentation. <br>
689
-- If the value is a multiline string _or_ a template, it will insert
690
-- the lines at the correct indentation. <br>
691
-- Furthermore, if a template, then that template will be substituted
692
-- using the same table.
693
-- @tparam table tbl a table of name-value pairs.
694
-- @return string with place holders substituted
695
function Template:indent_substitute(tbl)
684 ✔
696
  assert_arg(1,tbl,'table')
19 ✔
697
  if not self.strings then
19 ✔
698
    self.strings = usplit(self.tmpl,'\n')
24 ✔
699
  end
700

701
  -- the idea is to substitute line by line, grabbing any spaces as
702
  -- well as the $var. If the value to be substituted contains newlines,
703
  -- then we split that into lines and adjust the indent before inserting.
704
  local function subst(line)
705
    return line:gsub('(%s*)%$([%w_]+)',function(sp,f)
99 ✔
706
      local subtmpl
707
      local s = tbl[f]
19 ✔
708
      if not s then error("not present in table "..f) end
19 ✔
709
      if getmetatable(s) == Template then
19 ✔
710
        subtmpl = s
×
711
        s = s.tmpl
×
712
      else
713
        s = tostring(s)
19 ✔
714
      end
715
      if s:find '\n' then
19 ✔
716
        local lines = usplit(s, '\n')
19 ✔
717
        for i, line in ipairs(lines) do
76 ✔
718
          lines[i] = sp..line
57 ✔
719
        end
720
        s = concat(lines, '\n') .. '\n'
19 ✔
721
      end
722
      if subtmpl then
19 ✔
723
        return _substitute(s, tbl)
×
724
      else
725
        return s
19 ✔
726
      end
727
    end)
728
  end
729

730
  local lines = {}
19 ✔
731
  for i, line in ipairs(self.strings) do
76 ✔
732
    lines[i] = subst(line)
72 ✔
733
  end
734
  return concat(lines,'\n')..'\n'
19 ✔
735
end
736

737

738

739
--- Miscellaneous
740
-- @section misc
741

742
--- return an iterator over all lines in a string
743
-- @string s the string
744
-- @return an iterator
745
-- @usage
746
-- local line_no = 1
747
-- for line in stringx.lines(some_text) do
748
--   print(line_no, line)
749
--   line_no = line_no + 1
750
-- end
751
function stringx.lines(s)
684 ✔
752
    assert_string(1,s)
57 ✔
753
    if not s:find '\n$' then s = s..'\n' end
57 ✔
754
    return s:gmatch('([^\n]*)\n')
57 ✔
755
end
756

757
--- initial word letters uppercase ('title case').
758
-- Here 'words' mean chunks of non-space characters.
759
-- @string s the string
760
-- @return a string with each word's first letter uppercase
761
-- @usage stringx.title("hello world") == "Hello World")
762
function stringx.title(s)
684 ✔
763
    assert_string(1,s)
57 ✔
764
    return (s:gsub('(%S)(%S*)',function(f,r)
114 ✔
765
        return f:upper()..r:lower()
116 ✔
766
    end))
767
end
768

769
stringx.capitalize = stringx.title
684 ✔
770

771
do
772
  local ellipsis = '...'
684 ✔
773
  local n_ellipsis = #ellipsis
684 ✔
774

775
  --- Return a shortened version of a string.
776
  -- Fits string within w characters. Removed characters are marked with ellipsis.
777
  -- @string s the string
778
  -- @int w the maximum size allowed
779
  -- @bool tail true if we want to show the end of the string (head otherwise)
780
  -- @usage ('1234567890'):shorten(8) == '12345...'
781
  -- @usage ('1234567890'):shorten(8, true) == '...67890'
782
  -- @usage ('1234567890'):shorten(20) == '1234567890'
783
  function stringx.shorten(s,w,tail)
684 ✔
784
      assert_string(1,s)
342 ✔
785
      if #s > w then
342 ✔
786
          if w < n_ellipsis then return ellipsis:sub(1,w) end
209 ✔
787
          if tail then
95 ✔
788
              local i = #s - w + 1 + n_ellipsis
38 ✔
789
              return ellipsis .. s:sub(i)
46 ✔
790
          else
791
              return s:sub(1,w-n_ellipsis) .. ellipsis
68 ✔
792
          end
793
      end
794
      return s
133 ✔
795
  end
796
end
797

798

799
do
800
  -- Utility function that finds any patterns that match a long string's an open or close.
801
  -- Note that having this function use the least number of equal signs that is possible is a harder algorithm to come up with.
802
  -- Right now, it simply returns the greatest number of them found.
803
  -- @param s The string
804
  -- @return 'nil' if not found. If found, the maximum number of equal signs found within all matches.
805
  local function has_lquote(s)
806
      local lstring_pat = '([%[%]])(=*)%1'
907 ✔
807
      local equals, new_equals, _
808
      local finish = 1
907 ✔
809
      repeat
810
          _, finish, _, new_equals = s:find(lstring_pat, finish)
1,838 ✔
811
          if new_equals then
1,838 ✔
812
              equals = max(equals or 0, #new_equals)
931 ✔
813
          end
814
      until not new_equals
1,838 ✔
815

816
      return equals
907 ✔
817
  end
818

819
  --- Quote the given string and preserve any control or escape characters, such that reloading the string in Lua returns the same result.
820
  -- @param s The string to be quoted.
821
  -- @return The quoted string.
822
  function stringx.quote_string(s)
684 ✔
823
      assert_string(1,s)
907 ✔
824
      -- Find out if there are any embedded long-quote sequences that may cause issues.
825
      -- This is important when strings are embedded within strings, like when serializing.
826
      -- Append a closing bracket to catch unfinished long-quote sequences at the end of the string.
827
      local equal_signs = has_lquote(s .. "]")
907 ✔
828

829
      -- Note that strings containing "\r" can't be quoted using long brackets
830
      -- as Lua lexer converts all newlines to "\n" within long strings.
831
      if (s:find("\n") or equal_signs) and not s:find("\r") then
907 ✔
832
          -- If there is an embedded sequence that matches a long quote, then
833
          -- find the one with the maximum number of = signs and add one to that number.
834
          equal_signs = ("="):rep((equal_signs or -1) + 1)
323 ✔
835
          -- Long strings strip out leading newline. We want to retain that, when quoting.
836
          if s:find("^\n") then s = "\n" .. s end
323 ✔
837
          local lbracket, rbracket =
838
              "[" .. equal_signs .. "[",
323 ✔
839
              "]" .. equal_signs .. "]"
323 ✔
840
          s = lbracket .. s .. rbracket
323 ✔
841
      else
842
          -- Escape funny stuff. Lua 5.1 does not handle "\r" correctly.
843
          s = ("%q"):format(s):gsub("\r", "\\r")
584 ✔
844
      end
845
      return s
907 ✔
846
  end
847
end
848

849

850
--- Python-style formatting operator.
851
-- Calling `text.format_operator()` overloads the % operator for strings to give
852
-- Python/Ruby style formatted output.
853
-- This is extended to also do template-like substitution for map-like data.
854
--
855
-- Note this goes further than the original, and will allow these cases:
856
--
857
-- 1. a single value
858
-- 2. a list of values
859
-- 3. a map of var=value pairs
860
-- 4. a function, as in gsub
861
--
862
-- For the second two cases, it uses $-variable substitution.
863
--
864
-- When called, this function will monkey-patch the global `string` metatable by
865
-- adding a `__mod` method.
866
--
867
-- See <a href="http://lua-users.org/wiki/StringInterpolation">the lua-users wiki</a>
868
--
869
-- @usage
870
-- require 'pl.text'.format_operator()
871
-- local out1 = '%s = %5.3f' % {'PI',math.pi}                   --> 'PI = 3.142'
872
-- local out2 = '$name = $value' % {name='dog',value='Pluto'}   --> 'dog = Pluto'
873
function stringx.format_operator()
684 ✔
874

875
  local format = string.format
19 ✔
876

877
  -- a more forgiving version of string.format, which applies
878
  -- tostring() to any value with a %s format.
879
  local function formatx (fmt,...)
880
    local args = pack(...)
57 ✔
881
    local i = 1
57 ✔
882
    for p in fmt:gmatch('%%.') do
133 ✔
883
      if p == '%s' and type(args[i]) ~= 'string' then
76 ✔
884
        args[i] = tostring(args[i])
24 ✔
885
      end
886
      i = i + 1
76 ✔
887
    end
888
    return format(fmt,unpack(args))
72 ✔
889
  end
890

891
  local function basic_subst(s,t)
892
    return (s:gsub('%$([%w_]+)',t))
19 ✔
893
  end
894

895
  getmetatable("").__mod = function(a, b)
19 ✔
896
    if b == nil then
95 ✔
897
      return a
×
898
    elseif type(b) == "table" and getmetatable(b) == nil then
95 ✔
899
      if #b == 0 then -- assume a map-like table
38 ✔
900
        return _substitute(a,b,true)
19 ✔
901
      else
902
        return formatx(a,unpack(b))
24 ✔
903
      end
904
    elseif type(b) == 'function' then
57 ✔
905
      return basic_subst(a,b)
19 ✔
906
    else
907
      return formatx(a,b)
38 ✔
908
    end
909
  end
910
end
911

912
--- import the stringx functions into the global string (meta)table
913
function stringx.import()
684 ✔
914
    utils.import(stringx,string)
×
915
end
916

917
return stringx
684 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc