diff --git a/fava_client.py b/fava_client.py index 053ed0d..eaed06b 100644 --- a/fava_client.py +++ b/fava_client.py @@ -60,11 +60,21 @@ def _escape_beancount_string(value: str) -> str: ) +# Beancount's DATE token (parser/lexer.l): (17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ +# — '-' OR '/' separators, 1+ digit month/day. Inter-token whitespace is any +# run of [ \t\r] (ignored by the lexer). The duplicate-detection regex must +# mirror this, or a validly-formatted existing Open (e.g. '2024/3/5 open X' or +# '2020-01-01 open X') escapes detection and a duplicate Open is appended, +# which bean-check then rejects — breaking every later write. +_OPEN_DATE = r"(?:17|18|19|20)\d\d[-/]\d+[-/]\d+" + + def _open_directive_exists(source: str, account_name: str) -> bool: """Return True if `source` already contains an Open directive for exactly `account_name`. - Anchored to a real `YYYY-MM-DD open ` directive line (re.MULTILINE) + Anchored to a real ` open ` directive line (re.MULTILINE), + with `` and the inter-token whitespace matching Beancount's grammar, so the account name can't match text inside another account's description metadata or a comment (false positive → spurious 409). The trailing negative-lookahead `(?![\\w:-])` requires the next char not to be an @@ -72,12 +82,11 @@ def _open_directive_exists(source: str, account_name: str) -> bool: - a prefix (Expenses:Gas) does not match a longer sibling (Expenses:GasStation / Expenses:Gas:Vehicle), and - a real directive with an inline comment and no space - (`open Expenses:Gas;legacy`) is still detected (`;` ends the name), - which the previous `(?:\\s|$)` boundary missed → duplicate write. + (`open Expenses:Gas;legacy`) is still detected (`;` ends the name). """ return bool( re.search( - rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(account_name)}(?![\w:-])", + rf"^{_OPEN_DATE}[ \t]+open[ \t]+{re.escape(account_name)}(?![\w:-])", source, re.MULTILINE, ) diff --git a/tests/test_unit.py b/tests/test_unit.py index 4f97b03..1c7dabc 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -84,6 +84,22 @@ def test_open_directive_exists_does_not_match_deeper_child(): assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False +@pytest.mark.parametrize( + "line", + [ + "2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D + "2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D + "2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces + "2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators + "1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained + ], +) +def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line): + # All of these are valid Beancount Open directives per lexer.l's DATE token + # and ignored inter-token whitespace; each must be detected as existing. + assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True + + # --------------------------------------------------------------------------- # beancount_format.sanitize_link # ---------------------------------------------------------------------------