Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions crates/opy-rs/src/compiler/tests/preprocessing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,98 @@ fn multiline_object_define_preserves_statement_boundaries() {
assert!(matches!(rule.actions[1], Stmt::Assign { .. }));
}

#[test]
fn multiline_function_define_preserves_block_structure() {
// #506: expanded tokens keep the expansion's relative layout for the
// indentation-sensitive parser even though their authored provenance is
// the use site — a collapsed layout would let `C = 2` escape the body.
let hir = crate::compile(
"#!define check() if A == 1:\\\n B = 1\\\n C = 2\nglobalvar A\nglobalvar B\nglobalvar C\nrule \"block macro\":\n @Event global\n check()\n",
"main.opy",
std::path::Path::new("."),
)
.expect("multiline function-like defines must preserve block structure");

let RuleEntry::Rule(rule) = &hir.rules[0] else {
panic!("expected a rule");
};
assert_eq!(rule.actions.len(), 1);
let Stmt::If { branches, .. } = &rule.actions[0] else {
panic!("expected an if statement, got {:?}", rule.actions[0]);
};
assert_eq!(branches.len(), 1);
assert_eq!(
branches[0].body.len(),
2,
"both expansion statements must stay inside the if body"
);
}

#[test]
fn multiline_function_define_preserves_for_block() {
let hir = crate::compile(
"#!define gen() for i in range(0, 3):\\\n B = i\nglobalvar B\nglobalvar i\nrule \"for macro\":\n @Event global\n gen()\n",
"main.opy",
std::path::Path::new("."),
)
.expect("multiline defines must keep expect_block_indent constructs intact");

let RuleEntry::Rule(rule) = &hir.rules[0] else {
panic!("expected a rule");
};
assert!(matches!(rule.actions[0], Stmt::For { .. }));
}

#[test]
fn multiline_function_define_preserves_conditional_expressions() {
// #506 re-review: the conditional-expression statement-boundary guard
// must compare layout lines on both sides; reading the then-value's
// provenance span collapses expanded tokens to the use site and the
// guard misfires, so `expected ':' after the if condition` fails.
let hir = crate::compile(
"#!define E() A = 0\\\n B = 0\\\n y = B if C else D\nglobalvar A\nglobalvar B\nglobalvar C\nglobalvar D\nglobalvar y\nrule \"a\":\n @Event global\n E()\n",
"main.opy",
std::path::Path::new("."),
)
.expect("a conditional expression on a later expansion line must parse");

let RuleEntry::Rule(rule) = &hir.rules[0] else {
panic!("expected a rule");
};
assert_eq!(rule.actions.len(), 3);
assert!(matches!(rule.actions[2], Stmt::Assign { .. }));
}

#[test]
fn expanded_f_string_interpolation_errors_stay_inside_the_use_site() {
// #506: an interpolation inside an expanded string has no authored
// extent; its derived positions clamp into the use-site span rather
// than fabricating columns past the authored line.
let error = crate::compile(
"globalvar B\nrule \"bad f-string\":\n @Event global\n A = f\"hp: {B +}\"\n",
"main.opy",
std::path::Path::new("."),
)
.expect_err("the malformed interpolation must fail");
let span = error.span.expect("the error must carry a span");
assert_eq!(span.start.line, 4);

let error = crate::compile(
"#!define M f\"hp: {B +}\"\nglobalvar A\nglobalvar B\nrule \"expanded bad f-string\":\n @Event global\n A = M\n",
"main.opy",
std::path::Path::new("."),
)
.expect_err("the malformed expanded interpolation must fail");
let span = error.span.expect("the error must carry a span");
// `M` is the use site at 6:9-6:10; the fabricated interpolation column
// (string start + index) must clamp into it instead of escaping.
assert_eq!(span.start.line, 6);
assert!(
(9..=10).contains(&span.start.col) && (9..=10).contains(&span.end.col),
"the reported position must stay inside the authored use site, got {span:?}"
);
}

#[test]
fn nested_includes_resolve_relative_to_the_including_file() {
let overlay = BTreeMap::from([
Expand Down
9 changes: 9 additions & 0 deletions crates/opy-rs/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,15 @@ pub struct Token {
/// constructs such as f-string interpolations can recover expression
/// spans without losing provenance during preprocessing.
pub raw: Option<String>,
/// Authored-source provenance. Tokens produced by macro or script
/// expansion carry the use-site span: the expanded text does not exist
/// in authored source, so the invocation is the only honest position.
pub span: Span,
/// The position the indentation-sensitive parser reads for layout
/// decisions. It equals `span` for authored tokens; expanded tokens keep
/// the expansion's relative line and column structure here so blocks and
/// statement boundaries still resolve (#506).
pub(crate) layout: Span,
}

impl Token {
Expand All @@ -86,6 +94,7 @@ impl Token {
text: text.into(),
raw: None,
span,
layout: span,
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/opy-rs/src/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ impl SettingsLowerer {
file: u32,
origin: crate::diag::Position,
) -> OpyResult<HirExpr> {
let expression = crate::parser::parse_expression_fragment(text, file, origin)?;
let expression = crate::parser::parse_expression_fragment(text, file, origin, None)?;
self.lowerer.errors.clear();
self.lowerer.texture_used = false;
let lowered = self
Expand Down
28 changes: 22 additions & 6 deletions crates/opy-rs/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ impl<'a> Parser<'a> {
if token.kind == TokenKind::Newline {
let entries = newline_lines.entry(token.span.file).or_default();
let line = token
.span
.layout
.start
.line
.max(entries.last().map_or(0, |&(_, max)| max));
Expand Down Expand Up @@ -522,7 +522,7 @@ impl Parser<'_> {
self.error_at_current("expected an indented block".to_string());
return None;
}
let indent = self.peek().span.start.col;
let indent = self.peek().layout.start.col;
if indent <= line_indent {
self.error_at_current("expected an indented block after ':'".to_string());
return None;
Expand All @@ -543,7 +543,7 @@ impl Parser<'_> {
let continued_line = self
.tokens
.get(self.pos.saturating_sub(1))
.is_some_and(|previous| self.peek().span.start.line > previous.span.end.line);
.is_some_and(|previous| self.peek().layout.start.line > previous.layout.end.line);
self.last_statement_continued = continued_line;
if matches!(self.peek_kind(), TokenKind::Newline | TokenKind::Eof) || continued_line {
Ok(())
Expand All @@ -554,19 +554,24 @@ impl Parser<'_> {
}
}

/// Parse one f-string expression fragment and shift its local token spans
/// into the original source file.
/// Parse one expression fragment and shift its local token spans into the
/// original source file. `bounds` limits the authored provenance of the
/// fragment's tokens: inside an expanded string the interpolation has no
/// authored extent, so derived positions clamp into the string's span
/// rather than fabricating columns past it (#506).
pub(crate) fn parse_expression_fragment(
text: &str,
file: u32,
origin: Position,
bounds: Option<Span>,
) -> Result<Expr, OpyError> {
let mut tokens = crate::lexer::lex(crate::lexer::LexInput {
file_id: file,
text,
})?;
for token in &mut tokens {
token.span = shift_span(token.span, origin);
token.layout = shift_span(token.span, origin);
token.span = bounds.map_or(token.layout, |bounds| bounded_span(token.layout, bounds));
}
let mut parser = Parser::new(&tokens, false);
let expression = parser.parse_expr().map_err(|()| {
Expand All @@ -592,6 +597,17 @@ fn shift_span(span: Span, origin: Position) -> Span {
)
}

pub(crate) fn bounded_span(span: Span, bounds: Span) -> Span {
if span.file != bounds.file {
return span;
}
Span::new(
span.file,
span.start.clamp(bounds.start, bounds.end),
span.end.clamp(bounds.start, bounds.end),
)
}

fn is_string_modifier(text: &str) -> bool {
matches!(text, "f" | "w" | "l" | "b" | "c" | "t")
}
Expand Down
6 changes: 3 additions & 3 deletions crates/opy-rs/src/parser/declarations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,14 @@ impl Parser<'_> {
Err(()) => return false,
};
let Ok(body_indent) =
self.expect_block_indent(start.span.start.col, "':' after the enum name")
self.expect_block_indent(start.layout.start.col, "':' after the enum name")
else {
return false;
};
let mut members = Vec::new();
loop {
self.skip_newlines();
if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col < body_indent {
if self.peek_kind() == TokenKind::Eof || self.peek().layout.start.col < body_indent {
break;
}
if self.peek_kind() == TokenKind::Ident {
Expand Down Expand Up @@ -202,7 +202,7 @@ impl Parser<'_> {
args.insert(0, "self".to_string());
}
let Ok(body_indent) =
self.expect_block_indent(start.span.start.col, "':' after the macro signature")
self.expect_block_indent(start.layout.start.col, "':' after the macro signature")
else {
return false;
};
Expand Down
6 changes: 3 additions & 3 deletions crates/opy-rs/src/parser/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ impl Parser<'_> {
.max(name_token_span.start.col + 1),
),
);
let line_indent = start.span.start.col;
let line_indent = start.layout.start.col;
if self
.expect_block_indent(line_indent, "':' after the rule name")
.is_err()
Expand All @@ -69,7 +69,7 @@ impl Parser<'_> {
let mut actions = Vec::new();
loop {
self.skip_newlines();
if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col < body_indent {
if self.peek_kind() == TokenKind::Eof || self.peek().layout.start.col < body_indent {
break;
}
if self.peek_kind() == TokenKind::At {
Expand Down Expand Up @@ -350,7 +350,7 @@ impl Parser<'_> {
return false;
}
let Ok(body_indent) =
self.expect_block_indent(start.span.start.col, "':' after the subroutine signature")
self.expect_block_indent(start.layout.start.col, "':' after the subroutine signature")
else {
return false;
};
Expand Down
45 changes: 30 additions & 15 deletions crates/opy-rs/src/parser/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ impl Parser<'_> {
) -> Result<Expr, ()> {
self.skip_expression_newlines();
let then_value = self.parse_or()?;
// The statement-boundary heuristic compares virtual layout lines on
// both sides; `then_value`'s last token is the one just consumed, and
// its provenance span would collapse every expanded token to the
// macro use site (#506 re-review).
if self.is_ident("if")
&& !self.inside_delimiter_group()
&& self.peek().span.start.line != then_value.span().end.line
&& self.newline_after_line(then_value.span().file, then_value.span().end.line)
&& self.peek().layout.start.line != self.tokens[self.pos - 1].layout.end.line
&& self.newline_after_line(
self.tokens[self.pos - 1].layout.file,
self.tokens[self.pos - 1].layout.end.line,
)
{
return Ok(then_value);
}
Expand Down Expand Up @@ -697,16 +704,19 @@ impl Parser<'_> {
self.errors.push(OpyError::at(
"parse-error",
"f-string interpolation cannot be empty".to_string(),
Span::new(
string_span.file,
Position::new(
string_span.start.line,
string_span.start.col + index as u32 + 1,
),
Position::new(
string_span.start.line,
string_span.start.col + end as u32 + 1,
crate::parser::bounded_span(
Span::new(
string_span.file,
Position::new(
string_span.start.line,
string_span.start.col + index as u32 + 1,
),
Position::new(
string_span.start.line,
string_span.start.col + end as u32 + 1,
),
),
string_span,
),
));
return Err(());
Expand All @@ -715,10 +725,15 @@ impl Parser<'_> {
string_span.start.line,
string_span.start.col + index as u32 + 1,
);
let parsed = parse_expression_fragment(&expression, string_span.file, origin)
.map_err(|error| {
self.errors.push(error);
});
let parsed = parse_expression_fragment(
&expression,
string_span.file,
origin,
Some(string_span),
)
.map_err(|error| {
self.errors.push(error);
});
let Ok(parsed) = parsed else {
return Err(());
};
Expand Down
Loading
Loading