Skip to content

add-target breaks resulting package when target name is not valid Swift identifier #7764

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

dmhts
Copy link
Contributor

@dmhts dmhts commented Jul 8, 2024

Executing swift package add-target my-target --type executable breaks the updated package because the executable target name is not a valid Swift identifier.

Motivation:

After executing swift package add-target my-target --type executable, I expect the generated stub to not break the modified package. However, in this case, the resulting stub file would contain invalid Swift code:

@main
struct my-targetMain {
    static func main() {
        print("Hello, world")
    }
}

The same issue applies to macro and test target types. It's also important to note that dash case is quite often used for target names, like e.g. in swift-argument-parser.

Additionaly, when AddTarget is consumed via an API (as opposed to the CLI), the following parsing error is thrown into stdout:

Screenshot 2024-07-08 at 08 40 51

Modifications:

I believe there are at least two ways to solve this issue:

  1. Use a predefined type name in the generated stub, such as struct ExampleMain, so it doesn't depend on the target name.
  2. Introduce a sanitizer function to ensure that the target name is always a valid Swift identifier. For example, a function that would convert "$my-target-name%" into "myTargetName".

For the sake of simplicity, I opted for approach #1 in the proposed PR. However, if it's necessary to preserve target names in the generated stubs, we can certainly go with approach #2.

Result:

swift package add-target my-target --type executable

@main
struct ExampleMain {
    static func main() {
        print("Hello, world")
    }
}

Comment on lines 452 to 453
func testAddExecutableTargetWithNameAsInvalidSwiftIdentifier() throws {
try assertManifestRefactor("""
Copy link
Contributor Author

@dmhts dmhts Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce the amount of duplicated code in tests, we could also use a "non-valid Swift identifier target name" instead of "MyProgram" in the original testAddExecutableTargetWithDependencies test (the same goes for testAddSwiftTestingTestTargetWithNameAsInvalidSwiftIdentifier test below).

Alternatively, we could extend the assertManifestRefactor function to handle multiple editing operations at once, allowing us to add multiple targets with different names within a single test.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of using a "non-valid Swift identifier target name" in existing tests, because it gives us better coverage without duplication. We also often create new tests from old ones, so it'll help us make sure we cover this case whenever we are introducing a new test (without always introducing two tests).

Copy link
Contributor Author

@dmhts dmhts Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points! I updated the tests. One thing though, the only special characters I've ever seen used for target names are dashes and spaces. It’s still based on my anecdotal experience, but I’m not sure if we need to overcomplicate the tests with every possible unlikely input.

@@ -234,10 +234,10 @@ public struct AddTarget {
case .macro:
"""
\(imports)
struct \(raw: target.name): Macro {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about using c99name instead?

Copy link
Contributor Author

@dmhts dmhts Jul 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a very good point. c99name would certainly cover most edge cases, such as using dash-case, special characters, and digits in the first character. Well, it's already used for language-level target name sanitizing so it must be robust enough for this use case.

The only remaining concern is that c99 (obviously) doesn't cover Swift's reserved words. However, in that case, we could "just" escape the reserved word based on a list taken e.g. from swift-syntax, which SwiftPM already depends on.

I believe combining these two safeguards should be a good enough way to proceed. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just visiting. :) There may be a better way to avoid reserved names—the package owners probably have ideas!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright :) @DougGregor, maybe you have some ideas about this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of your ideas together (cc9name and using the swift-syntax keyword checking) sound reasonable to me.

@DougGregor
Copy link
Member

@swift-ci please test

}

fileprivate extension String {
func capitalizingFirstLetter() -> String { prefix(1).uppercased() + dropFirst() }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using both prefix(1) and dropFirst() is absolutely safe with strings of any length, including empty ones, as both are concerned with a maximum number of characters to process, not a minimum.

Copy link
Contributor Author

@dmhts dmhts Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've decided to introduce capitalizingFirstLetter() instead of using the existing capitalized() function because the latter has an undesirable by-design behavior. It lowercases all remaining letters in a word, so "MyNewShinyTarget" would be turned into "Mynewshinytarget". This makes it a rather poor option for camel-case notation, which is the most popular convention for target names, from my experience.

On the other hand, capitalizing only the first letter would turn "my-new-shiny-target" into "My_new_shiny_target" instead of the potentially preferred "My_New_Shiny_Target" (which capitalized() would produce), but using underscores would be a non-preferred Swift naming convention anyway. Therefore I would consider it a very minor downside (if a downside at all) given that we're talking about example names here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, worth taking a moment to write some unit tests where the macro name does not use the English Latin alphabet. For instance, does the French "être" correctly capitalize to "Être"? Does the Japanese "マクロ" stay unmodified? These are trivial test cases but worth adding.

More complex to solve would be something like the "Turkish I" capitalization problem (uppercase "i" in Turkish is "İ", not "I", and lowercase "I" is "ı".) Should this code attempt to take the user's localization settings into consideration?

Copy link
Contributor Author

@dmhts dmhts Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's a good point again. I switched to localizedCapitalized instead of uppercased(), which addresses all the cases mentioned above while taking the current user's locale into account. Interestingly, the case with the Turkish "İ" is specifically covered in Foundation :) I'm surprised that the tests there are not as rigorous as expected.

I'd love to write more tests that cover localization edge cases. However, this would require some refactoring of the test helpers (as I mentioned here) if we want to avoid significant duplication in tests.
An alternative approach could be to use the Turkish "I" in the existing test target names to confirm that at least some localization is taken into account. However, that would require some additional test setup to manipulate Locale.current.

At the same time, what we have might already be good enough for this specific use case, as the main goal of this PR was to prevent broken code in modified packages in the first place.

@DougGregor I'd also like to hear your opinion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly I'm not sure Locale.current reports anything other than en_US for a command-line tool like SwiftPM anyway. 🤔

Copy link
Contributor Author

@dmhts dmhts Aug 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just checked, and Locale.current correctly reports en_DE in my case, which is aligned with the system settings:

defaults read ~/Library/Preferences/.GlobalPreferences AppleLocale
en_DE

At the same time, the system locale utility reports the default en_US, as you said:

locale
LANG="en_US.UTF-8"

Meaning, String.localizedCapitalized should work as expected in the CLI context.

Comment on lines 419 to 423
var sanitizedName: String {
name
.spm_mangledToC99ExtendedIdentifier()
.capitalizingFirstLetter()
}
Copy link
Contributor Author

@dmhts dmhts Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the Keyword enum turned out to be not that trivial for this task, as there are many exceptions due to the keyword's context-specific nature, namely:

  • objc, escaping and some others are considered keywords only when they are used in combination with the leading @ character.
  • The same goes for available and selector but in combination with the # character
  • metadata and module - I'm not even sure in which context they would be considered actual language keywords.
  • etc.

As simple solutions are generally better, I preferred to use c99name in combination with a capitalization of the first letter. With the capitalization strategy, we can feed two birds with one scone:

  • It forces example type names to follow the overall type naming convention: "sanitizer" -> "struct Sanitizer"
  • Reserved words are turned into valid identifiers when capitalized: "macro" -> "struct Macro"

I believe the combination of these two safeguards covers all known edge cases (to the best of my knowledge).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, I missed the discussion above. We actually added isValidSwiftIdentifier in swift-syntax for the purpose you described above. Having said that, IMO c99name + uppercase is probably good enough here anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks! That's really nice to know.

@@ -538,33 +538,33 @@ class ManifestEditTests: XCTestCase {
)
""",
expectedAuxiliarySources: [
RelativePath("Sources/MyMacro/MyMacro.swift") : """
RelativePath("Sources/MyMacro target-name/MyMacro target-name.swift") : """
Copy link
Contributor Author

@dmhts dmhts Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though spaces work for file names, we could consider sanitizing them too.

@dmhts
Copy link
Contributor Author

dmhts commented Aug 23, 2024

@DougGregor just a friendly reminder when you have time. I believe this PR can be already merged once the CI tests have passed.

Sidenote: I've been using the new package editing API quite a lot in my own project, and I've come across a few other bugs that I'd be glad to fix in follow-up PRs. For example, sanitizing imports, avoiding the use of product names in imports (only targets can be imported), etc.

I could also address some FIXMEs, such as plugin and resources support, as I currently have to resort to workarounds to bypass them.

@dschaefer2
Copy link
Member

@swift-ci please test

@dschaefer2
Copy link
Member

Looks good. Sorry about the delay.

@DougGregor
Copy link
Member

@DougGregor just a friendly reminder when you have time. I believe this PR can be already merged once the CI tests have passed.

Thank you for the ping, and I'm sorry I lost track of this. Your changes look great to me and we'll get them merged once CI passes. Thank you!

@dmhts
Copy link
Contributor Author

dmhts commented Aug 23, 2024

I just fixed some conflicts that popped up only during the tests (which succeeded). Next time, I'll trust GitHub's UI less and merge main beforehand.

@dschaefer2
Copy link
Member

@swift-ci please test

@dschaefer2
Copy link
Member

@swift-ci please test windows

@dmhts
Copy link
Contributor Author

dmhts commented Aug 23, 2024

--- bootstrap: error: Command '['env', 'SDKROOT=/...' returned non-zero exit status 1.`

It seems to be a CI issue :(

@dschaefer2
Copy link
Member

@swift-ci please test

@dschaefer2
Copy link
Member

--- bootstrap: error: Command '['env', 'SDKROOT=/...' returned non-zero exit status 1.`

It seems to be a CI issue :(

Yup CI is down at the moment. We're trying to get it back.

@dschaefer2
Copy link
Member

I should say working to get it back. I think we've identified the issue. I'll retrigger the tests once I know we're clear.

@dschaefer2
Copy link
Member

@swift-ci please test

@DougGregor
Copy link
Member

All clear, merging! Thank you @dmhts

@DougGregor DougGregor merged commit 742157a into swiftlang:main Aug 25, 2024
5 checks passed
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.

5 participants