shirobai 2026.0709.0000 → 2026.0713.0000

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +3 -2
  3. data/Cargo.toml +7 -0
  4. data/crates/shirobai-core/Cargo.toml +10 -1
  5. data/crates/shirobai-core/src/bin/bundle_profile.rs +1 -0
  6. data/crates/shirobai-core/src/bin/pm_lex_probe.rs +246 -0
  7. data/crates/shirobai-core/src/lib.rs +5 -0
  8. data/crates/shirobai-core/src/pm_lex.rs +300 -0
  9. data/crates/shirobai-core/src/pm_lex_token_names.rs +173 -0
  10. data/crates/shirobai-core/src/rules/abc_size.rs +229 -43
  11. data/crates/shirobai-core/src/rules/aligner.rs +502 -0
  12. data/crates/shirobai-core/src/rules/argument_alignment.rs +10 -1
  13. data/crates/shirobai-core/src/rules/ascii_identifiers.rs +232 -0
  14. data/crates/shirobai-core/src/rules/block_alignment.rs +29 -7
  15. data/crates/shirobai-core/src/rules/bundle.rs +475 -17
  16. data/crates/shirobai-core/src/rules/code_length.rs +2 -3
  17. data/crates/shirobai-core/src/rules/complexity.rs +134 -8
  18. data/crates/shirobai-core/src/rules/dot_position.rs +2 -3
  19. data/crates/shirobai-core/src/rules/duplicate_magic_comment.rs +192 -3
  20. data/crates/shirobai-core/src/rules/duplicate_methods.rs +2 -3
  21. data/crates/shirobai-core/src/rules/else_alignment.rs +35 -26
  22. data/crates/shirobai-core/src/rules/empty_line_between_defs.rs +92 -18
  23. data/crates/shirobai-core/src/rules/empty_lines_around_arguments.rs +12 -5
  24. data/crates/shirobai-core/src/rules/end_of_line.rs +148 -0
  25. data/crates/shirobai-core/src/rules/extra_spacing.rs +721 -0
  26. data/crates/shirobai-core/src/rules/file_null.rs +32 -11
  27. data/crates/shirobai-core/src/rules/first_argument_indentation.rs +15 -8
  28. data/crates/shirobai-core/src/rules/first_array_element_indentation.rs +2 -3
  29. data/crates/shirobai-core/src/rules/first_hash_element_indentation.rs +2 -3
  30. data/crates/shirobai-core/src/rules/hash_syntax.rs +21 -13
  31. data/crates/shirobai-core/src/rules/hash_transform_keys.rs +10 -0
  32. data/crates/shirobai-core/src/rules/if_unless_modifier.rs +2 -3
  33. data/crates/shirobai-core/src/rules/indentation_consistency.rs +19 -2
  34. data/crates/shirobai-core/src/rules/indentation_width.rs +86 -7
  35. data/crates/shirobai-core/src/rules/initial_indentation.rs +170 -0
  36. data/crates/shirobai-core/src/rules/line_length_breakable.rs +329 -38
  37. data/crates/shirobai-core/src/rules/magic_comment_format.rs +128 -0
  38. data/crates/shirobai-core/src/rules/method_length.rs +16 -14
  39. data/crates/shirobai-core/src/rules/mod.rs +13 -0
  40. data/crates/shirobai-core/src/rules/multiline_method_call_brace_layout.rs +6 -1
  41. data/crates/shirobai-core/src/rules/multiline_method_call_indentation.rs +23 -7
  42. data/crates/shirobai-core/src/rules/ordered_magic_comments.rs +211 -0
  43. data/crates/shirobai-core/src/rules/parse_cache.rs +208 -61
  44. data/crates/shirobai-core/src/rules/percent_literal_delimiters.rs +21 -7
  45. data/crates/shirobai-core/src/rules/rails_app.rs +206 -0
  46. data/crates/shirobai-core/src/rules/redundant_self.rs +18 -7
  47. data/crates/shirobai-core/src/rules/rescue_ensure_alignment.rs +139 -0
  48. data/crates/shirobai-core/src/rules/rspec_dispatcher.rs +504 -38
  49. data/crates/shirobai-core/src/rules/rspec_empty_line.rs +15 -565
  50. data/crates/shirobai-core/src/rules/send_range.rs +94 -0
  51. data/crates/shirobai-core/src/rules/space_around_equals_in_parameter_default.rs +202 -0
  52. data/crates/shirobai-core/src/rules/space_around_operators.rs +1228 -0
  53. data/crates/shirobai-core/src/rules/space_inside_string_interpolation.rs +314 -0
  54. data/crates/shirobai-core/src/rules/tokens.rs +894 -0
  55. data/crates/shirobai-core/src/rules/unreachable_code.rs +24 -7
  56. data/crates/shirobai-core/src/rules/variable_number.rs +30 -1
  57. data/crates/shirobai-core/src/rules/void.rs +11 -7
  58. data/crates/shirobai-core/tests/pm_lex_canary.rs +130 -0
  59. data/ext/shirobai/Cargo.toml +1 -1
  60. data/ext/shirobai/extconf.rb +6 -0
  61. data/ext/shirobai/src/lib.rs +355 -13
  62. data/lib/shirobai/cop/layout/argument_alignment.rb +21 -12
  63. data/lib/shirobai/cop/layout/array_alignment.rb +10 -10
  64. data/lib/shirobai/cop/layout/end_of_line.rb +90 -0
  65. data/lib/shirobai/cop/layout/extra_spacing.rb +124 -0
  66. data/lib/shirobai/cop/layout/first_argument_indentation.rb +21 -10
  67. data/lib/shirobai/cop/layout/indentation_consistency.rb +9 -9
  68. data/lib/shirobai/cop/layout/indentation_width.rb +13 -8
  69. data/lib/shirobai/cop/layout/initial_indentation.rb +80 -0
  70. data/lib/shirobai/cop/layout/line_continuation_spacing.rb +157 -0
  71. data/lib/shirobai/cop/layout/line_length.rb +12 -4
  72. data/lib/shirobai/cop/layout/rescue_ensure_alignment.rb +250 -0
  73. data/lib/shirobai/cop/layout/space_around_equals_in_parameter_default.rb +79 -0
  74. data/lib/shirobai/cop/layout/space_around_operators.rb +121 -0
  75. data/lib/shirobai/cop/layout/space_inside_string_interpolation.rb +81 -0
  76. data/lib/shirobai/cop/lint/ordered_magic_comments.rb +62 -0
  77. data/lib/shirobai/cop/metrics/abc_size.rb +10 -5
  78. data/lib/shirobai/cop/naming/ascii_identifiers.rb +63 -0
  79. data/lib/shirobai/cop/style/arguments_forwarding.rb +1 -1
  80. data/lib/shirobai/cop/style/empty_literal.rb +184 -0
  81. data/lib/shirobai/cop/style/magic_comment_format.rb +229 -0
  82. data/lib/shirobai/cop/style/mutable_constant.rb +286 -0
  83. data/lib/shirobai/cop/style/semicolon.rb +11 -9
  84. data/lib/shirobai/dispatch.rb +53 -4
  85. data/lib/shirobai/inject.rb +95 -0
  86. data/lib/shirobai/version.rb +1 -1
  87. data/lib/shirobai.rb +11 -1
  88. metadata +49 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1be87c75d6dee4b98605941e80e3244bac21b501cd78f0fcc6d16b5182897e63
4
- data.tar.gz: 466974572bc21d590b8cc0a8fb6fe6c6844c45c17ffe58cc98f71c766b7b9c42
3
+ metadata.gz: ac67035ad74e8850bceadc1fe62046339127dba699f7976ce45bbea34646273f
4
+ data.tar.gz: 3453f727a31a8fdf6d12376e6af577a09f503d80b7e0dfe2e8c9df6a10af3260
5
5
  SHA512:
6
- metadata.gz: 0a10cbada94bc6ac95afdf33d793f0b5b9698eacb4bd5742fb4bb0a9296dff75d21ffc97be68741ceff51caeaa15274a25a6a8ba6db727567354fd0d31d15d2b
7
- data.tar.gz: cf0574176bdbc245ad66d3ddd6291cd65ecf3ba7c4d3e6a48004825873612fa7824f2682b3f983cbb1dfec7b2bcce0bcb029fcb4481927e8825e95cdb6c84619
6
+ metadata.gz: ebe11c9d5e1638871f75f24b5139605694acb3af3387eb442a5c96c1a09aba51afa21c09c5097a23979ca4bb887897fdb4f8ac0005d4cde5efb6ae30d3f64299
7
+ data.tar.gz: 8807b3a11cd85ba0366c723076db229d96966eda367a0a0413e5a01908975ac8af4102e5b05acc498a204e62be38fc3ab8396ce9e09ef5e80bc66b812a0975d5
data/Cargo.lock CHANGED
@@ -356,7 +356,7 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
356
356
 
357
357
  [[package]]
358
358
  name = "shirobai"
359
- version = "0.1.0"
359
+ version = "2026.713.0"
360
360
  dependencies = [
361
361
  "magnus",
362
362
  "shirobai-core",
@@ -364,10 +364,11 @@ dependencies = [
364
364
 
365
365
  [[package]]
366
366
  name = "shirobai-core"
367
- version = "0.1.0"
367
+ version = "2026.713.0"
368
368
  dependencies = [
369
369
  "libc",
370
370
  "ruby-prism",
371
+ "ruby-prism-sys",
371
372
  "self_cell",
372
373
  "unicode-width",
373
374
  ]
data/Cargo.toml CHANGED
@@ -2,6 +2,13 @@
2
2
  members = ["ext/shirobai", "crates/shirobai-core"]
3
3
  resolver = "3"
4
4
 
5
+ # Single source of the crate version. bump-version.yml rewrites this line to
6
+ # the semver form of the gem CalVer (leading zeros stripped: gem 2026.0709.0000
7
+ # is crate 2026.709.0 -- cargo rejects leading zeros in numeric segments, and
8
+ # Gem::Version normalizes to the same integers, so the two stay value-equal).
9
+ [workspace.package]
10
+ version = "2026.713.0"
11
+
5
12
  # Release tuning. The hot path crosses crate boundaries (ext cdylib -> core
6
13
  # check_* -> ruby-prism); without LTO the cross-crate calls are not inlined and
7
14
  # codegen-units=16 (the release default) further fragments optimization. Fat LTO
@@ -1,10 +1,14 @@
1
1
  [package]
2
2
  name = "shirobai-core"
3
- version = "0.1.0"
3
+ version.workspace = true
4
4
  edition = "2024"
5
5
 
6
6
  [dependencies]
7
7
  ruby-prism = "1.9.0"
8
+ # Direct dep on the -sys crate to reach pm_parser_init / pm_parse / lex_callback
9
+ # from pm_lex. ruby-prism 1.9.0 already depends on this exact version, so cargo
10
+ # unifies it into a single build (no second prism compile).
11
+ ruby-prism-sys = "=1.9.0"
8
12
  self_cell = "1"
9
13
  unicode-width = "0.2"
10
14
 
@@ -18,3 +22,8 @@ path = "src/bin/walk_profile.rs"
18
22
  [[bin]]
19
23
  name = "analysis_profile"
20
24
  path = "src/bin/analysis_profile.rs"
25
+
26
+ # pm_lex probe: token dump / canary / profile harness.
27
+ [[bin]]
28
+ name = "pm_lex_probe"
29
+ path = "src/bin/pm_lex_probe.rs"
@@ -297,6 +297,7 @@ fn run_perrule(sources: &[Vec<u8>], cfg: &BundleConfig, iterations: usize) {
297
297
  s,
298
298
  cfg.abc_size_max_floor,
299
299
  cfg.abc_size_discount_repeated,
300
+ cfg.abc_size_it_is_send,
300
301
  ));
301
302
  }),
302
303
  e!("indentation_consistency", |s: &[u8]| {
@@ -0,0 +1,246 @@
1
+ //! pm_lex probe harness (measurement / verification only). Three subcommands:
2
+ //!
3
+ //! dump-tokens <corpus> <out_file>
4
+ //! Per .rb file: `# <path>` then one `name\tstart\tlen\tstate` line per
5
+ //! token. Diff against the Ruby `Prism.lex` oracle (oracle_lex.rb).
6
+ //!
7
+ //! canary <corpus>
8
+ //! For every .rb file compare the AST reached via the transmute path
9
+ //! (parse_with_lex) against a normal ruby_prism::parse(). Reports files,
10
+ //! AST fingerprint mismatches, and comment-count mismatches. This is the
11
+ //! ad-hoc, whole-corpus version of the tests/pm_lex_canary.rs CI gate.
12
+ //!
13
+ //! profile <parse|lex|lex_reuse|reparse> <corpus> [iters]
14
+ //! CLOCK_PROCESS_CPUTIME_ID median over iters (default 5). Modes:
15
+ //! parse - ruby_prism::parse only (AST, no tokens)
16
+ //! lex - parse_with_lex (AST + a fresh token Vec per file)
17
+ //! lex_reuse - parse_with_lex_into (AST + one reused token buffer)
18
+ //! reparse - two full parses (reproduces the pm_serialize_lex tax:
19
+ //! AST from one parse, tokens from a second full parse)
20
+
21
+ use std::io::Write;
22
+ use std::path::{Path, PathBuf};
23
+
24
+ use ruby_prism::{Node, Visit};
25
+ use shirobai_core::pm_lex::{RawToken, parse_with_lex, parse_with_lex_into};
26
+
27
+ #[cfg(unix)]
28
+ fn cpu_time_secs() -> f64 {
29
+ let mut ts = libc::timespec {
30
+ tv_sec: 0,
31
+ tv_nsec: 0,
32
+ };
33
+ // SAFETY: ts is a valid timespec.
34
+ unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut ts) };
35
+ ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9
36
+ }
37
+
38
+ fn collect_rb_files(dir: &Path, out: &mut Vec<PathBuf>) {
39
+ let Ok(entries) = std::fs::read_dir(dir) else {
40
+ return;
41
+ };
42
+ for entry in entries.flatten() {
43
+ let path = entry.path();
44
+ if path.is_dir() {
45
+ collect_rb_files(&path, out);
46
+ } else if path.extension().is_some_and(|e| e == "rb") {
47
+ out.push(path);
48
+ }
49
+ }
50
+ }
51
+
52
+ fn sorted_rb_files(corpus: &Path) -> Vec<PathBuf> {
53
+ let mut files = Vec::new();
54
+ collect_rb_files(corpus, &mut files);
55
+ files.sort();
56
+ files
57
+ }
58
+
59
+ // Pre-order (start,end) offset fingerprint of an AST via the Visit trait.
60
+ struct Fingerprint(Vec<(usize, usize)>);
61
+ impl<'pr> Visit<'pr> for Fingerprint {
62
+ fn visit_branch_node_enter(&mut self, node: Node<'pr>) {
63
+ let l = node.location();
64
+ self.0.push((l.start_offset(), l.end_offset()));
65
+ }
66
+ fn visit_leaf_node_enter(&mut self, node: Node<'pr>) {
67
+ let l = node.location();
68
+ self.0.push((l.start_offset(), l.end_offset()));
69
+ }
70
+ }
71
+ fn fingerprint(root: &Node<'_>) -> Vec<(usize, usize)> {
72
+ let mut fp = Fingerprint(Vec::new());
73
+ fp.visit(root);
74
+ fp.0
75
+ }
76
+
77
+ fn dump_tokens(corpus: &Path, out_file: &Path) {
78
+ let files = sorted_rb_files(corpus);
79
+ let mut out = std::io::BufWriter::new(std::fs::File::create(out_file).expect("create out"));
80
+ for path in &files {
81
+ let Ok(src) = std::fs::read(path) else {
82
+ continue;
83
+ };
84
+ let rel = path.strip_prefix(corpus).unwrap_or(path);
85
+ writeln!(out, "# {}", rel.display()).unwrap();
86
+ let (_result, tokens) = parse_with_lex(&src);
87
+ for t in &tokens {
88
+ writeln!(
89
+ out,
90
+ "{}\t{}\t{}\t{}",
91
+ t.type_name(),
92
+ t.start_offset,
93
+ t.length,
94
+ t.lex_state
95
+ )
96
+ .unwrap();
97
+ }
98
+ }
99
+ eprintln!("dumped {} files -> {}", files.len(), out_file.display());
100
+ }
101
+
102
+ fn canary(corpus: &Path) {
103
+ let files = sorted_rb_files(corpus);
104
+ let mut n_ok = 0usize;
105
+ let mut ast_mismatch = 0usize;
106
+ let mut comment_mismatch = 0usize;
107
+ let mut first_bad: Vec<String> = Vec::new();
108
+ for path in &files {
109
+ let Ok(src) = std::fs::read(path) else {
110
+ continue;
111
+ };
112
+ let normal = ruby_prism::parse(&src);
113
+ let fp_normal = fingerprint(&normal.node());
114
+ let n_comments_normal = normal.comments().count();
115
+ drop(normal);
116
+
117
+ let (spiked, _tokens) = parse_with_lex(&src);
118
+ let fp_spiked = fingerprint(&spiked.node());
119
+ let n_comments_spiked = spiked.comments().count();
120
+ drop(spiked); // exercise Drop of a transmuted ParseResult.
121
+
122
+ let ast_ok = fp_normal == fp_spiked;
123
+ let com_ok = n_comments_normal == n_comments_spiked;
124
+ if ast_ok && com_ok {
125
+ n_ok += 1;
126
+ } else {
127
+ if !ast_ok {
128
+ ast_mismatch += 1;
129
+ }
130
+ if !com_ok {
131
+ comment_mismatch += 1;
132
+ }
133
+ if first_bad.len() < 10 {
134
+ first_bad.push(format!(
135
+ "{} ast_ok={} nodes {}/{} comments {}/{}",
136
+ path.display(),
137
+ ast_ok,
138
+ fp_normal.len(),
139
+ fp_spiked.len(),
140
+ n_comments_normal,
141
+ n_comments_spiked
142
+ ));
143
+ }
144
+ }
145
+ }
146
+ println!("canary files={}", files.len());
147
+ println!(" parity_ok = {n_ok}");
148
+ println!(" ast_fingerprint_bad = {ast_mismatch}");
149
+ println!(" comment_count_bad = {comment_mismatch}");
150
+ for b in &first_bad {
151
+ println!(" BAD {b}");
152
+ }
153
+ let parity = if files.is_empty() {
154
+ 0.0
155
+ } else {
156
+ 100.0 * n_ok as f64 / files.len() as f64
157
+ };
158
+ println!(" parity = {parity:.2}%");
159
+ }
160
+
161
+ fn profile(mode: &str, corpus: &Path, iters: usize) {
162
+ let files = sorted_rb_files(corpus);
163
+ let sources: Vec<Vec<u8>> = files.iter().filter_map(|p| std::fs::read(p).ok()).collect();
164
+ eprintln!("files: {}", sources.len());
165
+
166
+ let mut buf: Vec<RawToken> = Vec::new();
167
+ let run = |mode: &str, buf: &mut Vec<RawToken>| -> usize {
168
+ let mut sink = 0usize;
169
+ for s in &sources {
170
+ match mode {
171
+ "parse" => {
172
+ let r = ruby_prism::parse(s);
173
+ sink = sink.wrapping_add(r.node().location().end_offset());
174
+ }
175
+ "lex" => {
176
+ let (r, tokens) = parse_with_lex(s);
177
+ sink = sink.wrapping_add(r.node().location().end_offset());
178
+ sink = sink.wrapping_add(tokens.len());
179
+ }
180
+ "lex_reuse" => {
181
+ // Reuse one token buffer across files (retains capacity):
182
+ // isolates pure callback+push cost from per-file heap alloc.
183
+ let r = parse_with_lex_into(s, buf);
184
+ sink = sink.wrapping_add(r.node().location().end_offset());
185
+ sink = sink.wrapping_add(buf.len());
186
+ }
187
+ "reparse" => {
188
+ // AST from one parse; tokens would come from a *second*
189
+ // full parse (the pm_serialize_lex tax being reproduced).
190
+ let r = ruby_prism::parse(s);
191
+ sink = sink.wrapping_add(r.node().location().end_offset());
192
+ let r2 = ruby_prism::parse(s);
193
+ sink = sink.wrapping_add(r2.node().location().end_offset());
194
+ }
195
+ other => {
196
+ eprintln!("unknown profile mode: {other}");
197
+ std::process::exit(2);
198
+ }
199
+ }
200
+ }
201
+ sink
202
+ };
203
+
204
+ // Warm-up (untimed).
205
+ let warm = run(mode, &mut buf);
206
+ let mut times: Vec<f64> = (0..iters)
207
+ .map(|_| {
208
+ let t0 = cpu_time_secs();
209
+ let sink = run(mode, &mut buf);
210
+ let dt = cpu_time_secs() - t0;
211
+ eprintln!(" iter {mode}: {dt:.4}s (sink {sink})");
212
+ dt
213
+ })
214
+ .collect();
215
+ times.sort_by(|a, b| a.partial_cmp(b).unwrap());
216
+ let median = times[times.len() / 2];
217
+ eprintln!("warm sink {warm}");
218
+ println!("{mode}\t{median:.4}");
219
+ }
220
+
221
+ fn main() {
222
+ let args: Vec<String> = std::env::args().collect();
223
+ match args.get(1).map(String::as_str) {
224
+ Some("dump-tokens") => {
225
+ let corpus = PathBuf::from(args.get(2).expect("corpus dir"));
226
+ let out = PathBuf::from(args.get(3).expect("out file"));
227
+ dump_tokens(&corpus, &out);
228
+ }
229
+ Some("canary") => {
230
+ let corpus = PathBuf::from(args.get(2).expect("corpus dir"));
231
+ canary(&corpus);
232
+ }
233
+ Some("profile") => {
234
+ let mode = args.get(2).expect("mode").clone();
235
+ let corpus = PathBuf::from(args.get(3).expect("corpus dir"));
236
+ let iters: usize = args.get(4).map_or(5, |v| v.parse().expect("iters"));
237
+ profile(&mode, &corpus, iters);
238
+ }
239
+ _ => {
240
+ eprintln!(
241
+ "usage:\n pm_lex_probe dump-tokens <corpus> <out_file>\n pm_lex_probe canary <corpus>\n pm_lex_probe profile <parse|lex|lex_reuse|reparse> <corpus> [iters]"
242
+ );
243
+ std::process::exit(2);
244
+ }
245
+ }
246
+ }
@@ -1 +1,6 @@
1
1
  pub mod rules;
2
+
3
+ // Single-pass parse + lex: one prism parse that yields both the AST and the
4
+ // token stream. See `pm_lex` for the transmute contract.
5
+ pub mod pm_lex;
6
+ mod pm_lex_token_names;
@@ -0,0 +1,300 @@
1
+ //! Single-pass parse + lex: raw prism parse that also collects the token stream.
2
+ //!
3
+ //! Goal: get the same in-memory AST that `ruby_prism::parse()` produces AND the
4
+ //! token stream from the *same single parse*, without prism's `pm_serialize_lex`
5
+ //! tax (a second full parse just to get tokens).
6
+ //!
7
+ //! How: `ruby_prism::parse()` hard-wires `pm_parser_init` -> `pm_parse` with no
8
+ //! seam to install a `lex_callback`. We replicate that body ourselves with the
9
+ //! raw `ruby_prism_sys` symbols, set `parser.lex_callback` to a token-collecting
10
+ //! C callback in between, then hand the result back to safe `ruby_prism` code by
11
+ //! `transmute`-ing a layout-identical look-alike into `ruby_prism::ParseResult`
12
+ //! (whose fields are private and whose only constructor, `parse()`, cannot take
13
+ //! a callback).
14
+ //!
15
+ //! The transmute is a load-bearing bet on `ParseResult`'s field layout. Two
16
+ //! things guard it: [`assert_parse_result_layout`] is a `const` static assert on
17
+ //! size + alignment (a compile error if either drifts), and the canary tests
18
+ //! (here and in `tests/pm_lex_canary.rs`) compare the transmute-path AST against
19
+ //! a normal `parse()` on a large corpus, catching any residual field-order
20
+ //! change on a rustc update. A constructor that takes a `lex_callback` is being
21
+ //! proposed upstream to prism; once it lands this module can drop the transmute.
22
+
23
+ use std::mem::MaybeUninit;
24
+ use std::os::raw::c_void;
25
+ use std::ptr::NonNull;
26
+
27
+ use ruby_prism::ParseResult;
28
+ use ruby_prism_sys::{
29
+ pm_lex_callback_t, pm_node_t, pm_parse, pm_parser_init, pm_parser_t, pm_token_t,
30
+ };
31
+
32
+ pub use crate::pm_lex_token_names::PM_TOKEN_NAMES;
33
+
34
+ /// One lexer token, collected in-line during the parse.
35
+ /// Byte offsets are from the start of the source (prism is byte-oriented).
36
+ #[derive(Debug, Clone, PartialEq, Eq)]
37
+ pub struct RawToken {
38
+ /// prism `pm_token_type` integer. Name via [`RawToken::type_name`].
39
+ pub token_type: u32,
40
+ pub start_offset: usize,
41
+ pub length: usize,
42
+ /// `parser.lex_state` sampled at callback time (matches `Prism.lex`).
43
+ pub lex_state: u32,
44
+ }
45
+
46
+ impl RawToken {
47
+ /// Ruby `Prism::Token#type` symbol name for this token type.
48
+ #[must_use]
49
+ pub fn type_name(&self) -> &'static str {
50
+ PM_TOKEN_NAMES
51
+ .get(self.token_type as usize)
52
+ .copied()
53
+ .unwrap_or("")
54
+ }
55
+ }
56
+
57
+ /// A look-alike of `ruby_prism::ParseResult<'pr>`.
58
+ ///
59
+ /// `ParseResult`'s fields are private, so we cannot name them; we mirror their
60
+ /// declared order and types here and `transmute`. `ParseResult` is `repr(Rust)`
61
+ /// (no `repr(C)`), so field order is not guaranteed by the language — this only
62
+ /// works because two structs with identical field types get the same layout
63
+ /// under the current rustc, and we pin size+alignment with a static assert. If a
64
+ /// future rustc reorders fields, the canary tests break loudly.
65
+ ///
66
+ /// Fields are only ever read through the `transmute` into `ParseResult`, so the
67
+ /// compiler cannot see the reads — hence `dead_code` is allowed here.
68
+ #[repr(Rust)]
69
+ #[allow(dead_code)]
70
+ struct ParseResultLookAlike<'pr> {
71
+ source: &'pr [u8],
72
+ parser: NonNull<pm_parser_t>,
73
+ node: NonNull<pm_node_t>,
74
+ }
75
+
76
+ /// Compile-time guard: our look-alike must match `ParseResult` byte-for-byte in
77
+ /// size and alignment. (Field *order* is the residual, canary-tested bet.)
78
+ pub const fn assert_parse_result_layout() {
79
+ use std::mem::{align_of, size_of};
80
+ assert!(
81
+ size_of::<ParseResultLookAlike<'_>>() == size_of::<ParseResult<'_>>(),
82
+ "ParseResult size drifted from look-alike; transmute is unsafe"
83
+ );
84
+ assert!(
85
+ align_of::<ParseResultLookAlike<'_>>() == align_of::<ParseResult<'_>>(),
86
+ "ParseResult alignment drifted from look-alike; transmute is unsafe"
87
+ );
88
+ }
89
+ const _: () = assert_parse_result_layout();
90
+
91
+ /// The C callback prism invokes for every lexed token. `data` is a
92
+ /// `*mut Vec<RawToken>`. Reads `parser.lex_state` at call time, mirroring how
93
+ /// `Prism.lex` records lex state.
94
+ ///
95
+ /// # Safety
96
+ /// `data` must point to a live `Vec<RawToken>`; `parser`/`token` are valid for
97
+ /// the duration of the call (prism guarantees this while lexing).
98
+ unsafe extern "C" fn collect_token(
99
+ data: *mut c_void,
100
+ parser: *mut pm_parser_t,
101
+ token: *mut pm_token_t,
102
+ ) {
103
+ // SAFETY: contract documented above; all derefs are of live prism/caller
104
+ // memory for the span of this call.
105
+ unsafe {
106
+ let tokens = &mut *(data.cast::<Vec<RawToken>>());
107
+ let tok = &*token;
108
+ let source_start = (*parser).start;
109
+ let start_offset = tok.start.offset_from(source_start) as usize;
110
+ let length = tok.end.offset_from(tok.start) as usize;
111
+ let lex_state = (*parser).lex_state;
112
+ tokens.push(RawToken {
113
+ token_type: tok.type_,
114
+ start_offset,
115
+ length,
116
+ lex_state,
117
+ });
118
+ }
119
+ }
120
+
121
+ /// Like [`parse_with_lex`] but appends tokens into a caller-owned buffer
122
+ /// (cleared first, capacity retained). Lets a caller amortize the token Vec
123
+ /// allocation across files, isolating the pure callback+push cost from
124
+ /// per-file heap allocation. Returns the `ParseResult`.
125
+ ///
126
+ /// # Safety
127
+ /// Uses raw prism FFI and a `transmute` into `ParseResult`. Sound as long as
128
+ /// `ParseResultLookAlike` matches `ParseResult`'s layout (guarded by the static
129
+ /// assert + canary tests) and prism's C ABI is stable.
130
+ #[must_use]
131
+ pub fn parse_with_lex_into<'s>(
132
+ source: &'s [u8],
133
+ out: &mut Vec<RawToken>,
134
+ ) -> ParseResult<'s> {
135
+ out.clear();
136
+ let mut callback = pm_lex_callback_t {
137
+ data: (out as *mut Vec<RawToken>).cast::<c_void>(),
138
+ callback: Some(collect_token),
139
+ };
140
+
141
+ // SAFETY: replicate ruby_prism::parse()'s body verbatim, plus lex_callback.
142
+ let look_alike = unsafe {
143
+ let uninit = Box::new(MaybeUninit::<pm_parser_t>::uninit());
144
+ let uninit = Box::into_raw(uninit);
145
+
146
+ pm_parser_init(
147
+ (*uninit).as_mut_ptr(),
148
+ source.as_ptr(),
149
+ source.len(),
150
+ std::ptr::null(),
151
+ );
152
+
153
+ let parser = (*uninit).assume_init_mut();
154
+ let parser = NonNull::new_unchecked(parser);
155
+
156
+ (*parser.as_ptr()).lex_callback = &mut callback as *mut pm_lex_callback_t;
157
+
158
+ let node = pm_parse(parser.as_ptr());
159
+ let node = NonNull::new_unchecked(node);
160
+
161
+ (*parser.as_ptr()).lex_callback = std::ptr::null_mut();
162
+
163
+ ParseResultLookAlike {
164
+ source,
165
+ parser,
166
+ node,
167
+ }
168
+ };
169
+
170
+ // SAFETY: identical layout (static assert on size/align; canary on fields).
171
+ unsafe { std::mem::transmute(look_alike) }
172
+ }
173
+
174
+ /// Parse `source` and collect its token stream in the *same* parse.
175
+ ///
176
+ /// Returns a real `ruby_prism::ParseResult` (safe to walk / drop like any other)
177
+ /// plus the collected tokens. Mirrors `ruby_prism::parse()` exactly, only adding
178
+ /// the `lex_callback` wiring in between init and parse. Allocates a fresh token
179
+ /// Vec per call; use [`parse_with_lex_into`] to reuse one buffer across files.
180
+ ///
181
+ /// # Safety
182
+ /// Same contract as [`parse_with_lex_into`].
183
+ #[must_use]
184
+ pub fn parse_with_lex(source: &[u8]) -> (ParseResult<'_>, Vec<RawToken>) {
185
+ // Tokens Vec lives on the heap in a stable slot that outlives `pm_parse`.
186
+ let mut tokens: Box<Vec<RawToken>> = Box::default();
187
+ let mut callback = pm_lex_callback_t {
188
+ data: (&mut *tokens as *mut Vec<RawToken>).cast::<c_void>(),
189
+ callback: Some(collect_token),
190
+ };
191
+
192
+ // SAFETY: replicate ruby_prism::parse()'s body verbatim, plus lex_callback.
193
+ let look_alike = unsafe {
194
+ let uninit = Box::new(MaybeUninit::<pm_parser_t>::uninit());
195
+ let uninit = Box::into_raw(uninit);
196
+
197
+ pm_parser_init(
198
+ (*uninit).as_mut_ptr(),
199
+ source.as_ptr(),
200
+ source.len(),
201
+ std::ptr::null(),
202
+ );
203
+
204
+ let parser = (*uninit).assume_init_mut();
205
+ let parser = NonNull::new_unchecked(parser);
206
+
207
+ // Install the collecting callback before parsing.
208
+ (*parser.as_ptr()).lex_callback = &mut callback as *mut pm_lex_callback_t;
209
+
210
+ let node = pm_parse(parser.as_ptr());
211
+ let node = NonNull::new_unchecked(node);
212
+
213
+ // Detach the callback: nothing reads it post-parse, and `callback`
214
+ // (and `tokens`) get moved/dropped independently of the parser.
215
+ (*parser.as_ptr()).lex_callback = std::ptr::null_mut();
216
+
217
+ ParseResultLookAlike {
218
+ source,
219
+ parser,
220
+ node,
221
+ }
222
+ };
223
+
224
+ // The load-bearing transmute. Look-alike -> real ParseResult.
225
+ // SAFETY: identical layout (static assert on size/align; canary on fields).
226
+ let result: ParseResult<'_> = unsafe { std::mem::transmute(look_alike) };
227
+
228
+ (result, *tokens)
229
+ }
230
+
231
+ #[cfg(test)]
232
+ mod tests {
233
+ use super::*;
234
+ use ruby_prism::{Node, Visit};
235
+
236
+ // Pre-order (start,end) offset fingerprint of an AST via the Visit trait.
237
+ struct Fingerprint(Vec<(usize, usize)>);
238
+ impl<'pr> Visit<'pr> for Fingerprint {
239
+ fn visit_branch_node_enter(&mut self, node: Node<'pr>) {
240
+ let l = node.location();
241
+ self.0.push((l.start_offset(), l.end_offset()));
242
+ }
243
+ fn visit_leaf_node_enter(&mut self, node: Node<'pr>) {
244
+ let l = node.location();
245
+ self.0.push((l.start_offset(), l.end_offset()));
246
+ }
247
+ }
248
+ fn fingerprint(root: &Node<'_>) -> Vec<(usize, usize)> {
249
+ let mut fp = Fingerprint(Vec::new());
250
+ fp.visit(root);
251
+ fp.0
252
+ }
253
+
254
+ #[test]
255
+ fn layout_static_assert_holds() {
256
+ assert_parse_result_layout();
257
+ }
258
+
259
+ #[test]
260
+ fn collects_tokens_matching_a_known_snippet() {
261
+ let src = b"def foo(a)\n a + 1\nend\n";
262
+ let (_result, tokens) = parse_with_lex(src);
263
+ let named: Vec<(&str, usize, usize, u32)> = tokens
264
+ .iter()
265
+ .map(|t| (t.type_name(), t.start_offset, t.length, t.lex_state))
266
+ .collect();
267
+ // From `Prism.lex` on the same source.
268
+ assert_eq!(named[0], ("KEYWORD_DEF", 0, 3, 128));
269
+ assert_eq!(named[1], ("IDENTIFIER", 4, 3, 8));
270
+ assert_eq!(named[2], ("PARENTHESIS_LEFT", 7, 1, 1025));
271
+ // Last token is always EOF.
272
+ assert_eq!(tokens.last().unwrap().type_name(), "EOF");
273
+ }
274
+
275
+ #[test]
276
+ fn transmute_path_ast_matches_normal_parse() {
277
+ let srcs: &[&[u8]] = &[
278
+ b"x = 1\n",
279
+ b"def m(a, b)\n a.foo { |c| c + b }\nend\n",
280
+ b"class C < D\n attr_reader :x\n # comment\nend\n",
281
+ b"[1, 2, 3].map { _1 * 2 }.select(&:even?)\n",
282
+ "\u{3042} = 'multibyte'\n".as_bytes(), // non-ASCII
283
+ ];
284
+ for src in srcs {
285
+ let normal = ruby_prism::parse(src);
286
+ let (spiked, _tokens) = parse_with_lex(src);
287
+ assert_eq!(
288
+ fingerprint(&normal.node()),
289
+ fingerprint(&spiked.node()),
290
+ "AST fingerprint diverged for {:?}",
291
+ String::from_utf8_lossy(src)
292
+ );
293
+ // Comments must also survive the transmute path.
294
+ let n_comments_normal = normal.comments().count();
295
+ let n_comments_spiked = spiked.comments().count();
296
+ assert_eq!(n_comments_normal, n_comments_spiked);
297
+ // Drop of `spiked` (a transmuted ParseResult) must not crash.
298
+ }
299
+ }
300
+ }