Skip to content

fix: reject names the expression parser cannot read (#2062) - #2064

Merged
jcschaff merged 3 commits into
masterfrom
fix/ascii-identifier-names
Sep 4, 2026
Merged

fix: reject names the expression parser cannot read (#2062)#2064
jcschaff merged 3 commits into
masterfrom
fix/ascii-identifier-names

Conversation

@jcschaff

@jcschaff jcschaff commented Sep 4, 2026

Copy link
Copy Markdown
Member

Fixes #2062.

What was wrong

Name validation and the expression grammar disagreed on what an identifier is:

  • validation used Character.isJavaIdentifierStart/Part, which accept any Unicode letter
  • Parser.jjt defines #LETTER as ["a"-"z", "_", "A"-"Z"]ASCII only

So a species named PROTEÍNA_A was accepted, saved, and then failed math generation with
Parse Error while parsing expression 'PROTEÍNA_A'"Application has no generated Math".
The error names an expression, so nothing pointed at the species. Three Contact-Us crash
reports from one Spanish-language model.

The root cause is narrower than the issue says

Model.validateNamingConflicts already required a legal identifier and already produced a
good message with a suggested replacement:

if(!newSymbolName.equals(TokenMangler.fixTokenStrict(newSymbolName))){
    throw new ModelPropertyVetoException(... " not legal identifier, try '" + ... + "'.", e);
}

The guard was correct in shape. It never fired because fixTokenStrict used
Character.isLetterOrDigit, so it considered Í a letter and returned the name unchanged. The
manglers that exist precisely to turn external names into legal identifiers were passing
non-ASCII letters straight through.

I've updated the issue with this correction.

The fix

  1. TokenMangler gains one definition of the rule — isValidExpressionIdentifier /
    indexOfFirstIllegalIdentifierChar — and fixToken / fixTokenStrict now use it.
    Behaviour is unchanged for any name that is already ASCII, i.e. every name these manglers
    previously handled correctly.
  2. Model.validateNamingConflicts asks that predicate directly, so the existing guard now
    works for every model symbol, not just species contexts. This is what actually fixes the
    reported bug.
  3. SpeciesContext.vetoableChange gets the same rule, and its message now states what a
    name may contain instead of only naming the offending character.

Keeping already-saved models openable

Models saved with such a name are unrunnable today, but they do open — and opening them is
the user's only way to rename the species. Enforcing the rule on the read path would have taken
that away, turning "opens but won't run" into "won't open", which is worse than the bug.

Reads from VCML and from the database therefore go through
SpeciesContext.fromPersistedContent / Model.setRestoringFromPersistedContent, which relax
the lexicon check for the duration of the read only — naming conflicts are still checked. The
illegal name is then reported by SpeciesContext.gatherIssues as an error the user can act on.

I found this the right way: the round-trip test failed, which is also how I found the
Model-level guard I'd missed on the first pass.

Tests

New SpeciesContextNameTest (10 tests, Fast). The central one asserts the rule and the parser
agree by actually parsing — every accepted name must parse as a single identifier equal to
itself, every rejected name must not — so tightening or loosening either side alone fails here.
That is the check whose absence let the two drift apart.

It also covers the real round trip (a VCML holding an unreadable name still loads, keeps the
name, and is reported as an error issue) and records one thing worth knowing: . is the single
character the grammar accepts inside an IDENTIFIER — as a name-scope qualifier,
(<ID> ".")* <ID> — that a name still may not contain.

Verification

  • SpeciesContextNameTest — 10/10
  • vcell-core Fast — 606 tests, 1 error, and that one is the documented Poetry/VCellDataTest
    environmental failure, present before this change
  • vcell-util Fast 42/42 · vcell-math Fast 6545/6545 · vcell-client Fast 31/31
  • full mvn compile test-compile across all modules — clean

vcell-server Fast was still running locally when I stopped it; every class it had reported was
green. Worth watching the SBML/SEDML regression suites in the merge queue, since re-arming the
mangler could change how an imported model with non-ASCII names is renamed on import — that is
the intended behaviour, but it is the one place the blast radius extends past the reported bug.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx

jcschaff and others added 3 commits September 4, 2026 12:29
Name validation and the expression grammar disagreed on what an identifier is.
Validation used Character.isJavaIdentifierStart/Part, which accept any Unicode
letter; Parser.jjt defines LETTER as ["a"-"z", "_", "A"-"Z"]. A name such as
PROTEINA_A with an accent was therefore accepted and saved, and then failed to
parse during math generation.

Add isValidExpressionIdentifier / indexOfFirstIllegalIdentifierChar as the single
definition of the rule, and switch fixToken and fixTokenStrict to it. Those two
manglers exist to turn externally supplied names into legal identifiers, but
inherited the same Unicode-wide notion of a letter and so passed non-ASCII
letters through untouched. Behaviour is unchanged for any name that is already
ASCII, which is every name the manglers previously handled correctly.

Refs #2062

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
…ls loadable

Model.validateNamingConflicts already required a symbol name to be a legal
identifier and already produced a good message with a suggested replacement. It
never fired for a non-ASCII name only because the mangler it compared against
treated any Unicode letter as a letter. It now asks TokenMangler directly, so
the guard covers every model symbol, not just species contexts.

SpeciesContext.vetoableChange gets the same rule, and its message now states
what a name may contain rather than only naming the offending character.

The catch is models already saved with such a name. They are unrunnable today,
but they do open, and opening them is the user's only way to rename the species.
Enforcing the rule on the read path would have taken that away, so reads from
VCML and from the database go through SpeciesContext.fromPersistedContent and
Model.setRestoringFromPersistedContent, which relax the lexicon check for the
duration of the read only - naming conflicts are still checked. The illegal name
is then reported by SpeciesContext.gatherIssues as an error the user can act on,
instead of surfacing at math-generation time as a parse error that names an
expression rather than the species.

Fixes #2062

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
The two definitions drifted apart once and nothing caught it, so the central
test asserts agreement by actually parsing: every name the rule accepts must
parse as a single identifier equal to itself, and every name it rejects must
not. Tightening or loosening either side alone now fails here.

Also covers the round trip that matters in practice - a VCML holding a name the
parser cannot read still loads, keeps the name as saved, and is reported as an
error issue - and records that '.' is the one character the grammar accepts in
an IDENTIFIER (as a name-scope qualifier) that a name still may not contain.

Refs #2062

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D71LBYmQNf5J94wPqr81Jx
@jcschaff
jcschaff merged commit 059a7c4 into master Sep 4, 2026
9 checks passed
@jcschaff
jcschaff deleted the fix/ascii-identifier-names branch September 4, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Species/compartment names accept non-ASCII letters that the expression parser cannot read, leaving the application with no generated math

1 participant