Skip to content

Commit

Permalink
Update Project Name Validation (#1570)
Browse files Browse the repository at this point in the history
PR moves all the project validation logic to the 
`org.enso.pkg.validation` module and updates the 
project manager to use the logic from the new module
  • Loading branch information
4e6 authored Mar 12, 2021
1 parent 7f461e4 commit 13c9939
Show file tree
Hide file tree
Showing 12 changed files with 345 additions and 153 deletions.
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

0 comments on commit 13c9939

Please sign in to comment.