@@ -6,6 +6,7 @@ module Spago.Command.Init
66 , InitOptions
77 , defaultConfig
88 , defaultConfig'
9+ , folderToPackageName
910 , pursReplFile
1011 , run
1112 , srcMainTemplate
@@ -14,8 +15,10 @@ module Spago.Command.Init
1415
1516import Spago.Prelude
1617
18+ import Data.Array (mapMaybe )
1719import Data.Map as Map
1820import Data.String as String
21+ import Data.String.Utils as StringUtils
1922import Registry.PackageName as PackageName
2023import Registry.Version as Version
2124import Spago.Config (Dependencies (..), SetAddress (..), Config )
@@ -106,19 +109,30 @@ run opts = do
106109 getPackageName :: Spago (InitEnv a ) PackageName
107110 getPackageName = do
108111 { rootPath } <- ask
112+ -- When the user explicitly provides a name, validate it directly and show the actual error.
113+ -- When deriving from directory name, use folderToPackageName which sanitizes and gives a generic error.
109114 let
110- candidateName = case opts.mode of
111- InitWorkspace { packageName: Nothing } -> String .take 150 $ Path .basename rootPath
112- InitWorkspace { packageName: Just n } -> n
113- InitSubpackage { packageName: n } -> n
114- logDebug [ Path .quote rootPath, " \" " <> candidateName <> " \" " ]
115- pname <- case PackageName .parse (PackageName .stripPureScriptPrefix candidateName) of
116- Left err -> die
117- [ toDoc " Could not figure out a name for the new package. Error:"
118- , Log .break
119- , Log .indent2 $ toDoc err
120- ]
121- Right p -> pure p
115+ explicitName = case opts.mode of
116+ InitWorkspace { packageName: Just n } -> Just n
117+ InitSubpackage { packageName: n } -> Just n
118+ InitWorkspace { packageName: Nothing } -> Nothing
119+ pname <- case explicitName of
120+ Just n -> case PackageName .parse (PackageName .stripPureScriptPrefix n) of
121+ Left err -> die
122+ [ toDoc " Could not figure out a name for the new package. Error:"
123+ , Log .break
124+ , Log .indent2 $ toDoc err
125+ ]
126+ Right p -> pure p
127+ Nothing -> do
128+ let candidateName = String .take 150 $ Path .basename rootPath
129+ case folderToPackageName candidateName of
130+ Nothing -> die
131+ [ " Could not derive a valid package name from directory " <> Path .quote rootPath <> " ."
132+ , " Please use --name to specify a package name."
133+ ]
134+ Just p -> pure p
135+ logDebug [ Path .quote rootPath, PackageName .print pname ]
122136 logDebug [ " Got packageName and setVersion:" , PackageName .print pname, unsafeStringify opts.setVersion ]
123137 pure pname
124138
@@ -299,3 +313,59 @@ foundExistingDirectory dir = "Found existing directory " <> Path.quote dir <> ",
299313
300314foundExistingFile :: LocalPath -> String
301315foundExistingFile file = " Found existing file " <> Path .quote file <> " , not overwriting it"
316+
317+ -- SANITIZATION -----------------------------------------------------------------
318+
319+ -- | Convert a folder name to a valid package name.
320+ -- | We try to convert as much Unicode as possible to ASCII (through NFD normalisation),
321+ -- | and otherwise strip out and/or replace non-alpanumeric chars with dashes.
322+ -- | After all this work that is still not enough to guarantee a successful PackageName
323+ -- | parse, so this is still a Maybe.
324+ folderToPackageName :: String -> Maybe PackageName
325+ folderToPackageName input =
326+ input
327+ # String .toLower
328+ -- NFD normalization decomposes accented chars (é → e + combining accent)
329+ -- so the base ASCII letter is preserved when we filter non-ASCII later
330+ # StringUtils .normalize' StringUtils.NFD
331+ # String .toCodePointArray
332+ # mapMaybe sanitizeCodePoint
333+ # String .fromCodePointArray
334+ # collapseConsecutiveDashes
335+ # stripLeadingTrailingDashes
336+ # PackageName .stripPureScriptPrefix
337+ # PackageName .parse
338+ # hush
339+ where
340+ dash = String .codePointFromChar ' -'
341+
342+ -- Transform each codepoint:
343+ -- - ASCII lowercase (a-z) and digits (0-9): keep as-is
344+ -- - Apostrophes and quotes: remove (shouldn't create word boundaries)
345+ -- - Other ASCII: convert to dash (word boundaries)
346+ -- - Non-ASCII (combining marks from NFD, etc.): remove
347+ sanitizeCodePoint cp
348+ | isAsciiLower cp || isAsciiDigit cp = Just cp
349+ | isRemovable cp = Nothing
350+ | isAscii cp = Just dash
351+ | otherwise = Nothing
352+
353+ isAsciiLower cp = cp >= String .codePointFromChar ' a' && cp <= String .codePointFromChar ' z'
354+ isAsciiDigit cp = cp >= String .codePointFromChar ' 0' && cp <= String .codePointFromChar ' 9'
355+ isAscii cp = cp <= String .codePointFromChar ' \x7F '
356+ -- ASCII apostrophe and quote shouldn't create word boundaries (Tim's → tims, not tim-s)
357+ isRemovable cp = cp == String .codePointFromChar ' \' ' || cp == String .codePointFromChar ' "'
358+
359+ -- Collapse consecutive dashes into one
360+ collapseConsecutiveDashes str =
361+ case String .indexOf (String.Pattern " --" ) str of
362+ Nothing -> str
363+ Just _ -> collapseConsecutiveDashes $ String .replaceAll (String.Pattern " --" ) (String.Replacement " -" ) str
364+
365+ -- Remove all leading and trailing dashes
366+ stripLeadingTrailingDashes str =
367+ case String .stripPrefix (String.Pattern " -" ) str of
368+ Just stripped -> stripLeadingTrailingDashes stripped
369+ Nothing -> case String .stripSuffix (String.Pattern " -" ) str of
370+ Just stripped -> stripLeadingTrailingDashes stripped
371+ Nothing -> str
0 commit comments