diff --git a/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt b/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt index e5e941a9bb..208a540982 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.withContext import org.apache.commons.text.StringEscapeUtils import org.greenrobot.eventbus.EventBus import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardOpenOption class FileActionManager { private val scope = CoroutineScope(Dispatchers.IO) @@ -51,4 +54,45 @@ class FileActionManager { } } } + + fun createNewFiles( + baseDir: File, + files: List>, + onResult: (Result>) -> Unit, + ) { + scope.launch { + val result = + try { + Result.success(writeNewFiles(baseDir, files)) + } catch (e: IOException) { + Result.failure(e) + } catch (e: SecurityException) { + Result.failure(e) + } + result.getOrNull()?.forEach { EventBus.getDefault().post(FileCreationEvent(it)) } + withContext(Dispatchers.Main) { onResult(result) } + } + } +} + +internal fun writeNewFiles( + baseDir: File, + files: List>, +): List { + val created = mutableListOf() + try { + for ((path, content) in files) { + val target = File(baseDir, path) + target.parentFile?.let { Files.createDirectories(it.toPath()) } + Files.write(target.toPath(), content.toByteArray(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + created += target + } + } catch (e: IOException) { + created.forEach { it.delete() } + throw e + } catch (e: SecurityException) { + created.forEach { it.delete() } + throw e + } + return created } diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt index 2b0a8398b6..65e3cd340d 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt @@ -18,12 +18,15 @@ package com.itsaky.androidide.actions.filetree import android.content.Context +import android.content.DialogInterface import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.FileActionManager import com.itsaky.androidide.actions.observers.FileActionObserver import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.databinding.LayoutCreateFileCppBinding import com.itsaky.androidide.databinding.LayoutCreateFileJavaBinding import com.itsaky.androidide.eventbus.events.file.FileCreationEvent import com.itsaky.androidide.idetooltips.TooltipTag @@ -37,6 +40,7 @@ import com.itsaky.androidide.utils.ClassBuilder.SourceLanguage import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FileIOUtils +import com.itsaky.androidide.utils.NativeSourceBuilder import com.itsaky.androidide.utils.ProjectWriter import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.flashError @@ -49,6 +53,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.slf4j.LoggerFactory import java.io.File +import java.nio.file.FileAlreadyExistsException import java.util.Objects import java.util.regex.Pattern @@ -81,6 +86,8 @@ class NewFileAction( const val MENU_RES_PATH_REGEX = "/.*/src/.*/res/menu" const val DRAWABLE_RES_PATH_REGEX = "/.*/src/.*/res/drawable" const val JAVA_PATH_REGEX = "/.*/src/.*/java" + const val CPP_PATH_REGEX = "/.*/src/[^/]+/cpp(/.*)?$" + private const val MAX_FILE_NAME_LENGTH = 40 private val log = LoggerFactory.getLogger(NewFileAction::class.java) } @@ -112,8 +119,6 @@ class NewFileAction( val projectDir = IProjectManager.getInstance().projectDirPath Objects.requireNonNull(projectDir) - val isJava = - Pattern.compile(Pattern.quote(projectDir) + JAVA_PATH_REGEX).matcher(file.absolutePath).find() val isRes = Pattern.compile(Pattern.quote(projectDir) + RES_PATH_REGEX).matcher(file.absolutePath).find() val isLayoutRes = @@ -132,9 +137,18 @@ class NewFileAction( .matcher(file.absolutePath) .find() - if (isJava) { - createJavaClass(context, node, file) - return + when (sourceDialogFor(projectDir, file.absolutePath)) { + SourceDialog.CPP -> { + createNativeSource(context, node, file) + return + } + + SourceDialog.JAVA -> { + createJavaClass(context, node, file) + return + } + + null -> {} } if (isLayoutRes && file.name == "layout") { @@ -312,6 +326,123 @@ class NewFileAction( private fun isValidJavaName(s: CharSequence?) = s == null || !SourceVersion.isName(s) || SourceVersion.isKeyword(s) + private fun createNativeSource( + context: Context, + node: TreeNode?, + directory: File, + ) { + val binding = LayoutCreateFileCppBinding.inflate(LayoutInflater.from(context)) + val dialog = + DialogUtils + .newMaterialDialogBuilder(context) + .setView(binding.root) + .setTitle(R.string.new_file) + .setPositiveButton(R.string.text_create, null) + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) + .create() + .attachTooltip(TooltipTag.PROJECT_FOLDER_NEWFILE) + binding.languageGroup.addOnButtonCheckedListener { _, _, _ -> refreshNativeDialog(context, binding, dialog) } + binding.typeGroup.addOnButtonCheckedListener { _, _, _ -> refreshNativeDialog(context, binding, dialog) } + binding.name.editText?.addTextChangedListener( + object : SingleTextWatcher() { + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) { + refreshNativeDialog(context, binding, dialog) + } + }, + ) + + dialog.show() + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + doCreateNativeSource(context, binding, dialog, directory, node) + } + refreshNativeDialog(context, binding, dialog) + } + + private fun refreshNativeDialog( + context: Context, + binding: LayoutCreateFileCppBinding, + dialog: AlertDialog, + ) { + val language = nativeLanguage(binding) + val isCpp = language == NativeSourceBuilder.Language.CPP + if (!isCpp && binding.typeGroup.checkedButtonId == binding.typeClass.id) { + binding.typeGroup.check(binding.typeSource.id) + } + binding.typeClass.isVisible = isCpp + + val kind = nativeKind(binding) + binding.languageGroup.isEnabled = kind != NativeSourceBuilder.Kind.OTHER + binding.name.suffixText = + NativeSourceBuilder + .extensions(language, kind) + .joinToString(" + ") { ".$it" } + .ifEmpty { null } + binding.name.counterMaxLength = NativeSourceBuilder.maxNameLength(language, kind, MAX_FILE_NAME_LENGTH) + + val name = nativeName(binding) + val isValid = NativeSourceBuilder.isValidName(name, language, kind, MAX_FILE_NAME_LENGTH) + val showError = name.isNotEmpty() && !isValid + binding.name.isErrorEnabled = showError + binding.name.error = if (showError) context.getString(R.string.msg_invalid_name) else null + dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = isValid + } + + private fun doCreateNativeSource( + context: Context, + binding: LayoutCreateFileCppBinding, + dialog: AlertDialog, + directory: File, + node: TreeNode?, + ) { + val files = NativeSourceBuilder.createFiles(nativeName(binding), nativeLanguage(binding), nativeKind(binding)) + val createButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE) + createButton.isEnabled = false + fileActionManager.createNewFiles(directory, files.map { it.name to it.content }) { result -> + result + .onSuccess { + dialog.dismiss() + onFilesCreated(node) + }.onFailure { error -> + createButton.isEnabled = true + if (error is FileAlreadyExistsException) { + binding.name.isErrorEnabled = true + binding.name.error = context.getString(R.string.msg_file_exists) + } else { + log.error("Failed to create native source files", error) + flashError(error.message) + } + } + } + } + + private fun nativeName(binding: LayoutCreateFileCppBinding): String = + binding.name.editText!! + .text + .toString() + .trim() + + private fun nativeLanguage(binding: LayoutCreateFileCppBinding): NativeSourceBuilder.Language = + when (val id = binding.languageGroup.checkedButtonId) { + binding.langC.id -> NativeSourceBuilder.Language.C + binding.langCpp.id -> NativeSourceBuilder.Language.CPP + else -> error("Unexpected language button: $id") + } + + private fun nativeKind(binding: LayoutCreateFileCppBinding): NativeSourceBuilder.Kind = + when (val id = binding.typeGroup.checkedButtonId) { + binding.typeSource.id -> NativeSourceBuilder.Kind.SOURCE + binding.typeHeader.id -> NativeSourceBuilder.Kind.HEADER + binding.typeClass.id -> NativeSourceBuilder.Kind.CLASS + binding.typeOther.id -> NativeSourceBuilder.Kind.OTHER + else -> error("Unexpected type button: $id") + } + private fun createLayoutRes( context: Context, node: TreeNode?, @@ -478,7 +609,7 @@ class NewFileAction( name: String, content: String, ) { - if (name.length !in 1..40 || name.startsWith("/")) { + if (name.length !in 1..MAX_FILE_NAME_LENGTH || name.startsWith("/")) { flashError(R.string.msg_invalid_name) return } @@ -497,10 +628,14 @@ class NewFileAction( message: String, createdFile: File?, ) { + onFilesCreated(currentNode) + } + + private fun onFilesCreated(node: TreeNode?) { flashSuccess(R.string.msg_file_created) - if (currentNode != null) { - requestCollapseNode(currentNode!!, false) - requestExpandNode(currentNode!!) + if (node != null) { + requestCollapseNode(node, false) + requestExpandNode(node) } else { requestFileListing() } @@ -510,3 +645,20 @@ class NewFileAction( flashError(errorMessage) } } + +internal enum class SourceDialog { + CPP, + JAVA, +} + +internal fun sourceDialogFor( + projectDir: String, + path: String, +): SourceDialog? { + fun matches(regex: String) = Pattern.compile(Pattern.quote(projectDir) + regex).matcher(path).find() + return when { + matches(NewFileAction.CPP_PATH_REGEX) -> SourceDialog.CPP + matches(NewFileAction.JAVA_PATH_REGEX) -> SourceDialog.JAVA + else -> null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/NativeSourceBuilder.kt b/app/src/main/java/com/itsaky/androidide/utils/NativeSourceBuilder.kt new file mode 100644 index 0000000000..5b2ce9eb92 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/NativeSourceBuilder.kt @@ -0,0 +1,201 @@ +package com.itsaky.androidide.utils + +object NativeSourceBuilder { + enum class Language( + val sourceExtension: String, + ) { + C("c"), + CPP("cpp"), + } + + enum class Kind { + SOURCE, + HEADER, + CLASS, + OTHER, + } + + data class NativeFile( + val name: String, + val content: String, + ) + + private const val HEADER_EXTENSION = "h" + + private val FILE_NAME = Regex("[A-Za-z_][A-Za-z0-9_-]*") + + private val IDENTIFIER = Regex("[A-Za-z_][A-Za-z0-9_]*") + + private val CPP_KEYWORDS = + setOf( + "alignas", + "alignof", + "and", + "and_eq", + "asm", + "auto", + "bitand", + "bitor", + "bool", + "break", + "case", + "catch", + "char", + "char8_t", + "char16_t", + "char32_t", + "class", + "compl", + "concept", + "const", + "consteval", + "constexpr", + "constinit", + "const_cast", + "continue", + "co_await", + "co_return", + "co_yield", + "decltype", + "default", + "delete", + "do", + "double", + "dynamic_cast", + "else", + "enum", + "explicit", + "export", + "extern", + "false", + "float", + "for", + "friend", + "goto", + "if", + "inline", + "int", + "long", + "mutable", + "namespace", + "new", + "noexcept", + "not", + "not_eq", + "nullptr", + "operator", + "or", + "or_eq", + "private", + "protected", + "public", + "register", + "reinterpret_cast", + "requires", + "return", + "short", + "signed", + "sizeof", + "static", + "static_assert", + "static_cast", + "struct", + "switch", + "template", + "this", + "thread_local", + "throw", + "true", + "try", + "typedef", + "typeid", + "typename", + "union", + "unsigned", + "using", + "virtual", + "void", + "volatile", + "wchar_t", + "while", + "xor", + "xor_eq", + ) + + fun extensions( + language: Language, + kind: Kind, + ): List = + when (kind) { + Kind.SOURCE -> listOf(language.sourceExtension) + Kind.HEADER -> listOf(HEADER_EXTENSION) + Kind.CLASS -> listOf(HEADER_EXTENSION, requireCpp(language).sourceExtension) + Kind.OTHER -> emptyList() + } + + fun maxNameLength( + language: Language, + kind: Kind, + maxFileNameLength: Int, + ): Int = maxFileNameLength - (extensions(language, kind).maxOfOrNull { it.length + 1 } ?: 0) + + fun isValidName( + name: String, + language: Language, + kind: Kind, + maxFileNameLength: Int, + ): Boolean = followsNamingRule(name, kind) && name.length <= maxNameLength(language, kind, maxFileNameLength) + + fun createFiles( + name: String, + language: Language, + kind: Kind, + ): List { + require(followsNamingRule(name, kind)) { "Invalid $kind name: '$name'" } + val headerName = "$name.$HEADER_EXTENSION" + return when (kind) { + Kind.SOURCE -> { + listOf(NativeFile("$name.${language.sourceExtension}", "")) + } + + Kind.HEADER -> { + listOf(NativeFile(headerName, header(body = null))) + } + + Kind.CLASS -> { + listOf( + NativeFile(headerName, header(body = "class $name {\n};\n")), + NativeFile("$name.${requireCpp(language).sourceExtension}", "#include \"$headerName\"\n"), + ) + } + + Kind.OTHER -> { + listOf(NativeFile(name, "")) + } + } + } + + private fun followsNamingRule( + name: String, + kind: Kind, + ): Boolean = + when (kind) { + Kind.CLASS -> IDENTIFIER.matches(name) && name !in CPP_KEYWORDS + Kind.SOURCE, Kind.HEADER -> FILE_NAME.matches(name) + Kind.OTHER -> name.split('/').all { it.isNotBlank() && it != "." && it != ".." } + } + + private fun requireCpp(language: Language): Language { + require(language == Language.CPP) { "A class needs C++, not $language" } + return language + } + + private fun header(body: String?): String = + buildString { + appendLine("#pragma once") + if (body != null) { + appendLine() + append(body) + } + } +} diff --git a/app/src/main/res/layout/layout_create_file_cpp.xml b/app/src/main/res/layout/layout_create_file_cpp.xml new file mode 100644 index 0000000000..bb7c16af4f --- /dev/null +++ b/app/src/main/res/layout/layout_create_file_cpp.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/itsaky/androidide/actions/WriteNewFilesTest.kt b/app/src/test/java/com/itsaky/androidide/actions/WriteNewFilesTest.kt new file mode 100644 index 0000000000..8cee990a77 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/actions/WriteNewFilesTest.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.actions + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.FileAlreadyExistsException + +class WriteNewFilesTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `writes every file with its content`() { + val dir = tempFolder.newFolder("cpp") + + val created = writeNewFiles(dir, listOf("Renderer.h" to "header", "Renderer.cpp" to "source")) + + assertThat(created.map { it.name }).containsExactly("Renderer.h", "Renderer.cpp").inOrder() + assertThat(File(dir, "Renderer.h").readText()).isEqualTo("header") + assertThat(File(dir, "Renderer.cpp").readText()).isEqualTo("source") + } + + @Test + fun `an existing file is never overwritten`() { + val dir = tempFolder.newFolder("cpp") + File(dir, "util.c").writeText("user code") + + assertThrows(FileAlreadyExistsException::class.java) { + writeNewFiles(dir, listOf("util.c" to "")) + } + + assertThat(File(dir, "util.c").readText()).isEqualTo("user code") + } + + @Test + fun `a failure part way through removes the files this call already wrote`() { + val dir = tempFolder.newFolder("cpp") + File(dir, "Renderer.cpp").writeText("user code") + + assertThrows(FileAlreadyExistsException::class.java) { + writeNewFiles(dir, listOf("Renderer.h" to "header", "Renderer.cpp" to "source")) + } + + assertThat(File(dir, "Renderer.h").exists()).isFalse() + assertThat(File(dir, "Renderer.cpp").readText()).isEqualTo("user code") + } + + @Test + fun `a path with folders creates them`() { + val dir = tempFolder.newFolder("cpp") + + writeNewFiles(dir, listOf("include/defs.hpp" to "")) + + assertThat(File(dir, "include/defs.hpp").isFile).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/actions/filetree/SourceDialogRoutingTest.kt b/app/src/test/java/com/itsaky/androidide/actions/filetree/SourceDialogRoutingTest.kt new file mode 100644 index 0000000000..c9511ef8de --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/actions/filetree/SourceDialogRoutingTest.kt @@ -0,0 +1,34 @@ +package com.itsaky.androidide.actions.filetree + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SourceDialogRoutingTest { + private val projectDir = "/storage/emulated/0/CodeOnTheGoProjects/MyApp" + + private fun route(relativePath: String) = sourceDialogFor(projectDir, "$projectDir/$relativePath") + + @Test + fun `a cpp source root and its subfolders open the native dialog`() { + assertThat(route("app/src/main/cpp")).isEqualTo(SourceDialog.CPP) + assertThat(route("app/src/debug/cpp/engine")).isEqualTo(SourceDialog.CPP) + } + + @Test + fun `a cpp subfolder whose name contains java still opens the native dialog`() { + assertThat(route("app/src/main/cpp/javabridge")).isEqualTo(SourceDialog.CPP) + } + + @Test + fun `a java source folder opens the class dialog`() { + assertThat(route("app/src/main/java")).isEqualTo(SourceDialog.JAVA) + assertThat(route("app/src/main/java/com/example")).isEqualTo(SourceDialog.JAVA) + assertThat(route("app/src/main/java/cpp")).isEqualTo(SourceDialog.JAVA) + } + + @Test + fun `other folders open neither source dialog`() { + assertThat(route("app/src/main/res")).isNull() + assertThat(route("app/src/main/cppx")).isNull() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/NativeSourceBuilderTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NativeSourceBuilderTest.kt new file mode 100644 index 0000000000..e489e8d23f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NativeSourceBuilderTest.kt @@ -0,0 +1,106 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NativeSourceBuilder.Kind +import com.itsaky.androidide.utils.NativeSourceBuilder.Language +import com.itsaky.androidide.utils.NativeSourceBuilder.NativeFile +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class NativeSourceBuilderTest { + @Test + fun givenCSource_whenCreated_thenOneEmptyDotCFile() { + assertThat(NativeSourceBuilder.createFiles("util", Language.C, Kind.SOURCE)) + .containsExactly(NativeFile("util.c", "")) + } + + @Test + fun givenCppSource_whenCreated_thenOneEmptyDotCppFile() { + assertThat(NativeSourceBuilder.createFiles("native-lib", Language.CPP, Kind.SOURCE)) + .containsExactly(NativeFile("native-lib.cpp", "")) + } + + @Test + fun givenHeader_whenCreated_thenGuardedByPragmaOnceRatherThanANameDerivedMacro() { + assertThat(NativeSourceBuilder.createFiles("_impl", Language.C, Kind.HEADER)) + .containsExactly(NativeFile("_impl.h", "#pragma once\n")) + } + + @Test + fun givenCppClass_whenCreated_thenHeaderDeclaresItAndSourceIncludesHeader() { + assertThat(NativeSourceBuilder.createFiles("Renderer", Language.CPP, Kind.CLASS)) + .containsExactly( + NativeFile("Renderer.h", "#pragma once\n\nclass Renderer {\n};\n"), + NativeFile("Renderer.cpp", "#include \"Renderer.h\"\n"), + ).inOrder() + } + + @Test + fun givenOtherFile_whenCreated_thenExactNameWithNoContent() { + assertThat(NativeSourceBuilder.createFiles("CMakeLists.txt", Language.CPP, Kind.OTHER)) + .containsExactly(NativeFile("CMakeLists.txt", "")) + } + + @Test + fun givenCClass_whenCreated_thenRejected() { + assertThrows(IllegalArgumentException::class.java) { + NativeSourceBuilder.createFiles("Renderer", Language.C, Kind.CLASS) + } + } + + @Test + fun givenNames_whenValidated_thenEachKindAppliesItsOwnRule() { + assertThat(isValid("native-lib", Language.CPP, Kind.SOURCE)).isTrue() + assertThat(isValid("_impl2", Language.C, Kind.HEADER)).isTrue() + assertThat(isValid("Renderer", Language.CPP, Kind.CLASS)).isTrue() + assertThat(isValid("CMakeLists.txt", Language.CPP, Kind.OTHER)).isTrue() + assertThat(isValid("include/defs.hpp", Language.CPP, Kind.OTHER)).isTrue() + + assertThat(isValid("", Language.CPP, Kind.SOURCE)).isFalse() + assertThat(isValid("2d", Language.CPP, Kind.SOURCE)).isFalse() + assertThat(isValid("util.cpp", Language.CPP, Kind.SOURCE)).isFalse() + assertThat(isValid("dir/util", Language.C, Kind.HEADER)).isFalse() + assertThat(isValid("native-lib", Language.CPP, Kind.CLASS)).isFalse() + assertThat(isValid("delete", Language.CPP, Kind.CLASS)).isFalse() + assertThat(isValid("", Language.CPP, Kind.OTHER)).isFalse() + assertThat(isValid("/abs.txt", Language.CPP, Kind.OTHER)).isFalse() + assertThat(isValid("../escape.txt", Language.CPP, Kind.OTHER)).isFalse() + assertThat(isValid("dir//file.txt", Language.CPP, Kind.OTHER)).isFalse() + } + + @Test + fun givenTheFileNameLimit_whenSizingTheBareName_thenTheLongestExtensionIsReserved() { + assertThat(NativeSourceBuilder.maxNameLength(Language.C, Kind.SOURCE, 40)).isEqualTo(38) + assertThat(NativeSourceBuilder.maxNameLength(Language.CPP, Kind.SOURCE, 40)).isEqualTo(36) + assertThat(NativeSourceBuilder.maxNameLength(Language.CPP, Kind.HEADER, 40)).isEqualTo(38) + assertThat(NativeSourceBuilder.maxNameLength(Language.CPP, Kind.CLASS, 40)).isEqualTo(36) + assertThat(NativeSourceBuilder.maxNameLength(Language.CPP, Kind.OTHER, 40)).isEqualTo(40) + } + + @Test + fun givenANameThatFitsButItsFileNameDoesNot_whenValidated_thenRejected() { + val name = "a".repeat(37) + + assertThat(isValid(name, Language.CPP, Kind.HEADER)).isTrue() + assertThat(isValid(name, Language.CPP, Kind.CLASS)).isFalse() + assertThat(NativeSourceBuilder.createFiles("a".repeat(36), Language.CPP, Kind.CLASS).map { it.name.length }) + .containsExactly(38, 40) + } + + @Test + fun givenEachKind_whenAskedForExtensions_thenMatchesCreatedFiles() { + assertThat(NativeSourceBuilder.extensions(Language.C, Kind.SOURCE)).containsExactly("c") + assertThat(NativeSourceBuilder.extensions(Language.CPP, Kind.HEADER)).containsExactly("h") + assertThat(NativeSourceBuilder.extensions(Language.CPP, Kind.CLASS)).containsExactly("h", "cpp").inOrder() + assertThat(NativeSourceBuilder.extensions(Language.CPP, Kind.OTHER)).isEmpty() + } + + private fun isValid( + name: String, + language: Language, + kind: Kind, + ) = NativeSourceBuilder.isValidName(name, language, kind, maxFileNameLength = 40) +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bf4508360b..76bb499084 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -320,6 +320,10 @@ Enum Java Kotlin + C + C++ + Source + Header New Kotlin class Failed to list project files Failed to import dropped files