Skip to content

experiment: model MusicXML ids with an api::Id type - #409

Open
webern wants to merge 2 commits into
claude/musicxml-id-attributes-api-rfi9qvfrom
claude/musicxml-api-id-type
Open

experiment: model MusicXML ids with an api::Id type#409
webern wants to merge 2 commits into
claude/musicxml-id-attributes-api-rfi9qvfrom
claude/musicxml-api-id-type

Conversation

@webern

@webern webern commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Merges into #401, not into main. This is the "what would it look like" experiment for replacing the bare std::optional<std::string> id with a type.

The problem

An id attribute is not any old string. MusicXML requires it to be an XML name: a letter or an underscore first, then letters, digits, dots, hyphens, or underscores.

On #401 as it stands, mx enforces that at the last possible moment. mx::impl hands the caller's text to core::Token on the way out, core::Token scrubs it, and the file gets an id the caller never asked for and never hears about:

note.id = "3 blind mice";   // the file gets id="blindmice"

The change

Every id member in mx::api becomes a std::optional<Id>.

api::Id holds a core::Token behind a pimpl. That is the point of the design: the text is scrubbed once, when the Id is built, and from then on mx::impl hands mx::core the token itself. A core::Token cannot hold a malformed id, so there is no second conversion left to make. Id.cpp is now the only place in mx where an api id string becomes a token.

The public header names no core type. IdAccess, in src/private/mx/api/IdAccess.h, is the only door between an Id and its token, and it is not part of the public api.

What a caller sees

Scrubbing is silent, so Id documents the check rather than inventing an error channel:

const auto id = Id{myText};
if (id.value() != myText)
{
    // myText was not a legal id; decide what to do about it
}

Uniqueness is unchanged. It is a property of the whole score rather than of one name, so Id cannot check it (#397).

Value semantics

Id copies, assigns, compares, sorts, and hashes like any other value. std::hash is specialized for it, so an Id works as a key in both std::map and std::unordered_map. Copies share one immutable token instead of allocating another, and a moved-from Id still holds a token, so reading one is harmless rather than undefined.

Scope

All 43 id members are converted, so the api has one way to say this and not three:

  • the 25 added by feat: expose musicxml id attributes in mx::api #401;
  • the 16 that already shipped as std::optional<std::string> (DampData, EyeglassesData, HarpPedalsData, ImageData, MarkDataChoice, OtherDirectionData, PercussionData, PrincipalVoiceData, ScordaturaData, StaffDivideData, StringMuteData, TempoData, AccordionRegistrationData);
  • SegnoData and CodaData, which held a std::string id plus an isIdSpecified flag. Both flags are gone, along with their equality-block lines.

The reader and writer sites that hand-rolled the id attribute now go through getId / setId alongside the rest.

PartData::uniqueId and InstrumentData::uniqueId are left alone. They are the required part-id join between <score-part> and <part>, not the optional-unique-id attribute this change is about.

Two things worth a look

  • Id{""} is "X". There is no nearest legal id for empty text, so it gets core::Token's natural zero. To write no id attribute, leave the std::optional empty. This is not new behavior — feat: expose musicxml id attributes in mx::api #401 already writes id="X" for an unusable string — but the type makes it visible.
  • The reader no longer reports what the file literally said. A file containing id="3 mice" reaches the api as mice. By doctrine that is right (an invalid document should be hard to express, and this is documented normalization), but it is a real change and worth naming.

Gates

Run natively with MX_RUNNING_IN_DOCKER=1; the Docker daemon is not available in this environment.

Gate Result
make fmt-check passed
make api-test 6050 assertions in 546 test cases
make core-roundtrip-test 839 test cases
unit tests 221 assertions in 44 test cases
make api-roundtrip 366 passed, 0 failed (of 366 pinned)
unity build (BATCH_SIZE=0) built

make wasm-test was not run locally (no Emscripten here); CI covers it.

New tests in IdAttributeApiTest.cpp cover scrubbing, the compare-your-input check, a scrubbed id surviving a round trip, and the value semantics including both map kinds. The existing segnoAndCodaRoundTrip test in DirectionWriterTest.cpp compares whole objects, so it covers the two converted ids through the equality block.

webern added 2 commits August 20, 2026 13:58
An id attribute is not any old string. It has to follow the XML name rules, and
until now mx enforced that only at the last moment, inside mx::impl, by handing
the text to core::Token on the way out. The caller never learned that their id
had changed.

Replace std::optional<std::string> id with std::optional<api::Id> on all 41 api
id members. api::Id holds a core::Token behind a pimpl, so the text is scrubbed
once, as the Id is built, and mx::impl passes the token itself to mx::core. No
second conversion is possible. Callers who need to know whether their text was
legal compare Id::value() with what they passed in.

Id copies, assigns, compares, sorts, and hashes like a plain value. std::hash is
specialized for it, so an Id works as a key in std::map and std::unordered_map.
Copies share one immutable token instead of allocating, and a moved-from Id
still holds a token, so reading one is harmless.

IdAccess (private to mx) is the only door between Id and its token. The public
header names no core type.

The id explanation moves from ApiCommon.h to Id.h, where the type it describes
lives. SegnoData and CodaData keep their legacy std::string id plus
isIdSpecified; those belong with the rest of issue 249.
These two were the last api types spelling an id as a std::string plus an
isIdSpecified flag. Every other id in mx::api is now a std::optional<api::Id>,
so leaving these on the old pattern would mean two ways to say one thing.

Replace the pair with std::optional<Id>, drop isIdSpecified from the equality
block, and route the reader and writer through getId and setId like the rest.

The api-level direction test now covers the <segno> and <coda> ids alongside the
other direction-type ids.
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.

1 participant