Skip to content
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

Update Project Name Validation #1570

Merged
merged 3 commits into from
Mar 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 11 additions & 61 deletions lib/scala/pkg/src/main/scala/org/enso/pkg/Package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import cats.Show

import scala.jdk.CollectionConverters._
import org.enso.filesystem.FileSystem
import org.enso.pkg.validation.NameValidation

import scala.io.Source
import scala.util.{Failure, Try, Using}
Expand Down Expand Up @@ -202,7 +203,7 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
maintainers: List[Contact] = List()
): Package[F] = {
val config = Config(
name = normalizeName(name),
name = NameValidation.normalizeName(name),
version = version,
ensoVersion = ensoVersion,
license = "",
Expand Down Expand Up @@ -281,90 +282,39 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
existing.getOrElse(create(root, generateName(root)))
}

/** Checks if a character is allowed in a project name.
*
* @param char the char to validate
* @return `true` if it's allowed, `false` otherwise
*/
private def isAllowedNameCharacter(char: Char): Boolean = {
char.isLetterOrDigit || char == '_'
}

/** Takes a name containing letters, digits, and `_` characters and makes it
* a proper `Upper_Snake_Case` name.
*
* @param string the input string
* @return the transformed string
*/
private def toUpperSnakeCase(string: String): String = {
val beginMarker = '#'
val chars = string.toList
val charPairs = (beginMarker :: chars).zip(chars)
charPairs
.map { case (previous, current) =>
if (previous == beginMarker) {
current.toString
} else if (previous.isLower && current.isUpper) {
s"_$current"
} else if (previous.isLetter && current.isDigit) {
s"_$current"
} else if (previous == '_' && current == '_') {
""
} else if (previous.isDigit && current.isLetter) {
s"_${current.toUpper}"
} else {
current.toString
}
}
.mkString("")
}

/** Transforms the given string into a valid package name (i.e. a CamelCased identifier).
*
* @param name the original name.
* @return the transformed name conforming to the specification.
*/
def normalizeName(name: String): String = {
val startingWithLetter =
if (name.length == 0 || !name(0).isLetter) "Project_" ++ name else name
val startingWithUppercase = startingWithLetter.capitalize
val onlyAlphanumeric = startingWithUppercase.filter(isAllowedNameCharacter)
toUpperSnakeCase(onlyAlphanumeric)
}

/** Generates a name for the package, by normalizing the last segment of its root path.
*
* @param file the root location of the package.
* @return the generated package name.
*/
def generateName(file: F): String = {
val dirname = file.getName
normalizeName(dirname)
NameValidation.normalizeName(dirname)
}
}

object PackageManager {
val Default = new PackageManager[File]()(FileSystem.Default)

/** A general exception indicating that a package cannot be loaded.
*/
/** A general exception indicating that a package cannot be loaded. */
class PackageLoadingException(message: String, cause: Throwable)
extends RuntimeException(message, cause) {

/** @inheritdoc
*/
/** @inheritdoc */
override def toString: String = message
}

/** The error indicating that the requested package does not exist.
*/
/** The error indicating that the requested package does not exist. */
case class PackageNotFound()
extends PackageLoadingException(s"The package file does not exist.", null)

/** The error indicating that the package exists, but cannot be loaded.
*/
/** The error indicating that the package exists, but cannot be loaded. */
case class PackageLoadingFailure(message: String, cause: Throwable)
extends PackageLoadingException(message, cause)

/** The error indicating that the project name is invalid. */
case class InvalidNameException(message: String)
extends RuntimeException(message)
}

/** A companion object for static methods on the [[Package]] class.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.enso.pkg.validation

/** Base trait for a project name validation errors. */
sealed trait InvalidNameError
object InvalidNameError {

/** Indicates that project name is empty. */
case object Empty extends InvalidNameError

/** Indicates that project name should start with a capital letter. */
case object ShouldStartWithCapitalLetter extends InvalidNameError

/** Indicates that projet name contains invalid characters.
*
* @param invalidCharacters the list of invalid characters
*/
case class ContainsInvalidCharacters(invalidCharacters: Set[Char])
extends InvalidNameError

/** Indicates that project name should be in upper shane case.
*
* @param validName initial project name rewritten in upper snake case
*/
case class ShouldBeUpperSnakeCase(validName: String) extends InvalidNameError
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.enso.pkg.validation

import scala.collection.immutable.ListSet

object NameValidation {

/** Transforms the given string into a valid package name (i.e. a CamelCased identifier).
*
* @param name the original name.
* @return the transformed name conforming to the specification.
*/
def normalizeName(name: String): String = {
val startingWithLetter =
if (name.isEmpty || !name.head.isLetter) "Project_" ++ name else name
val startingWithUppercase = startingWithLetter.capitalize
val onlyAlphanumeric = startingWithUppercase.filter(isAllowedNameCharacter)
toUpperSnakeCase(onlyAlphanumeric)
}

/** Validate the project name.
*
* @param name the project name to validate
* @return either a validation error or a project name if it's valid
*/
def validateName(name: String): Either[InvalidNameError, String] =
if (name.isEmpty) {
Left(InvalidNameError.Empty)
} else if (!name.head.isLetter || !name.head.isUpper) {
Left(InvalidNameError.ShouldStartWithCapitalLetter)
} else if (!name.forall(isAllowedNameCharacter)) {
val invalidCharacters = name.filterNot(isAllowedNameCharacter)
Left(
InvalidNameError.ContainsInvalidCharacters(
ListSet(invalidCharacters: _*)
)
)
} else if (name != toUpperSnakeCase(name)) {
Left(InvalidNameError.ShouldBeUpperSnakeCase(toUpperSnakeCase(name)))
} else {
Right(name)
}

/** Checks if a character is allowed in a project name.
*
* @param char the char to validate
* @return `true` if it's allowed, `false` otherwise
*/
private def isAllowedNameCharacter(char: Char): Boolean = {
char.isLetterOrDigit || char == '_'
}

/** Takes a name containing letters, digits, and `_` characters and makes it
* a proper `Upper_Snake_Case` name.
*
* @param string the input string
* @return the transformed string
*/
private def toUpperSnakeCase(string: String): String = {
val beginMarker = '#'
val chars = string.toList
val charPairs = (beginMarker :: chars).zip(chars)
charPairs
.map { case (previous, current) =>
if (previous == beginMarker) {
current.toString
} else if (previous.isLower && current.isUpper) {
s"_$current"
} else if (previous.isLetter && current.isDigit) {
s"_$current"
} else if (previous == '_' && current == '_') {
""
} else if (previous.isDigit && current.isLetter) {
s"_${current.toUpper}"
} else {
current.toString
}
}
.mkString("")
}

}
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
package org.enso.pkg

import java.io.File

import org.enso.filesystem.FileSystem
import org.enso.pkg.validation.{InvalidNameError, NameValidation}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec

import scala.collection.immutable.ListSet

class NameSanitizationSpec extends AnyWordSpec with Matchers {
"Creating a new project" should {
"sanitize the name of the project" in {
implicit val fileSystem: FileSystem[File] = FileSystem.Default

val manager = new PackageManager()
"Name Validation" should {

"sanitize the name of the project" in {
normalizeName("My_Project") shouldEqual "My_Project"
normalizeName("My___Project") shouldEqual "My_Project"
normalizeName("myProject") shouldEqual "My_Project"
normalizeName("myPro??^ject123") shouldEqual "My_Project_123"
normalizeName("???%$6543lib") shouldEqual "Project_6543_Lib"
}

manager.normalizeName("My_Project") shouldEqual "My_Project"
manager.normalizeName("My___Project") shouldEqual "My_Project"
manager.normalizeName("myProject") shouldEqual "My_Project"
manager.normalizeName("myPro??^ject123") shouldEqual "My_Project_123"
manager.normalizeName("???%$6543lib") shouldEqual "Project_6543_Lib"
"validate the project name" in {
validateName("My") shouldEqual Right("My")
validateName("My_Project") shouldEqual Right("My_Project")
validateName("") shouldEqual Left(InvalidNameError.Empty)
validateName("My___Project") shouldEqual Left(
InvalidNameError.ShouldBeUpperSnakeCase("My_Project")
)
validateName("FooBar") shouldEqual Left(
InvalidNameError.ShouldBeUpperSnakeCase("Foo_Bar")
)
validateName("myProject") shouldEqual Left(
InvalidNameError.ShouldStartWithCapitalLetter
)
validateName("MyPro??^ject123") shouldEqual Left(
InvalidNameError.ContainsInvalidCharacters(ListSet('?', '^'))
)
}
}

private def normalizeName(name: String): String =
NameValidation.normalizeName(name)

private def validateName(name: String): Either[InvalidNameError, String] =
NameValidation.validateName(name)
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class ProjectFileRepository[
override def exists(
name: String
): F[ProjectRepositoryFailure, Boolean] =
getAll().map(_.exists(_.name == PackageManager.Default.normalizeName(name)))
getAll().map(_.exists(_.name == name))

/** @inheritdoc */
override def find(
Expand Down Expand Up @@ -165,11 +165,10 @@ class ProjectFileRepository[

private def renamePackage(
projectPath: File,
name: String
newName: String
): F[ProjectRepositoryFailure, Unit] =
getPackage(projectPath)
.flatMap { projectPackage =>
val newName = PackageManager.Default.normalizeName(name)
Sync[F]
.blockingOp { projectPackage.rename(newName) }
.map(_ => ())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,37 @@ package org.enso.projectmanager.service

import cats.MonadError
import cats.implicits._
import org.enso.projectmanager.service.ValidationFailure.{
EmptyName,
NameContainsForbiddenCharacter
}
import org.enso.pkg.validation.{InvalidNameError, NameValidation}

/** MTL implementation of the project validator.
*/
/** MTL implementation of the project validator. */
class MonadicProjectValidator[F[_, _]](implicit
M: MonadError[F[ValidationFailure, *], ValidationFailure]
) extends ProjectValidator[F] {

import M._

private val validCharSpec: Char => Boolean = { char =>
char.isLetterOrDigit || char == '_' || char == '-'
}

/** Validates a project name.
*
* @param name the project name
* @return either validation failure or success
*/
override def validateName(name: String): F[ValidationFailure, Unit] =
checkIfNonEmptyName(name) *> checkCharacters(name)

private def checkIfNonEmptyName(name: String): F[ValidationFailure, Unit] =
if (name.trim.isEmpty) {
raiseError(EmptyName)
} else {
unit
M.fromEither {
NameValidation
.validateName(name)
.leftMap(toValidationFailure)
.map(_ => ())
}

private def checkCharacters(name: String): F[ValidationFailure, Unit] = {
val forbiddenChars = name.toCharArray.filterNot(validCharSpec).toSet
if (forbiddenChars.isEmpty) unit
else raiseError(NameContainsForbiddenCharacter(forbiddenChars))
}
/** Convert project name error to validation error. */
private def toValidationFailure(error: InvalidNameError): ValidationFailure =
error match {
case InvalidNameError.Empty =>
ValidationFailure.EmptyName
case InvalidNameError.ShouldStartWithCapitalLetter =>
ValidationFailure.NameShouldStartWithCapitalLetter
case InvalidNameError.ContainsInvalidCharacters(invalidCharacters) =>
ValidationFailure.NameContainsForbiddenCharacter(invalidCharacters)
case InvalidNameError.ShouldBeUpperSnakeCase(validName) =>
ValidationFailure.NameShouldBeUpperSnakeCase(validName)
}

}
Loading