Skip to content

Commit 328cc73

Browse files
replace greeery with regex_solver
1 parent d57d41f commit 328cc73

10 files changed

Lines changed: 51 additions & 54 deletions

File tree

.tool-versions

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
erlang 28.3
2-
elixir 1.20.0-rc.6-otp-28
1+
erlang 29.0.1
2+
elixir 1.20.0-otp-29

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44

55
## Unreleased
66

7+
## 0.0.9 [2026-06-15]
8+
- Fix regex intersection timeouts by replacing `greenery`/`pythonx` with rustler precompiled `regex_solver`
9+
710
## 0.0.8 [2026-06-11]
811
- Fix bug where properties named `"required"` and `"dependentRequired"` weren't being simplified
912

@@ -43,4 +46,4 @@ This version contains several bug fixes and optimisations, mainly for handling o
4346
- Fix generation not considering `minItems`, `minProperties`, `maxItems`, `maxProperties` when scaling
4447

4548
## 0.0.1 [2026-05-03]
46-
- Initial demo release
49+
- Initial demo release

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
**This project is still in experimental stage**. See [Known bugs and issues](#knwon-bugs-and-issues) and the [roadmap](./ROADMAP.md)
2-
31
[![CI](https://github.com/IgnacioGoldchluk/rock_solid/actions/workflows/ci.yaml/badge.svg)](https://github.com/IgnacioGoldchluk/rock_solid/actions/workflows/ci.yml)
42
[![License](https://img.shields.io/hexpm/l/rock_solid)](https://github.com/IgnacioGoldchluk/rock_solid/blob/main/LICENSE.md)
53
[![Version](https://img.shields.io/hexpm/v/rock_solid.svg)](https://hex.pm/packages/rock_solid)
64
[![Docs](https://img.shields.io/badge/documentation-gray.svg)](https://rock-solid.hexdocs.pm)
75

86
Data generation tool from JSON schemas.
97

8+
> [!IMPORTANT]
9+
> This project is still in experimental stage. See [Known bugs and issues](#knwon-bugs-and-issues) and the [roadmap](./ROADMAP.md)
10+
11+
1012
## Usage
1113
Add to your list of dependencies
1214
```elixir
1315
def deps do
1416
[
15-
{:rock_solid, "~> 0.0.8", only: :test}
17+
{:rock_solid, "~> 0.0.9", only: :test}
1618
]
1719
end
1820
```
@@ -107,7 +109,7 @@ When reaching the data generation step, `StreamData` throws an error because too
107109
Another case are schemas containing a `not` clause that overlaps with most elements generated. Aside from implementing a smarter `not` intersection there is not much to do.
108110

109111
### Timeout
110-
Usually due to heavy recursive definitions where the recursive schemas also contain many fields and options to generate from. One possible solution is to peek at the next value, if it is a `$ref` then geenrate with "less chance" if it's a property, or if it's an array of `$ref` scale down the generation size even further. The other case where this often happens are regex intersections, [`greenery`](https://github.com/qntm/greenery) is quite slow sometimes and can cause timeouts.
112+
Usually due to heavy recursive definitions where the recursive schemas also contain many fields and options to generate from. One possible solution is to peek at the next value, if it is a `$ref` then geenrate with "less chance" if it's a property, or if it's an array of `$ref` scale down the generation size even further.
111113

112114
### Recursive intersection
113115
In order to perform intersection of recurisve schemas, we create a placeholder, and when we reach it again we return it and create a new schema on demand. The problem is that sometimes recursive schemas are reached from different branches, the code tries to return the placeholder but it doesn't exist yet because we are in the process of creating it.
@@ -124,4 +126,4 @@ The contains keyword is transformed by adding a `prefixItem` or intersecting wit
124126
### dependentSchemas and oneOf
125127
Both keywords can often cause timeouts if the number of elements is too large:
126128
- For `dependentSchemas` we compute the power set of all the keys and then calculate each intersection. This is fine when there are few `dependentSchemas` but the number grows exponentially, the number of combinations is `2**length`, meaning that for 8 keys there will be 256 schemas. There is no current solution or alternative to this.
127-
- For `oneOf`, since it behaves as a XOR, we have to intersect each clause with the negation of the rest. Prefer `anyOf` instead which performs a single-pass intersection between all the schemas.
129+
- For `oneOf`, since it behaves as a XOR, we have to intersect each clause with the negation of the rest. Prefer `anyOf` instead which performs a single-pass intersection between all the schemas.

ROADMAP.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@ The current implementation only considers recursive `$ref` reachable from a sing
77
## pattern and length
88
When generating data, if we have `pattern` + `minLength`/`maxLength` we generate based on `pattern` and then apply filtering. This approach often throws a `StreamData.FilterTooNarrowError`. Instead we should add support for generating strings based on regular expressions + length options. This change should likely be implemented in [MoreStreamData](https://github.com/IgnacioGoldchluk/more_stream_data)
99

10-
## pattern intersection
11-
To compute the intersection between two `pattern` we are using [greenery](https://github.com/qntm/greenery) which is a Python package, forcing us to include Pythonx as dependency. Additionally, `greenery` is quite slow and buggy. If there is no alternative in a faster language that can be included via NIF then we might have to develop an alternative in Elixir. It does not matter that the final regex is "ugly" because it gets passed to `MoreStreamData` and tokenized anyway.
12-
1310
## contains keyword
1411
The current solution is a hack that places the `contains` value on the first available `prefixItems`, which returns empty intersection errors when the schema must be intersected with another one that contains incompatible prefix items. Instead we should keep the `contains` keyword in the array object as use it in the data generation step.
1512

1613
## error messages
1714
We are currently raising and letting the code throw a `MatchError` without much context. We should standarize the type of errors (`EmptyIntersection`, `EmptyAnyOf`, etc.) and provide context.
1815

1916
## dependentSchemas
20-
Find an alternative to calculating the powerset and creating potentially thousands of schemas.
17+
Find an alternative to calculating the powerset and creating potentially thousands of schemas.

config/config.exs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
11
import Config
22

3-
config :pythonx, :uv_init,
4-
pyproject_toml: """
5-
[project]
6-
name = "project"
7-
version = "0.0.0"
8-
requires-python = "==3.13.*"
9-
dependencies = [
10-
"greenery>=4.2.2"
11-
]
12-
"""
13-
143
import_config "#{config_env()}.exs"

lib/rock_solid/intersection/pattern.ex

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,27 @@ defmodule RockSolid.Intersection.Pattern do
99
def intersection(r1, r2) when r1 in @catchalls, do: {:ok, r2}
1010
def intersection(r1, r2) when r2 in @catchalls, do: {:ok, r1}
1111

12-
def intersection(r1, r2) when is_binary(r1) and is_binary(r2) do
13-
{result, _globals} =
14-
Pythonx.eval(
15-
"""
16-
import greenery.parse
17-
18-
str(greenery.parse(str(r1)) & greenery.parse(str(r2)))
19-
""",
20-
%{"r1" => r1, "r2" => r2}
21-
)
22-
23-
result = Pythonx.decode(result)
24-
25-
case Regex.scan(binary_pattern(), result) do
26-
[[_, "[]"]] -> {:error, :empty_intersection}
27-
[[_, group]] -> {:ok, group}
28-
_ -> {:error, "invalid regex intersection: #{inspect(result)}"}
12+
def intersection(r1, r2) do
13+
case RegexSolver.intersect(r1, r2) do
14+
{:error, _} = error -> error
15+
{:ok, value} -> {:ok, maybe_add_anchors(value, r1, r2)}
2916
end
30-
rescue
31-
# For practical purposes we can assume the intersection is empty
32-
_ -> {:error, :empty_intersection}
3317
end
3418

35-
defp binary_pattern, do: ~r/b'(.*)'/
19+
defp maybe_add_anchors(value, r1, r2) do
20+
anchor_start? = String.starts_with?(r1, "^") or String.starts_with?(r2, "^")
21+
anchor_end? = String.ends_with?(r1, "$") or String.ends_with?(r2, "$")
22+
23+
value |> anchor_start(anchor_start?) |> anchor_end(anchor_end?)
24+
end
25+
26+
defp anchor_start(value, false), do: value
27+
defp anchor_start("^" <> _ = value, _), do: value
28+
defp anchor_start(value, true), do: "^" <> value
29+
30+
defp anchor_end(value, false), do: value
31+
32+
defp anchor_end(value, true) do
33+
if String.ends_with?(value, "$"), do: value, else: value <> "$"
34+
end
3635
end

mix.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ defmodule RockSolid.MixProject do
22
use Mix.Project
33

44
@source_url "https://github.com/IgnacioGoldchluk/rock_solid"
5-
@version "0.0.8"
5+
@version "0.0.9"
66

77
def project do
88
[
99
app: :rock_solid,
1010
version: @version,
11-
elixir: "~> 1.16",
11+
elixir: "~> 1.18",
1212
description: description(),
1313
package: package(),
1414
elixirc_paths: elixirc_paths(Mix.env()),
@@ -47,7 +47,7 @@ defmodule RockSolid.MixProject do
4747
[
4848
{:zoi, "~> 0.17"},
4949
{:jsv, "~> 0.19"},
50-
{:pythonx, "~> 0.4.0"},
50+
{:regex_solver, "~> 0.1"},
5151
{:nimble_options, "~> 1.1"},
5252
{:req, "~> 0.5.0", only: [:dev, :test]},
5353
# Data generation

mix.lock

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
%{
22
"abnf_parsec": {:hex, :abnf_parsec, "2.1.0", "c4e88d5d089f1698297c0daced12be1fb404e6e577ecf261313ebba5477941f9", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e0ed6290c7cc7e5020c006d1003520390c9bdd20f7c3f776bd49bfe3c5cd362a"},
33
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
4-
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
54
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
65
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
76
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
8-
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
97
"ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
108
"excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"},
119
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
1210
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
13-
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
1411
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
1512
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
1613
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
@@ -26,9 +23,9 @@
2623
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
2724
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
2825
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
29-
"protobuf": {:hex, :protobuf, "0.16.0", "d1878725105d49162977cf3408ccc3eac4f3532e26e5a9e250f2c624175d10f6", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f0d0d3edd8768130f24cc2cfc41320637d32c80110e80d13f160fa699102c828"},
30-
"pythonx": {:hex, :pythonx, "0.4.9", "7de58a3abec183234b4e9a77978ccd061302beed5e388f0671c55e64176f8e57", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.2", [hex: :fine, repo: "hexpm", optional: false]}, {:flame, "~> 0.5", [hex: :flame, repo: "hexpm", optional: true]}], "hexpm", "bc6df5bb9a07af12dec9f8d3d92719c62dde7bfdb760d48e6b0eb6f71b7d774c"},
26+
"regex_solver": {:hex, :regex_solver, "0.1.3", "d4abe92ae0bf29fb29276d4f3cff6eaa49c916dff3b73ab8b9dd546ec4d3f466", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.9", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "de0430677a9ac534bdcfecca40cbf4d2bba70c4d66fe4e4e59b930456ad6f456"},
3127
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
28+
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
3229
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
3330
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
3431
"texture": {:hex, :texture, "1.0.0", "8791d167516749da9a3e5542af2fff49ba14474768b4af1b735dd46850461a22", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}], "hexpm", "77d3ca19d884f5263655b74b63b55f2952d21326fa324dcd74ab87a435427c10"},

test/rock_solid/intersection/string_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ defmodule RockSolid.Intersection.StringTest do
6161
s1 = %{"type" => "string", "pattern" => "[A-Z]+"}
6262
s2 = %{"type" => "string", "pattern" => "\d+"}
6363

64-
assert {:error, "invalid regex intersection" <> _} = Intersection.intersection(s1, s2)
64+
assert {:error, :empty_intersection} == Intersection.intersection(s1, s2)
6565
end
6666
end
6767
end

test/rock_solid/schema_store_test.exs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ defmodule RockSolid.SchemaStoreTest do
3131

3232
@impossible [
3333
# Wrong schemas
34+
# Unsupported regex features
35+
"claude-code-settings.json",
36+
"dein.json",
37+
"neoload.json",
38+
## type: "vrl" ?
39+
"vector.json",
3440
## Has "const": ["list", "of"] instead of "enum"
3541
"prometheus.json",
3642
## Enums cannot match, idk what happened in that schema
@@ -552,7 +558,7 @@ defmodule RockSolid.SchemaStoreTest do
552558
"partial-pdm-dockerize.json",
553559
"partial-pdm.json",
554560
"partial-pixi.json",
555-
"partial-poetry.json",
561+
# "partial-poetry.json", Started failing after switching to RegexSolver
556562
"partial-pyright.json",
557563
"partial-pytest.json",
558564
"partial-repo-review.json",
@@ -774,6 +780,10 @@ defmodule RockSolid.SchemaStoreTest do
774780
"sigmacv.json",
775781
"anodizer.json",
776782
"pgrls.json",
783+
"vtestunit-schema.json",
784+
"flatpak-manifest.json",
785+
"claude-code-plugin-manifest.json",
786+
"claude-code-marketplace.json",
777787
# Modified to pass, original was likely auto generated and the
778788
# schema doesn't make sense or is incomplete. The modified schemas
779789
# are still equivalent or a stricter version of the originals

0 commit comments

Comments
 (0)