From 6e1cfd78103dce4950ad1c94f3c8f605c1183b45 Mon Sep 17 00:00:00 2001 From: "Trevor L. Davis" Date: Sun, 26 Apr 2026 22:12:55 -0700 Subject: [PATCH] feat: Support recursive macros Macro values are now expanded at state-creation time via `expand_macro_values()`, so a macro whose value references another macro is fully resolved before any move is processed. Circular references produce an error. Co-Authored-By: Claude Sonnet 4.6 --- DESCRIPTION | 2 +- NEWS.md | 1 + R/default_parser.R | 2 +- R/parse_moves.R | 33 +++++++++++++++++++++++++++++++-- tests/testthat/_snaps/ppn.md | 8 ++++++++ tests/testthat/test_ppn.R | 26 ++++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 4 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 4f72975..6b6414f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -2,7 +2,7 @@ Encoding: UTF-8 Package: ppn Type: Package Title: Portable Piecepack Notation Parser -Version: 0.3.0-1 +Version: 0.3.0-2 Authors@R: c(person("Trevor L.", "Davis", role=c("aut", "cre"), email="trevor.l.davis@gmail.com", comment = c(ORCID = "0000-0001-6341-4639"))) diff --git a/NEWS.md b/NEWS.md index 51c062f..857544d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,7 @@ ppn 0.3.0 (development) New features ------------ +* Macros may now reference other macros (recursive macros). Macro values are fully expanded at parse time; circular references produce an error. * We now export (and document their arguments) the following movetext parsers: + `piecepack_parser()` diff --git a/R/default_parser.R b/R/default_parser.R index 8e0d2e2..90a86e3 100644 --- a/R/default_parser.R +++ b/R/default_parser.R @@ -195,7 +195,7 @@ create_state <- function(df, metadata = list()) { } as.environment(list( df_move_start = df, - macros = c(metadata$Macros, attr(df, "macros"), macros), + macros = expand_macro_values(c(metadata$Macros, attr(df, "macros"), macros)), max_id = nrow(df), active_id = character(), scale_factor = as.numeric(scale_factor) diff --git a/R/parse_moves.R b/R/parse_moves.R index b8d3584..7bb7230 100644 --- a/R/parse_moves.R +++ b/R/parse_moves.R @@ -1170,10 +1170,39 @@ get_y <- function(coords) { } } +expand_macro_values <- function(macros) { + max_iter <- 100L + for (i in seq_len(max_iter)) { + changed <- FALSE + for (name in names(macros)) { + value <- macros[[name]] + ml <- str_locate_all(value, "`[^']+'")[[1L]] + if (nrow(ml) == 0L) { + next + } + for (r in rev(seq_len(nrow(ml)))) { + ref <- str_sub(value, ml[r, 1L] + 1L, ml[r, 2L] - 1L) + if (!is.null(macros[[ref]])) { + str_sub(value, ml[r, 1L], ml[r, 2L]) <- macros[[ref]] + changed <- TRUE + } + } + macros[[name]] <- value + } + if (!changed) { + break + } + if (i == max_iter) { + abort("Circular macro reference detected", class = "circular_macro") + } + } + macros +} + replace_macros <- function(df, text, state = create_state(df)) { - ml <- str_locate_all(text, "`[^']+'")[[1]] + ml <- str_locate_all(text, "`[^']+'")[[1L]] for (r in rev(seq_len(nrow(ml)))) { - mtext <- str_sub(text, ml[r, 1] + 1, ml[r, 2] - 1) + mtext <- str_sub(text, ml[r, 1L] + 1L, ml[r, 2L] - 1L) if (is.null(state$macros[[mtext]])) { abort(paste("Macro", mtext, "is unknown"), class = "identify_macro") } diff --git a/tests/testthat/_snaps/ppn.md b/tests/testthat/_snaps/ppn.md index 2487b46..14bc07a 100644 --- a/tests/testthat/_snaps/ppn.md +++ b/tests/testthat/_snaps/ppn.md @@ -337,3 +337,11 @@ +# Setup and GameType work as expected + + Code + default_parser(c("1. `a'@b2"), meta_circular) + Condition + Error in `expand_macro_values()`: + ! Circular macro reference detected + diff --git a/tests/testthat/test_ppn.R b/tests/testthat/test_ppn.R index 8d767fe..a26ca89 100644 --- a/tests/testthat/test_ppn.R +++ b/tests/testthat/test_ppn.R @@ -908,6 +908,32 @@ test_that("Setup and GameType work as expected", { expect_equal(df$suit, 1) expect_equal(df$piece_side, "pawn_face") + # macro expanding to multiple submoves (e.g. chess castling) + meta_castle <- list( + GameType = "International Chess", + Macros = list("O-O" = "e1-g1 h1-f1") + ) + g_castle <- default_parser(c("1. `O-O'"), meta_castle) + df_before <- g_castle$dfs[[1]] + df_after <- g_castle$dfs[[2]] + king_id <- df_before$id[near(df_before$x, 5) & near(df_before$y, 1)] + rook_id <- df_before$id[near(df_before$x, 8) & near(df_before$y, 1)] + expect_equal(df_after$x[df_after$id == king_id], 7) + expect_equal(df_after$x[df_after$id == rook_id], 6) + + # recursive macro: macro value references another macro + meta_recursive <- list(Macros = list(sun = "S", sp = "`sun'p")) + df_recursive <- default_parser(c("1. `sp'@b2"), meta_recursive)$dfs[[2]] + expect_equal(df_recursive$piece_side, "pawn_face") + expect_equal(df_recursive$suit, 1L) + + # circular macro reference is an error + meta_circular <- list(Macros = list(a = "`b'", b = "`a'")) + expect_snapshot( + error = TRUE, + default_parser(c("1. `a'@b2"), meta_circular) + ) + none1 <- "" df1 <- read_ppn(textConnection(none1))[[1]]$dfs[[1]] expect_equal(nrow(df1), 0)