@owenlamont/ryl 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.github/CODEOWNERS +1 -0
  2. package/.github/dependabot.yml +13 -0
  3. package/.github/workflows/ci.yml +107 -0
  4. package/.github/workflows/release.yml +613 -0
  5. package/.github/workflows/update_dependencies.yml +61 -0
  6. package/.github/workflows/update_linters.yml +56 -0
  7. package/.pre-commit-config.yaml +87 -0
  8. package/.yamllint +4 -0
  9. package/AGENTS.md +200 -0
  10. package/Cargo.lock +908 -0
  11. package/Cargo.toml +32 -0
  12. package/LICENSE +21 -0
  13. package/README.md +230 -0
  14. package/bin/ryl.js +1 -0
  15. package/clippy.toml +1 -0
  16. package/docs/config-presets.md +100 -0
  17. package/img/benchmark-5x5-5runs.svg +2176 -0
  18. package/package.json +28 -0
  19. package/pyproject.toml +42 -0
  20. package/ruff.toml +107 -0
  21. package/rumdl.toml +20 -0
  22. package/rust-toolchain.toml +3 -0
  23. package/rustfmt.toml +3 -0
  24. package/scripts/benchmark_perf_vs_yamllint.py +400 -0
  25. package/scripts/coverage-missing.ps1 +80 -0
  26. package/scripts/coverage-missing.sh +60 -0
  27. package/src/bin/discover_config_bin.rs +24 -0
  28. package/src/cli_support.rs +33 -0
  29. package/src/conf/mod.rs +85 -0
  30. package/src/config.rs +2099 -0
  31. package/src/decoder.rs +326 -0
  32. package/src/discover.rs +31 -0
  33. package/src/lib.rs +19 -0
  34. package/src/lint.rs +558 -0
  35. package/src/main.rs +535 -0
  36. package/src/migrate.rs +233 -0
  37. package/src/rules/anchors.rs +517 -0
  38. package/src/rules/braces.rs +77 -0
  39. package/src/rules/brackets.rs +77 -0
  40. package/src/rules/colons.rs +475 -0
  41. package/src/rules/commas.rs +372 -0
  42. package/src/rules/comments.rs +299 -0
  43. package/src/rules/comments_indentation.rs +243 -0
  44. package/src/rules/document_end.rs +175 -0
  45. package/src/rules/document_start.rs +84 -0
  46. package/src/rules/empty_lines.rs +152 -0
  47. package/src/rules/empty_values.rs +255 -0
  48. package/src/rules/float_values.rs +259 -0
  49. package/src/rules/flow_collection.rs +562 -0
  50. package/src/rules/hyphens.rs +104 -0
  51. package/src/rules/indentation.rs +803 -0
  52. package/src/rules/key_duplicates.rs +218 -0
  53. package/src/rules/key_ordering.rs +303 -0
  54. package/src/rules/line_length.rs +326 -0
  55. package/src/rules/mod.rs +25 -0
  56. package/src/rules/new_line_at_end_of_file.rs +23 -0
  57. package/src/rules/new_lines.rs +95 -0
  58. package/src/rules/octal_values.rs +121 -0
  59. package/src/rules/quoted_strings.rs +577 -0
  60. package/src/rules/span_utils.rs +37 -0
  61. package/src/rules/trailing_spaces.rs +65 -0
  62. package/src/rules/truthy.rs +420 -0
  63. package/tests/brackets_carriage_return.rs +114 -0
  64. package/tests/build_global_cfg_error.rs +23 -0
  65. package/tests/cli_anchors_rule.rs +143 -0
  66. package/tests/cli_braces_rule.rs +104 -0
  67. package/tests/cli_brackets_rule.rs +104 -0
  68. package/tests/cli_colons_rule.rs +65 -0
  69. package/tests/cli_commas_rule.rs +104 -0
  70. package/tests/cli_comments_indentation_rule.rs +61 -0
  71. package/tests/cli_comments_rule.rs +67 -0
  72. package/tests/cli_config_data_error.rs +30 -0
  73. package/tests/cli_config_flags.rs +66 -0
  74. package/tests/cli_config_migrate.rs +229 -0
  75. package/tests/cli_document_end_rule.rs +92 -0
  76. package/tests/cli_document_start_rule.rs +92 -0
  77. package/tests/cli_empty_lines_rule.rs +87 -0
  78. package/tests/cli_empty_values_rule.rs +68 -0
  79. package/tests/cli_env_config.rs +34 -0
  80. package/tests/cli_exit_and_errors.rs +41 -0
  81. package/tests/cli_file_encoding.rs +203 -0
  82. package/tests/cli_float_values_rule.rs +64 -0
  83. package/tests/cli_format_options.rs +316 -0
  84. package/tests/cli_global_cfg_relaxed.rs +20 -0
  85. package/tests/cli_hyphens_rule.rs +104 -0
  86. package/tests/cli_indentation_rule.rs +65 -0
  87. package/tests/cli_invalid_project_config.rs +39 -0
  88. package/tests/cli_key_duplicates_rule.rs +104 -0
  89. package/tests/cli_key_ordering_rule.rs +59 -0
  90. package/tests/cli_line_length_rule.rs +85 -0
  91. package/tests/cli_list_files.rs +29 -0
  92. package/tests/cli_new_line_rule.rs +141 -0
  93. package/tests/cli_new_lines_rule.rs +119 -0
  94. package/tests/cli_octal_values_rule.rs +60 -0
  95. package/tests/cli_quoted_strings_rule.rs +47 -0
  96. package/tests/cli_toml_config.rs +119 -0
  97. package/tests/cli_trailing_spaces_rule.rs +77 -0
  98. package/tests/cli_truthy_rule.rs +83 -0
  99. package/tests/cli_yaml_files_negation.rs +45 -0
  100. package/tests/colons_rule.rs +303 -0
  101. package/tests/common/compat.rs +114 -0
  102. package/tests/common/fake_env.rs +93 -0
  103. package/tests/common/mod.rs +1 -0
  104. package/tests/conf_builtin.rs +9 -0
  105. package/tests/config_anchors.rs +84 -0
  106. package/tests/config_braces.rs +121 -0
  107. package/tests/config_brackets.rs +127 -0
  108. package/tests/config_commas.rs +79 -0
  109. package/tests/config_comments.rs +65 -0
  110. package/tests/config_comments_indentation.rs +20 -0
  111. package/tests/config_deep_merge_nonstring_key.rs +24 -0
  112. package/tests/config_document_end.rs +54 -0
  113. package/tests/config_document_start.rs +55 -0
  114. package/tests/config_empty_lines.rs +48 -0
  115. package/tests/config_empty_values.rs +35 -0
  116. package/tests/config_env_errors.rs +23 -0
  117. package/tests/config_env_invalid_inline.rs +15 -0
  118. package/tests/config_env_missing.rs +63 -0
  119. package/tests/config_env_shim.rs +301 -0
  120. package/tests/config_explicit_file_parse_error.rs +55 -0
  121. package/tests/config_extended_features.rs +225 -0
  122. package/tests/config_extends_inline.rs +185 -0
  123. package/tests/config_extends_sequence.rs +18 -0
  124. package/tests/config_find_project_home_boundary.rs +54 -0
  125. package/tests/config_find_project_two_files_in_cwd.rs +47 -0
  126. package/tests/config_float_values.rs +34 -0
  127. package/tests/config_from_yaml_paths.rs +32 -0
  128. package/tests/config_hyphens.rs +51 -0
  129. package/tests/config_ignore_errors.rs +243 -0
  130. package/tests/config_ignore_overrides.rs +83 -0
  131. package/tests/config_indentation.rs +65 -0
  132. package/tests/config_invalid_globs.rs +16 -0
  133. package/tests/config_invalid_types.rs +19 -0
  134. package/tests/config_key_duplicates.rs +34 -0
  135. package/tests/config_key_ordering.rs +70 -0
  136. package/tests/config_line_length.rs +65 -0
  137. package/tests/config_locale.rs +111 -0
  138. package/tests/config_merge.rs +26 -0
  139. package/tests/config_new_lines.rs +89 -0
  140. package/tests/config_octal_values.rs +33 -0
  141. package/tests/config_quoted_strings.rs +195 -0
  142. package/tests/config_rule_level.rs +147 -0
  143. package/tests/config_rules_non_string_keys.rs +23 -0
  144. package/tests/config_scalar_overrides.rs +27 -0
  145. package/tests/config_to_toml.rs +110 -0
  146. package/tests/config_toml_coverage.rs +80 -0
  147. package/tests/config_toml_discovery.rs +304 -0
  148. package/tests/config_trailing_spaces.rs +152 -0
  149. package/tests/config_truthy.rs +77 -0
  150. package/tests/config_yaml_files.rs +62 -0
  151. package/tests/config_yaml_files_all_non_string.rs +15 -0
  152. package/tests/config_yaml_files_empty.rs +30 -0
  153. package/tests/coverage_commas.rs +46 -0
  154. package/tests/decoder_decode.rs +338 -0
  155. package/tests/discover_config_bin_all.rs +66 -0
  156. package/tests/discover_config_bin_env_invalid_yaml.rs +26 -0
  157. package/tests/discover_config_bin_project_config_parse_error.rs +24 -0
  158. package/tests/discover_config_bin_user_global_error.rs +26 -0
  159. package/tests/discover_module.rs +30 -0
  160. package/tests/discover_per_file_dir.rs +10 -0
  161. package/tests/discover_per_file_project_config_error.rs +21 -0
  162. package/tests/float_values.rs +43 -0
  163. package/tests/lint_multi_errors.rs +32 -0
  164. package/tests/main_yaml_ok_filtering.rs +30 -0
  165. package/tests/migrate_module.rs +259 -0
  166. package/tests/resolve_ctx_empty_parent.rs +16 -0
  167. package/tests/rule_anchors.rs +442 -0
  168. package/tests/rule_braces.rs +258 -0
  169. package/tests/rule_brackets.rs +217 -0
  170. package/tests/rule_commas.rs +205 -0
  171. package/tests/rule_comments.rs +197 -0
  172. package/tests/rule_comments_indentation.rs +127 -0
  173. package/tests/rule_document_end.rs +118 -0
  174. package/tests/rule_document_start.rs +60 -0
  175. package/tests/rule_empty_lines.rs +96 -0
  176. package/tests/rule_empty_values.rs +102 -0
  177. package/tests/rule_float_values.rs +109 -0
  178. package/tests/rule_hyphens.rs +65 -0
  179. package/tests/rule_indentation.rs +455 -0
  180. package/tests/rule_key_duplicates.rs +76 -0
  181. package/tests/rule_key_ordering.rs +207 -0
  182. package/tests/rule_line_length.rs +200 -0
  183. package/tests/rule_new_lines.rs +51 -0
  184. package/tests/rule_octal_values.rs +53 -0
  185. package/tests/rule_quoted_strings.rs +290 -0
  186. package/tests/rule_trailing_spaces.rs +41 -0
  187. package/tests/rule_truthy.rs +236 -0
  188. package/tests/user_global_invalid_yaml.rs +32 -0
  189. package/tests/yamllint_compat_anchors.rs +280 -0
  190. package/tests/yamllint_compat_braces.rs +411 -0
  191. package/tests/yamllint_compat_brackets.rs +364 -0
  192. package/tests/yamllint_compat_colons.rs +298 -0
  193. package/tests/yamllint_compat_colors.rs +80 -0
  194. package/tests/yamllint_compat_commas.rs +375 -0
  195. package/tests/yamllint_compat_comments.rs +167 -0
  196. package/tests/yamllint_compat_comments_indentation.rs +281 -0
  197. package/tests/yamllint_compat_config.rs +170 -0
  198. package/tests/yamllint_compat_document_end.rs +243 -0
  199. package/tests/yamllint_compat_document_start.rs +136 -0
  200. package/tests/yamllint_compat_empty_lines.rs +117 -0
  201. package/tests/yamllint_compat_empty_values.rs +179 -0
  202. package/tests/yamllint_compat_float_values.rs +216 -0
  203. package/tests/yamllint_compat_hyphens.rs +223 -0
  204. package/tests/yamllint_compat_indentation.rs +398 -0
  205. package/tests/yamllint_compat_key_duplicates.rs +139 -0
  206. package/tests/yamllint_compat_key_ordering.rs +170 -0
  207. package/tests/yamllint_compat_line_length.rs +375 -0
  208. package/tests/yamllint_compat_list.rs +127 -0
  209. package/tests/yamllint_compat_new_line.rs +133 -0
  210. package/tests/yamllint_compat_newline_types.rs +185 -0
  211. package/tests/yamllint_compat_octal_values.rs +172 -0
  212. package/tests/yamllint_compat_quoted_strings.rs +154 -0
  213. package/tests/yamllint_compat_syntax.rs +200 -0
  214. package/tests/yamllint_compat_trailing_spaces.rs +162 -0
  215. package/tests/yamllint_compat_truthy.rs +130 -0
  216. package/tests/yamllint_compat_yaml_files.rs +81 -0
  217. package/typos.toml +2 -0
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@owenlamont/ryl",
3
+ "version": "0.4.1",
4
+ "description": "Fast YAML linter inspired by yamllint",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "ryl": "bin/ryl.js"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/owenlamont/ryl.git"
12
+ },
13
+ "keywords": [
14
+ "yaml",
15
+ "lint",
16
+ "cli",
17
+ "rust"
18
+ ],
19
+ "author": "Owen Lamont",
20
+ "license": "MIT",
21
+ "bugs": {
22
+ "url": "https://github.com/owenlamont/ryl/issues"
23
+ },
24
+ "homepage": "https://github.com/owenlamont/ryl#readme",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.4,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "ryl"
7
+ version = "0.4.1"
8
+ description = "Fast YAML linter inspired by yamllint"
9
+ requires-python = ">=3.10"
10
+ readme = "README.md"
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: MacOS :: MacOS X",
17
+ "Operating System :: Microsoft :: Windows",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Programming Language :: Rust",
28
+ "Topic :: File Formats",
29
+ "Topic :: Text Processing :: Markup",
30
+ "Topic :: Software Development :: Quality Assurance",
31
+ "Topic :: Utilities",
32
+ ]
33
+ keywords = ["yaml", "linter", "lint", "cli", "rust"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/owenlamont/ryl"
37
+ Repository = "https://github.com/owenlamont/ryl"
38
+ Issues = "https://github.com/owenlamont/ryl/issues"
39
+
40
+ [tool.maturin]
41
+ bindings = "bin"
42
+ sdist = true
package/ruff.toml ADDED
@@ -0,0 +1,107 @@
1
+ line-length = 88
2
+ indent-width = 4
3
+
4
+ [format]
5
+ quote-style = "double"
6
+ indent-style = "space"
7
+ skip-magic-trailing-comma = true
8
+ line-ending = "lf"
9
+
10
+ [lint]
11
+ # See https://docs.astral.sh/ruff/rules/
12
+ select = [
13
+ "A",
14
+ "ASYNC",
15
+ "B",
16
+ "C4",
17
+ "D",
18
+ "DOC",
19
+ "E",
20
+ "ERA",
21
+ "F",
22
+ "FURB",
23
+ "I",
24
+ "ISC",
25
+ "N",
26
+ "NPY",
27
+ "PD",
28
+ "PERF",
29
+ "PT",
30
+ "PTH",
31
+ "Q",
32
+ "S",
33
+ "RET",
34
+ "RUF",
35
+ "SIM",
36
+ "TID",
37
+ "UP",
38
+ ]
39
+ ignore = [
40
+ "D100", # Missing docstring in public module
41
+ "D101", # Missing docstring in public class
42
+ "D104", # Missing docstring in public package
43
+ "D105", # Missing docstring in magic method
44
+ "D106", # Missing docstring in public nested class
45
+ "D107", # Missing docstring in __init__
46
+ "D202", # No blank lines allowed after function docstring
47
+ "D213", # Multi-line docstring summary should start at the second line
48
+ "D214", # Section is over-indented
49
+ "D215", # Section underline is over-indented
50
+ "D400", # First line should end with a period
51
+ "D401", # First line of docstring should be in imperative mood
52
+ "D415", # First line should end with a period, question mark, or exclamation
53
+ "D416", # Section name should end with a colon
54
+ "D417", # Missing argument descriptions in the docstring
55
+ "D418", # Function/ Method decorated with @overload shouldn't contain a docstring
56
+ "E203", # Whitespace before ':' (fights ruff format)
57
+ "ISC001", # Implicitly concatenated string literals on one line
58
+ ]
59
+ extend-select = ["RUF027"]
60
+ preview = true
61
+
62
+ [lint.per-file-ignores]
63
+ "tests/**" = ["D102", "D103", "S101"]
64
+ "tests/uv_secure/package_info/test_dependency_file_parser.py" = ["E501"]
65
+ "scripts/benchmark_perf_vs_yamllint.py" = [
66
+ "B008",
67
+ "B904",
68
+ "B905",
69
+ "D103",
70
+ "E501",
71
+ "S404",
72
+ "S603",
73
+ ]
74
+
75
+ # Allow fix for all enabled rules (when `--fix`) is provided.
76
+ fixable = ["ALL"]
77
+ unfixable = [
78
+ "B905", # Enforce strict argument on zip - but don't autofix as strict=False
79
+ ]
80
+
81
+ [lint.flake8-pytest-style]
82
+ mark-parentheses = false
83
+
84
+ [lint.flake8-tidy-imports]
85
+ # Disallow all relative imports.
86
+ ban-relative-imports = "all"
87
+
88
+ [lint.flake8-tidy-imports.banned-api]
89
+ "typing.cast".msg = "Type casts are not allowed"
90
+
91
+ [lint.isort]
92
+ case-sensitive = false
93
+ combine-as-imports = true
94
+ force-sort-within-sections = true
95
+ lines-after-imports = 2
96
+ order-by-type = false
97
+ section-order = [
98
+ "future",
99
+ "standard-library",
100
+ "third-party",
101
+ "first-party",
102
+ "local-folder",
103
+ ]
104
+ split-on-trailing-comma = false
105
+
106
+ [lint.pydocstyle]
107
+ convention = "google"
package/rumdl.toml ADDED
@@ -0,0 +1,20 @@
1
+ line-length = 88
2
+ disable = []
3
+ exclude = []
4
+ respect-gitignore = true
5
+
6
+ [MD004]
7
+ style = "dash"
8
+
9
+ [MD013]
10
+ line_length = 88
11
+ code_blocks = false
12
+ tables = true
13
+ headings = true
14
+ strict = false
15
+
16
+ [MD029]
17
+ style = "ordered"
18
+
19
+ [MD035]
20
+ style = "---"
@@ -0,0 +1,3 @@
1
+ [toolchain]
2
+ channel = "1.93.1"
3
+ components = ["llvm-tools-preview"]
package/rustfmt.toml ADDED
@@ -0,0 +1,3 @@
1
+ edition = "2024"
2
+ style_edition = "2024"
3
+ max_width = 88
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env -S uv run
2
+ # /// script
3
+ # requires-python = ">=3.14"
4
+ # dependencies = [
5
+ # "matplotlib>=3.9,<4",
6
+ # "orjson>=3.11,<4",
7
+ # "polars>=1.30,<2",
8
+ # "ryl",
9
+ # "tqdm>=4.67,<5",
10
+ # "typer>=0.16,<1",
11
+ # "yamllint",
12
+ # ]
13
+ # ///
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass
19
+ from datetime import datetime, timezone
20
+ import os
21
+ from pathlib import Path
22
+ import shutil
23
+ import string
24
+ import subprocess
25
+ import sys
26
+
27
+ import matplotlib.pyplot as plt
28
+ import orjson
29
+ import polars as pl
30
+ from tqdm import tqdm
31
+ import typer
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Case:
36
+ file_count: int
37
+ file_size_kib: int
38
+ dataset_dir: Path
39
+
40
+
41
+ app = typer.Typer(add_completion=False)
42
+
43
+
44
+ def parse_int_list(raw: str) -> list[int]:
45
+ values = [int(part.strip()) for part in raw.split(",") if part.strip()]
46
+ if not values:
47
+ raise ValueError("expected at least one integer")
48
+ if any(value <= 0 for value in values):
49
+ raise ValueError("all values must be > 0")
50
+ return values
51
+
52
+
53
+ def quote_shell(text: str) -> str:
54
+ if os.name == "nt":
55
+ return subprocess.list2cmdline([text])
56
+ import shlex
57
+
58
+ return shlex.quote(text)
59
+
60
+
61
+ def run_checked(
62
+ args: list[str], *, cwd: Path | None = None
63
+ ) -> subprocess.CompletedProcess[str]:
64
+ return subprocess.run(args, cwd=cwd, check=True, text=True, capture_output=True)
65
+
66
+
67
+ def require_command(name: str) -> None:
68
+ if shutil.which(name) is None:
69
+ raise RuntimeError(f"required command is not installed or not on PATH: {name}")
70
+
71
+
72
+ def resolve_tool_path(name: str) -> Path:
73
+ path = shutil.which(name)
74
+ if path is None:
75
+ raise RuntimeError(
76
+ f"{name} executable not found on PATH in this uv environment"
77
+ )
78
+ return Path(path)
79
+
80
+
81
+ def build_yaml_blob(target_bytes: int, seed: int) -> str:
82
+ lines = ["root:"]
83
+ alphabet = string.ascii_lowercase + string.digits
84
+ index = 0
85
+ while len(("\n".join(lines) + "\n").encode("utf-8")) < target_bytes:
86
+ offset = (seed + index) % len(alphabet)
87
+ token = "".join(alphabet[(offset + i) % len(alphabet)] for i in range(28))
88
+ lines.append(f' key_{index:06d}: "{token}"')
89
+ index += 1
90
+ return "\n".join(lines) + "\n"
91
+
92
+
93
+ def materialize_case(case: Case, seed: int) -> None:
94
+ case.dataset_dir.mkdir(parents=True, exist_ok=True)
95
+ target_bytes = case.file_size_kib * 1024
96
+ for file_index in range(case.file_count):
97
+ payload = build_yaml_blob(target_bytes=target_bytes, seed=seed + file_index)
98
+ file_path = case.dataset_dir / f"file_{file_index:05d}.yaml"
99
+ file_path.write_text(payload, encoding="utf-8")
100
+
101
+
102
+ def iter_cases(
103
+ file_counts: Iterable[int], file_sizes_kib: Iterable[int], base_dir: Path
104
+ ) -> list[Case]:
105
+ cases: list[Case] = []
106
+ for size_kib in file_sizes_kib:
107
+ for count in file_counts:
108
+ case_dir = base_dir / f"files_{count:05d}__size_{size_kib:04d}kib"
109
+ cases.append(
110
+ Case(file_count=count, file_size_kib=size_kib, dataset_dir=case_dir)
111
+ )
112
+ return cases
113
+
114
+
115
+ def expand_range(
116
+ *, start: int | None, end: int | None, step: int | None, value_name: str
117
+ ) -> list[int] | None:
118
+ values = (start, end, step)
119
+ if all(value is None for value in values):
120
+ return None
121
+ if any(value is None for value in values):
122
+ raise typer.BadParameter(
123
+ f"{value_name} range requires start, end, and step together."
124
+ )
125
+ if start <= 0 or end <= 0 or step <= 0:
126
+ raise typer.BadParameter(f"{value_name} range values must be > 0.")
127
+ if end < start:
128
+ raise typer.BadParameter(f"{value_name} range end must be >= start.")
129
+ return list(range(start, end + 1, step))
130
+
131
+
132
+ def benchmark_case(
133
+ case: Case,
134
+ *,
135
+ ryl_bin: Path,
136
+ yamllint_bin: Path,
137
+ runs: int,
138
+ warmup: int,
139
+ output_json_path: Path,
140
+ ) -> dict[str, dict[str, float | list[float] | str]]:
141
+ cfg = "extends: relaxed"
142
+ ryl_cmd = f"{quote_shell(str(ryl_bin))} -d {quote_shell(cfg)} {quote_shell(str(case.dataset_dir))}"
143
+ yamllint_cmd = f"{quote_shell(str(yamllint_bin))} -d {quote_shell(cfg)} {quote_shell(str(case.dataset_dir))}"
144
+ run_checked(
145
+ [
146
+ "hyperfine",
147
+ "--runs",
148
+ str(runs),
149
+ "--warmup",
150
+ str(warmup),
151
+ "--export-json",
152
+ str(output_json_path),
153
+ "-n",
154
+ "ryl",
155
+ ryl_cmd,
156
+ "-n",
157
+ "yamllint",
158
+ yamllint_cmd,
159
+ ]
160
+ )
161
+ raw = orjson.loads(output_json_path.read_bytes())
162
+ parsed: dict[str, dict[str, float | list[float] | str]] = {}
163
+ for result in raw["results"]:
164
+ parsed[str(result["command"])] = {
165
+ "mean": float(result["mean"]),
166
+ "stddev": float(result["stddev"] or 0.0),
167
+ "min": float(result["min"]),
168
+ "max": float(result["max"]),
169
+ "times": [float(value) for value in result["times"]],
170
+ }
171
+ return parsed
172
+
173
+
174
+ def plot_results(
175
+ df: pl.DataFrame,
176
+ out_png: Path,
177
+ out_svg: Path,
178
+ *,
179
+ runs: int,
180
+ ryl_version: str,
181
+ yamllint_version: str,
182
+ ) -> None:
183
+ plt.style.use("seaborn-v0_8-whitegrid")
184
+ tools = ["ryl", "yamllint"]
185
+ sizes = sorted(int(value) for value in df["file_size_kib"].unique().to_list())
186
+ cmap = plt.cm.Blues
187
+ min_tone = 0.35
188
+ max_tone = 0.95
189
+ tones = [
190
+ min_tone + (max_tone - min_tone) * idx / max(len(sizes) - 1, 1)
191
+ for idx in range(len(sizes))
192
+ ]
193
+ fig, axes = plt.subplots(1, len(tools), figsize=(14, 5.5), sharey=True)
194
+ if len(tools) == 1:
195
+ axes = [axes]
196
+ version_map = {"ryl": ryl_version, "yamllint": yamllint_version}
197
+ for axis, tool in zip(axes, tools):
198
+ tool_df = df.filter(pl.col("tool") == tool)
199
+ for idx, size in enumerate(sizes):
200
+ size_df = tool_df.filter(pl.col("file_size_kib") == size).sort("file_count")
201
+ x_values = [int(value) for value in size_df["file_count"].to_list()]
202
+ y_values = [float(value) for value in size_df["mean_seconds"].to_list()]
203
+ y_stddev = [float(value) for value in size_df["stddev_seconds"].to_list()]
204
+ color = cmap(tones[idx])
205
+ axis.plot(
206
+ x_values,
207
+ y_values,
208
+ color=color,
209
+ linewidth=2,
210
+ marker="o",
211
+ label=f"{size} KiB",
212
+ )
213
+ axis.fill_between(
214
+ x_values,
215
+ [value - std for value, std in zip(y_values, y_stddev)],
216
+ [value + std for value, std in zip(y_values, y_stddev)],
217
+ color=color,
218
+ alpha=0.16,
219
+ )
220
+ axis.set_title(version_map.get(tool, tool))
221
+ axis.set_xlabel("Number of YAML files")
222
+ axes[0].set_ylabel("Mean runtime (seconds)")
223
+ axes[-1].legend(title="File size", loc="upper left")
224
+ fig.suptitle(f"ryl vs yamllint (hyperfine, {runs} runs per point)", fontsize=13)
225
+ fig.tight_layout()
226
+ out_png.parent.mkdir(parents=True, exist_ok=True)
227
+ fig.savefig(out_png, dpi=170)
228
+ fig.savefig(out_svg)
229
+ plt.close(fig)
230
+
231
+
232
+ @app.command()
233
+ def main(
234
+ file_counts: str = typer.Option(
235
+ "25,100,400,1000",
236
+ help="Comma-separated file counts. Ignored when --file-count-start/end/step are set.",
237
+ ),
238
+ file_count_start: int | None = typer.Option(
239
+ None, help="Start of file-count range (inclusive)."
240
+ ),
241
+ file_count_end: int | None = typer.Option(
242
+ None, help="End of file-count range (inclusive)."
243
+ ),
244
+ file_count_step: int | None = typer.Option(
245
+ None, help="Increment for file-count range."
246
+ ),
247
+ file_sizes_kib: str = typer.Option(
248
+ "1,8,32,128",
249
+ help="Comma-separated file sizes in KiB. Ignored when --file-size-start-kib/end/step are set.",
250
+ ),
251
+ file_size_start_kib: int | None = typer.Option(
252
+ None, help="Start of file-size range in KiB (inclusive)."
253
+ ),
254
+ file_size_end_kib: int | None = typer.Option(
255
+ None, help="End of file-size range in KiB (inclusive)."
256
+ ),
257
+ file_size_step_kib: int | None = typer.Option(
258
+ None, help="Increment for file-size range in KiB."
259
+ ),
260
+ runs: int = typer.Option(10, help="Number of hyperfine runs per point."),
261
+ warmup: int = typer.Option(2, help="Number of warmup runs per point."),
262
+ seed: int = typer.Option(7331, help="Base RNG seed for synthetic YAML generation."),
263
+ output_dir: Path = typer.Option(
264
+ Path("manual_outputs") / "benchmarks",
265
+ help="Directory where all artifacts are written.",
266
+ ),
267
+ keep_datasets: bool = typer.Option(
268
+ False, help="Keep generated YAML datasets on disk instead of deleting them."
269
+ ),
270
+ ) -> None:
271
+ if runs <= 0:
272
+ raise typer.BadParameter("--runs must be > 0")
273
+ if warmup < 0:
274
+ raise typer.BadParameter("--warmup must be >= 0")
275
+
276
+ file_counts_range = expand_range(
277
+ start=file_count_start,
278
+ end=file_count_end,
279
+ step=file_count_step,
280
+ value_name="file count",
281
+ )
282
+ file_sizes_range = expand_range(
283
+ start=file_size_start_kib,
284
+ end=file_size_end_kib,
285
+ step=file_size_step_kib,
286
+ value_name="file size",
287
+ )
288
+ try:
289
+ file_counts_values = (
290
+ file_counts_range
291
+ if file_counts_range is not None
292
+ else parse_int_list(file_counts)
293
+ )
294
+ file_size_values = (
295
+ file_sizes_range
296
+ if file_sizes_range is not None
297
+ else parse_int_list(file_sizes_kib)
298
+ )
299
+ except ValueError as err:
300
+ raise typer.BadParameter(str(err)) from err
301
+
302
+ require_command("uv")
303
+ require_command("hyperfine")
304
+
305
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
306
+ run_dir = output_dir / timestamp
307
+ run_dir.mkdir(parents=True, exist_ok=True)
308
+ dataset_root = run_dir / "datasets"
309
+ dataset_root.mkdir(parents=True, exist_ok=True)
310
+ raw_dir = run_dir / "hyperfine-json"
311
+ raw_dir.mkdir(parents=True, exist_ok=True)
312
+
313
+ ryl_bin = resolve_tool_path("ryl")
314
+ yamllint_bin = resolve_tool_path("yamllint")
315
+ ryl_version = run_checked([str(ryl_bin), "--version"]).stdout.strip()
316
+ yamllint_version = run_checked([str(yamllint_bin), "--version"]).stdout.strip()
317
+
318
+ cases = iter_cases(file_counts_values, file_size_values, dataset_root)
319
+ rows: list[dict[str, float | int | str]] = []
320
+ for case_index, case in enumerate(tqdm(cases, desc="Benchmark cases", unit="case")):
321
+ materialize_case(case, seed=seed + case_index * 100_000)
322
+ case_json = (
323
+ raw_dir / f"files_{case.file_count}__size_{case.file_size_kib}kib.json"
324
+ )
325
+ results = benchmark_case(
326
+ case,
327
+ ryl_bin=ryl_bin,
328
+ yamllint_bin=yamllint_bin,
329
+ runs=runs,
330
+ warmup=warmup,
331
+ output_json_path=case_json,
332
+ )
333
+ for tool in ("ryl", "yamllint"):
334
+ row = {
335
+ "tool": tool,
336
+ "file_count": case.file_count,
337
+ "file_size_kib": case.file_size_kib,
338
+ "mean_seconds": float(results[tool]["mean"]),
339
+ "stddev_seconds": float(results[tool]["stddev"]),
340
+ "min_seconds": float(results[tool]["min"]),
341
+ "max_seconds": float(results[tool]["max"]),
342
+ }
343
+ rows.append(row)
344
+
345
+ results_df = pl.DataFrame(rows).select(
346
+ [
347
+ "tool",
348
+ "file_count",
349
+ "file_size_kib",
350
+ "mean_seconds",
351
+ "stddev_seconds",
352
+ "min_seconds",
353
+ "max_seconds",
354
+ ]
355
+ )
356
+ csv_path = run_dir / "summary.csv"
357
+ results_df.write_csv(csv_path)
358
+
359
+ meta_path = run_dir / "meta.json"
360
+ meta_path.write_bytes(
361
+ orjson.dumps(
362
+ {
363
+ "generated_at_utc": timestamp,
364
+ "ryl_version": ryl_version,
365
+ "yamllint_version": yamllint_version,
366
+ "runs": runs,
367
+ "warmup": warmup,
368
+ "file_counts": file_counts_values,
369
+ "file_sizes_kib": file_size_values,
370
+ },
371
+ option=orjson.OPT_INDENT_2,
372
+ )
373
+ + b"\n"
374
+ )
375
+
376
+ plot_png = run_dir / "benchmark.png"
377
+ plot_svg = run_dir / "benchmark.svg"
378
+ plot_results(
379
+ results_df,
380
+ plot_png,
381
+ plot_svg,
382
+ runs=runs,
383
+ ryl_version=ryl_version,
384
+ yamllint_version=yamllint_version,
385
+ )
386
+
387
+ if not keep_datasets:
388
+ shutil.rmtree(dataset_root)
389
+
390
+ print(f"Benchmark complete. Artifacts: {run_dir}")
391
+ print(f"Versions: {ryl_version}; {yamllint_version}")
392
+ print(f"Plot: {plot_png}")
393
+
394
+
395
+ if __name__ == "__main__":
396
+ try:
397
+ app()
398
+ except KeyboardInterrupt:
399
+ print("Interrupted.", file=sys.stderr)
400
+ raise SystemExit(130)
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env pwsh
2
+ Set-StrictMode -Version Latest
3
+ $ErrorActionPreference = 'Stop'
4
+
5
+ function Fail([string]$Message) {
6
+ [Console]::Error.WriteLine($Message)
7
+ exit 1
8
+ }
9
+
10
+ function Require-Command([string]$Name) {
11
+ if (-not (Get-Command -Name $Name -ErrorAction SilentlyContinue)) {
12
+ Fail "$Name is required to run this script"
13
+ }
14
+ }
15
+
16
+ $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
17
+ if (-not $scriptRoot) { $scriptRoot = Get-Location }
18
+ $projectRoot = (Resolve-Path (Join-Path $scriptRoot '..')).Path
19
+ $normalizedRoot = $projectRoot -replace '\\', '/'
20
+ if (-not $normalizedRoot.EndsWith('/')) { $normalizedRoot += '/' }
21
+
22
+ Require-Command 'cargo'
23
+ Require-Command 'jq'
24
+
25
+ $tmp = $null
26
+ $locationPushed = $false
27
+ try {
28
+ Push-Location $projectRoot
29
+ $locationPushed = $true
30
+
31
+ & cargo llvm-cov nextest --summary-only *> $null
32
+ if ($LASTEXITCODE -ne 0) {
33
+ Fail 'cargo llvm-cov nextest --summary-only failed; inspect the output above for details.'
34
+ }
35
+
36
+ $tmp = [System.IO.Path]::GetTempFileName()
37
+
38
+ & cargo llvm-cov report --json --output-path $tmp *> $null
39
+ if ($LASTEXITCODE -ne 0) {
40
+ Fail 'Failed to generate coverage report.'
41
+ }
42
+
43
+ $jqFilter = @'
44
+ def ranges:
45
+ sort
46
+ | unique
47
+ | reduce .[] as $line ([];
48
+ if length > 0 and $line == (.[-1][1] + 1) then
49
+ (.[-1] = [.[-1][0], $line])
50
+ else
51
+ . + [[ $line, $line ]]
52
+ end)
53
+ | map(if .[0] == .[1] then (.[0] | tostring) else "\(.[0])-\(.[1])" end);
54
+
55
+ .data[].files[]
56
+ | select(.summary.regions.percent < 100)
57
+ | {file: (.filename | gsub("\\\\"; "/") | sub("^" + $prefix; "")),
58
+ uncovered: [ .segments[]
59
+ | select(.[2] == 0 and .[3] == true and .[5] == false)
60
+ | .[0]
61
+ ] }
62
+ | select(.uncovered | length > 0)
63
+ | "\(.file):\(.uncovered | ranges | join(","))"
64
+ '@
65
+
66
+ $report = (& jq -r --arg prefix $normalizedRoot $jqFilter $tmp)
67
+
68
+ if ([string]::IsNullOrWhiteSpace($report)) {
69
+ Write-Output 'Coverage OK: no uncovered regions.'
70
+ } else {
71
+ Write-Output 'Uncovered regions (file:path line ranges):'
72
+ $report | ForEach-Object { Write-Output $_ }
73
+ }
74
+ }
75
+ finally {
76
+ if ($tmp -and (Test-Path -LiteralPath $tmp)) {
77
+ Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
78
+ }
79
+ if ($locationPushed) { Pop-Location }
80
+ }