fix(generate): honor right associativity despite a lower-precedence shift

A SHIFT/REDUCE conflict can bundle several shift interpretations with
different precedences against a single reduce. `handle_conflict` weighed them
with only `shift_is_less` and `shift_is_more`, so a lone lower-precedence
shift set `shift_is_less` and the REDUCE won outright, even when another
interpretation tied the REDUCE in precedence and the REDUCE was declared
right-associative. That tie's associativity should have won by shifting, so a
right-associative rule silently became left-associative as soon as an
unrelated lower-precedence rule was added to the grammar.

Track the equal-precedence case explicitly, and in the reduce-wins branch
shift instead when a tying interpretation exists and the reduce actions are
purely right-associative.

Co-authored-by: Will Lillis <will.lillis24@gmail.com>
This commit is contained in:
Sjoerd Langkemper 2026-07-23 11:17:07 +02:00 committed by Will Lillis
parent 4d0f126996
commit 0900f84eab
5 changed files with 114 additions and 3 deletions

View file

@ -770,6 +770,7 @@ impl<'a> ParseTableBuilder<'a> {
// If the SHIFT action has higher precedence, remove all the REDUCE actions.
let mut shift_is_less = false;
let mut shift_is_equal = false;
let mut shift_is_more = false;
for p in shift_precedence {
match Self::compare_precedence(
@ -781,7 +782,7 @@ impl<'a> ParseTableBuilder<'a> {
) {
Ordering::Greater => shift_is_more = true,
Ordering::Less => shift_is_less = true,
Ordering::Equal => {}
Ordering::Equal => shift_is_equal = true,
}
}
@ -790,8 +791,28 @@ impl<'a> ParseTableBuilder<'a> {
}
// If the REDUCE actions have higher precedence, remove the SHIFT action.
else if shift_is_less && !shift_is_more {
entry.actions.pop();
conflicting_items.retain(|item| item.is_done());
// Exception: if one SHIFT interpretation ties the REDUCE actions in
// precedence while another has lower precedence, and the REDUCE
// actions are purely right associative, honor that right
// associativity by shifting rather than reducing. The
// lower-precedence interpretation coexists with the tying one, so on
// its own it must not force a REDUCE that would flip the tie to left
// associative.
if shift_is_equal
&& matches!(
(
reduction_info.has_left_assoc,
reduction_info.has_non_assoc,
reduction_info.has_right_assoc,
),
(false, false, true)
)
{
entry.actions.drain(0..entry.actions.len() - 1);
} else {
entry.actions.pop();
conflicting_items.retain(|item| item.is_done());
}
}
// If the SHIFT and REDUCE actions have the same precedence, consider
// the REDUCE actions' associativity.

View file

@ -0,0 +1,37 @@
================================================================================
Addition is left associative
================================================================================
1 + 1 + 1 + 1
--------------------------------------------------------------------------------
(expression
(addition
(expression
(addition
(expression
(addition
(expression
(number))
(expression
(number))))
(expression
(number))))
(expression
(number))))
================================================================================
Superaddition still parses
================================================================================
1 + + 1
--------------------------------------------------------------------------------
(expression
(superaddition
(expression
(number))
(expression
(number))))

View file

@ -0,0 +1,16 @@
export default grammar({
name: 'associativity_left_with_lower_precedence_shift',
rules: {
expression: $ => choice(
$.addition,
$.superaddition,
$.number,
),
addition: $ => prec.left(1, seq($.expression, '+', $.expression)),
superaddition: $ => prec.right(seq($.expression, '+', '+', $.expression)),
number: _ => '1',
}
});

View file

@ -0,0 +1,22 @@
================================================================================
Addition is right associative
================================================================================
1 + 1 + 1 + 1
--------------------------------------------------------------------------------
(expression
(addition
(expression
(number))
(expression
(addition
(expression
(number))
(expression
(addition
(expression
(number))
(expression
(number))))))))

View file

@ -0,0 +1,15 @@
module.exports = grammar({
name: 'associativity_right_with_lower_precedence_shift',
rules: {
expression: $ => choice(
$.addition,
$.superaddition,
$.number
),
addition: $ => prec.right(1, seq($.expression, "+", $.expression)),
superaddition: $ => prec.right(seq($.expression, "+", "+", $.expression)),
number: _ => "1"
}
});