Skip to content

Commit 4abe35a

Browse files
authored
Use string_slice in ascii_string and binary_string (dashbitco#155)
1 parent 5b8bb30 commit 4abe35a

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Changelog for NimbleParsec
22

3+
## Unreleased
4+
5+
### Enhancements
6+
7+
* Compile `ascii_string/3` and `utf8_string/3` with `:min`/`:max` over a character
8+
class that cannot match a newline into a scan plus a single slice of the input,
9+
rather than accumulating a codepoint list and joining it. Between 1.1x and 7.6x
10+
faster depending on how much is matched. Note the resulting string may be a
11+
sub-binary of the input, so see the memory usage notes in the documentation if
12+
you retain long strings from a much larger input
13+
14+
### Bug fixes
15+
16+
* `ascii_string/3` and `utf8_string/3` nested in `choice/2`, `lookahead/2` or
17+
`lookahead_not/2` now produce an error message naming the character class once,
18+
instead of repeating it `:min` plus one times
19+
320
## v1.4.2 (2025-01-21)
421

522
### Enhancements

lib/nimble_parsec.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ defmodule NimbleParsec do
231231
# 1. Update the combinator type below
232232
# 2. Update the compiler with combinator
233233
# 3. Update the compiler with label step
234+
# 4. Update the generator with combinator
234235
#
235236
@typep combinator :: bound_combinator | maybe_bound_combinator | unbound_combinator
236237

@@ -250,6 +251,8 @@ defmodule NimbleParsec do
250251
| {:lookahead, t, :positive | :negative}
251252
| {:parsec, atom | {module, atom}}
252253
| {:repeat, t, mfargs, gen_times}
254+
| {:string_slice, [inclusive_range], [exclusive_range], bin_modifier, non_neg_integer,
255+
pos_integer | nil}
253256
| {:times, t, pos_integer}
254257

255258
@doc ~S"""
@@ -377,6 +380,12 @@ defmodule NimbleParsec do
377380
generate(parsecs, mod, gen_times(t, int_random(gen), mod, acc))
378381
end
379382

383+
defp generate([{:string_slice, inclusive, exclusive, modifier, min, max} | parsecs], mod, acc) do
384+
extra = if max, do: Enum.random(0..(max - min)), else: int_random(nil)
385+
segment = [{:bin_segment, inclusive, exclusive, modifier}]
386+
generate(parsecs, mod, gen_times(segment, min + extra, mod, acc))
387+
end
388+
380389
defp generate([{:times, t, max} | parsecs], mod, acc) do
381390
generate(parsecs, mod, gen_times(t, Enum.random(0..max), mod, acc))
382391
end
@@ -2004,6 +2013,13 @@ defmodule NimbleParsec do
20042013
when is_list(opts) do
20052014
{min, max} = validate_min_and_max!(opts)
20062015

2016+
case NimbleParsec.Compiler.string_slice(to_repeat, runtime, min, max) do
2017+
nil -> min_max_runtime_chars(combinator, to_repeat, min, max, compile, runtime, args)
2018+
slice -> [slice | combinator]
2019+
end
2020+
end
2021+
2022+
defp min_max_runtime_chars(combinator, to_repeat, min, max, compile, runtime, args) do
20072023
chars =
20082024
if min > 0 do
20092025
min_max_compile_runtime_chars(empty(), to_repeat, min, compile, runtime, args)

lib/nimble_parsec/compiler.ex

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,64 @@ defmodule NimbleParsec.Compiler do
231231
compile_unbound_traverse(combinators, kind, current, step, config, fun)
232232
end
233233

234+
defp compile_unbound_combinator(
235+
{:string_slice, inclusive, exclusive, modifier, min, max},
236+
current,
237+
step,
238+
config
239+
) do
240+
{scan, step} = build_next(step, config)
241+
{next, step} = build_next(step, config)
242+
243+
{var, _} = build_var(0)
244+
input = apply_bin_modifier(var, modifier)
245+
246+
{count, init, incr, done} =
247+
if max do
248+
{[quote(do: count)], [0], [quote(do: count + 1)], [quote(do: _count)]}
249+
else
250+
{[], [], [], []}
251+
end
252+
253+
args = fn rest, count ->
254+
quote(do: [unquote(rest), orig, unquote_splicing(count), acc, stack, context, line, offset])
255+
end
256+
257+
guards = compile_bin_ranges(var, inclusive, exclusive)
258+
guards = if max, do: guards ++ [quote(do: count < unquote(max - min))], else: guards
259+
260+
recur_def =
261+
{scan, args.(quote(do: <<unquote(input), rest::binary>>), count),
262+
guards_list_to_quoted(guards), {scan, [], args.(quote(do: rest), incr)}}
263+
264+
slice =
265+
if config.replace, do: quote(do: acc), else: quote(do: [binary_part(orig, 0, len) | acc])
266+
267+
done_def =
268+
{scan, args.(quote(do: rest), done), true,
269+
quote do
270+
len = byte_size(orig) - byte_size(rest)
271+
unquote(next)(rest, unquote(slice), stack, context, line, offset + len)
272+
end}
273+
274+
{orig, guards, rest, failure_defs, inline, step} =
275+
if min > 0 do
276+
segments = List.duplicate({:bin_segment, inclusive, exclusive, modifier}, min)
277+
{[], inputs, guards, _, _, metadata} = take_bound_combinators(segments)
278+
{prefix, _} = compile_bound_bin_pattern(inputs, metadata, quote(do: rest))
279+
failure_defs = [build_catch_all(:positive, current, segments, config)]
280+
281+
{quote(do: unquote(prefix) = orig), guards, quote(do: rest), failure_defs, [], step}
282+
else
283+
{quote(do: orig), [], quote(do: orig), [], [{current, @arity}], step}
284+
end
285+
286+
head = quote(do: [unquote(orig), acc, stack, context, line, offset])
287+
entry_def = {current, head, guards_list_to_quoted(guards), {scan, [], args.(rest, init)}}
288+
289+
{[entry_def | failure_defs] ++ [recur_def, done_def], inline, next, step, :catch_none}
290+
end
291+
234292
defp compile_unbound_combinator({:times, combinators, count}, current, step, config) do
235293
if all_no_context_combinators?(combinators) do
236294
compile_bound_times(combinators, count, current, step, config)
@@ -887,7 +945,7 @@ defmodule NimbleParsec.Compiler do
887945
end
888946

889947
line =
890-
if newline_allowed?(inclusive) and not newline_forbidden?(exclusive) do
948+
if newline_possible?(inclusive, exclusive) do
891949
add_line(line, offset, var)
892950
else
893951
line
@@ -959,6 +1017,26 @@ defmodule NimbleParsec.Compiler do
9591017
{:+, [], [var, extra]}
9601018
end
9611019

1020+
@doc """
1021+
Lowers a repeated character class into a `:string_slice` combinator, if possible.
1022+
1023+
A class that may match a newline is left alone, as line tracking happens per
1024+
codepoint on the accumulator path.
1025+
"""
1026+
def string_slice([{:bin_segment, ors, ands, modifier}], :__runtime_string__, min, max) do
1027+
if not newline_possible?(ors, ands) do
1028+
{:string_slice, ors, ands, modifier, min, max}
1029+
end
1030+
end
1031+
1032+
def string_slice(_to_repeat, _runtime, _min, _max), do: nil
1033+
1034+
# Whether a character class can match a newline, and therefore whether the
1035+
# combinator consuming it has to track lines at all.
1036+
defp newline_possible?(inclusive, exclusive) do
1037+
newline_allowed?(inclusive) and not newline_forbidden?(exclusive)
1038+
end
1039+
9621040
defp newline_allowed?([]), do: true
9631041

9641042
defp newline_allowed?(ors) do
@@ -1024,6 +1102,9 @@ defmodule NimbleParsec.Compiler do
10241102
prefix <> Enum.join([Enum.join(inclusive, " or") | exclusive], ", and not")
10251103
end
10261104

1105+
defp label({:string_slice, inclusive, exclusive, modifier, _min, _max}),
1106+
do: label({:bin_segment, inclusive, exclusive, modifier})
1107+
10271108
defp label(:eos) do
10281109
"end of string"
10291110
end

test/nimble_parsec_test.exs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ defmodule NimbleParsecTest do
220220
defparsecp :min_ascii_string, ascii_string([?0..?9], min: 2)
221221
defparsecp :max_ascii_string, ascii_string([?0..?9], max: 3)
222222
defparsecp :min_max_ascii_string, ascii_string([?0..?9], min: 2, max: 3)
223+
defparsecp :min_zero_ascii_string, ascii_string([?0..?9], min: 0)
223224

224225
@error "expected ASCII character in the range \"0\" to \"9\", followed by ASCII character in the range \"0\" to \"9\""
225226

@@ -229,6 +230,11 @@ defmodule NimbleParsecTest do
229230
assert min_ascii_string("123o") == {:ok, ["123"], "o", %{}, {1, 0}, 3}
230231
assert min_ascii_string("1234") == {:ok, ["1234"], "", %{}, {1, 0}, 4}
231232
assert min_ascii_string("1") == {:error, @error, "1", %{}, {1, 0}, 0}
233+
234+
assert min_zero_ascii_string("12") == {:ok, ["12"], "", %{}, {1, 0}, 2}
235+
assert min_zero_ascii_string("123o") == {:ok, ["123"], "o", %{}, {1, 0}, 3}
236+
assert min_zero_ascii_string("o") == {:ok, [""], "o", %{}, {1, 0}, 0}
237+
assert min_zero_ascii_string("") == {:ok, [""], "", %{}, {1, 0}, 0}
232238
end
233239

234240
test "returns ok/error with max" do
@@ -247,6 +253,10 @@ defmodule NimbleParsecTest do
247253
assert min_max_ascii_string("12o") == {:ok, ["12"], "o", %{}, {1, 0}, 2}
248254
end
249255

256+
test "treats a max without a min as min: 0" do
257+
assert ascii_string([?0..?9], max: 3) == ascii_string([?0..?9], min: 0, max: 3)
258+
end
259+
250260
test "is not bound" do
251261
assert not_bound?(ascii_string([?0..?9], min: 3))
252262
assert not_bound?(ascii_string([?0..?9], max: 3))
@@ -309,6 +319,10 @@ defmodule NimbleParsecTest do
309319
assert min_max_utf8_string("áé\xFF") == {:ok, ["áé"], "\xFF", %{}, {1, 0}, 4}
310320
end
311321

322+
test "treats a max without a min as min: 0" do
323+
assert utf8_string([], max: 3) == utf8_string([], min: 0, max: 3)
324+
end
325+
312326
test "is not bound" do
313327
assert not_bound?(utf8_string([], min: 3))
314328
assert not_bound?(utf8_string([], max: 3))
@@ -320,6 +334,7 @@ defmodule NimbleParsecTest do
320334
defparsecp :min_sliced_utf8_string, utf8_string([?a..?z, ..], min: 2)
321335
defparsecp :max_sliced_utf8_string, utf8_string([?a..?z, ..], max: 3)
322336
defparsecp :min_max_sliced_utf8_string, utf8_string([?a..?z, ..], min: 2, max: 3)
337+
defparsecp :min_zero_sliced_utf8_string, utf8_string([?a..?z, ..], min: 0)
323338
defparsecp :not_newline_utf8_string, utf8_string([not: ?\n], min: 1)
324339

325340
@error "expected utf8 codepoint in the range \"a\" to \"z\" or in the range \"á\" to \"é\", " <>
@@ -331,6 +346,11 @@ defmodule NimbleParsecTest do
331346
assert min_sliced_utf8_string("áé1") == {:ok, ["áé"], "1", %{}, {1, 0}, 4}
332347
assert min_sliced_utf8_string("á") == {:error, @error, "á", %{}, {1, 0}, 0}
333348
assert min_sliced_utf8_string("1á") == {:error, @error, "1á", %{}, {1, 0}, 0}
349+
350+
assert min_zero_sliced_utf8_string("áé") == {:ok, ["áé"], "", %{}, {1, 0}, 4}
351+
assert min_zero_sliced_utf8_string("aébc") == {:ok, ["aébc"], "", %{}, {1, 0}, 5}
352+
assert min_zero_sliced_utf8_string("1á") == {:ok, [""], "1á", %{}, {1, 0}, 0}
353+
assert min_zero_sliced_utf8_string("") == {:ok, [""], "", %{}, {1, 0}, 0}
334354
end
335355

336356
test "returns ok/error with max" do
@@ -351,6 +371,46 @@ defmodule NimbleParsecTest do
351371
end
352372
end
353373

374+
describe "ascii_string/3 over a newline-free range nested in other combinators" do
375+
defparsecp :lookahead_sliced,
376+
lookahead(ascii_string([?a..?z], min: 2)) |> concat(string("ab"))
377+
378+
defparsecp :lookahead_not_sliced,
379+
lookahead_not(ascii_string([?a..?z], min: 2)) |> concat(string("12"))
380+
381+
defparsecp :choice_sliced,
382+
choice([ascii_string([?a..?z], min: 2), ascii_string([?0..?9], min: 2)])
383+
384+
# These combinators build their error message from the labels of what they
385+
# wrap. A repeated character class names the class once, regardless of `min`.
386+
@lower "ASCII character in the range \"a\" to \"z\""
387+
@digit "ASCII character in the range \"0\" to \"9\""
388+
389+
test "lookahead names the character class once" do
390+
assert lookahead_sliced("abc") == {:ok, ["ab"], "c", %{}, {1, 0}, 2}
391+
392+
assert lookahead_sliced("1ab") ==
393+
{:error, "expected " <> @lower, "1ab", %{}, {1, 0}, 0}
394+
395+
assert lookahead_sliced("a") == {:error, "expected " <> @lower, "a", %{}, {1, 0}, 0}
396+
end
397+
398+
test "lookahead_not names the character class once" do
399+
assert lookahead_not_sliced("12") == {:ok, ["12"], "", %{}, {1, 0}, 2}
400+
401+
assert lookahead_not_sliced("ab12") ==
402+
{:error, "did not expect " <> @lower, "ab12", %{}, {1, 0}, 0}
403+
end
404+
405+
test "choice names each character class once" do
406+
assert choice_sliced("abc") == {:ok, ["abc"], "", %{}, {1, 0}, 3}
407+
assert choice_sliced("12x") == {:ok, ["12"], "x", %{}, {1, 0}, 2}
408+
409+
assert choice_sliced("!") ==
410+
{:error, "expected #{@lower} or #{@digit}", "!", %{}, {1, 0}, 0}
411+
end
412+
end
413+
354414
describe "ignore/2 combinator at compile time" do
355415
defparsecp :compile_ignore, ignore(string("TO"))
356416
defparsecp :compile_ignore_with_newline, ignore(string("T\nO"))

0 commit comments

Comments
 (0)