diff --git a/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp b/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp index 1a722bbaf70..ea9425be4f0 100644 --- a/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp +++ b/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp @@ -774,11 +774,8 @@ void ContentLibraryView::exportLib3DComponent(const ModelNode &node) QString ContentLibraryView::nodeNameToComponentFileName(const QString &name) const { - QString fileName = UniqueName::generateId(name); - if (fileName.isEmpty()) - fileName = "Component"; - else - fileName[0] = fileName.at(0).toUpper(); + QString fileName = UniqueName::generateId(name, "Component"); + fileName[0] = fileName.at(0).toUpper(); fileName.prepend("My"); return fileName + ".qml"; diff --git a/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.cpp b/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.cpp index 7a487c451ac..e22ab6cb985 100644 --- a/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.cpp +++ b/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.cpp @@ -5,8 +5,6 @@ #include -#include - #include #include @@ -16,22 +14,55 @@ using namespace Qt::Literals; namespace { -QString toCamelCase(const QString &input) +bool isAsciiLetter(QChar c) { - QString result = input.at(0).toLower(); + return (c >= u'A' && c <= u'Z') || (c >= u'a' && c <= u'z'); +} + +bool isValidLetter(QChar c) +{ + return c == u'_' || c.isDigit() || isAsciiLetter(c); +} + +QString filterInvalidLettersAndCapitalizeAfterInvalidLetter(QStringView id) +{ + QString result; bool capitalizeNext = false; - - for (const QChar &c : Utils::span{input}.subspan(1)) { - bool isValidChar = c.isLetterOrNumber() || c == '_'; - if (isValidChar) + for (const QChar &c : id) { + if (isValidLetter(c)) { result += capitalizeNext ? c.toUpper() : c; - - capitalizeNext = !isValidChar; + capitalizeNext = false; + } else { + capitalizeNext = true; + } } return result; } +void lowerFirstLetter(QString &id) +{ + if (id.size()) + id.front() = id.front().toLower(); +} + +void prependUnderscoreIfBanned(QString &id) +{ + if (id.size() && (id.front().isDigit() || ModelUtils::isBannedQmlId(id))) + id.prepend(u'_'); +} + +QString toValidId(QStringView id) +{ + QString validId = filterInvalidLettersAndCapitalizeAfterInvalidLetter(id); + + lowerFirstLetter(validId); + + prependUnderscoreIfBanned(validId); + + return validId; +} + } // namespace /** @@ -114,30 +145,47 @@ QString generatePath(const QString &path) * @brief Generates a unique ID based on the provided id * * This works similar to get() with additional restrictions: - * - Removes non-Latin1 characters - * - Removes spaces + * - Removes all characters except A-Z, a-z, 0-9, and underscore. * - Ensures the first letter is lowercase - * - Converts spaces to camel case + * - Converts to camel case by making the following character of an invalid character uppercase. * - Prepends an underscore if id starts with a number or is a reserved word * * @param id The original id to be made unique. + * @param predicate Called with a new version of generated id until predicate returns true. * @return A unique Id (when predicate() returns false) */ -QString generateId(const QString &id, std::function predicate) +QString generateId(QStringView id, std::function predicate) { - if (id.isEmpty()) - return {}; + QString newId = toValidId(id); + if (!predicate || newId.isEmpty()) + return newId; - // remove non word (non A-Z, a-z, 0-9) or space characters - QString newId = id.trimmed(); + return UniqueName::generate(newId, predicate); +} - newId = toCamelCase(newId); +/** + * @brief Generates a unique ID based on the provided id + * + * This works similar to get() with additional restrictions: + * - Removes all characters except A-Z, a-z, 0-9, and underscore. + * - Ensures the first letter is lowercase + * - Converts to camel case by making the following character of an invalid character uppercase. + * - Prepends an underscore if id starts with a number or is a reserved word + * + * @param id The original id to be made unique. + * @param fallbackId This is used when the id is empty or contains only invalid chars. + * @param predicate Called with a new version of generated id until predicate returns true. + * @return A unique Id (when predicate() returns false) + */ +QString generateId(QStringView id, + const QString &fallbackId, + std::function predicate) +{ + QString newId = toValidId(id); + if (newId.isEmpty()) + newId = fallbackId; - // prepend _ if starts with a digit or invalid id (such as reserved words) - if (newId.at(0).isDigit() || ModelUtils::isBannedQmlId(newId)) - newId.prepend('_'); - - if (!predicate) + if (newId.isEmpty() || !predicate) return newId; return UniqueName::generate(newId, predicate); diff --git a/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.h b/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.h index 85927c45147..20bcacefdd1 100644 --- a/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.h +++ b/src/plugins/qmldesigner/designercore/designercoreutils/uniquename.h @@ -11,7 +11,11 @@ namespace QmlDesigner::UniqueName { QString generate(const QString &name, std::function predicate); QString generatePath(const QString &path); -QMLDESIGNERCORE_EXPORT QString generateId(const QString &id, + +QMLDESIGNERCORE_EXPORT QString generateId(QStringView id, + std::function predicate = {}); +QMLDESIGNERCORE_EXPORT QString generateId(QStringView id, + const QString &fallbackId, std::function predicate = {}); } // namespace QmlDesigner::UniqueName diff --git a/src/plugins/qmldesigner/designercore/model/model.cpp b/src/plugins/qmldesigner/designercore/model/model.cpp index cbfa695da63..6b4ddcc7327 100644 --- a/src/plugins/qmldesigner/designercore/model/model.cpp +++ b/src/plugins/qmldesigner/designercore/model/model.cpp @@ -1890,12 +1890,7 @@ bool Model::hasImport(const QString &importUrl) const QString Model::generateNewId(const QString &prefixName, const QString &fallbackPrefix) const { - QString newId = prefixName; - - if (newId.isEmpty()) - newId = fallbackPrefix; - - return UniqueName::generateId(prefixName, [&] (const QString &id) { + return UniqueName::generateId(prefixName, fallbackPrefix, [&](const QString &id) { // Properties of the root node are not allowed for ids, because they are available in the // complete context without qualification. return hasId(id) || d->rootNode()->property(id.toUtf8()); diff --git a/tests/unit/tests/unittests/designercoreutils/uniquename-test.cpp b/tests/unit/tests/unittests/designercoreutils/uniquename-test.cpp index c1caa346fa5..5967936713e 100644 --- a/tests/unit/tests/unittests/designercoreutils/uniquename-test.cpp +++ b/tests/unit/tests/unittests/designercoreutils/uniquename-test.cpp @@ -81,6 +81,71 @@ TEST(UniqueName, generateId_prefixes_with_underscore_if_id_is_a_number) ASSERT_THAT(uniqueId, "_436"); } +TEST(UniqueName, generateId_stable_captilzation) +{ + QString id = "A CaMeL*cAsE"; + + QString uniqueId = UniqueName::generateId(id); + + ASSERT_THAT(uniqueId, "aCaMeLCAsE"); +} + +TEST(UniqueName, generateId_begins_with_non_latin) +{ + QString id = "πŸ˜‚_saneId"; + + QString uniqueId = UniqueName::generateId(id); + + ASSERT_THAT(uniqueId, "_saneId"); +} + +TEST(UniqueName, generateId_non_latin_chars) +{ + QString id = "πŸ˜‚1πŸ˜‚testπŸ˜…*chars"; + + QString uniqueId = UniqueName::generateId(id); + + ASSERT_THAT(uniqueId, "_1TestChars"); +} + +TEST(UniqueName, generateId_use_fallback_id) +{ + QString id = "πŸ˜‚πŸ˜‚ πŸ˜…* "; + + QString uniqueId = UniqueName::generateId(id, "validFallbackId"); + + ASSERT_THAT(uniqueId, "validFallbackId"); +} + +TEST(UniqueName, generateId_unused_fallback_id) +{ + QString id = "saneId"; + + QString uniqueId = UniqueName::generateId(id, "fallbackId"); + + ASSERT_THAT(uniqueId, "saneId"); +} + +TEST(UniqueName, generateId_use_emtpy_fallback) +{ + QString id = "πŸ˜‚πŸ˜‚ πŸ˜…* "; + + QString uniqueId = UniqueName::generateId(id, QString{}); + + ASSERT_TRUE(uniqueId.isEmpty()); +} + +TEST(UniqueName, generateId_use_fallback_when_id_exists) +{ + QStringList existingIds = {"validFallbackId", "validFallbackId1"}; + auto pred = [&](const QString &id) -> bool { return existingIds.contains(id); }; + QString id = "πŸ˜‚πŸ˜‚ πŸ˜…* "; + + QString uniqueId = UniqueName::generateId(id, "validFallbackId", pred); + + ASSERT_THAT(uniqueId, "validFallbackId2"); +} + TEST(UniqueName, generatePath_returns_same_path_when_path_doesnt_exist) { QString path = "<<>>";