{{ message }}
Repository navigation
Support non-ASCII identifiers following UAX #31 - #6968
Merged
Merged
Conversation
Identifiers were limited to [A-Za-z_$][A-Za-z0-9_]*, and anything else was
rejected by the lexer without a message. This adds an opt-in identifier
syntax behind --enable=unicode-identifiers:
ID_Start := (XID_Start + { U+0024, U+005F }) & Identifier_Status=Allowed
ID_Continue := XID_Continue & Identifier_Status=Allowed
XID_Start/XID_Continue is the UAX #31 default that C++23 and Rust use. The
intersection with the UTS #39 general security profile removes code points
that are invisible, obsolete, or canonical duplicates; plain XID does not
cover this, since variation selectors are Mn and therefore XID_Continue.
Identifiers are normalised to NFC before they are validated, so that
canonically equivalent spellings name the same variable. Validating the
normalised form also accepts the canonical singletons U+2126, U+212A and
U+212B, which the UTS #39 profile excludes precisely because NFC folds them.
Encoding the code point ranges as UTF-8 byte patterns in the flex rules grows
the scanner tables by more than an order of magnitude, so the rules match any
non-ASCII sequence and the classification happens in the action, as a binary
search over a generated table. The tables are generated from the UCD by
scripts/generate-unicode-identifier-tables.py and checked in, so there is no
build-time dependency on the Unicode data files.
The editor's lexertl rules are widened to match, otherwise a non-ASCII byte
splits an identifier into differently styled tokens.
Fixes #3736.
t-paul
approved these changes
Aug 30, 2026
t-paul
left a comment
Member
There was a problem hiding this comment.
Very nice 🎉. Thanks for the clean solution. Basing this on the official Unicode config should make it future proof.
t-paul
pushed a commit
that referenced
this pull request
Aug 31, 2026
Identifiers reaching the AST are NFC, because the lexer normalises them. Parameter set files and the -P command line argument do not go through the lexer and carry whatever normalisation their author used, so comparing the two can fail on canonically equivalent spellings. Nothing reports it: ParameterObjects::importValues() falls back to the design default, and openscad.cc applies no set at all, both without a message. An export driven by -p/-P can therefore produce the wrong dimensions and still exit zero. This cannot happen with a file OpenSCAD wrote itself, since those keys come from the AST. It needs a parameter set produced elsewhere - a generator script, a hand edit, or text taken from a macOS source, where the filesystem hands out NFD. Add normalize_utf8_nfc() to core/str_utf8_wrapper.h, next to the other UTF-8 helpers, using the g_utf8_normalize() glib already provides. ASCII returns without conversion or allocation, which covers every existing parameter set. A string containing a NUL byte is passed through untouched, because g_utf8_normalize() returns a NUL terminated buffer and normalising one would truncate it, which could collapse two distinct names into one lookup key. ParameterSet::setName() normalises rather than each of its callers, so a set name is NFC whether it came from a file or from the customizer. Parameter names are map keys with no such choke point, so they are normalised where ParameterSets::readFile() reads them, and openscad.cc normalises the -P argument before comparing. Also add the <ios> include UnicodeIdentifier.cc needs for std::hex and std::uppercase, which include-cleaner flags. Follow-up to #6968.
gsohler
added a commit
to pythonscad/pythonscad
that referenced
this pull request
Sep 7, 2026
* Rename CGALWorker to GeometryWorker The class has not been CGAL-specific for some time: it evaluates the tree through whichever backend is selected, and Manifold is the default. The name now points at the wrong one. The member in MainWindow is renamed to match -- leaving it as cgalworker would have kept the stale name in the place it is actually read. Also initialises three members that were left indeterminate: GeometryWorker's tree pointer, and MainWindow's compileErrors and compileWarnings. Each is assigned before use today, so this changes no behaviour; it just stops that being something a reader has to verify. No functional change. No test expectations were touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Normalise parameter set names to NFC Identifiers reaching the AST are NFC, because the lexer normalises them. Parameter set files and the -P command line argument do not go through the lexer and carry whatever normalisation their author used, so comparing the two can fail on canonically equivalent spellings. Nothing reports it: ParameterObjects::importValues() falls back to the design default, and openscad.cc applies no set at all, both without a message. An export driven by -p/-P can therefore produce the wrong dimensions and still exit zero. This cannot happen with a file OpenSCAD wrote itself, since those keys come from the AST. It needs a parameter set produced elsewhere - a generator script, a hand edit, or text taken from a macOS source, where the filesystem hands out NFD. Add normalize_utf8_nfc() to core/str_utf8_wrapper.h, next to the other UTF-8 helpers, using the g_utf8_normalize() glib already provides. ASCII returns without conversion or allocation, which covers every existing parameter set. A string containing a NUL byte is passed through untouched, because g_utf8_normalize() returns a NUL terminated buffer and normalising one would truncate it, which could collapse two distinct names into one lookup key. ParameterSet::setName() normalises rather than each of its callers, so a set name is NFC whether it came from a file or from the customizer. Parameter names are map keys with no such choke point, so they are normalised where ParameterSets::readFile() reads them, and openscad.cc normalises the -P argument before comparing. Also add the <ios> include UnicodeIdentifier.cc needs for std::hex and std::uppercase, which include-cleaner flags. Follow-up to openscad#6968. * Extract MainWindow::resetCompileMessageCounts() (openscad#6994) Preparation for running geometry evaluation in a separate process, where a second caller resets these counters on the path that receives a worker's result. Extracting it now keeps that later change to the one line that needs it. * Bump github/codeql-action in the github-actions group (openscad#7002) Bumps the github-actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.37.3 to 4.37.9 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](github/codeql-action@v4.37.3...v4.37.9) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update German translation (openscad#7006) * Make sure boost headers are correctly installed (openscad#7003) * Add an "experimental, may change or be removed without notice" warning to Preferences/Features. Fixes openscad#7008. * Default to UTF-8. * Quiet unsolicited info level log message from Qt multimedia plugin. * Update translation template (closes openscad#7007). * Stop marking internal values as translatable (openscad#7024) Some strings extracted into locale/openscad.pot are not user interface text. Translating them changes what gets written to the settings or what is compared against them, and nothing warns a translator about it. * Update translation template for openscad#7024 changes. (openscad#7026) * Rename Preferences "Features" tab to "Experimental". (openscad#7020) * Fix use-after-free when a MainWindow is destroyed (openscad#7005) Closing a window crashes in MainWindow::onEditorDockVisibilityChanged(): MainWindow::~MainWindow QWidget::~QWidget -> QObjectPrivate::deleteChildren Dock::~Dock -> QDockWidget::~QDockWidget QWidget::setParent -> QWidgetPrivate::hide_helper visibilityChanged -> MainWindow::onEditorDockVisibilityChanged MainWindow::updateExportActions MainWindow::formatIdentifierToAction exportMap.find() <- already destroyed The docks are children of the MainWindow, so ~QWidget deletes them, and deleting a QDockWidget reparents and hides it, which emits visibilityChanged and runs the window's event filter. Both land back in MainWindow after its own members have been destroyed: member destruction happens right after the ~MainWindow body, while Qt only severs connections in ~QObject, which runs last. The slot then reads a destroyed exportMap and a dangling activeEditor; MainWindow::eventFilter() reads a destroyed rubberBandManager. Disconnect and un-filter the docks in the destructor body, while the object is still whole. Claude-Session: https://claude.ai/code/session_01Wm1jGSWf57JvbSJ6i6gxTQ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jordan Brown <31949071+jordanbrown0@users.noreply.github.com> Co-authored-by: Marius Kintel <marius@kintel.net> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: whosawhatsis <whosawhatsis@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Torsten Paul <Torsten.Paul@gmx.de> Co-authored-by: otzie2023 <otzelot2021@outlook.de> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ótzï fón GlüϷgor <141450226+Otzie2023@users.noreply.github.com> Co-authored-by: Marius Kintel <marius@kintel.net> Co-authored-by: Jordan Brown <31949071+jordanbrown0@users.noreply.github.com> Co-authored-by: Daniel Jalkut <jalkut@red-sweater.com> Co-authored-by: Guenther Sohler <guenther.sohler@photeon.com>
Otzie2023
pushed a commit
to Otzie2023/openscad
that referenced
this pull request
Oct 9, 2026
Restore the Unicode lexer validation and NFC normalization from openscad#6968 and normalize -P parameter-set names as in openscad#7000. Keep unicode-identifiers available in non-experimental builds, enabled by default, and allow an explicit --disable=unicode-identifiers or Features preference opt-out. Preserve existing saved preferences and accept the existing --enable flag. Disable requests take precedence over enable requests. Restore integration coverage for Unicode identifiers, security-profile rejection, NFC/NFD parameter names and set selection, and opt-out behavior.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Support non-ASCII identifiers following UAX #31
Fixes #3736. Closes #737 as a side effect (see below).
Identifiers are currently limited to
[A-Za-z_$][A-Za-z0-9_]*; anything else isrejected by the lexer without a message. This adds an opt-in identifier syntax
based on Unicode Standard Annex #31, behind
--enable=unicode-identifiers.The character set
XID_Start/XID_Continueis the UAX #31 default, and the same grammar C++23(P1949R7) and Rust 1.53 use.
U+0024 DOLLAR SIGNhas to be added explicitlybecause it is
Sc, notXID, and OpenSCAD needs it for$fn,$faand therest;
U+005F LOW LINEis added for compatibility with the current syntax.The intersection with
Identifier_Status=Allowed, the general security profileof UTS #39, is the part that goes beyond C++ and Rust's grammar. It addresses
the objection raised in this issue directly: plain XID does not keep invisible
characters out, because variation selectors U+FE00–FE0F are
Mnand thereforein
XID_Continue. The UTS #39 profile removes them, along with obsoletescripts, technical symbols, and canonical duplicates.
Emoji, ZWJ/ZWNJ, and mathematical operators (∑ ∏ ∫ ∂ ∇ √ ∞ °) are outside the
set. The Greek letters people usually reach for — π, Δ, Σ, Ω, θ, λ, φ — are in
XID_Startalready, so most of the "special math symbols" wish from the 2021comment is covered without a bespoke allowlist.
Normalisation
Identifiers are normalised to NFC in the lexer, and validated after
normalisation. P1949 instead makes non-NFC source ill-formed; normalising seemed
the better fit here, because
äas U+00E4 and as U+0061 U+0308 areindistinguishable in the editor and in a diff, and most OpenSCAD files are
edited in the bundled editor rather than in a toolchain that can enforce NFC on
save. Rust made the same call.
Validating the normalised form also accepts the canonical singletons: U+2126
OHM SIGN, U+212A KELVIN SIGN and U+212B ANGSTROM SIGN are excluded from the UTS
#39 profile precisely because NFC folds them into U+03A9, U+004B and U+00C5, so
after normalisation they are ordinary identifier characters and
Ωtyped eitherway is one variable. Roughly 1,000 code points fall into that category,
including the composition exclusions in Devanagari, Bengali and Gurmukhi that
several input methods emit by default.
The generator verifies that the profile is closed under NFC before writing the
tables, so normalising an accepted identifier can never turn it into a rejected
one.
g_utf8_normalize()comes from glib, which is already a required dependency.The lexer tables
The 2021 discussion stalled on the scanner tables blowing up. That happens when
the code point ranges are encoded as UTF-8 byte patterns in the rules — Unicode
16 has 767
XID_Startand 1400XID_Continueranges, which expand to hundredsof byte alternatives each. Measured against this tree with flex 2.6.4:
The last row does not compile with flex's defaults at all (
Definition value for {XIDS} too long), and after splitting the pattern across 61 definitions it uses12,502 of the 13,000 default NFA slots.
This PR keeps Unicode out of the DFA. The rules match any non-ASCII sequence
using a variant of the
{UNICODE}macro that was already inlexer.l, and theclassification happens in the action as a binary search over a generated table:
The exclusions carved out of
{UNICODE}are U+00A0 and U+FEFF, whichlexer.ltreats as whitespace; without that,
a<NBSP>= 1would lex as a single token andnbsp-utf8-test.scadwould break.The ASCII rule
{IDSTART}{IDREST}*is kept unchanged and placed before the newone. Both match a pure-ASCII identifier equally long, so flex resolves the tie
in favour of the earlier rule and ASCII input never reaches the new code path —
no runtime check needed.
0x1F,1aand the deprecated digit-leading form areunaffected.
Because
{UNICODEID}is a coarse approximation, it also accepts overlong forms,surrogates and values above U+10FFFF; the action validates the encoding with
g_utf8_validate()before normalising. Malformed input now reports why it wasrejected instead of failing silently, which is an improvement even for people
who never write a non-ASCII identifier.
The blanket
{UNICODE}reject rule inINITIALbecomes unreachable and isremoved — flex confirms this with
rule cannot be matchedif it is left in.Tables
src/core/UnicodeIdentifierTables.his generated and checked in, the way GCCdoes with
ucnid.h, so there is no build-time dependency on the UCD.scripts/generate-unicode-identifier-tables.pyregenerates it fromDerivedCoreProperties.txtandIdentifierStatus.txt; it refuses to run if thetwo files are from different Unicode versions. Current tables: Unicode 16.0.0,
305 start ranges and 380 continue ranges, 5,480 bytes. ASCII is deliberately not
tabulated.
Editor highlighting
src/gui/ScadLexer.ccneeds a matching change. The editor uses a separatelexertl-based lexer whose identifier rules are ASCII-only, so a non-ASCII byte
splits an identifier into several tokens with different styles:
The second line is the visible one: the tail of
$wandstärkefalls back fromthe special-variable colour to the ordinary variable colour. The rules now
accept any byte outside ASCII:
That is deliberately coarser than the grammar in
lexer.l— the editor lexeronly drives highlighting, and the parser reports the code points that are not
actually allowed. One cosmetic consequence:
a<NBSP>= 1now highlights as asingle identifier token, though it still parses as two.
Other code paths
I traced the other places that produce identifier names:
-D/--Dis appended to the source text (openscad.cc) and goes through thenormal lexer, so it is covered.
assignment->getName(), i.e. from theAST, so they are covered too.
customizer/comment_lexer.lonly lexes annotation bodies; the parameter nameis matched by source location, so it needs no change.
ParameterSet::readFile()uses parameter names as JSON keys and could see anon-NFC key from an older file or an external tool. Not addressed here, since
it cannot happen until non-ASCII identifiers are actually in use — happy to
add it to this PR if you'd prefer.
Identifiers are plain
std::stringthroughLookup,AssignmentandContext::lookup_variable, so normalising at lex time means nothing downstreamchanges.
Issue #737
µm = 0.001 * mm;from 2014 now producesParser error: Identifier cannot start with U+00B5instead of failing silently.U+00B5 MICRO SIGN is
Restrictedin UTS #39 because it is confusable withU+03BC GREEK SMALL LETTER MU and NFC does not fold the two.
μmwith the Greekletter works. This is also the worked example in Rust's
uncommon_codepointslint.
Not included
as a warning, along the lines of Rust's
confusable_identsandmixed_script_confusables, but it is separable and should not gate thegrammar change.
Feature flag
Everything is behind
Feature::ExperimentalUnicodeIdentifiers. With the flagoff, the only change in behaviour is that a non-ASCII identifier now says
Non-ASCII identifiers are experimental, enable them with --enable=unicode-identifiersinstead of failing without a message. Dropping thegate later is a one-line change.
Tests
src/core/UnicodeIdentifier_test.cc— 33 Catch2 assertions covering theaccepted set, NFC folding in both directions, the canonical singletons, the
rejected classes, and malformed UTF-8.
tests/data/scad/misc/unicode-identifiers.scad— echo test with--enable=unicode-identifiers, including a variable defined precomposed andread decomposed.
tests/data/scad/misc/unicode-identifiers-fail.scad— the#737case, as anexpected parse failure.
Built and run against this tree on Linux with GCC 15.2, Qt 6.10.2,

QScintilla 2.14.1, CGAL 6.1.1, Boost 1.90