shirobai 2026.0905.1300 → 2026.0920.1100

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f4f98851b8d60acd4a275498959e3e6a0fca00455715bcdfa5b16a653a90015
4
- data.tar.gz: 9bb15c3004bfa6415d01d2e6038dfa430cd930f260f988faf22133f445869597
3
+ metadata.gz: f65a6269e60ead3a7b5175cad5be36b3364bb5ee6025dd7bcc3a2b82d69466c6
4
+ data.tar.gz: 6af011435008fec04f1a8bced1d5e5d620160b465b6a290963fcfcda1561a8a2
5
5
  SHA512:
6
- metadata.gz: 7effd8214232c64b9fee9ecbe046bf102e2b24936aef4c991039bd8028acc79d936b4c6d0d42ed184a247e1e1ac0b1ea0ca268f57c6001a3d16e9913af9106a5
7
- data.tar.gz: 186a7b1f6e790e78c73f85ed0000bacf40ed19dbb8c0ab426b52b31b124f81f7eb907392400732b242b8fd92026907bef51f637f117c0957e92c14f117f1f3ae
6
+ metadata.gz: d88a4fc322d82e59683f33de8d8a3ae93a62f59ac358dc583a83201ccf3db4021a5076ac60a4a5ba60802d557ab8d18ad3771d6be40a1c01268582aa4083e743
7
+ data.tar.gz: 2cee35078890630d5291e962e5fb6d6a9c2f49a4595bd1b212ead6db8aaff8a73aa8460845e11d49d2360d26680d2006bbde79b2ba7c2646e6fbbbefa2b48032
data/Cargo.lock CHANGED
@@ -356,7 +356,7 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
356
356
 
357
357
  [[package]]
358
358
  name = "shirobai"
359
- version = "2026.905.1300"
359
+ version = "2026.920.1100"
360
360
  dependencies = [
361
361
  "magnus",
362
362
  "shirobai-core",
@@ -364,7 +364,7 @@ dependencies = [
364
364
 
365
365
  [[package]]
366
366
  name = "shirobai-core"
367
- version = "2026.905.1300"
367
+ version = "2026.920.1100"
368
368
  dependencies = [
369
369
  "libc",
370
370
  "ruby-prism",
data/Cargo.toml CHANGED
@@ -7,7 +7,7 @@ resolver = "3"
7
7
  # is crate 2026.709.0 -- cargo rejects leading zeros in numeric segments, and
8
8
  # Gem::Version normalizes to the same integers, so the two stay value-equal).
9
9
  [workspace.package]
10
- version = "2026.905.1300"
10
+ version = "2026.920.1100"
11
11
 
12
12
  # Release tuning. The hot path crosses crate boundaries (ext cdylib -> core
13
13
  # check_* -> ruby-prism); without LTO the cross-crate calls are not inlined and
@@ -213,7 +213,7 @@ fn run_perrule(sources: &[Vec<u8>], cfg: &BundleConfig, iterations: usize) {
213
213
  s,
214
214
  cfg.argument_alignment_style,
215
215
  cfg.argument_alignment_indent,
216
- cfg.argument_alignment_incompatible,
216
+ cfg.argument_alignment_hash_separator,
217
217
  ));
218
218
  }),
219
219
  e!("first_argument_indentation", |s: &[u8]| {
@@ -389,6 +389,7 @@ impl<'a> Aligner<'a> {
389
389
  let original_line_indent = self.line_indentation(first);
390
390
  let mut relevant_line_indent_at_level = true;
391
391
  let assignment_lines = self.assignment_lines();
392
+ let boundary_lines = self.interrupting_operator_lines(&assignment_lines);
392
393
 
393
394
  for &line_number in line_range {
394
395
  let current_line_indent = self.line_indentation(line_number);
@@ -396,7 +397,11 @@ impl<'a> Aligner<'a> {
396
397
  .line_text(line_number)
397
398
  .map(is_blank_line)
398
399
  .unwrap_or(true);
399
- if (current_line_indent < original_line_indent && !blank_line)
400
+ // A line carrying another alignable operator (`<<`, `==`, ...)
401
+ // that is not an assignment line ends the group (1.91,
402
+ // `interrupting_operator_lines` as `relevant_lines` boundaries).
403
+ if (line_number != first && boundary_lines.contains(&line_number))
404
+ || (current_line_indent < original_line_indent && !blank_line)
400
405
  || (relevant_line_indent_at_level && blank_line)
401
406
  {
402
407
  break;
@@ -413,6 +418,20 @@ impl<'a> Aligner<'a> {
413
418
  result
414
419
  }
415
420
 
421
+ /// `interrupting_operator_lines`: the lines of every
422
+ /// `ASSIGNMENT_OR_COMPARISON_TOKENS` token that are not assignment lines.
423
+ fn interrupting_operator_lines(
424
+ &self,
425
+ assignment_lines: &[usize],
426
+ ) -> std::collections::BTreeSet<usize> {
427
+ self.tokens
428
+ .iter()
429
+ .filter(|t| t.assignment_or_comparison())
430
+ .map(|t| self.line(t.begin_pos))
431
+ .filter(|line| !assignment_lines.contains(line))
432
+ .collect()
433
+ }
434
+
416
435
  /// `assignment_lines` = lines of `assignment_tokens`.
417
436
  fn assignment_lines(&self) -> Vec<usize> {
418
437
  self.assignment_tokens()
@@ -36,9 +36,9 @@ pub fn check_argument_alignment(
36
36
  source: &[u8],
37
37
  style: u8,
38
38
  indent_width: usize,
39
- incompatible: bool,
39
+ hash_separator: bool,
40
40
  ) -> Vec<ArgAlignOffense> {
41
- let Some(mut rule) = build_rule(source, style, indent_width, incompatible) else {
41
+ let Some(mut rule) = build_rule(source, style, indent_width, hash_separator) else {
42
42
  return Vec::new();
43
43
  };
44
44
  super::dispatch::run(source, &mut [&mut rule]);
@@ -47,11 +47,15 @@ pub fn check_argument_alignment(
47
47
 
48
48
  /// Build the rule for use standalone or in a shared-walk bundle. `None` when
49
49
  /// the cop is disabled outright (`autocorrect_incompatible_with_other_cops?`).
50
+ /// `hash_separator` is `enforce_hash_argument_with_separator?`: a
51
+ /// separator-aligned `Layout/HashAlignment`. With `with_first_argument` it
52
+ /// disables the cop; with `with_fixed_indentation` it leaves a trailing
53
+ /// braceless hash's pairs to HashAlignment (1.91).
50
54
  pub(crate) fn build_rule(
51
55
  source: &[u8],
52
56
  style: u8,
53
57
  indent_width: usize,
54
- incompatible: bool,
58
+ hash_separator: bool,
55
59
  ) -> Option<Visitor<'_>> {
56
60
  let style = if style == 1 {
57
61
  Style::WithFixedIndentation
@@ -59,7 +63,7 @@ pub(crate) fn build_rule(
59
63
  Style::WithFirstArgument
60
64
  };
61
65
  // `autocorrect_incompatible_with_other_cops?`: disables the cop entirely.
62
- if incompatible && style == Style::WithFirstArgument {
66
+ if hash_separator && style == Style::WithFirstArgument {
63
67
  return None;
64
68
  }
65
69
  let line_index = super::line_index::with_line_index(source, |li| li.clone());
@@ -68,6 +72,7 @@ pub(crate) fn build_rule(
68
72
  line_index,
69
73
  style,
70
74
  indent: indent_width,
75
+ hash_separator,
71
76
  offenses: Vec::new(),
72
77
  })
73
78
  }
@@ -77,6 +82,8 @@ pub(crate) struct Visitor<'a> {
77
82
  line_index: Rc<LineIndex>,
78
83
  style: Style,
79
84
  indent: usize,
85
+ /// `enforce_hash_argument_with_separator?`.
86
+ hash_separator: bool,
80
87
  pub(crate) offenses: Vec<ArgAlignOffense>,
81
88
  }
82
89
 
@@ -170,6 +177,10 @@ impl Visitor<'_> {
170
177
  .collect();
171
178
  let last = args.last().unwrap();
172
179
  match braceless_hash_pairs(last) {
180
+ // `return items if enforce_hash_argument_with_separator?`
181
+ // (1.91): the pairs belong to HashAlignment's separator
182
+ // alignment, so they are not alignment items here.
183
+ Some(_) if self.hash_separator => {}
173
184
  Some(pairs) => items.extend(pairs),
174
185
  None => items.push(loc(&last.location())),
175
186
  }
@@ -306,4 +317,24 @@ mod tests {
306
317
  assert_eq!(got.len(), 1);
307
318
  assert_eq!(got[0].2, 3); // `bar` (col 2) aligns under `foo` (col 5)
308
319
  }
320
+
321
+ // 1.91: with a separator-aligned `Layout/HashAlignment`, the pairs of a
322
+ // trailing braceless hash are not alignment items under
323
+ // `with_fixed_indentation` (they belong to HashAlignment); other
324
+ // arguments are still checked. Under `with_first_argument` the cop is
325
+ // disabled outright.
326
+ #[test]
327
+ fn hash_separator_leaves_trailing_hash_pairs_to_hash_alignment() {
328
+ let sep = |src: &str, style: u8| {
329
+ check_argument_alignment(src.as_bytes(), style, 2, true)
330
+ .into_iter()
331
+ .map(|o| o.column_delta)
332
+ .collect::<Vec<_>>()
333
+ };
334
+ assert!(sep("validates :foo,\n bar: 1,\n bazqux: 2\n", 1).is_empty());
335
+ assert_eq!(sep("func(:foo,\n :bar)\n", 1), vec![-3]);
336
+ assert!(sep("func(a,\n b,\nc)\n", 0).is_empty());
337
+ // Without the separator style the pairs are aligned as before.
338
+ assert_eq!(run("validates :foo,\n bar: 1,\n bazqux: 2\n", 1).len(), 2);
339
+ }
309
340
  }
@@ -332,7 +332,7 @@ pub struct BundleConfig {
332
332
  pub multiline_method_call: (u8, usize, usize),
333
333
  pub argument_alignment_style: u8,
334
334
  pub argument_alignment_indent: usize,
335
- pub argument_alignment_incompatible: bool,
335
+ pub argument_alignment_hash_separator: bool,
336
336
  pub array_alignment_style: u8,
337
337
  pub array_alignment_indent: usize,
338
338
  pub first_argument_style: u8,
@@ -579,7 +579,7 @@ impl BundleConfig {
579
579
  multiline_method_call: (nums[18] as u8, nums[19] as usize, nums[20] as usize),
580
580
  argument_alignment_style: nums[21] as u8,
581
581
  argument_alignment_indent: nums[22] as usize,
582
- argument_alignment_incompatible: nums[23] != 0,
582
+ argument_alignment_hash_separator: nums[23] != 0,
583
583
  array_alignment_style: nums[112] as u8,
584
584
  array_alignment_indent: nums[113] as usize,
585
585
  frozen_string_literal_comment_style: nums[116] as u8,
@@ -1212,7 +1212,7 @@ pub fn check_all_bundle(source: &[u8], cfg: &BundleConfig) -> BundleResult {
1212
1212
  source,
1213
1213
  cfg.argument_alignment_style,
1214
1214
  cfg.argument_alignment_indent,
1215
- cfg.argument_alignment_incompatible,
1215
+ cfg.argument_alignment_hash_separator,
1216
1216
  );
1217
1217
  let mut ara_rule = array_alignment::build_rule(
1218
1218
  source,
@@ -4486,7 +4486,7 @@ mod tests {
4486
4486
  fn shared_walk_respects_disabled_rules() {
4487
4487
  let src = "foo(bar,\n baz)\nfoo([\n 1\n])\n";
4488
4488
  let (mut nums, lists) = default_packed();
4489
- nums[0][23] = 1; // argument_alignment incompatible (with_first_argument)
4489
+ nums[0][23] = 1; // argument_alignment hash_separator (disables with_first_argument)
4490
4490
  nums[0][26] = 1; // first_argument enforce_fixed_no_line_break
4491
4491
  nums[0][37] = 1; // first_array_element enforce_fixed_indentation
4492
4492
  let cfg = BundleConfig::from_packed(&nums, lists).unwrap();
@@ -730,9 +730,9 @@ fn simple_fsl_value(line: &[u8]) -> Option<FslValue> {
730
730
  /// is already false: `valid?` = `@comment.start_with?('#')` (no leading
731
731
  /// space) AND `any?` = one of the remaining magic kinds is specified
732
732
  /// (`frozen_string_literal`, `shareable_constant_value`, `rbs_inline`,
733
- /// `typed`). `rbs_inline` is special: it counts only when the value is
734
- /// exactly `enabled`/`disabled` (`rbs_inline_specified?` ==
735
- /// `valid_rbs_inline_value?`).
733
+ /// `warn_indent` since 1.91, `typed`). `rbs_inline` is special: it counts
734
+ /// only when the value is exactly `enabled`/`disabled`
735
+ /// (`rbs_inline_specified?` == `valid_rbs_inline_value?`).
736
736
  /// - [`OrderedBucket::None`] — neither.
737
737
  ///
738
738
  /// The format dispatch mirrors `MagicComment.parse` (Emacs, else Vim, else
@@ -750,15 +750,19 @@ pub(crate) fn ordered_bucket(line: &[u8]) -> OrderedBucket {
750
750
  let hash_prefixed = line.first() == Some(&b'#');
751
751
 
752
752
  if let Some(token) = emacs_token(line) {
753
- // EmacsComment: `;`-separated tokens, each stripped. `encoding` and
754
- // `frozen_string_literal`/`shareable_constant_value` are the only kinds
755
- // Emacs can carry (`rbs_inline`/`typed` extraction is nil).
753
+ // EmacsComment: `;`-separated tokens, each stripped. `encoding`,
754
+ // `frozen_string_literal`, `shareable_constant_value` and `warn_indent`
755
+ // are the only kinds Emacs can carry (`rbs_inline`/`typed` extraction
756
+ // is nil).
756
757
  let mut encoding = false;
757
758
  let mut other = false;
758
759
  for tok in split_strip(token, b";") {
759
760
  if editor_kv_matches(tok, &[b"encoding", b"coding"], b':') {
760
761
  encoding = true;
761
- } else if fsl_keyword_kv_matches(tok) || shareable_keyword_kv_matches(tok) {
762
+ } else if fsl_keyword_kv_matches(tok)
763
+ || shareable_keyword_kv_matches(tok)
764
+ || warn_indent_keyword_kv_matches(tok)
765
+ {
762
766
  other = true;
763
767
  }
764
768
  }
@@ -790,6 +794,7 @@ pub(crate) fn ordered_bucket(line: &[u8]) -> OrderedBucket {
790
794
  && (simple_fsl(line)
791
795
  || simple_shareable_specified(line)
792
796
  || simple_rbs_inline_specified(line)
797
+ || simple_warn_indent_specified(line)
793
798
  || simple_typed_specified(line))
794
799
  {
795
800
  return OrderedBucket::OtherValid;
@@ -797,6 +802,29 @@ pub(crate) fn ordered_bucket(line: &[u8]) -> OrderedBucket {
797
802
  OrderedBucket::None
798
803
  }
799
804
 
805
+ /// Emacs editor token match for `\Awarn[_-]indent\s*:\s*TOKEN\z`
806
+ /// (case-SENSITIVE keyword, like `EditorComment#match`). `warn_indent` is a
807
+ /// magic comment since 1.91 (`MagicComment::KEYWORDS[:warn_indent]`).
808
+ fn warn_indent_keyword_kv_matches(tok: &[u8]) -> bool {
809
+ match warn_indent_keyword_len(tok, false) {
810
+ Some(klen) => editor_kv_matches(&tok[klen..], &[b""], b':'),
811
+ None => false,
812
+ }
813
+ }
814
+
815
+ /// Length of `warn[_-]indent` at the start of `s`, or None.
816
+ fn warn_indent_keyword_len(s: &[u8], ci: bool) -> Option<usize> {
817
+ let mut p = eat(s, 0, b"warn", ci)?;
818
+ p = eat_one_of(s, p, b"_-")?;
819
+ p = eat(s, p, b"indent", ci)?;
820
+ Some(p)
821
+ }
822
+
823
+ /// `warn_indent_specified?` for a SimpleComment line (any TOKEN).
824
+ fn simple_warn_indent_specified(line: &[u8]) -> bool {
825
+ simple_anchored_token(line, |s| warn_indent_keyword_len(s, true)).is_some()
826
+ }
827
+
800
828
  /// Emacs editor token match for `\Ashareable[_-]constant[_-]value\s*:\s*TOKEN\z`
801
829
  /// (case-SENSITIVE keyword, like `EditorComment#match`).
802
830
  fn shareable_keyword_kv_matches(tok: &[u8]) -> bool {
@@ -223,6 +223,8 @@ enum Kind {
223
223
  chain: Option<ConstChain>,
224
224
  },
225
225
  Lvasgn,
226
+ /// `@ivar = Class.new do ... end`: an `:ivasgn` parent (1.91).
227
+ Ivasgn,
226
228
  If,
227
229
  Other,
228
230
  }
@@ -491,6 +493,7 @@ enum PParent<'a> {
491
493
  BeginWrap { pp_block: bool },
492
494
  Def(usize),
493
495
  Casgn,
496
+ Ivasgn,
494
497
  Lvasgn,
495
498
  Disallowed,
496
499
  }
@@ -564,6 +567,7 @@ impl<'s> DuplicateMethodsRule<'s> {
564
567
  }
565
568
  Kind::Call(info) => PParent::Call(info),
566
569
  Kind::Casgn { .. } => PParent::Casgn,
570
+ Kind::Ivasgn => PParent::Ivasgn,
567
571
  Kind::Lvasgn => PParent::Lvasgn,
568
572
  Kind::Def { .. } => PParent::Def(call_idx - 1),
569
573
  _ => PParent::Disallowed,
@@ -633,7 +637,9 @@ impl<'s> DuplicateMethodsRule<'s> {
633
637
  None => path_line(),
634
638
  }
635
639
  }
636
- PParent::Casgn => path_line(),
640
+ // `:casgn` and `:ivasgn` (1.91) parents have no receiver; the
641
+ // block gets its unique source-location id.
642
+ PParent::Casgn | PParent::Ivasgn => path_line(),
637
643
  PParent::BeginWrap { pp_block } => {
638
644
  if pp_block {
639
645
  path_line()
@@ -1244,6 +1250,7 @@ impl<'s> DuplicateMethodsRule<'s> {
1244
1250
  }
1245
1251
  }
1246
1252
  Node::LocalVariableWriteNode { .. } => frame.kind = Kind::Lvasgn,
1253
+ Node::InstanceVariableWriteNode { .. } => frame.kind = Kind::Ivasgn,
1247
1254
  Node::CallNode { .. } => {
1248
1255
  let c = node.as_call_node().unwrap();
1249
1256
  let kind = block_kind(&c);
@@ -1905,4 +1912,20 @@ mod tests {
1905
1912
  );
1906
1913
  assert_eq!(&src[evts[0].off_start..evts[0].off_end], "alias_method :a, :b");
1907
1914
  }
1915
+
1916
+ // 1.91: a Class.new block assigned to an instance variable (`:ivasgn`
1917
+ // parent) gets its own path:line scope id like a casgn one, so singleton
1918
+ // methods in separate blocks are not duplicates of each other.
1919
+ #[test]
1920
+ fn ivasgn_class_new_blocks_get_distinct_scope_ids() {
1921
+ let src = "def setup\n @first = Class.new do\n class << self\n def name; end\n end\n end\n\n Class.new do\n class << self\n def name; end\n end\n end\nend\n";
1922
+ let evts = events(src);
1923
+ // `def setup`, then the two singleton `name`s.
1924
+ assert_eq!(evts.len(), 3);
1925
+ assert_eq!(evts[1].0, evts[2].0);
1926
+ assert_eq!(evts[1].2, 2); // @path:2 (the ivasgn block)
1927
+ // The second block's parent is the def body's `begin`: no scope id,
1928
+ // as before.
1929
+ assert_eq!(evts[2].2, -1);
1930
+ }
1908
1931
  }
@@ -114,6 +114,9 @@ enum FrameKind {
114
114
  },
115
115
  /// A block node (`BlockNode`).
116
116
  Block,
117
+ /// A `class` / `module` / `class << self` body: the opening keyword range.
118
+ /// An implicit-begin `rescue`/`else` in such a body aligns with it (1.91).
119
+ ClassLike { kw_start: usize, kw_end: usize },
117
120
  /// A write node (assignment LHS): the LHS span (start..value_start trimmed),
118
121
  /// here approximated by the whole write-node range start and its first line.
119
122
  Write,
@@ -413,6 +416,13 @@ impl<'a> Visitor<'a> {
413
416
  let call_start = self.frames[nf - 2].start;
414
417
  return self.start_line_range(call_start);
415
418
  }
419
+ // `when :class, :module, :sclass then parent.loc.keyword` (1.91).
420
+ FrameKind::ClassLike { kw_start, kw_end } => {
421
+ return AlignRange {
422
+ start: *kw_start,
423
+ end: *kw_end,
424
+ };
425
+ }
416
426
  _ => {}
417
427
  }
418
428
  }
@@ -670,6 +680,24 @@ impl<'a> Visitor<'a> {
670
680
  }
671
681
  } else if node.as_block_node().is_some() {
672
682
  FrameKind::Block
683
+ } else if let Some(n) = node.as_class_node() {
684
+ let (s, e) = loc(&n.class_keyword_loc());
685
+ FrameKind::ClassLike {
686
+ kw_start: s,
687
+ kw_end: e,
688
+ }
689
+ } else if let Some(n) = node.as_module_node() {
690
+ let (s, e) = loc(&n.module_keyword_loc());
691
+ FrameKind::ClassLike {
692
+ kw_start: s,
693
+ kw_end: e,
694
+ }
695
+ } else if let Some(n) = node.as_singleton_class_node() {
696
+ let (s, e) = loc(&n.class_keyword_loc());
697
+ FrameKind::ClassLike {
698
+ kw_start: s,
699
+ kw_end: e,
700
+ }
673
701
  } else if is_write_node(node) {
674
702
  FrameKind::Write
675
703
  } else {
@@ -843,4 +871,30 @@ mod tests {
843
871
  fn rescue_inside_if_branches_no_offense() {
844
872
  assert!(variable("if a\n a rescue nil\nelse\n a rescue nil\nend\n").is_empty());
845
873
  }
874
+
875
+ // 1.91: an implicit-begin `rescue`/`else` in a class body aligns with
876
+ // the `class` keyword (`when :class, :module, :sclass then
877
+ // parent.loc.keyword`), not with `rescue`.
878
+ #[test]
879
+ fn class_body_else_aligns_with_class_keyword() {
880
+ let r = keyword("class MyClass\n foo\nrescue SomeException\n bar\n else\n baz\nend\n");
881
+ assert_eq!(r.len(), 1);
882
+ assert!(r[0].message.contains("Align `else` with `class`."));
883
+ assert_eq!(r[0].column_delta, -2);
884
+ assert!(keyword("class MyClass\n foo\nrescue SomeException\n bar\nelse\n baz\nend\n").is_empty());
885
+ }
886
+
887
+ #[test]
888
+ fn module_body_else_aligns_with_module_keyword() {
889
+ let r = keyword("module M\n foo\nrescue E\n bar\n else\n baz\nend\n");
890
+ assert_eq!(r.len(), 1);
891
+ assert!(r[0].message.contains("Align `else` with `module`."));
892
+ }
893
+
894
+ #[test]
895
+ fn sclass_body_else_aligns_with_class_keyword() {
896
+ let r = keyword("class << self\n foo\nrescue E\n bar\n else\n baz\nensure\n q\nend\n");
897
+ assert_eq!(r.len(), 1);
898
+ assert!(r[0].message.contains("Align `else` with `class`."));
899
+ }
846
900
  }
@@ -1050,4 +1050,15 @@ mod tests {
1050
1050
  // The first `=` (col 2) is not aligned with the block's max -> flagged.
1051
1051
  assert_eq!(off.len(), 1);
1052
1052
  }
1053
+
1054
+ // 1.91: a line carrying another alignable operator (`<<`, `==`, ...)
1055
+ // that is not itself an assignment line ends the equal-sign alignment
1056
+ // group, so the assignments around it are not "unaligned".
1057
+ #[test]
1058
+ fn force_alignment_group_ends_at_an_interrupting_operator_line() {
1059
+ assert!(run("aaaa = b\ne << f\ng += h\n", cfg(true, false, true)).is_empty());
1060
+ assert!(run("aaaa = b\nraise if e == f\ng += h\n", cfg(true, false, true)).is_empty());
1061
+ // Sanity: adjacent assignments still form a group.
1062
+ assert_eq!(run("aaaa = b\ng += h\n", cfg(true, false, true)).len(), 1);
1063
+ }
1053
1064
  }
@@ -86,7 +86,8 @@ pub fn check_first_array_element_indentation(
86
86
  /// Build the rule for use standalone or in a shared-walk bundle. `None` when
87
87
  /// the cop is disabled outright: `Layout/ArrayAlignment` enforcing
88
88
  /// `with_fixed_indentation` gates both `on_array` and `on_send` unless the
89
- /// style is `consistent`.
89
+ /// style is `consistent` (`autocorrect_incompatible_with_other_cops?`, whose
90
+ /// per-array part for the `consistent` style runs in `process_array`).
90
91
  pub(crate) fn build_rule(
91
92
  source: &[u8],
92
93
  style: u8,
@@ -102,6 +103,7 @@ pub(crate) fn build_rule(
102
103
  line_index: super::line_index::with_line_index(source, |li| li.clone()),
103
104
  style,
104
105
  indent: indent_width,
106
+ enforce_fixed_indentation,
105
107
  stack: Vec::new(),
106
108
  offenses: Vec::new(),
107
109
  })
@@ -166,6 +168,10 @@ pub(crate) struct Visitor<'a> {
166
168
  line_index: Rc<LineIndex>,
167
169
  style: Style,
168
170
  indent: usize,
171
+ /// `enforce_first_argument_with_fixed_indentation?` (`Layout/ArrayAlignment`
172
+ /// enforces `with_fixed_indentation`). Only reaches the visitor with the
173
+ /// `consistent` style; the other styles stand down in `build_rule`.
174
+ enforce_fixed_indentation: bool,
169
175
  stack: Vec<Frame>,
170
176
  pub(crate) offenses: Vec<FirstArrayElemIndentOffense>,
171
177
  }
@@ -313,6 +319,21 @@ impl<'a> Visitor<'a> {
313
319
  let paren = self.claimed_paren(open_start);
314
320
  let first = a.elements().iter().next().map(|e| loc(&e.location()));
315
321
 
322
+ // `autocorrect_incompatible_with_other_cops?` for the `consistent`
323
+ // style (1.91): under `Layout/ArrayAlignment: with_fixed_indentation`
324
+ // an array of two or more elements is left alone unless its indent
325
+ // base is the start of its line (ArrayAlignment does not align
326
+ // single-element arrays, so those are still checked).
327
+ if self.enforce_fixed_indentation
328
+ && a.elements().iter().count() >= 2
329
+ && !matches!(
330
+ self.indent_base(open_start, first, paren),
331
+ (_, BaseType::StartOfLine)
332
+ )
333
+ {
334
+ return;
335
+ }
336
+
316
337
  if let Some(f) = first {
317
338
  if self.line_of(f.0) == self.line_of(open_start) {
318
339
  return;
@@ -640,4 +661,25 @@ mod tests {
640
661
  .is_empty()
641
662
  );
642
663
  }
664
+
665
+ // 1.91 `autocorrect_incompatible_with_other_cops?` for the `consistent`
666
+ // style under `Layout/ArrayAlignment: with_fixed_indentation`: an array of
667
+ // two or more elements whose indent base is not the start of its line is
668
+ // left to ArrayAlignment; single-element and bracketless arrays are not.
669
+ #[test]
670
+ fn consistent_style_defers_multi_element_arrays_to_array_alignment() {
671
+ let enforced = |src: &str| {
672
+ check_first_array_element_indentation(src.as_bytes(), 1, 2, true)
673
+ .into_iter()
674
+ .map(|o| o.message)
675
+ .collect::<Vec<_>>()
676
+ };
677
+ assert!(enforced("foo bar: [\n 'foo',\n 'bar'\n],\nbaz: 'baz'\n").is_empty());
678
+ let single = enforced("foo bar: [\n1\n],\nbaz: 3\n");
679
+ assert_eq!(single.len(), 2);
680
+ assert!(single[0].contains("relative to the parent hash key"));
681
+ assert!(enforced("x = 1,\n 2\n").is_empty());
682
+ // A start-of-line base is still checked (the bracket itself is fine).
683
+ assert_eq!(enforced("x = [\n1,\n2\n]\n").len(), 1);
684
+ }
643
685
  }
@@ -151,6 +151,10 @@ enum FrameKind {
151
151
  braces: bool,
152
152
  /// `pairs.last` range and whether `key.source == value.source` (omittable).
153
153
  last_pair: Option<((usize, usize), bool)>,
154
+ /// `hash_rockets_enforced?(hash)` (1.91): the whole hash converts to
155
+ /// rockets (`EnforcedStyle: hash_rockets` or `force_hash_rockets?`),
156
+ /// so its pairs skip the shorthand checks.
157
+ rockets_enforced: bool,
154
158
  },
155
159
  /// Prism `ArgumentsNode`: transparent in parser, skipped when finding a
156
160
  /// node's "parser parent".
@@ -334,11 +338,25 @@ impl Visitor<'_> {
334
338
  let h = node.as_keyword_hash_node()?;
335
339
  (h.elements().iter().collect(), false)
336
340
  };
337
- let last_pair = elements.iter().filter_map(|e| e.as_assoc_node()).next_back().map(|a| {
338
- let p = pair_from_assoc(self.source, &a);
339
- (p.range, p.key_source == p.value_source)
340
- });
341
- Some(FrameKind::Hash { braces, last_pair })
341
+ let pairs: Vec<Pair> = elements
342
+ .iter()
343
+ .filter_map(|e| e.as_assoc_node())
344
+ .map(|a| pair_from_assoc(self.source, &a))
345
+ .collect();
346
+ let last_pair = pairs
347
+ .last()
348
+ .map(|p| (p.range, p.key_source == p.value_source));
349
+ let rockets_enforced = self.hash_rockets_enforced(&pairs);
350
+ Some(FrameKind::Hash {
351
+ braces,
352
+ last_pair,
353
+ rockets_enforced,
354
+ })
355
+ }
356
+
357
+ /// `hash_rockets_enforced?(hash_node)` (1.91).
358
+ fn hash_rockets_enforced(&self, pairs: &[Pair]) -> bool {
359
+ self.cfg.style == STYLE_HASH_ROCKETS || self.force_hash_rockets(pairs)
342
360
  }
343
361
 
344
362
  fn call_frame(&self, node: &Node<'_>) -> Option<CallFrame> {
@@ -557,12 +575,16 @@ impl Visitor<'_> {
557
575
  // ---- shorthand: `on_hash_for_mixed_shorthand` (consistent / either_consistent) ----
558
576
 
559
577
  fn on_hash_for_mixed_shorthand(&mut self, pairs: &[Pair]) {
578
+ // `ignore_mixed_hash_shorthand_syntax?`.
560
579
  if !self.cfg.ruby31_plus {
561
580
  return;
562
581
  }
563
582
  if self.cfg.shorthand != SHORT_CONSISTENT && self.cfg.shorthand != SHORT_EITHER_CONSISTENT {
564
583
  return;
565
584
  }
585
+ if self.hash_rockets_enforced(pairs) {
586
+ return;
587
+ }
566
588
  let mut omitted = Vec::new();
567
589
  let mut omittable = Vec::new();
568
590
  let mut needed = false;
@@ -618,6 +640,17 @@ impl Visitor<'_> {
618
640
  if self.ignore_hash_shorthand_syntax() {
619
641
  return;
620
642
  }
643
+ // `hash_rockets_enforced?(pair_node.parent)` (1.91): the parent hash
644
+ // is the stack top when `on_pair` runs from `enter(AssocNode)`.
645
+ if matches!(
646
+ self.stack.last().map(|f| &f.kind),
647
+ Some(FrameKind::Hash {
648
+ rockets_enforced: true,
649
+ ..
650
+ })
651
+ ) {
652
+ return;
653
+ }
621
654
  if self.cfg.shorthand == SHORT_ALWAYS {
622
655
  if pair.value_omission || self.require_hash_value(pair) {
623
656
  return;
@@ -785,7 +818,9 @@ impl Visitor<'_> {
785
818
  // return unless last_pair.key.source == last_pair.value.source
786
819
  let hash_idx = self.stack.len().checked_sub(1)?;
787
820
  let (hash_braces, last_pair) = match &self.stack[hash_idx].kind {
788
- FrameKind::Hash { braces, last_pair } => (*braces, *last_pair),
821
+ FrameKind::Hash {
822
+ braces, last_pair, ..
823
+ } => (*braces, *last_pair),
789
824
  _ => return None,
790
825
  };
791
826
  let (_last_pair_range, last_pair_omittable) = last_pair?;
@@ -1309,4 +1344,23 @@ mod tests {
1309
1344
  assert!(offenses("{foo: foo}", &c).is_empty());
1310
1345
  assert!(offenses("{foo:}", &c).is_empty());
1311
1346
  }
1347
+
1348
+ // 1.91 `hash_rockets_enforced?`: when the whole hash converts to rockets
1349
+ // (`EnforcedStyle: hash_rockets` or `UseHashRocketsWithSymbolValues`),
1350
+ // its pairs skip the shorthand checks instead of also being told to omit
1351
+ // the value (which clobbered the rocket correction).
1352
+ #[test]
1353
+ fn rockets_enforced_hash_skips_shorthand_checks() {
1354
+ // hash_rockets + always: only the rocket offense.
1355
+ assert_eq!(offenses("f(a: a)", &cfg(1, 0)), vec![(2, 4, 1)]);
1356
+ // ruby19 + always + UseHashRocketsWithSymbolValues.
1357
+ let mut c = cfg(0, 0);
1358
+ c.use_hash_rockets_with_symbol_values = true;
1359
+ assert_eq!(offenses("f(a: :sym, b: b)", &c), vec![(2, 4, 1), (11, 13, 1)]);
1360
+ // hash_rockets + consistent: no mixed-shorthand offense either.
1361
+ assert!(offenses("f(a: a, b: 1)", &cfg(1, 3)).iter().all(|o| o.2 == 1));
1362
+ // Without rockets the shorthand check still fires.
1363
+ assert_eq!(offenses("f(a: a)", &cfg(0, 0)).len(), 1);
1364
+ assert_eq!(offenses("f(a: a)", &cfg(0, 0))[0].2, 3);
1365
+ }
1312
1366
  }
@@ -334,7 +334,11 @@ impl<'a> Visitor<'a> {
334
334
  } else {
335
335
  (begin_pos as isize - indentation) as usize
336
336
  };
337
- if indentation >= 0 {
337
+ // `pos = ind <= begin_pos ? ind..begin_pos : begin_pos..ind` (1.91):
338
+ // ordered by position, not by the sign of the delta, so an
339
+ // under-indented tab body (negative delta but `ind` behind
340
+ // `begin_pos`) still yields a forward range.
341
+ if ind <= begin_pos {
338
342
  (ind, begin_pos)
339
343
  } else {
340
344
  (begin_pos, ind)
@@ -1307,4 +1311,15 @@ mod tests {
1307
1311
  });
1308
1312
  assert!(got.is_empty());
1309
1313
  }
1314
+
1315
+ // 1.91: an under-indented tab body yields a forward offense range
1316
+ // (`ind <= begin_pos ? ind..begin_pos : begin_pos..ind`), with the
1317
+ // negative tab count in the message.
1318
+ #[test]
1319
+ fn tabs_under_indented_body_has_forward_range() {
1320
+ let got = run_cfg("\t\ta = if b\n\tc\n\t\tend\n", |c| c.use_tabs = true);
1321
+ assert_eq!(got.len(), 1);
1322
+ assert!(got[0].message.contains("Use 1 (not -1) tabs"), "{}", got[0].message);
1323
+ assert!(got[0].start_offset <= got[0].end_offset);
1324
+ }
1310
1325
  }
@@ -373,6 +373,17 @@ fn offset_after_chars(source: &[u8], begin: usize, n_chars: usize) -> usize {
373
373
 
374
374
  /// `rindex(/\\(u[\da-f]{0,4}|x[\da-f]{0,2})?\z/)` — the char index of a trailing
375
375
  /// escape sequence (the backslash), if the substring ends in one.
376
+ /// `rindex(/\s/)` — the char index of the last whitespace in `substr`.
377
+ fn rindex_whitespace(substr: &str) -> Option<usize> {
378
+ substr.char_indices().rev().find_map(|(i, c)| {
379
+ if c.is_whitespace() {
380
+ Some(substr[..i].chars().count())
381
+ } else {
382
+ None
383
+ }
384
+ })
385
+ }
386
+
376
387
  fn trailing_escape_char_index(substr: &str) -> Option<usize> {
377
388
  let chars: Vec<char> = substr.chars().collect();
378
389
  // Find the rightmost backslash whose suffix (to end) matches the pattern.
@@ -881,14 +892,11 @@ impl<'pr> BreakableVisitor<'_> {
881
892
  range_end: usize,
882
893
  ) -> Option<usize> {
883
894
  let substr = self.largest_possible_string(node, parent_quote, range_begin, range_end);
884
- // rindex(/\s/)
885
- if let Some(space_char_idx) = substr.char_indices().rev().find_map(|(i, c)| {
886
- if c.is_whitespace() {
887
- Some(substr[..i].chars().count())
888
- } else {
889
- None
890
- }
891
- }) {
895
+ // `breakable_space_position` (1.91): `rindex(/\s/)`, but a space that
896
+ // is the last character of the string content is no break point (the
897
+ // split would leave the content unchanged and loop); retry on the
898
+ // substring before it.
899
+ if let Some(space_char_idx) = self.breakable_space_position(node, range_begin, &substr) {
892
900
  // resize(space_pos + 1)
893
901
  return Some(offset_after_chars(
894
902
  self.source,
@@ -918,6 +926,40 @@ impl<'pr> BreakableVisitor<'_> {
918
926
  Some(offset_after_chars(self.source, range_begin, new_chars))
919
927
  }
920
928
 
929
+ /// `breakable_space_position(node, substr)` — the char index of the last
930
+ /// whitespace in `substr`, unless that space sits at the end of the string
931
+ /// content (`space_pos + 1 > string_content_length - 1`); then the last
932
+ /// whitespace of `substr[0, limit]`.
933
+ fn breakable_space_position(
934
+ &self,
935
+ node: &Node<'pr>,
936
+ range_begin: usize,
937
+ substr: &str,
938
+ ) -> Option<usize> {
939
+ let limit = self.string_content_length(node, range_begin) as isize - 1;
940
+ let space_pos = rindex_whitespace(substr)?;
941
+ if space_pos as isize + 1 > limit {
942
+ if limit <= 0 {
943
+ return None;
944
+ }
945
+ let head: String = substr.chars().take(limit as usize).collect();
946
+ return rindex_whitespace(&head);
947
+ }
948
+ Some(space_pos)
949
+ }
950
+
951
+ /// `string_content_length(node)`: chars from the node's start to its
952
+ /// closing delimiter (`loc.end`), or to the node's end when it has none
953
+ /// (a dstr part).
954
+ fn string_content_length(&self, node: &Node<'pr>, range_begin: usize) -> usize {
955
+ let content_end = node
956
+ .as_string_node()
957
+ .and_then(|s| s.closing_loc())
958
+ .map(|c| c.start_offset())
959
+ .unwrap_or_else(|| node.location().end_offset());
960
+ char_count(&self.source[range_begin..content_end])
961
+ }
962
+
921
963
  /// `largest_possible_string` — the leading substring (as a `&str`) considered
922
964
  /// when choosing a break point.
923
965
  fn largest_possible_string(
@@ -1742,4 +1784,17 @@ mod tests {
1742
1784
  // Broken as a hash (no string delimiter).
1743
1785
  assert_eq!(r[0].2, "");
1744
1786
  }
1787
+
1788
+ // 1.91 `breakable_space_position`: a space that is the last character of
1789
+ // the string content is no break point (the split would change nothing
1790
+ // and loop); the previous space is used instead.
1791
+ #[test]
1792
+ fn split_string_skips_a_trailing_space_break_point() {
1793
+ let src = "foo(\"aaaaaaaaaaaaaaaaaaaaaaaaa#{b} cc dd \" \\\n \"ee\")\n";
1794
+ let got = run(src, 40, true);
1795
+ assert_eq!(got.len(), 1);
1796
+ assert_eq!(got[0].0, 0);
1797
+ assert_eq!(got[0].1, src.find("dd \"").unwrap());
1798
+ assert_eq!(got[0].2, "\"");
1799
+ }
1745
1800
  }
@@ -208,4 +208,13 @@ mod tests {
208
208
  Some((3, 2))
209
209
  );
210
210
  }
211
+
212
+ // 1.91: `warn_indent` is a magic comment (simple and Emacs forms).
213
+ #[test]
214
+ fn warn_indent_then_encoding() {
215
+ assert_eq!(run("# warn_indent: true\n# encoding: ascii\n"), Some((2, 1)));
216
+ assert_eq!(run("# warn-indent: true\n# encoding: ascii\n"), Some((2, 1)));
217
+ assert_eq!(run("# -*- warn_indent: true -*-\n# encoding: ascii\n"), Some((2, 1)));
218
+ assert_eq!(run("# encoding: ascii\n# warn_indent: true\n"), None);
219
+ }
211
220
  }
@@ -113,6 +113,14 @@ impl ParenthesesAsGroupedExpressionVisitor {
113
113
  let first_arg = &args[0];
114
114
  let Some(parens) = first_arg.as_parentheses_node() else { return; };
115
115
 
116
+ // `invalid_bare_argument?` (1.91): the parentheses are required when
117
+ // their (possibly re-parenthesised) sole expression could not stand as
118
+ // a bare argument — `and` / `or` / `not`, a modifier `rescue`, or a
119
+ // modifier `if` / `unless` / `while` / `until`.
120
+ if invalid_bare_argument(first_arg) {
121
+ return;
122
+ }
123
+
116
124
  // `node.operator_method?` / `node.setter_method?` — outer method name.
117
125
  // The `ParenthesesNode` gate already rejects block / chain / hash /
118
126
  // ternary / unparenthesised range first arguments by construction
@@ -149,6 +157,78 @@ impl ParenthesesAsGroupedExpressionVisitor {
149
157
  }
150
158
  }
151
159
 
160
+ /// Stock's `invalid_bare_argument?`:
161
+ ///
162
+ /// ```ruby
163
+ /// node = node.children.first while node&.begin_type? && node.children.one?
164
+ /// return false unless node
165
+ /// keyword_operator?(node) || modifier_expression?(node)
166
+ /// ```
167
+ ///
168
+ /// Parser wraps `(...)` in a `begin` whose children are the statements; prism
169
+ /// gives a `ParenthesesNode` whose `body` is a `StatementsNode`. Peeling a
170
+ /// single-statement parenthesis repeatedly lands on the innermost expression
171
+ /// (or on nothing for `()`).
172
+ fn invalid_bare_argument(node: &Node<'_>) -> bool {
173
+ if let Some(parens) = node.as_parentheses_node() {
174
+ // `()`: `children.first` is nil.
175
+ let Some(body) = parens.body() else { return false };
176
+ if let Some(stmts) = body.as_statements_node() {
177
+ let children: Vec<Node<'_>> = stmts.body().iter().collect();
178
+ if children.len() == 1 {
179
+ return invalid_bare_argument(&children[0]);
180
+ }
181
+ }
182
+ // A multi-statement `begin` is neither a keyword operator nor a
183
+ // modifier expression.
184
+ return false;
185
+ }
186
+ keyword_operator(node) || modifier_expression(node)
187
+ }
188
+
189
+ /// `keyword_operator?`: `(operator_keyword? && semantic_operator?)` — an
190
+ /// `and` / `or` spelled with the keyword — or a modifier `rescue`, or a `not`
191
+ /// call (`send_type? && prefix_not?`).
192
+ fn keyword_operator(node: &Node<'_>) -> bool {
193
+ if let Some(n) = node.as_and_node() {
194
+ return n.operator_loc().as_slice() == b"and";
195
+ }
196
+ if let Some(n) = node.as_or_node() {
197
+ return n.operator_loc().as_slice() == b"or";
198
+ }
199
+ if node.as_rescue_modifier_node().is_some() {
200
+ return true;
201
+ }
202
+ if let Some(c) = node.as_call_node() {
203
+ // `prefix_not?` = `keyword_not?`: the selector is spelled `not`.
204
+ return c.message_loc().is_some_and(|m| m.as_slice() == b"not");
205
+ }
206
+ false
207
+ }
208
+
209
+ /// `modifier_expression?`: `type?(:if, :while, :until) && modifier_form?`. In
210
+ /// prism a modifier form has its keyword after the node's start (the body
211
+ /// comes first). `begin ... end while x` is parser's `while_post`, which
212
+ /// `type?(:while)` does not match, so the begin-modifier flag excludes it.
213
+ fn modifier_expression(node: &Node<'_>) -> bool {
214
+ let node_start = node.location().start_offset();
215
+ if let Some(n) = node.as_if_node() {
216
+ return n
217
+ .if_keyword_loc()
218
+ .is_some_and(|k| k.start_offset() > node_start);
219
+ }
220
+ if let Some(n) = node.as_unless_node() {
221
+ return n.keyword_loc().start_offset() > node_start;
222
+ }
223
+ if let Some(n) = node.as_while_node() {
224
+ return !n.is_begin_modifier() && n.keyword_loc().start_offset() > node_start;
225
+ }
226
+ if let Some(n) = node.as_until_node() {
227
+ return !n.is_begin_modifier() && n.keyword_loc().start_offset() > node_start;
228
+ }
229
+ false
230
+ }
231
+
152
232
  /// rubocop-ast `OPERATOR_METHODS` — every operator that can be defined as a
153
233
  /// method. Identical to the table in `nested_parenthesized_calls`. The list is
154
234
  /// closed (no Ruby version adds new ones in practice), so we encode it
@@ -407,4 +487,21 @@ mod tests {
407
487
  // selector `func` ends at 6, arg `(x)` starts at 9.
408
488
  assert_eq!(off, vec![(6, 9, 9, 12)]);
409
489
  }
490
+
491
+ // 1.91 `invalid_bare_argument?`: parentheses around an `and` / `or` /
492
+ // `not`, a modifier `rescue` or a modifier conditional are required, so
493
+ // they are not a grouped expression; `&&` still is.
494
+ #[test]
495
+ fn accepts_parentheses_required_by_keyword_operators_and_modifiers() {
496
+ assert!(detect("false? (foo and bar)\n").is_empty());
497
+ assert!(detect("false? (foo or bar)\n").is_empty());
498
+ assert!(detect("false? (not foo)\n").is_empty());
499
+ assert!(detect("false? (foo rescue bar)\n").is_empty());
500
+ assert!(detect("false? (foo if bar)\n").is_empty());
501
+ assert!(detect("false? (foo unless bar)\n").is_empty());
502
+ assert!(detect("false? (foo while bar)\n").is_empty());
503
+ assert!(detect("false? ((foo and bar))\n").is_empty());
504
+ assert_eq!(detect("false? (foo && bar)\n"), vec![(6, 7, 7, 19)]);
505
+ assert_eq!(detect("false? (!foo)\n"), vec![(6, 7, 7, 13)]);
506
+ }
410
507
  }
@@ -108,8 +108,10 @@ pub struct SpaceAroundOperatorsOffense {
108
108
  }
109
109
 
110
110
  /// Whether an operator routes through the assignment alignment path (`:assignment`)
111
- /// or the generic operator alignment path (everything else). Stock distinguishes
112
- /// only these two for `excess_leading_space?`.
111
+ /// or the generic operator alignment path (everything else). Stock's
112
+ /// `excess_leading_space?` distinguishes `:assignment`, `:special_asgn` (which
113
+ /// joins the assignment path only under `ForceEqualSignAlignment`, 1.91) and
114
+ /// the rest.
113
115
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
114
116
  enum AlignType {
115
117
  /// `on_assignment` for a plain assignment (`lvasgn` / `casgn` / ... /
@@ -117,8 +119,9 @@ enum AlignType {
117
119
  /// diverts `op_asgn_type?` — the arithmetic `+=` family — to
118
120
  /// `:special_asgn`.
119
121
  Assignment,
120
- /// Everything else (binary, ternary, pair, class, setter, `+=`-style
121
- /// op-assign, ...).
122
+ /// `:special_asgn`: the `+=`-style op-assign family and setter methods.
123
+ SpecialAsgn,
124
+ /// Everything else (binary, ternary, pair, class, ...).
122
125
  Other,
123
126
  }
124
127
 
@@ -501,7 +504,11 @@ fn excess_leading_space(
501
504
  if !config.allow_for_alignment {
502
505
  return false;
503
506
  }
504
- if align_type != AlignType::Assignment {
507
+ // `grouped_alignment_type?(type)` (1.91): `:assignment`, or `:special_asgn`
508
+ // when `Layout/ExtraSpacing` forces equal-sign alignment.
509
+ let grouped = align_type == AlignType::Assignment
510
+ || (align_type == AlignType::SpecialAsgn && config.force_equal_sign_alignment);
511
+ if !grouped {
505
512
  // `!aligned_with_operator?(operator)`.
506
513
  return !aligner.aligned_with_operator(op_start, op_end);
507
514
  }
@@ -777,7 +784,7 @@ impl Visitor<'_> {
777
784
  if let Some(arg) = self.first_argument(node) {
778
785
  let (rs, re) = (arg.start_offset(), arg.end_offset());
779
786
  self.check_operator(
780
- AlignType::Other,
787
+ AlignType::SpecialAsgn,
781
788
  eq.start_offset(),
782
789
  eq.end_offset(),
783
790
  rs,
@@ -1017,10 +1024,10 @@ impl Visitor<'_> {
1017
1024
  );
1018
1025
  }
1019
1026
 
1020
- /// `on_assignment` for an op-assign (`op_asgn_type?`): :special_asgn (Other).
1027
+ /// `on_assignment` for an op-assign (`op_asgn_type?`): :special_asgn.
1021
1028
  fn check_op_assign<'pr>(&mut self, op: Location<'pr>, right: Location<'pr>) {
1022
1029
  self.check_operator(
1023
- AlignType::Other,
1030
+ AlignType::SpecialAsgn,
1024
1031
  op.start_offset(),
1025
1032
  op.end_offset(),
1026
1033
  right.start_offset(),
@@ -1266,4 +1273,19 @@ end
1266
1273
  assert!(off.iter().any(|o| o.2 == MessageKind::Missing
1267
1274
  && &"uri.path+= '/'\n".as_bytes()[o.0..o.1] == b"+="));
1268
1275
  }
1276
+
1277
+ /// 1.91 `grouped_alignment_type?`: under `ForceEqualSignAlignment` an
1278
+ /// op-assign (`:special_asgn`) takes the assignment alignment path, so a
1279
+ /// `+=` aligned with a preceding `=` is accepted; without it the generic
1280
+ /// operator rule still flags the padding.
1281
+ #[test]
1282
+ fn op_assign_aligned_with_preceding_assignment_under_force_alignment() {
1283
+ let src = "aaaa = 1\nfoo\nb += 2\n";
1284
+ let forced = Config {
1285
+ force_equal_sign_alignment: true,
1286
+ ..default_cfg()
1287
+ };
1288
+ assert!(run(src, forced).is_empty());
1289
+ assert_eq!(run(src, default_cfg()).len(), 1);
1290
+ }
1269
1291
  }
@@ -2658,21 +2658,22 @@ fn check_line_end_concatenation(source: RString) -> Vec<(usize, usize, String, u
2658
2658
 
2659
2659
  /// Ruby entry point for `Layout/ArgumentAlignment`. Takes the source, the
2660
2660
  /// enforced style (0=with_first_argument, 1=with_fixed_indentation), the
2661
- /// configured indentation width and whether autocorrect is incompatible with
2662
- /// `Layout/HashAlignment`'s separator styles (which disables this cop's
2663
- /// autocorrect). Returns `[[start, end, column_delta, autocorrect], ...]`.
2661
+ /// configured indentation width and whether `Layout/HashAlignment` aligns on
2662
+ /// separators (`enforce_hash_argument_with_separator?`; with
2663
+ /// `with_first_argument` that disables the cop). Returns
2664
+ /// `[[start, end, column_delta, autocorrect], ...]`.
2664
2665
  fn check_argument_alignment(
2665
2666
  source: RString,
2666
2667
  style: u8,
2667
2668
  indent_width: usize,
2668
- incompatible: bool,
2669
+ hash_separator: bool,
2669
2670
  ) -> Vec<(usize, usize, isize, bool)> {
2670
2671
  map_argument_alignment(
2671
2672
  shirobai_core::rules::argument_alignment::check_argument_alignment(
2672
2673
  bytes(&source),
2673
2674
  style,
2674
2675
  indent_width,
2675
- incompatible,
2676
+ hash_separator,
2676
2677
  ),
2677
2678
  )
2678
2679
  }
@@ -27,20 +27,23 @@ module Shirobai
27
27
  def self.badge = RuboCop::Cop::Badge.parse("Layout/ArgumentAlignment")
28
28
 
29
29
  # Packed args for the bundled run: `[style, indentation_width,
30
- # incompatible]`. `incompatible` replicates the instance derivation
31
- # exactly: it is only true for the explicit `with_first_argument` style
32
- # combined with a separator-aligned `Layout/HashAlignment`.
30
+ # hash_separator]`. `hash_separator` is stock's
31
+ # `enforce_hash_argument_with_separator?` (a separator-aligned
32
+ # `Layout/HashAlignment`). Rust derives the rest from it: with
33
+ # `with_first_argument` it disables the cop
34
+ # (`autocorrect_incompatible_with_other_cops?`); with
35
+ # `with_fixed_indentation` a trailing braceless hash's pairs are left
36
+ # to HashAlignment (1.91).
33
37
  def self.bundle_args(config)
34
38
  cop_config = config.for_badge(badge)
35
39
  enforced_style = cop_config["EnforcedStyle"]
36
- incompatible = enforced_style == "with_first_argument" &&
37
- RuboCop::Cop::Layout::HashAlignment::SEPARATOR_ALIGNMENT_STYLES.any? do |sep_style|
38
- config.for_enabled_cop("Layout/HashAlignment")[sep_style]&.include?("separator")
39
- end
40
+ hash_separator = RuboCop::Cop::Layout::HashAlignment::SEPARATOR_ALIGNMENT_STYLES.any? do |sep_style|
41
+ config.for_enabled_cop("Layout/HashAlignment")[sep_style]&.include?("separator")
42
+ end
40
43
  [
41
44
  enforced_style == "with_fixed_indentation" ? 1 : 0,
42
45
  cop_config["IndentationWidth"] || config.for_cop("Layout/IndentationWidth")["Width"] || 2,
43
- incompatible
46
+ hash_separator
44
47
  ]
45
48
  end
46
49
 
@@ -91,8 +91,10 @@ module Shirobai
91
91
  if has_value
92
92
  correct_key_value(corrector, buffer, off, deltas, key, op, value)
93
93
  else
94
- # `correct_no_value`: adjust the whole node by the key delta.
95
- adjust(corrector, deltas[0], range)
94
+ # `correct_no_value`: adjust the whole node by the key delta,
95
+ # clamped so the node is not shifted past the start of its
96
+ # line (`clamped_key_delta`, 1.91).
97
+ adjust(corrector, clamped_key_delta(deltas[0], range.column), range)
96
98
  end
97
99
  end
98
100
  rescue Parser::ClobberingError
@@ -109,17 +111,21 @@ module Shirobai
109
111
  key_delta, separator_delta, value_delta = deltas
110
112
  key_start, _key_end, key_column = key
111
113
 
112
- key_delta = -key_column if key_delta < -key_column
113
-
114
114
  key_range = Parser::Source::Range.new(buffer, off[key_start], off[key[1]])
115
115
  op_range = Parser::Source::Range.new(buffer, off[op[0]], off[op[1]])
116
116
  value_range = Parser::Source::Range.new(buffer, off[value[0]], off[value[1]])
117
117
 
118
- adjust(corrector, key_delta, key_range)
118
+ adjust(corrector, clamped_key_delta(key_delta, key_column), key_range)
119
119
  adjust(corrector, separator_delta, op_range)
120
120
  adjust(corrector, value_delta, value_range)
121
121
  end
122
122
 
123
+ # Stock's `clamped_key_delta`: never remove more characters than the
124
+ # key has before it on its line.
125
+ def clamped_key_delta(key_delta, key_column)
126
+ [key_delta, -key_column].max
127
+ end
128
+
123
129
  # Mirrors stock's `adjust`: insert spaces for a positive delta, remove
124
130
  # `delta.abs` characters before the range for a negative one.
125
131
  def adjust(corrector, delta, range)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shirobai
4
- VERSION = "2026.0905.1300"
4
+ VERSION = "2026.0920.1100"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shirobai
3
3
  version: !ruby/object:Gem::Version
4
- version: 2026.0905.1300
4
+ version: 2026.0920.1100
5
5
  platform: ruby
6
6
  authors:
7
7
  - fusagiko / takayamaki
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 1.90.0
32
+ version: 1.91.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 1.90.0
39
+ version: 1.91.0
40
40
  executables: []
41
41
  extensions:
42
42
  - ext/shirobai/extconf.rb