├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .swiftlint.yml ├── Brewfile ├── Brewfile.lock.json ├── CHANGELOG.md ├── Examples ├── HTML │ ├── Murray │ │ └── HTML │ │ │ ├── HTML.yml │ │ │ └── page │ │ │ ├── Page.html.stencil │ │ │ └── page.yml │ ├── Murrayfile.yml │ ├── README.md │ ├── Skeleton.yml │ └── index.html └── iOS │ └── MurrayDemo │ ├── .gitignore │ ├── Makefile │ ├── Mintfile │ ├── Murray │ └── Project │ │ ├── Project.yml │ │ ├── sceneView │ │ ├── SceneView.swift.stencil │ │ └── sceneView.yml │ │ └── sceneViewModel │ │ ├── ViewModel.swift.stencil │ │ ├── ViewModelTests.swift.stencil │ │ └── sceneViewModel.yml │ ├── MurrayDemo.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ ├── MurrayDemo │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── MainTab.swift │ ├── MainTabViewModel.swift │ ├── MurrayDemoApp.swift │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── MurrayDemoTests │ └── MurrayDemoTests.swift │ ├── Murrayfile.yml │ └── README.md ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Murray │ ├── Commands │ │ ├── Clone.swift │ │ ├── List.swift │ │ ├── Run.swift │ │ └── Scaffold.swift │ ├── Menu.swift │ ├── Strings.swift │ └── main.swift └── MurrayKit │ ├── Coding │ ├── Decoder.swift │ ├── Encoder.swift │ ├── JSONCoder.swift │ ├── Parameters.swift │ └── YAMLCoder.swift │ ├── Commands │ ├── Clone.swift │ ├── Command.swift │ ├── List.swift │ ├── Run.swift │ └── Scaffold.swift │ ├── Errors.swift │ ├── Logger │ └── Logger.swift │ ├── Models │ ├── CodableFile.swift │ ├── Content.swift │ ├── Item │ │ ├── Item+Writeable.swift │ │ └── Item.swift │ ├── MurrayFile.swift │ ├── Package.swift │ ├── Pipeline.swift │ ├── Procedure │ │ ├── PackagedProcedure.swift │ │ └── Procedure.swift │ ├── Repository.swift │ ├── Resolvable.swift │ ├── RootFile.swift │ ├── Skeleton.swift │ ├── Template.swift │ └── WriteableFile.swift │ ├── Plugin │ ├── Plugin.swift │ ├── PluginDataContainer.swift │ ├── PluginExecution.swift │ ├── PluginManager.swift │ ├── ShellPlugin.swift │ └── XcodePlugin.swift │ └── Utilities │ ├── Files+Utilities.swift │ ├── Process+Extensions.swift │ └── String+Extensions.swift ├── Tests ├── LinuxMain.swift └── MurrayKitTests │ ├── CodableTests.swift │ ├── Commands │ ├── CloneTests.swift │ ├── CommandTests.swift │ ├── ListTests.swift │ ├── RunTests.swift │ └── ScaffoldTests.swift │ ├── ItemTests.swift │ ├── Mocks │ ├── SimpleJSON │ │ ├── Murray │ │ │ └── Simple │ │ │ │ ├── Folder │ │ │ │ ├── Subfolder │ │ │ │ │ ├── AnotherSubfolderWith{{name}} │ │ │ │ │ │ ├── Object.swift │ │ │ │ │ │ └── {{name}}.swift │ │ │ │ │ ├── Bone.swift │ │ │ │ │ └── {{name}}.swift │ │ │ │ └── item.yml │ │ │ │ ├── ReplacementOnly │ │ │ │ ├── Replacement.swift │ │ │ │ └── item.yml │ │ │ │ ├── Simple.json │ │ │ │ └── SimpleItem │ │ │ │ ├── Bone.swift │ │ │ │ ├── Replacement.swift │ │ │ │ └── SimpleItem.json │ │ ├── Murrayfile │ │ ├── Sources │ │ │ └── Files │ │ │ │ └── Default │ │ │ │ ├── Test.swift │ │ │ │ ├── Test2.swift │ │ │ │ └── TestReplacementOnly.swift │ │ └── Test.xcodeproj │ │ │ └── project.pbxproj │ ├── SimpleYaml │ │ ├── Murray │ │ │ ├── Simple │ │ │ │ ├── ReplacementOnly │ │ │ │ │ ├── Replacement.swift │ │ │ │ │ └── item.yml │ │ │ │ ├── Simple.yml │ │ │ │ └── SimpleItem │ │ │ │ │ ├── .gitKeep │ │ │ │ │ ├── Bone.swift │ │ │ │ │ ├── File │ │ │ │ │ ├── Replacement.swift │ │ │ │ │ └── SimpleItem.yml │ │ │ └── SimpleCopy │ │ │ │ ├── Simple.yml │ │ │ │ └── SimpleItem │ │ │ │ ├── Bone.swift │ │ │ │ ├── Replacement.swift │ │ │ │ └── SimpleItem.yml │ │ ├── Murrayfile.yml │ │ ├── Sources │ │ │ └── Files │ │ │ │ └── Default │ │ │ │ ├── Test.swift │ │ │ │ ├── Test2.swift │ │ │ │ └── TestReplacementOnly.swift │ │ └── Test.xcodeproj │ │ │ └── project.pbxproj │ ├── Skeleton │ │ ├── Skeleton.yml │ │ ├── Sources │ │ │ └── Swift.swift │ │ ├── main.swift │ │ └── {{name}} │ │ │ └── Hello{{name|uppercase}}.swift │ ├── SkeletonInSubfolder │ │ └── Subfolder │ │ │ ├── Skeleton.yml │ │ │ ├── Sources │ │ │ └── Swift.swift │ │ │ └── {{name}} │ │ │ └── Hello{{name|uppercase}}.swift │ └── WrongMurrayfile │ │ └── Murrayfile.yml │ ├── MurrayfileTests.swift │ ├── PipelineTests.swift │ ├── ProcedureTests.swift │ ├── TemplateTests.swift │ ├── Utilities │ ├── MurrayTestCase.swift │ └── Scenario.swift │ └── WriteableFileTests.swift └── docs └── diagram.svg /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: MurrayKit Tests 2 | on: 3 | push: 4 | branches: ["main"] 5 | paths: 6 | - Sources 7 | - Tests 8 | pull_request: 9 | branches: ["main"] 10 | types: [opened, edited, reopened, synchronize] 11 | paths: 12 | - Sources 13 | - Tests 14 | jobs: 15 | macOS: 16 | runs-on: macOS-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Lint 20 | run: swiftlint lint --strict 21 | - name: Resolve 22 | run: swift package resolve 23 | - name: Build 24 | run: swift build 25 | - name: Run tests 26 | run: swift test 2>&1 | xcpretty 27 | linux: 28 | runs-on: ubuntu-latest 29 | # container: swift:5.3 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Resolve 33 | run: swift package resolve 34 | - name: Build 35 | run: swift build 36 | - name: Run tests 37 | run: swift test --enable-test-discovery 2>&1 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | .swiftpm 6 | .vscode 7 | MurrayBar2 -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - blanket_disable_command 4 | opt_in_rules: 5 | - empty_count 6 | included: 7 | - Sources 8 | - Tests 9 | excluded: 10 | - Tests/MurrayKitTests/Mocks 11 | 12 | force_cast: error # implicitly 13 | force_try: 14 | severity: error # explicitly 15 | 16 | line_length: 155 17 | function_body_length: 80 18 | 19 | type_body_length: 20 | warning: 300 21 | error: 400 22 | file_length: 23 | warning: 300 24 | error: 700 25 | type_name: 26 | min_length: 3 27 | max_length: 28 | warning: 50 29 | error: 60 30 | nesting: 31 | type_level: 3 32 | identifier_name: 33 | min_length: 34 | error: 2 35 | excluded: 36 | - id 37 | - URL 38 | - to 39 | reporter: "xcode" 40 | 41 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | # Swiftlint 2 | brew 'swiftlint' 3 | 4 | # SwiftFormat 5 | brew 'swiftformat' 6 | brew 'swiftlint' -------------------------------------------------------------------------------- /Brewfile.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": { 3 | "brew": { 4 | "swiftlint": { 5 | "version": "0.48.0", 6 | "bottle": { 7 | "rebuild": 0, 8 | "root_url": "https://ghcr.io/v2/homebrew/core", 9 | "files": { 10 | "arm64_monterey": { 11 | "cellar": ":any_skip_relocation", 12 | "url": "https://ghcr.io/v2/homebrew/core/swiftlint/blobs/sha256:4942fa227daafa9aaf14197f3a5972e4b057dd168e592d236d98f7f337afbc25", 13 | "sha256": "4942fa227daafa9aaf14197f3a5972e4b057dd168e592d236d98f7f337afbc25" 14 | }, 15 | "arm64_big_sur": { 16 | "cellar": ":any_skip_relocation", 17 | "url": "https://ghcr.io/v2/homebrew/core/swiftlint/blobs/sha256:4e3a73e893d68ed38271e857b81720958bb55cbbb05a3d5bf31b9b67f7eddf52", 18 | "sha256": "4e3a73e893d68ed38271e857b81720958bb55cbbb05a3d5bf31b9b67f7eddf52" 19 | }, 20 | "monterey": { 21 | "cellar": ":any_skip_relocation", 22 | "url": "https://ghcr.io/v2/homebrew/core/swiftlint/blobs/sha256:381d414359505bc72348f151751bb77a3c402e9d5ff652579687e3c5391d6872", 23 | "sha256": "381d414359505bc72348f151751bb77a3c402e9d5ff652579687e3c5391d6872" 24 | }, 25 | "big_sur": { 26 | "cellar": ":any_skip_relocation", 27 | "url": "https://ghcr.io/v2/homebrew/core/swiftlint/blobs/sha256:74d707b9989d622af952db542b786a3ebecc3b8be53762fce8a25ac075f6da1a", 28 | "sha256": "74d707b9989d622af952db542b786a3ebecc3b8be53762fce8a25ac075f6da1a" 29 | }, 30 | "x86_64_linux": { 31 | "cellar": "/home/linuxbrew/.linuxbrew/Cellar", 32 | "url": "https://ghcr.io/v2/homebrew/core/swiftlint/blobs/sha256:8aa64a4dce96e961898e41c2240b192a030575571ae9fe0d3a41bb79424fe9b1", 33 | "sha256": "8aa64a4dce96e961898e41c2240b192a030575571ae9fe0d3a41bb79424fe9b1" 34 | } 35 | } 36 | } 37 | }, 38 | "swiftformat": { 39 | "version": "0.49.13", 40 | "bottle": { 41 | "rebuild": 0, 42 | "root_url": "https://ghcr.io/v2/homebrew/core", 43 | "files": { 44 | "arm64_monterey": { 45 | "cellar": ":any_skip_relocation", 46 | "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:9ca3ab9db1f528cd75201a3cbe8a165c92181fe4353a0aae3002e762992606e2", 47 | "sha256": "9ca3ab9db1f528cd75201a3cbe8a165c92181fe4353a0aae3002e762992606e2" 48 | }, 49 | "arm64_big_sur": { 50 | "cellar": ":any_skip_relocation", 51 | "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:7c9ca8d9f5d303a9e4e4e4359022075abb82d835cecab2f374960d80be6f366e", 52 | "sha256": "7c9ca8d9f5d303a9e4e4e4359022075abb82d835cecab2f374960d80be6f366e" 53 | }, 54 | "monterey": { 55 | "cellar": ":any_skip_relocation", 56 | "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:6686aa8139c4c0f70c1b6b491ace161e0d767ae3a0d9fb27aa9379968423c93e", 57 | "sha256": "6686aa8139c4c0f70c1b6b491ace161e0d767ae3a0d9fb27aa9379968423c93e" 58 | }, 59 | "big_sur": { 60 | "cellar": ":any_skip_relocation", 61 | "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:1d46d7bdd32e2743e54db27aa757c75f454626ddce633fed24c2981c2227617c", 62 | "sha256": "1d46d7bdd32e2743e54db27aa757c75f454626ddce633fed24c2981c2227617c" 63 | }, 64 | "catalina": { 65 | "cellar": ":any_skip_relocation", 66 | "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:309a419a4345fd1ef7ad75dddbe0995b5dced750b2621ca3aaa1ba49adaddaed", 67 | "sha256": "309a419a4345fd1ef7ad75dddbe0995b5dced750b2621ca3aaa1ba49adaddaed" 68 | }, 69 | "x86_64_linux": { 70 | "cellar": "/home/linuxbrew/.linuxbrew/Cellar", 71 | "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:af6b21401a7697c6f58d9eb84d14f896e017168193f585e3e571a6eddb77fe7b", 72 | "sha256": "af6b21401a7697c6f58d9eb84d14f896e017168193f585e3e571a6eddb77fe7b" 73 | } 74 | } 75 | } 76 | } 77 | } 78 | }, 79 | "system": { 80 | "macos": { 81 | "catalina": { 82 | "HOMEBREW_VERSION": "2.4.16", 83 | "HOMEBREW_PREFIX": "/usr/local", 84 | "Homebrew/homebrew-core": "a8b99e19e829d664c643c488031024d07317398d", 85 | "CLT": "11.5.0.0.1.1588476445", 86 | "Xcode": "11.7", 87 | "macOS": "10.15.6" 88 | }, 89 | "monterey": { 90 | "HOMEBREW_VERSION": "3.5.6-73-ge217fd3", 91 | "HOMEBREW_PREFIX": "/opt/homebrew", 92 | "Homebrew/homebrew-core": "7813809dc9f6c721c98a0b1f130e9f58aff76450", 93 | "CLT": "13.4.0.0.1.1651278267", 94 | "Xcode": "13.2.1", 95 | "macOS": "12.4" 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.0 4 | 5 | Murray has been rewritten from scratch! 6 | We tried to maintain compatibility with the previous structure, however some breaking changes may have occured. 7 | 8 | - New configuration file formats now supports YAML and JSON 9 | - MurrayKit completely rewritten in order to be used in graphical applications 10 | - `--preview` option for `murray run` command allows a quick preview about what will be written 11 | - Environment in Murrayfile now supports context resolution 12 | - Dynamic values in contexts now supports current git author, date, time and year and current file path. 13 | 14 | 15 | ## 2.2 16 | 17 | - Add before and after plugin to BonePaths 18 | - Renamed plugin phases to `before` and `after` 19 | 20 | ## 2.1 21 | 22 | - Shell plugin (#37) 23 | - Recursive resolution of parameters in json strings (#39) 24 | - Bugfix: Absolute url support in Murrayfile packages (#40) 25 | - Bugfix: Bone clone support for repositories with BonePackage.json file in root folder (#41) 26 | - Support for plugin execution before and after each procedure (#44) 27 | - XCodePlugin is now based upon tuist/XcodeProj (written in Swift) rather than Cocoapods/Xcodeproj (written in ruby) (#33) 28 | - Fix folder duplication bug (#34) 29 | - SnakeCase filter (#35) 30 | - Got rid of --param explicit command. (#36) 31 | 32 | ## 2.0 33 | 34 | Completely rewritten version of Murray with new JSON and folder structure. See Readme for more informations. 35 | -------------------------------------------------------------------------------- /Examples/HTML/Murray/HTML/HTML.yml: -------------------------------------------------------------------------------- 1 | name: HTML 2 | description: A package named HTML created from scaffold 3 | procedures: 4 | - name: page 5 | description: An item named page created from scaffold 6 | items: 7 | - page/page.yml 8 | -------------------------------------------------------------------------------- /Examples/HTML/Murray/HTML/page/Page.html.stencil: -------------------------------------------------------------------------------- 1 | 2 | {{name|firstUppercase}} 3 | 4 | 5 | 12 |

Welcome to {{name|firstUppercase}}

13 |

This is a very simple detail page

14 | 17 | -------------------------------------------------------------------------------- /Examples/HTML/Murray/HTML/page/page.yml: -------------------------------------------------------------------------------- 1 | name: page 2 | parameters: 3 | - name: name 4 | isRequired: true 5 | - name: folder 6 | isRequired: false 7 | paths: 8 | - from: Page.html.stencil 9 | to: | 10 | {{ folder|default:"Pages" }}/{{name|firstLowercase}}.html 11 | description: An item named page created from scaffold 12 | replacements: 13 | - destination: Murrayfile.yml 14 | placeholder: "#PagesPlaceholder" 15 | text: |+1 16 | - name: {{name}} 17 | path: {{ folder|default:"Pages"}}/{{name|firstLowercase}}.html 18 | - destination: index.html 19 | placeholder: "" 20 | text: | 21 | {{name|firstUppercase}} 22 | -------------------------------------------------------------------------------- /Examples/HTML/Murrayfile.yml: -------------------------------------------------------------------------------- 1 | packages: 2 | - Murray/HTML/HTML.yml 3 | environment: 4 | projectName: "{{name}}" 5 | pages: 6 | - name: Home 7 | path: index.html 8 | #PagesPlaceholder 9 | author: 10 | email: stefano.mondino.dev@gmail.com 11 | name: Stefano Mondino 12 | 13 | 14 | -------------------------------------------------------------------------------- /Examples/HTML/README.md: -------------------------------------------------------------------------------- 1 | # Murray + HTML 2 | 3 | This is a very basic example where Murray can be setup and used to create new HTML pages -------------------------------------------------------------------------------- /Examples/HTML/Skeleton.yml: -------------------------------------------------------------------------------- 1 | scripts: 2 | - "open index.html" 3 | paths: 4 | - from: index.html 5 | to: index.html 6 | - from: Murrayfile.yml 7 | to: Murrayfile.yml 8 | initializeGit: true 9 | -------------------------------------------------------------------------------- /Examples/HTML/index.html: -------------------------------------------------------------------------------- 1 | 2 | {{name|firstUppercase}} 3 | 4 | 5 | 9 |

Welcome to {{name|firstUppercase}}

10 |

This is a very simple html project

11 | 14 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | setup: 4 | git init 5 | mint bootstrap 6 | 7 | lint: 8 | mint run swiftlint --fix 9 | mint run swiftformat -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Mintfile: -------------------------------------------------------------------------------- 1 | realm/Swiftlint@0.51.0 2 | nicklockwood/SwiftFormat@0.51.5 -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murray/Project/Project.yml: -------------------------------------------------------------------------------- 1 | name: Project 2 | description: A package named Project created from scaffold 3 | procedures: 4 | - name: sceneView 5 | description: Creates a new scene in scene folder 6 | items: 7 | - sceneView/sceneView.yml 8 | - name: sceneViewModel 9 | description: Creates a new scene ViewModel in scene folder 10 | items: 11 | - sceneViewModel/sceneViewModel.yml 12 | - name: scene 13 | description: Creates a new scene with view and viewModel 14 | items: 15 | - sceneViewModel/sceneViewModel.yml 16 | - sceneView/sceneView.yml 17 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murray/Project/sceneView/SceneView.swift.stencil: -------------------------------------------------------------------------------- 1 | {{fileHeader}} 2 | 3 | import SwiftUI 4 | 5 | struct {{name|firstUppercase}}View: View { 6 | @ObservedObject var viewModel: {{name|firstUppercase}}ViewModel 7 | var body: some View { 8 | VStack { 9 | Text(viewModel.title) 10 | Text("This is the {{name}} screen") 11 | }.tabItem { 12 | Text("{{name|uppercase}}") 13 | } 14 | } 15 | } 16 | 17 | struct {{name|firstUppercase}}View_Previews: PreviewProvider { 18 | static var previews: some View { 19 | {{name|firstUppercase}}View(viewModel: .init()) 20 | } 21 | } -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murray/Project/sceneView/sceneView.yml: -------------------------------------------------------------------------------- 1 | name: sceneView 2 | parameters: 3 | - name: "name" 4 | isRequired: true 5 | paths: 6 | - from: SceneView.swift.stencil 7 | to: "{{paths.scenes}}/{{name|firstUppercase}}/{{name|firstUppercase}}View.swift" 8 | plugins: 9 | xcode: 10 | targets: ["{{mainTarget}}"] 11 | description: A SwiftUI view with previews 12 | replacements: 13 | - destination: "MurrayDemo/MainTab.swift" 14 | placeholder: "// murray: tab" 15 | text: "{{name|firstUppercase}}View(viewModel: viewModel.{{name|firstLowercase}}ViewModel)\n" 16 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murray/Project/sceneViewModel/ViewModel.swift.stencil: -------------------------------------------------------------------------------- 1 | {{fileHeader}} 2 | 3 | import Combine 4 | import SwiftUI 5 | 6 | class {{name|firstUppercase}}ViewModel: ObservableObject { 7 | var title: String { "{{name}}".uppercased() } 8 | init() { 9 | } 10 | } -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murray/Project/sceneViewModel/ViewModelTests.swift.stencil: -------------------------------------------------------------------------------- 1 | {{fileHeader}} 2 | 3 | import Foundation 4 | import XCTest 5 | @testable import {{mainTarget|firstUppercase}} 6 | 7 | class {{name|firstUppercase}}ViewModelTests: XCTestCase { 8 | 9 | var viewModel: {{name|firstUppercase}}ViewModel = .init() 10 | 11 | override func setUpWithError() throws { 12 | try super.setUpWithError() 13 | viewModel = .init() 14 | } 15 | 16 | func testTitleIsAlwaysUppercased() throws { 17 | XCTAssertEqual(viewModel.title, "{{name|uppercase}}") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murray/Project/sceneViewModel/sceneViewModel.yml: -------------------------------------------------------------------------------- 1 | name: sceneViewModel 2 | parameters: [] 3 | paths: 4 | - from: ViewModel.swift.stencil 5 | to: "{{paths.scenes}}/{{name|firstUppercase}}/{{name|firstUppercase}}ViewModel.swift" 6 | plugins: 7 | xcode: 8 | targets: ["{{mainTarget}}"] 9 | - from: ViewModelTests.swift.stencil 10 | to: "{{paths.tests}}/{{name|firstUppercase}}/{{name|firstUppercase}}ViewModel.swift" 11 | plugins: 12 | xcode: 13 | targets: ["{{mainTestTarget}}"] 14 | description: A scene view model with tests 15 | replacements: 16 | - destination: "MurrayDemo/MainTabViewModel.swift" 17 | placeholder: "// murray: viewModel" 18 | text: "let {{name|firstLowercase}}ViewModel = {{name|firstUppercase}}ViewModel()\n" -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/MainTab.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // MurrayDemo 4 | // 5 | // Created by Stefano Mondino on 28/04/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MainTab: View { 11 | @ObservedObject var viewModel: MainTabViewModel 12 | var body: some View { 13 | TabView { 14 | // murray: tab 15 | } 16 | } 17 | } 18 | 19 | struct ContentView_Previews: PreviewProvider { 20 | static var previews: some View { 21 | MainTab(viewModel: .init()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/MainTabViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabViewModel.swift 3 | // MurrayDemo 4 | // 5 | // Created by Stefano Mondino on 28/04/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class MainTabViewModel: ObservableObject { 12 | // murray: viewModel 13 | 14 | init() {} 15 | } 16 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/MurrayDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MurrayDemoApp.swift 3 | // MurrayDemo 4 | // 5 | // Created by Stefano Mondino on 28/04/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct MurrayDemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | MainTab(viewModel: .init()) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/MurrayDemoTests/MurrayDemoTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MurrayDemoTests.swift 3 | // MurrayDemoTests 4 | // 5 | // Created by Stefano Mondino on 28/04/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class MurrayDemoTests: XCTestCase { 11 | override func setUpWithError() throws { 12 | // Put setup code here. This method is called before the invocation of each test method in the class. 13 | } 14 | 15 | override func tearDownWithError() throws { 16 | // Put teardown code here. This method is called after the invocation of each test method in the class. 17 | } 18 | 19 | func testExample() throws { 20 | // This is an example of a functional test case. 21 | // Use XCTAssert and related functions to verify your tests produce the correct results. 22 | // Any test you write for XCTest can be annotated as throws and async. 23 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 24 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 25 | } 26 | 27 | func testPerformanceExample() throws { 28 | // This is an example of a performance test case. 29 | measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/Murrayfile.yml: -------------------------------------------------------------------------------- 1 | packages: 2 | - Murray/Project/Project.yml 3 | environment: 4 | mainTarget: MurrayDemo 5 | mainTestTarget: MurrayDemoTests 6 | fileHeader: | 7 | // {{_author}} - © {{_year}} 8 | paths: 9 | sources: MurrayDemo 10 | tests: MurrayDemoTests 11 | scenes: "{{paths.sources}}/Scenes" 12 | plugins: 13 | shell: 14 | after: 15 | - make lint 16 | -------------------------------------------------------------------------------- /Examples/iOS/MurrayDemo/README.md: -------------------------------------------------------------------------------- 1 | # Murray Demo - Basic SwiftUI example 2 | 3 | This project contains a super-basic xcode project with an iOS app made with SwiftUI. 4 | 5 | The app only contains an empty `TabView` intended to be used with MVVM pattern (`ObservableObject`) 6 | 7 | Goal: Use Murray CLI to quickly scaffold your empty views. 8 | 9 | ## Pre-requisites 10 | Have 🌱 *[Mint](https://github.com/yonaskolb/mint)* installed on your machine. 11 | 12 | Install Murray with `murray install synesthesia-it/murray` 13 | 14 | Project was tested with Xcode 14.3. 15 | 16 | ## Setup 17 | 18 | Just run 19 | ```console 20 | make setup 21 | ``` 22 | the first time you checkout this project. 23 | 24 | SwiftFormat and SwiftLint will be installed and used later on by Murray scripts 25 | 26 | ## Steps 27 | 28 | Open the xcodeproj and run the app. You can also check SwiftUI previews and see if they are working. 29 | 30 | Run the test target as well - it's empty (for now). 31 | 32 | Let's assume you want to add a screen to the tab view, like a `Products` screen with a list of product. 33 | 34 | Murray has been setup (in `Murray` folder + `Murrayfile.yml`) to create a View with a ViewModel that will be automatically included in your main tab. How? run 35 | ```console 36 | murray run scene Products 37 | ``` 38 | and double check your xcode project. 39 | 40 | You will find: 41 | - a `ProductsView` containing a super basic SwiftUI view (with preview) tied to a single view model 42 | - a `ProductsViewModel` ObservableObject, exposing (for now) a simple `title` property (uppercased) 43 | - a `ProductsViewModelTests` file in the tests folder, checking that your title is uppercased 44 | - the `MainTabViewModel` will expose a `productsViewModel` variable to it's own view 45 | - the `MainTabView` will include the `ProductsView` 46 | - all the new files added to their respective targets 47 | - the entire project linted with SwiftFormat and SwiftLint (with default set of rules for both of them) 48 | 49 | As bonus, if you did setup your git properly, you should automatically find a file header (first line with comments) containing your git name and current year. 50 | 51 | For more informations, check Murray documentation -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Synesthesia srl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Configuration variables: 2 | .DEFAULT_GOAL := build 3 | 4 | # Prepare Application workspace 5 | 6 | build: 7 | swift build -c release 8 | cp -f .build/release/Murray /usr/local/bin/murray 9 | 10 | archive: 11 | swift build -c release 12 | cp -f .build/release/Murray ./murray 13 | chmod +x murray 14 | zip murray.zip murray 15 | 16 | # Install dependencies, download build resources and add pre-commit hook 17 | 18 | lint: 19 | swiftformat ./Sources 20 | swiftlint --fix 21 | swiftlint lint --strict 22 | 23 | git_setup: 24 | eval "$$add_pre_commit_script" 25 | 26 | setup: 27 | brew update && brew bundle 28 | make git_setup 29 | 30 | # Define pre commit script to auto lint and format the code 31 | define _add_pre_commit 32 | if [ -d ".git" ]; then 33 | SWIFTLINT_PATH=`which swiftlint` 34 | SWIFTFORMAT_PATH=`which swiftformat` 35 | 36 | cat > .git/hooks/pre-commit << ENDOFFILE 37 | #!/bin/sh 38 | 39 | FILES=\$(git diff --cached --name-only --diff-filter=ACMR "*.swift" | sed 's| |\\ |g') 40 | [ -z "\$FILES" ] && exit 0 41 | 42 | # Format 43 | ${SWIFTFORMAT_PATH} \$FILES 44 | 45 | # Lint 46 | ${SWIFTLINT_PATH} --fix \$FILES 47 | ${SWIFTLINT_PATH} lint \$FILES 48 | 49 | # Add back the formatted/linted files to staging 50 | echo "\$FILES" | xargs git add 51 | 52 | exit 0 53 | ENDOFFILE 54 | 55 | chmod +x .git/hooks/pre-commit 56 | fi 57 | endef 58 | export add_pre_commit_script = $(value _add_pre_commit) 59 | 60 | 61 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AEXML", 6 | "repositoryURL": "https://github.com/tadija/AEXML.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", 10 | "version": "4.6.1" 11 | } 12 | }, 13 | { 14 | "package": "Commander", 15 | "repositoryURL": "https://github.com/kylef/Commander.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "4a1f2fb82fb6cef613c4a25d2e38f702e4d812c2", 19 | "version": "0.9.2" 20 | } 21 | }, 22 | { 23 | "package": "Files", 24 | "repositoryURL": "https://github.com/johnsundell/files.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "d273b5b7025d386feef79ef6bad7de762e106eaf", 28 | "version": "4.2.0" 29 | } 30 | }, 31 | { 32 | "package": "Komondor", 33 | "repositoryURL": "https://github.com/shibapm/Komondor.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "90b087b1e39069684b1ff4bf915c2aae594f2d60", 37 | "version": "1.1.3" 38 | } 39 | }, 40 | { 41 | "package": "PackageConfig", 42 | "repositoryURL": "https://github.com/shibapm/PackageConfig.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "58523193c26fb821ed1720dcd8a21009055c7cdb", 46 | "version": "1.1.3" 47 | } 48 | }, 49 | { 50 | "package": "PathKit", 51 | "repositoryURL": "https://github.com/kylef/PathKit.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", 55 | "version": "1.0.1" 56 | } 57 | }, 58 | { 59 | "package": "Rainbow", 60 | "repositoryURL": "https://github.com/onevcat/Rainbow", 61 | "state": { 62 | "branch": null, 63 | "revision": "626c3d4b6b55354b4af3aa309f998fae9b31a3d9", 64 | "version": "3.2.0" 65 | } 66 | }, 67 | { 68 | "package": "ShellOut", 69 | "repositoryURL": "https://github.com/JohnSundell/ShellOut.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568", 73 | "version": "2.3.0" 74 | } 75 | }, 76 | { 77 | "package": "Spectre", 78 | "repositoryURL": "https://github.com/kylef/Spectre.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7", 82 | "version": "0.10.1" 83 | } 84 | }, 85 | { 86 | "package": "Stencil", 87 | "repositoryURL": "https://github.com/stencilproject/Stencil.git", 88 | "state": { 89 | "branch": "master", 90 | "revision": "4f222ac85d673f35df29962fc4c36ccfdaf9da5b", 91 | "version": null 92 | } 93 | }, 94 | { 95 | "package": "StencilSwiftKit", 96 | "repositoryURL": "https://github.com/SwiftGen/StencilSwiftKit", 97 | "state": { 98 | "branch": null, 99 | "revision": "20e2de5322c83df005939d9d9300fab130b49f97", 100 | "version": "2.10.1" 101 | } 102 | }, 103 | { 104 | "package": "XcodeProj", 105 | "repositoryURL": "https://github.com/tuist/xcodeproj.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "c4d5f9d7f789dd944222be95938810947561e559", 109 | "version": "8.12.0" 110 | } 111 | }, 112 | { 113 | "package": "Yams", 114 | "repositoryURL": "https://github.com/jpsim/Yams.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", 118 | "version": "4.0.6" 119 | } 120 | } 121 | ] 122 | }, 123 | "version": 1 124 | } 125 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let dependencies: [Target.Dependency] = ["Files", "Rainbow", "Stencil", "StencilSwiftKit", "XcodeProj", "Yams"] 7 | 8 | let package = Package( 9 | name: "Murray", 10 | products: [ 11 | .executable(name: "murray", targets: ["Murray"]), 12 | .library(name: "MurrayKit", targets: ["MurrayKit"]) 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | .package(url: "https://github.com/onevcat/Rainbow", from: "3.0.0"), 18 | .package( 19 | name: "Files", 20 | url: "https://github.com/johnsundell/files.git", 21 | from: "4.0.0" 22 | ), 23 | .package(name: "XcodeProj", url: "https://github.com/tuist/xcodeproj.git", from: "8.0.0"), 24 | .package(url: "https://github.com/kylef/Commander.git", from: "0.8.0"), 25 | .package(url: "https://github.com/stencilproject/Stencil.git", .branch("master")), 26 | .package(url: "https://github.com/jpsim/Yams.git", from: "4.0.3"), 27 | .package(url: "https://github.com/SwiftGen/StencilSwiftKit", from: "2.10.1") 28 | 29 | ], 30 | targets: [ 31 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 32 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 33 | .target(name: "MurrayKit", 34 | dependencies: dependencies), 35 | .target(name: "Murray", 36 | dependencies: ["MurrayKit", "Commander"] + dependencies), 37 | .testTarget(name: "MurrayKitTests", 38 | dependencies: ["MurrayKit"] + dependencies, 39 | resources: [.copy("Mocks")]) 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /Sources/Murray/Commands/Clone.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 26/07/22. 6 | // 7 | 8 | import Commander 9 | import Foundation 10 | import MurrayKit 11 | 12 | extension Commander.Group { 13 | func cloneCommand(in folder: Folder, 14 | name: String = "clone") { 15 | command(name, 16 | Argument("mainPlaceholder", 17 | description: .runMainPlaceholderDescription), 18 | Argument("path", 19 | description: .cloneGitDescription), 20 | Flag("verbose", description: .verboseDescription), 21 | Flag("copyFromLocalFolder", description: .cloneForceLocalPathDescription), 22 | Argument("subfolder", 23 | description: .cloneGitSubfolderDescription), 24 | Argument<[String]?>("parameters", 25 | description: .runParametersDescription), 26 | description: .cloneDescription) { name, path, verbose, copyFromLocalFolder, subfolder, parameters in 27 | 28 | Clone(path: path, 29 | folder: folder, 30 | subfolderPath: subfolder, 31 | mainPlaceholder: name, 32 | copyFromLocalFolder: copyFromLocalFolder, 33 | parameters: parameters) 34 | .executeAndCatch(verbose: verbose) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Murray/Commands/List.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Commander 9 | import Foundation 10 | import MurrayKit 11 | 12 | extension Commander.Group { 13 | func listCommand(in folder: Folder, name: String = "list") { 14 | command(name, 15 | Flag("verbose", description: .verboseDescription), 16 | description: .listDescription) { verbose in 17 | try withVerbose(verbose) { 18 | try List(folder: folder) 19 | .executeAndCatch(verbose: verbose) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Murray/Commands/Run.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Commander 9 | import Foundation 10 | import MurrayKit 11 | 12 | extension Commander.Group { 13 | func runCommand(in folder: Folder, name: String = "run") { 14 | command(name, 15 | Argument("name", 16 | description: .runNameDescription), 17 | Argument("mainPlaceholder", 18 | description: .runMainPlaceholderDescription), 19 | Flag("verbose", description: .verboseDescription), 20 | Flag("preview", description: .runPreviewDescription), 21 | Argument<[String]?>("parameters", 22 | description: .runParametersDescription), 23 | description: .runDescription) { name, mainPlaceholder, verbose, preview, params in 24 | 25 | Run(folder: folder, 26 | mainPlaceholder: mainPlaceholder, 27 | name: name, 28 | preview: preview, 29 | verbose: verbose, 30 | params: params) 31 | .executeAndCatch(verbose: verbose) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Murray/Commands/Scaffold.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Commander 9 | import Foundation 10 | import MurrayKit 11 | 12 | extension Commander.Group { 13 | private func murrayfile(in folder: Folder, name: String = "murrayfile") { 14 | command(name, 15 | Flag("verbose"), 16 | Option("format", default: nil, description: .scaffoldFileFormatDescription), 17 | description: .scaffoldMurrayfileDescription) { verbose, format in 18 | try withVerbose(verbose) { 19 | Scaffold 20 | .murrayfile(encoding: .init(rawValue: format) ?? .yml, in: folder) 21 | .executeAndCatch(verbose: verbose) 22 | } 23 | } 24 | } 25 | 26 | private func skeletonfile(in folder: Folder, name: String = "skeleton") { 27 | command(name, 28 | Flag("verbose"), 29 | Option("format", default: nil, description: .scaffoldFileFormatDescription), 30 | description: .scaffoldSkeletonfileDescription) { verbose, format in 31 | Scaffold 32 | .skeletonfile(encoding: .init(rawValue: format) ?? .yml, in: folder) 33 | .executeAndCatch(verbose: verbose) 34 | } 35 | } 36 | 37 | private func package(in folder: Folder, 38 | name: String = "package") { 39 | command(name, 40 | Flag("verbose"), 41 | Argument("name", 42 | description: .scaffoldPackageNameDescription), 43 | 44 | Option("folder", 45 | default: "Murray", 46 | description: .scaffoldPackageFolderDescription), 47 | 48 | Option("format", 49 | default: nil, 50 | description: .scaffoldFileFormatDescription), 51 | description: .scaffoldPackageDescription) { verbose, name, path, format in 52 | Scaffold 53 | .package(named: name, 54 | encoding: .init(rawValue: format), 55 | description: .init(format: .scaffoldPackageDefaultDescriptionFormat, name), 56 | rootFolder: folder, 57 | path: path) 58 | .executeAndCatch(verbose: verbose) 59 | } 60 | } 61 | 62 | private func item(in folder: Folder, 63 | name: String = "bone") { 64 | command(name, 65 | Flag("verbose"), 66 | Argument("packageName", 67 | description: .scaffoldItemPackageNameDescription), 68 | Argument("name", 69 | description: .scaffoldItemNameDescription), 70 | Flag("skipProcedure", 71 | description: .scaffoldItemSkipProcedureDescription), 72 | Argument<[String]>("files", 73 | description: .scaffoldItemFilesDescription), 74 | description: .scaffoldItemDescription) { verbose, package, name, skipProcedure, files in 75 | Scaffold.item(named: name, 76 | package: package, 77 | description: .init(format: .scaffoldItemDefaultDescriptionFormat, name), 78 | rootFolder: folder, 79 | createProcedure: !skipProcedure, 80 | files: files) 81 | .executeAndCatch(verbose: verbose) 82 | } 83 | } 84 | 85 | private func procedure(in folder: Folder, 86 | name: String = "procedure") { 87 | command(name, 88 | Flag("verbose"), 89 | Argument("packageName", 90 | description: .scaffoldProcedurePackageNameDescription), 91 | Argument("name", 92 | description: .scaffoldProcedureNameDescription), 93 | Argument<[String]>("itemNames", 94 | description: .scaffoldProcedureItemsDescription), 95 | description: .scaffoldProcedureDescription) { verbose, package, name, items in 96 | Scaffold.procedure(named: name, 97 | package: package, 98 | description: .init(format: .scaffoldProcedureDefaultDescriptionFormat, name), 99 | rootFolder: folder, 100 | itemNames: items) 101 | .executeAndCatch(verbose: verbose) 102 | } 103 | } 104 | 105 | func scaffoldCommand(in folder: Folder, name: String = "scaffold") { 106 | group(name) { 107 | $0.murrayfile(in: folder) 108 | $0.skeletonfile(in: folder) 109 | $0.package(in: folder) 110 | $0.item(in: folder) 111 | $0.procedure(in: folder) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/Murray/Menu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Menu.swift 3 | // MurrayCore 4 | // 5 | // Created by Stefano Mondino on 12/07/18. 6 | // 7 | 8 | import Commander 9 | 10 | import Foundation 11 | import MurrayKit 12 | import Rainbow 13 | 14 | func commands() -> Group { 15 | let folder = Folder.current 16 | #if DEBUG 17 | Rainbow.enabled = false 18 | #endif 19 | 20 | return Group { group in 21 | group.listCommand(in: folder) 22 | group.runCommand(in: folder) 23 | group.scaffoldCommand(in: folder) 24 | group.cloneCommand(in: folder) 25 | group.group("bone", 26 | .boneDescription) { group in 27 | group.listCommand(in: folder) 28 | group.runCommand(in: folder, name: "new") 29 | } 30 | // Skeleton.commands(for: $0) 31 | // Bone.commands(for: $0) 32 | // Scaffold.commands(for: $0) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Murray/Strings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Foundation 9 | // swiftlint:disable identifier_name line_length 10 | extension String { 11 | @Translation("Print complete log during command execution") 12 | static var verboseDescription: String 13 | 14 | @Translation("A set of commands to interact with bones in current folder") 15 | static var boneDescription: String 16 | 17 | @Translation("List all available bones.") 18 | static var listDescription: String 19 | 20 | @Translation("Run selected procedure in current folder") 21 | static var runDescription: String 22 | 23 | @Translation("Name of the bone from bonespec (example: model). If multiple bonespecs are being used, use . syntax. Example: myBones.model") 24 | static var runNameDescription: String 25 | 26 | @Translation("Value that needs to be replaced in templates wherever the keyword is used.") 27 | static var runMainPlaceholderDescription: String 28 | 29 | @Translation("Previews results instead of actually execute it") 30 | static var runPreviewDescription: String 31 | 32 | @Translation("Custom parameters for templates. Use key:value syntax (ex: \"author:yourname with spaces\")") 33 | static var runParametersDescription: String 34 | 35 | @Translation("Create a new Murrayfile in current folder") 36 | static var scaffoldMurrayfileDescription: String 37 | 38 | @Translation("Create a new Skeleton in current folder") 39 | static var scaffoldSkeletonfileDescription: String 40 | 41 | @Translation("Create a new Package in specified folder") 42 | static var scaffoldPackageDescription: String 43 | 44 | @Translation("Package name") 45 | static var scaffoldPackageNameDescription: String 46 | 47 | @Translation("Default folder containing all Murray packages, relative to Murrayfile directory.") 48 | static var scaffoldPackageFolderDescription: String 49 | 50 | @Translation("A package named %@ created from scaffold") 51 | static var scaffoldPackageDefaultDescriptionFormat: String 52 | 53 | @Translation("An item named %@ created from scaffold") 54 | static var scaffoldItemDefaultDescriptionFormat: String 55 | 56 | @Translation("An procedure named %@ created from scaffold") 57 | static var scaffoldProcedureDefaultDescriptionFormat: String 58 | 59 | @Translation("Create a new item in specified package.") 60 | static var scaffoldItemDescription: String 61 | 62 | @Translation("Name of item to be created") 63 | static var scaffoldItemNameDescription: String 64 | @Translation("Name of package where current item will be included into") 65 | static var scaffoldItemPackageNameDescription: String 66 | 67 | @Translation("File names to be created (empty) and associated to current item") 68 | static var scaffoldItemFilesDescription: String 69 | 70 | @Translation("Skip automatic procedure creation for current item.") 71 | static var scaffoldItemSkipProcedureDescription: String 72 | 73 | @Translation("Create a new procedure in specified package with provided items in sequence.") 74 | static var scaffoldProcedureDescription: String 75 | @Translation("Name of package where procedure will be included into") 76 | static var scaffoldProcedurePackageNameDescription: String 77 | @Translation("Name of the procedure that will be used in run command") 78 | static var scaffoldProcedureNameDescription: String 79 | @Translation("Item names to include in this procedure") 80 | static var scaffoldProcedureItemsDescription: String 81 | 82 | @Translation("File format for file. Can be yml or json. Defaults to yml.") 83 | static var scaffoldFileFormatDescription: String 84 | 85 | @Translation("Clone a remote repository containing a Skeleton file") 86 | static var cloneDescription: String 87 | 88 | @Translation("A path pointing to a valid git repository, either local or remote. To specify a custom branch/tag, use @ or @ or @ right after the url. \nExample: https://github.com/synesthesia-it/Murray@develop") 89 | static var cloneGitDescription: String 90 | 91 | @Translation("Provided path should be intended as a local folder, ignoring git status, copy folder as-is. This is useful for local development to avoid committing every test.") 92 | static var cloneForceLocalPathDescription: String 93 | 94 | @Translation("A subfolder path containing the actual Skeleton project. This is useful when the same repository contains more than one Skeleton,") 95 | static var cloneGitSubfolderDescription: String 96 | } 97 | 98 | @propertyWrapper 99 | struct Translation { 100 | let key: String 101 | init(_ key: String) { 102 | self.key = key 103 | } 104 | 105 | var wrappedValue: String { key } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/Murray/main.swift: -------------------------------------------------------------------------------- 1 | import Commander 2 | import Foundation 3 | import MurrayKit 4 | 5 | commands().run() 6 | 7 | func withVerbose(_ verbose: Bool, callback: () throws -> Void) rethrows { 8 | if verbose { 9 | Logger.logLevel = .verbose 10 | } 11 | try callback() 12 | } 13 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Coding/Decoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | public protocol Decoder { 12 | func decode(_ data: Data) throws -> Value 13 | } 14 | 15 | public extension Decoder { 16 | func decode(_ data: Data, 17 | of _: Value.Type) throws -> Value { 18 | try decode(data) 19 | } 20 | 21 | func decode(_ string: String, 22 | encoding: String.Encoding = .utf8, 23 | of _: Value.Type) throws -> Value { 24 | let data = string.data(using: encoding) ?? Data() 25 | return try decode(data) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Coding/Encoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol Encoder { 11 | func encode(_ object: Value) throws -> Data 12 | } 13 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Coding/JSONCoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension JSONDecoder: Decoder { 11 | public func decode(_ data: Data) throws -> Value where Value: Decodable { 12 | do { 13 | return try decode(Value.self, from: data) 14 | } catch { 15 | switch error { 16 | case is Errors: throw error 17 | default: throw Errors.unparsableContent(error.localizedDescription) 18 | } 19 | } 20 | } 21 | } 22 | 23 | extension JSONEncoder: Encoder {} 24 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Coding/Parameters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias JSON = [String: AnyHashable] 11 | 12 | public struct Parameters: Codable, 13 | Hashable, 14 | Equatable, 15 | ExpressibleByDictionaryLiteral, 16 | ExpressibleByStringLiteral, 17 | ExpressibleByArrayLiteral, 18 | ExpressibleByNilLiteral, 19 | CustomDebugStringConvertible, 20 | CustomStringConvertible, 21 | Collection { 22 | private enum WrappedValue: Codable, Equatable, Hashable { 23 | case `nil` 24 | case string(String) 25 | case array([Parameters]) 26 | case dictionary([String: Parameters]) 27 | init(_ parameters: Parameters) { 28 | self = parameters.value 29 | } 30 | 31 | var value: AnyHashable? { 32 | switch self { 33 | case let .dictionary(value): return value.reduce(into: JSON()) { 34 | $0[$1.key.description] = $1.value.value.value 35 | } 36 | case let .array(value): return value.compactMap { $0.value.value } 37 | case let .string(value): return value 38 | default: return nil 39 | } 40 | } 41 | } 42 | 43 | public var startIndex: Int { array?.startIndex ?? 0 } 44 | 45 | public var endIndex: Int { array?.endIndex ?? 0 } 46 | 47 | private var value: WrappedValue 48 | 49 | private var array: [Parameters]? { 50 | switch value { 51 | case let .array(value): 52 | return value 53 | default: return nil 54 | } 55 | } 56 | 57 | var dictionaryValue: JSON? { 58 | value.value as? JSON 59 | } 60 | 61 | var arrayValue: [JSON.Value]? { 62 | value.value as? [JSON.Value] 63 | } 64 | 65 | var stringValue: String? { 66 | readValue() 67 | } 68 | 69 | public init(from decoder: Swift.Decoder) throws { 70 | let container = try decoder.singleValueContainer() 71 | if let dictionary = try? container.decode([String: Parameters].self) { 72 | value = .dictionary(dictionary) 73 | } else if let array = try? container.decode([Parameters].self) { 74 | value = .array(array) 75 | } else if let value = try? container.decode(String.self) { 76 | self.value = .string(value) 77 | } else { 78 | value = .nil 79 | } 80 | } 81 | 82 | public func encode(to encoder: Swift.Encoder) throws { 83 | var container = encoder.singleValueContainer() 84 | switch value { 85 | case let .dictionary(value): 86 | try container.encode(value) 87 | case let .array(value): 88 | try container.encode(value) 89 | case let .string(value): 90 | try container.encode(value) 91 | case .nil: 92 | break 93 | } 94 | } 95 | 96 | private init?(_ undefined: Any) { 97 | switch undefined { 98 | case let string as String: self.init(value: .string(string)) 99 | case let array as [JSON.Value]: self.init(value: .array(array.compactMap { Parameters($0) })) 100 | case let dictionary as JSON: self.init(dictionary) 101 | default: return nil 102 | } 103 | } 104 | 105 | public init(_ json: JSON) { 106 | value = .dictionary(json.reduce(into: [:]) { 107 | $0[$1.0] = Parameters($1.1) 108 | }) 109 | } 110 | 111 | public init(dictionaryLiteral elements: (String, Parameters)...) { 112 | value = .dictionary(elements.reduce(into: [:]) { 113 | $0[$1.0] = $1.1 114 | }) 115 | } 116 | 117 | public init(stringLiteral value: StringLiteralType) { 118 | self.value = .string(value) 119 | } 120 | 121 | public init(arrayLiteral elements: Parameters...) { 122 | value = .array(elements) 123 | } 124 | 125 | public init(nilLiteral _: ()) { 126 | value = .nil 127 | } 128 | 129 | private init(value: WrappedValue) { 130 | self.value = value 131 | } 132 | 133 | public var debugDescription: String { 134 | switch value { 135 | case let .dictionary(value): 136 | return value.debugDescription 137 | case let .array(value): 138 | return value.debugDescription 139 | case let .string(value): 140 | return value.debugDescription 141 | case .nil: return String?(nilLiteral: ()).debugDescription 142 | } 143 | } 144 | 145 | public var description: String { 146 | debugDescription 147 | } 148 | 149 | private func readValue() -> Value? { 150 | switch value { 151 | case let .dictionary(value): return value as? Value 152 | case let .array(value): return value as? Value 153 | case let .string(value): return value as? Value 154 | default: return nil 155 | } 156 | } 157 | 158 | public subscript(index: String) -> String? { 159 | self[index]?.readValue() 160 | } 161 | 162 | public subscript(index: String) -> [Parameters]? { 163 | self[index]?.readValue() 164 | } 165 | 166 | public subscript(index: String) -> Parameters? { 167 | get { 168 | switch value { 169 | case let .dictionary(value): return value[index] 170 | default: return nil 171 | } 172 | } 173 | set(newValue) { 174 | switch value { 175 | case let .dictionary(value): 176 | var dictionary = value 177 | dictionary[index] = newValue 178 | self.value = .dictionary(dictionary) 179 | default: break 180 | } 181 | } 182 | } 183 | 184 | public subscript(index: Int) -> Parameters? { 185 | get { 186 | switch value { 187 | case let .array(value): return value[index] 188 | default: return nil 189 | } 190 | } 191 | set(newValue) { 192 | switch value { 193 | case let .array(value): 194 | var array = value 195 | if let newValue = newValue { 196 | array[index] = newValue 197 | } else if array.count > index { 198 | array.remove(at: index) 199 | } 200 | self.value = .array(array) 201 | default: break 202 | } 203 | } 204 | } 205 | 206 | public func index(after index: Int) -> Int { 207 | array?.index(after: index) ?? 0 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Coding/YAMLCoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | extension YAMLDecoder: Decoder { 12 | public func decode(_ data: Data) throws -> Value where Value: Decodable { 13 | do { 14 | return try decode(Value.self, from: data) 15 | } catch { 16 | switch error { 17 | case let DecodingError.dataCorrupted(inner): 18 | switch inner.underlyingError ?? error { 19 | case is Errors: throw inner.underlyingError ?? error 20 | default: 21 | if let underlying = inner.underlyingError as? YamlError { 22 | throw Errors.unparsableContent(underlying.description) 23 | } 24 | throw Errors.unparsableContent((inner.underlyingError ?? error).localizedDescription) 25 | } 26 | default: throw Errors.unparsableContent(error.localizedDescription) 27 | } 28 | } 29 | } 30 | } 31 | 32 | extension YAMLEncoder: Encoder { 33 | public func encode(_ object: Value) throws -> Data where Value: Encodable { 34 | let string: String = try encode(object) 35 | return string.data(using: .utf8) ?? Data() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Commands/Clone.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Clone.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 26/07/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Clone: CommandWithContext { 11 | public let mainPlaceholder: String 12 | // public let repository: Repository 13 | public let path: String 14 | public let forceLocalCopy: Bool 15 | public let params: [String] 16 | public let folder: Folder 17 | public let subfolderPath: String? 18 | 19 | public init(path: String, 20 | folder: Folder, 21 | subfolderPath: String? = nil, 22 | mainPlaceholder: String, 23 | copyFromLocalFolder: Bool = false, 24 | parameters: [String]? = nil) { 25 | self.path = path 26 | self.folder = folder 27 | self.subfolderPath = subfolderPath 28 | self.mainPlaceholder = mainPlaceholder 29 | params = parameters ?? [] 30 | forceLocalCopy = copyFromLocalFolder 31 | } 32 | 33 | public func execute() throws { 34 | let context: Template.Context = .init(context(mainPlaceholderKey: "name")) 35 | guard let projectName: String = context.values["name"] as? String else { 36 | throw Errors.unknown 37 | } 38 | 39 | Logger.log("Template context:\n\(context)\n", level: .verbose) 40 | 41 | try? Folder.temporary.subfolder(named: projectName).delete() 42 | var temporaryProjectFolder: Folder 43 | if forceLocalCopy { 44 | Logger.log("Copying contents from: \(path)") 45 | let destination = try Folder.temporary.createSubfolderIfNeeded(withName: projectName) 46 | let copySource = try Folder(path: path) 47 | temporaryProjectFolder = try copySource.copy(to: destination) 48 | } else { 49 | let repository = Repository(at: path) 50 | Logger.log("Cloning repository from \(repository) into \(Folder.temporary.path)", 51 | level: .verbose) 52 | temporaryProjectFolder = try clone(from: repository, 53 | into: Folder.temporary, 54 | projectName: projectName) 55 | 56 | Logger.log("Project cloned to \(temporaryProjectFolder.path)") 57 | } 58 | Logger.log("Creating final project folder at \(folder.path)\(projectName)", 59 | level: .verbose) 60 | 61 | let projectFolder = try folder.createSubfolderIfNeeded(withName: projectName) 62 | 63 | Logger.log("Moving contents from temporary folder") 64 | 65 | if let subfolderPath = subfolderPath { 66 | Logger.log("Looking for subfolder \(subfolderPath) in checked out folder \(temporaryProjectFolder.path)") 67 | try temporaryProjectFolder = temporaryProjectFolder.subfolder(at: subfolderPath) 68 | } 69 | 70 | try temporaryProjectFolder.moveContents(to: projectFolder, includeHidden: true) 71 | 72 | guard let skeleton = try? CodableFile(in: projectFolder) else { 73 | throw Errors.noValidSkeletonFound("\(projectFolder.path)") 74 | } 75 | 76 | Logger.log("Deleting original git folder, if present", 77 | level: .verbose) 78 | try? projectFolder.subfolder(named: ".git").delete() 79 | 80 | try resolvePaths(for: skeleton, 81 | projectFolder: projectFolder, 82 | context: context) 83 | 84 | Logger.log("Launching custom scripts", 85 | level: .verbose) 86 | try skeleton.object.scripts.forEach { 87 | let script = try $0.resolve(with: context) 88 | Logger.log("\(script)", 89 | level: .verbose) 90 | try Process().launchBash(with: script, in: projectFolder) 91 | } 92 | 93 | if skeleton.object.initializeGit { 94 | Logger.log("Initializing git version control", 95 | level: .verbose) 96 | try Process().launchBash(with: "git init", in: projectFolder) 97 | } 98 | 99 | Logger.log("Deleting skeleton file at \(skeleton.file.path(relativeTo: folder))", 100 | level: .verbose) 101 | try skeleton.file.delete() 102 | Logger.log("Created new project named \(projectName) at \(projectFolder.path)") 103 | } 104 | 105 | private func resolvePaths(for skeleton: CodableFile, 106 | projectFolder: Folder, 107 | context: Template.Context) throws { 108 | Logger.log("Resolving paths", 109 | level: .verbose) 110 | let pluginManager = PluginManager.shared 111 | try skeleton.object.paths.forEach { path in 112 | let enrichedContext = context.adding(path.customParameters()) 113 | try pluginManager.execute(.init(element: path, 114 | context: enrichedContext, 115 | phase: .before, 116 | root: projectFolder)) 117 | 118 | try skeleton.writeableFiles(for: path, 119 | resolveSource: false, 120 | context: context, 121 | destinationRoot: projectFolder) 122 | .forEach { file in 123 | try file.commit(context: context) 124 | } 125 | try pluginManager.execute(.init(element: path, 126 | context: enrichedContext, 127 | phase: .after, 128 | root: projectFolder)) 129 | } 130 | Logger.log("Deleting original paths", 131 | level: .verbose) 132 | skeleton.object.paths 133 | .filter { $0.from != ((try? $0.to.resolve(with: context)) ?? $0.to) } 134 | .map { $0.from } 135 | .forEach { 136 | Logger.log("Deleting \($0)", 137 | level: .verbose) 138 | try? projectFolder.file(at: $0).delete() 139 | try? projectFolder.subfolder(at: $0).delete() 140 | } 141 | } 142 | 143 | private func clone(from repository: Repository, 144 | into folder: Folder, 145 | projectName: String) throws -> Folder { 146 | do { 147 | var command = "git clone --single-branch --depth 1 " 148 | if repository.version.isEmpty == false { 149 | command += "--branch \(repository.version) " 150 | } 151 | 152 | command += repository.repo + " " + folder.path + projectName 153 | Logger.log("Cloning - command: \(command)", level: .verbose) 154 | try Process().launchBash(with: command) 155 | let projectFolder = try folder.subfolder(named: projectName) 156 | return projectFolder 157 | } catch { 158 | Logger.log("\(error)", level: .verbose) 159 | switch error { 160 | case is Errors: throw error 161 | default: throw Errors.invalidGitRepository(repository.package) 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Commands/Command.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol Command { 11 | func execute() throws 12 | func executeAndCatch(verbose: Bool) 13 | } 14 | 15 | public protocol CommandWithContext: Command { 16 | var mainPlaceholder: String { get } 17 | var params: [String] { get } 18 | } 19 | 20 | public extension CommandWithContext { 21 | func context(mainPlaceholderKey: String) -> JSON { 22 | return params.reduce(into: [mainPlaceholderKey: mainPlaceholder]) { context, pair in 23 | let elements = pair.components(separatedBy: ":") 24 | guard elements.count == 2 else { return } 25 | context[elements[0]] = elements[1] 26 | } 27 | } 28 | } 29 | 30 | public extension Command { 31 | func executeAndCatch(verbose: Bool) { 32 | do { 33 | if verbose { 34 | Logger.logLevel = .verbose 35 | } 36 | try execute() 37 | } catch { 38 | switch error { 39 | case let error as Errors: Logger.log(.error(error)) 40 | // case let error as Files.LocationError: 41 | // Logger.log("Some error occured with file at:\(error.path)") 42 | default: Logger.log(error.localizedDescription) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Commands/List.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct List { 11 | public let murrayfile: CodableFile 12 | 13 | public init(murrayfile: CodableFile) { 14 | self.murrayfile = murrayfile 15 | } 16 | 17 | public init(folder: Folder, 18 | murrayfileName: String = Murrayfile.defaultName) 19 | throws { 20 | do { 21 | let murrayfile = try CodableFile(in: folder, defaultName: murrayfileName) 22 | self.init(murrayfile: murrayfile) 23 | } catch { 24 | switch error { 25 | case Errors.fileLocationError: throw Errors.murrayfileNotFound(folder.path) 26 | case is Errors: throw error 27 | default: throw Errors.murrayfileNotFound(folder.path) 28 | } 29 | } 30 | } 31 | 32 | public func packages() throws -> [CodableFile] { 33 | try murrayfile.packages() 34 | } 35 | 36 | public func list() throws -> [PackagedProcedure] { 37 | try murrayfile.packages() 38 | .flatMap { package in 39 | package.object 40 | .procedures 41 | .map { .init(package: package, 42 | procedure: $0) } 43 | } 44 | } 45 | } 46 | 47 | extension List: Command { 48 | public func execute() throws { 49 | let list = try list() 50 | let strings = list.map { 51 | "\($0.package.object.name.lightGreen).\($0.procedure.name.green): \($0.procedure.description)" 52 | } 53 | strings.forEach { 54 | Logger.log($0, level: .normal) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Commands/Run.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Run.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Run: CommandWithContext { 11 | var folder: Folder 12 | public let mainPlaceholder: String 13 | let name: String 14 | let preview: Bool 15 | public let params: [String] 16 | let verbose: Bool 17 | 18 | public init(folder: Folder, 19 | mainPlaceholder: String, 20 | name: String, 21 | preview: Bool, 22 | verbose: Bool, 23 | params: [String]?) { 24 | self.folder = folder 25 | self.mainPlaceholder = mainPlaceholder 26 | self.name = name 27 | self.params = params ?? [] 28 | self.preview = preview 29 | self.verbose = verbose 30 | } 31 | 32 | public func execute() throws { 33 | let murrayfile = try CodableFile(in: folder) 34 | let context = context(mainPlaceholderKey: murrayfile.object.namePlaceholder) 35 | 36 | let pipeline = try Pipeline(murrayfile: murrayfile, 37 | procedure: name, 38 | context: .init(context)) 39 | 40 | let missingParameters = try pipeline.missingParameters() 41 | guard missingParameters.isEmpty else { 42 | throw Errors 43 | .missingRequiredParameters(missingParameters) 44 | } 45 | 46 | let invalidParameters = try pipeline.invalidParameters() 47 | guard invalidParameters.isEmpty else { 48 | throw Errors 49 | .invalidParameters(invalidParameters) 50 | } 51 | 52 | let files = try pipeline.writeableFiles() 53 | if preview { 54 | try files.forEach { file in 55 | let contents = try file.preview(context: pipeline.context) 56 | if verbose { 57 | Logger.log("File contents preview:\n\n\(contents)", level: .normal) 58 | } 59 | } 60 | } else { 61 | Logger.log("Running pipeline '\(name)'") 62 | try pipeline.run() 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Commands/Scaffold.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 20/05/22. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | public struct Scaffold: Command { 12 | private let closure: () throws -> Void 13 | 14 | private init(_ closure: @escaping () throws -> Void) { 15 | self.closure = closure 16 | } 17 | 18 | public static func murrayfile(named name: String = Murrayfile.defaultName, 19 | encoding: CodableFile.Encoding, 20 | in folder: Folder) -> Scaffold { 21 | .init { 22 | let file = Murrayfile.empty 23 | try CodableFile.create(file, 24 | encoding: encoding, 25 | named: "\(name).\(encoding.rawValue)", 26 | in: folder) 27 | } 28 | } 29 | 30 | public static func skeletonfile(named name: String = Skeleton.defaultName, 31 | encoding: CodableFile.Encoding, 32 | in folder: Folder) -> Scaffold { 33 | .init { 34 | let file = Skeleton.empty 35 | try CodableFile.create(file, 36 | encoding: encoding, 37 | named: "\(name).\(encoding.rawValue)", 38 | in: folder) 39 | } 40 | } 41 | 42 | public static func package(named name: String, 43 | encoding: CodableFile.Encoding? = nil, 44 | description: String, 45 | rootFolder: Folder, 46 | path: String = "Murray") -> Scaffold { 47 | return Scaffold { 48 | var murrayfile = try CodableFile(in: rootFolder) 49 | 50 | let encoding = encoding ?? murrayfile.encoding() 51 | 52 | let package = Package(name: name, 53 | description: description, 54 | procedures: []) 55 | let packageName = "\(name).\(encoding.rawValue)" 56 | 57 | let packageStructure = try CodableFile.create(package, 58 | encoding: encoding, 59 | named: packageName, 60 | in: rootFolder.createSubfolderIfNeeded(withName: path).createSubfolderIfNeeded(withName: name)) 61 | 62 | let newPath = packageStructure.file.path(relativeTo: rootFolder) 63 | 64 | try murrayfile.update { 65 | $0.add(packagePath: newPath) 66 | } 67 | } 68 | } 69 | 70 | public static func item(named name: String, 71 | package packageName: String, 72 | encoding: CodableFile.Encoding? = nil, 73 | description: String, 74 | rootFolder: Folder, 75 | createProcedure: Bool = true, 76 | files: [String]) -> Scaffold { 77 | Scaffold { 78 | let murrayfile = try CodableFile(in: rootFolder) 79 | guard var package = try murrayfile 80 | .packages() 81 | .first(where: { $0.object.name == packageName }), 82 | let packageFolder = package.file.parent 83 | else { 84 | throw Errors.invalidPackageName(packageName) 85 | } 86 | 87 | let targetFolder = try packageFolder.createSubfolderIfNeeded(withName: name) 88 | let encoding = encoding ?? murrayfile.encoding() 89 | 90 | let paths = try files.map { 91 | try targetFolder 92 | .createFileIfNeeded(at: $0) 93 | .path(relativeTo: targetFolder) 94 | }.map { Item.Path(from: $0, to: "") } 95 | 96 | let item = MurrayKit.Item(name: name, 97 | parameters: [], 98 | paths: paths, 99 | plugins: nil, 100 | optionalDescription: description, 101 | replacements: []) 102 | let fileName = "\(name).\(encoding.rawValue)" 103 | 104 | guard (try? targetFolder.file(named: fileName)) == nil else { 105 | throw Errors.itemAlreadyExists(name) 106 | } 107 | 108 | let file = try CodableFile.create(item, 109 | encoding: encoding, 110 | named: fileName, 111 | in: targetFolder) 112 | 113 | if createProcedure { 114 | let itemRelativePath = file.file.path(relativeTo: packageFolder) 115 | let procedure = Procedure(name: name, 116 | description: description, 117 | plugins: nil, 118 | itemPaths: [itemRelativePath]) 119 | try package.update { 120 | $0.add(procedure: procedure) 121 | } 122 | } 123 | } 124 | } 125 | 126 | public static func procedure(named name: String, 127 | package packageName: String, 128 | description: String, 129 | rootFolder: Folder, 130 | itemNames: [String]) -> Scaffold { 131 | Scaffold { 132 | let murrayfile = try CodableFile(in: rootFolder) 133 | guard var package = try murrayfile 134 | .packages() 135 | .first(where: { $0.object.name == packageName }), 136 | let packageFolder = package.file.parent 137 | else { 138 | throw Errors.invalidPackageName(packageName) 139 | } 140 | 141 | guard package.object 142 | .procedures 143 | .first(where: { $0.name.lowercased() == name.lowercased() }) == nil 144 | else { 145 | throw Errors.procedureAlreadyExists(name) 146 | } 147 | 148 | let availableItems = try Set(package.items()) 149 | let itemPaths = try itemNames.map { itemName -> String in 150 | guard let item = availableItems.first(where: { $0.object.name == itemName }) else { 151 | throw Errors.itemNotFound(itemName) 152 | } 153 | return item.file.path(relativeTo: packageFolder) 154 | } 155 | 156 | let procedure = Procedure(name: name, 157 | description: description, 158 | plugins: nil, 159 | itemPaths: itemPaths) 160 | 161 | try package.update { 162 | $0.add(procedure: procedure) 163 | } 164 | } 165 | } 166 | 167 | public func execute() throws { 168 | try closure() 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Errors: Swift.Error, Equatable, Hashable { 11 | public static func == (lhs: Errors, rhs: Errors) -> Bool { 12 | lhs.localizedDescription == rhs.localizedDescription 13 | } 14 | 15 | case unparsableFile(String) 16 | case unparsableContent(String) 17 | case unresolvableString(string: String, context: JSON) 18 | case invalidReplacement 19 | case unknown 20 | case folderLocationError(String) 21 | case fileLocationError(String) 22 | case unreadableFile(String) 23 | case unwriteableFile(String) 24 | case copyFolder(String) 25 | case moveFolder(String) 26 | case createFolder(String) 27 | case deleteFolder(String) 28 | case deleteFile(String) 29 | case procedureNotFound(name: String) 30 | case multipleProceduresFound(name: String) 31 | case murrayfileNotFound(String) 32 | case invalidPackageName(String) 33 | case itemAlreadyExists(String) 34 | case itemNotFound(String) 35 | case procedureAlreadyExists(String) 36 | case noValidSkeletonFound(String) 37 | case invalidGitRepository(String) 38 | case missingRequiredParameters([Item.Parameter]) 39 | case invalidParameters([Item.Parameter]) 40 | } 41 | 42 | private extension Array where Element == Item.Parameter { 43 | func invalidDescription() -> String { 44 | map { 45 | [$0.name, 46 | $0.description != $0.name ? $0.description : nil, 47 | "Allowed values: \(($0.values ?? []).joined(separator: ", "))"] 48 | .compactMap { $0 } 49 | .joined(separator: " - ") 50 | } 51 | .joined(separator: "\n") 52 | } 53 | 54 | func missingDescription() -> String { 55 | map { 56 | if $0.name == $0.description { return $0.name } 57 | return [$0.name, 58 | $0.description] 59 | .joined(separator: " - ") 60 | } 61 | .joined(separator: "\n") 62 | } 63 | } 64 | 65 | extension Errors: LocalizedError, CustomStringConvertible { 66 | public var description: String { localizedDescription.red } 67 | var localizedDescription: String { 68 | switch self { 69 | case let .missingRequiredParameters(parameters): 70 | return "Missing required parameters: \(parameters.missingDescription())" 71 | case let .invalidParameters(parameters): 72 | return "Provided parameters are not valid:\n\(parameters.invalidDescription())" 73 | case let .unparsableFile(filePath): return "Path at \(filePath) is not parsable" 74 | case let .unparsableContent(error): return "Unparsable content: \(error)" 75 | case let .unresolvableString(string, context): 76 | return "Provided string is not properly resolvable\n\nString:\n\(string)\n\nContext:\n\n\(context)" 77 | case .invalidReplacement: return "Error during replacement" 78 | case let .procedureNotFound(name): return "Procedure '\(name)' not found." 79 | case let .multipleProceduresFound(name): return "Multiple procedures found for name \(name). Use syntax PackageName.procedureName instead." 80 | case let .folderLocationError(path): return "Invalid folder at \(path)" 81 | case let .fileLocationError(path): return "Invalid file at \(path)" 82 | case let .unreadableFile(path): return "Unreadable file at \(path)" 83 | case let .unwriteableFile(path): return "Unwriteable file at \(path)" 84 | case let .copyFolder(path): return "Error copying folder at \(path)" 85 | case let .moveFolder(path): return "Error moving folder to \(path)" 86 | case let .createFolder(path): return "Error creating folder at \(path)" 87 | case let .deleteFolder(path): return "Error deleting folder at \(path)" 88 | case let .deleteFile(path): return "Error deleting file at \(path)" 89 | case let .murrayfileNotFound(path): return "No valid Murrayfile found in \(path)" 90 | case let .invalidPackageName(name): return "Provided package name '\(name)' is invalid. Check your Murrayfile." 91 | case let .itemAlreadyExists(name): return "Item named'\(name)' already exists." 92 | case let .itemNotFound(name): return "Item named '\(name)' not found" 93 | case let .procedureAlreadyExists(name): return "Procedure named '\(name)' already exists" 94 | case let .noValidSkeletonFound(path): return "No valid skeleton file found at \(path)" 95 | case let .invalidGitRepository(path): return "Invalid git repository at \(path)" 96 | case .unknown: return "Some error occurred" 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Logger/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // App 4 | // 5 | // Created by Stefano Mondino on 17/07/18. 6 | // Copyright © 2018 Synesthesia. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Rainbow 11 | 12 | public enum LogLevel: Int { 13 | case verbose = 1 14 | case warning = 10 15 | case network = 20 16 | case error = 100 17 | case normal = 200 18 | case none = 1000 19 | 20 | public func colorize(string: String) -> String { 21 | switch self { 22 | case .verbose: return string 23 | case .warning: return string.yellow 24 | case .network: return string.blue 25 | case .error: return string.red 26 | default: return string 27 | } 28 | } 29 | 30 | public var symbol: String { 31 | switch self { 32 | case .verbose: return "💬" 33 | case .network: return "🌐" 34 | case .warning: return "⚠️" 35 | case .error: return "⛔️" 36 | default: return "" 37 | } 38 | } 39 | } 40 | 41 | public protocol LoggerType: AnyObject { 42 | var logLevel: LogLevel { get set } 43 | func log(_ log: Log, level: LogLevel, tag: String?) 44 | } 45 | 46 | public enum Log: CustomStringConvertible, CustomDebugStringConvertible, Equatable { 47 | case error(Errors) 48 | case message(String) 49 | 50 | public var debugDescription: String { 51 | switch self { 52 | case let .error(error): return error.localizedDescription 53 | case let .message(string): return string 54 | } 55 | } 56 | 57 | public var description: String { 58 | switch self { 59 | case let .error(error): return error.localizedDescription 60 | case let .message(string): return string 61 | } 62 | } 63 | } 64 | 65 | open class ConsoleLogger: LoggerType { 66 | public var logLevel: LogLevel 67 | 68 | public init(logLevel: LogLevel) { 69 | self.logLevel = logLevel 70 | } 71 | 72 | open func string(_ message: String, level: LogLevel, tag: String?) -> String? { 73 | if logLevel.rawValue > level.rawValue { return nil } 74 | 75 | let string = 76 | """ 77 | \([ 78 | [level.symbol, tag] 79 | .compactMap { $0 } 80 | .filter { !$0.isEmpty } 81 | .joined(separator: " "), 82 | level 83 | .colorize(string: message) 84 | ] 85 | .compactMap { $0 } 86 | .filter { !$0.isEmpty } 87 | .joined(separator: ": ") 88 | ) 89 | """ 90 | return string 91 | } 92 | 93 | open func log(_ log: Log, level: LogLevel, tag: String?) { 94 | guard let string = string(log.description, level: level, tag: tag) else { return } 95 | print(string) 96 | } 97 | } 98 | 99 | public final class TestLogger: ConsoleLogger { 100 | public var messages: [Log] = [] 101 | 102 | public var lastMessage: Log? { messages.last } 103 | 104 | override public func log(_ log: Log, level: LogLevel, tag _: String?) { 105 | // let message = log.description 106 | // guard let string = string(message, level: level, tag: tag) else { return } 107 | // print(string) 108 | if logLevel.rawValue > level.rawValue { return } 109 | messages.append(log) 110 | print(log.description) 111 | } 112 | } 113 | 114 | public enum Logger { 115 | public static var logger: LoggerType = ConsoleLogger(logLevel: .normal) 116 | public static var logLevel: LogLevel { 117 | get { logger.logLevel } 118 | set { logger.logLevel = newValue } 119 | } 120 | 121 | public static func log(_ message: String, level: LogLevel = .normal, tag: String? = nil) { 122 | log(.message(message), level: level, tag: tag) 123 | } 124 | 125 | public static func log(_ log: Log, level: LogLevel = .normal, tag: String? = nil) { 126 | logger.log(log, level: level, tag: tag) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/CodableFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | /// A codable object with a local file system (File) representation 12 | public struct CodableFile: Hashable { 13 | public enum Encoding: String { 14 | case json 15 | case yml 16 | 17 | static var allValidExtensions: [String] { 18 | ["json", "yml", "yaml"] 19 | } 20 | 21 | fileprivate var encoder: Encoder { 22 | switch self { 23 | case .json: return JSONEncoder() 24 | case .yml: return YAMLEncoder() 25 | } 26 | } 27 | 28 | public init?(rawValue: String?) { 29 | guard let rawValue = rawValue else { 30 | return nil 31 | } 32 | switch rawValue { 33 | case "json": self = .json 34 | case "yml", "yaml": self = .yml 35 | default: return nil 36 | } 37 | } 38 | } 39 | 40 | public let file: File 41 | public private(set) var object: Object 42 | 43 | public init(file: File, object: Object) { 44 | self.file = file 45 | self.object = object 46 | } 47 | 48 | public mutating func reload() throws { 49 | object = try Self(file: file).object 50 | } 51 | 52 | public init(file: File, 53 | type _: Object.Type = Object.self) throws { 54 | // self.file = file 55 | let data = try file.read() 56 | if let ext = file.extension, 57 | let decoder: Decoder = Self.decoder(from: ext) { 58 | do { 59 | try self.init(file: file, object: decoder.decode(data)) 60 | } catch { 61 | Logger.log("\(error)", level: .error) 62 | throw Errors.unparsableFile(file.path) 63 | } 64 | 65 | } else { 66 | let decoders: [Decoder] = [JSONDecoder(), YAMLDecoder()] 67 | guard let object = decoders 68 | .lazy 69 | .compactMap({ try? $0.decode(data, of: Object.self) }) 70 | .first 71 | else { 72 | throw Errors.unparsableFile(file.path) 73 | } 74 | self.init(file: file, object: object) 75 | } 76 | } 77 | 78 | fileprivate static func decoder(from ext: String) -> Decoder? { 79 | switch ext.lowercased() { 80 | case "json": 81 | return JSONDecoder() 82 | case "yml", "yaml": 83 | return YAMLDecoder(encoding: .utf8) 84 | default: 85 | return nil 86 | } 87 | } 88 | 89 | fileprivate static func encoder(from ext: String) -> Encoder? { 90 | switch ext.lowercased() { 91 | case "json": 92 | return JSONEncoder() 93 | case "yml", "yaml": 94 | return YAMLEncoder() 95 | default: 96 | return nil 97 | } 98 | } 99 | 100 | @discardableResult 101 | public static func create(_ object: Object, 102 | encoding: Encoding = .yml, 103 | named name: String, 104 | in folder: Folder) throws -> CodableFile { 105 | let data = try encoding.encoder.encode(object) 106 | 107 | let file = try folder.createFileIfNeeded(at: name, contents: data) 108 | return .init(file: file, object: object) 109 | } 110 | 111 | private mutating func update(_ object: Object) throws { 112 | let data: Data 113 | if let ext = file.extension, 114 | let encoder: Encoder = Self.encoder(from: ext) { 115 | data = try encoder.encode(object) 116 | } else { 117 | let encoders: [Encoder] = [JSONEncoder(), YAMLEncoder()] 118 | guard let encodedData = encoders 119 | .lazy 120 | .compactMap({ try? $0.encode(object) }) 121 | .first 122 | else { 123 | throw Errors.unwriteableFile(file.path) 124 | } 125 | data = encodedData 126 | } 127 | guard let string = String(data: data, encoding: .utf8) else { 128 | throw Errors.unwriteableFile(file.path) 129 | } 130 | try file.write(string, encoding: .utf8) 131 | self.object = object 132 | } 133 | 134 | public mutating func update(_ closure: @escaping (inout Object) throws -> Void) throws { 135 | try closure(&object) 136 | try update(object) 137 | } 138 | } 139 | 140 | public extension Encodable { 141 | func dictionary() throws -> JSON? { 142 | let data = try JSONEncoder().encode(self) 143 | return try JSONSerialization.jsonObject(with: data) as? JSON 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Content.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Content { 11 | case file(File) 12 | case text(String) 13 | 14 | public func contents() throws -> String { 15 | switch self { 16 | case let .file(file): return try file.readAsString() 17 | case let .text(text): return text 18 | } 19 | } 20 | } 21 | 22 | extension Content: Resolvable { 23 | public func resolve(with context: Template.Context) throws -> String { 24 | try contents().resolve(with: context) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Item/Item+Writeable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 14/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension CodableFile where Object == Item { 11 | func writeableFiles(context: Template.Context, 12 | destinationRoot: Folder) throws -> [WriteableFile] { 13 | let paths = try object.paths.flatMap { 14 | try writeableFiles(for: $0, 15 | context: context, 16 | destinationRoot: destinationRoot) 17 | } 18 | 19 | let replacements = try object.replacements.map { 20 | try writeableFile(for: $0, context: context, destinationRoot: destinationRoot) 21 | } 22 | 23 | return paths + replacements 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Item/Item.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Item.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Item: Codable, CustomStringConvertible, Hashable { 11 | public struct Parameter: Codable, CustomStringConvertible, Hashable { 12 | private enum CodingKeys: String, CodingKey { 13 | case name 14 | case isRequired 15 | case values 16 | case optionalDescription = "description" 17 | } 18 | 19 | public let name: String 20 | public let isRequired: Bool 21 | private var optionalDescription: String? 22 | public var description: String { optionalDescription ?? name } 23 | public var values: [String]? 24 | 25 | public init(name: String, 26 | description: String? = nil, 27 | isRequired: Bool = true, 28 | values: [String]? = nil) { 29 | self.name = name 30 | self.isRequired = isRequired 31 | self.values = values 32 | optionalDescription = description 33 | } 34 | 35 | public init(from decoder: Swift.Decoder) throws { 36 | let container = try decoder.container(keyedBy: CodingKeys.self) 37 | name = try container.decode(String.self, forKey: .name) 38 | isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false 39 | values = try container.decodeIfPresent([String].self, forKey: .values) 40 | optionalDescription = try container.decodeIfPresent(String.self, forKey: .optionalDescription) 41 | } 42 | } 43 | 44 | public struct Path: Codable, CustomStringConvertible, Hashable { 45 | public let from: String 46 | public let to: String 47 | private let plugins: Parameters? 48 | public var pluginData: Parameters? { 49 | plugins 50 | } 51 | 52 | public var description: String { 53 | "From: \(from) to: \(to)" 54 | } 55 | 56 | public init(from: String, to: String, plugins: Parameters? = nil) { 57 | self.from = from 58 | self.to = to 59 | self.plugins = plugins 60 | } 61 | 62 | public func customParameters() -> JSON { 63 | ["_path": ["_from": from, 64 | "_to": to], 65 | "_filename": ["_from": from.filename, 66 | "_to": to.filename]] 67 | } 68 | } 69 | 70 | public struct Replacement: Codable, CustomStringConvertible, Hashable { 71 | private enum CodingKeys: String, CodingKey { 72 | case placeholder 73 | case destination 74 | case text 75 | case source 76 | case plugins 77 | } 78 | 79 | public let placeholder: String 80 | public let destination: String 81 | public let text: String? 82 | public let source: String? 83 | public let plugins: Parameters? 84 | public var pluginData: Parameters? { plugins } 85 | public init(from decoder: Swift.Decoder) throws { 86 | let container = try decoder.container(keyedBy: CodingKeys.self) 87 | placeholder = try container.decode(String.self, forKey: .placeholder) 88 | destination = try container.decode(String.self, forKey: .destination) 89 | text = try container.decodeIfPresent(String.self, forKey: .text) 90 | plugins = try container.decodeIfPresent(Parameters.self, forKey: .plugins) 91 | source = try container.decodeIfPresent(String.self, forKey: .source) 92 | if text == nil, source == nil { 93 | throw Errors.invalidReplacement 94 | } 95 | } 96 | 97 | public var description: String { 98 | destination 99 | } 100 | 101 | public func customParameters() -> JSON { 102 | ["_replacement": try? dictionary()] 103 | } 104 | } 105 | 106 | private enum CodingKeys: String, CodingKey { 107 | case name 108 | case parameters 109 | case paths 110 | case plugins 111 | case optionalDescription = "description" 112 | case replacements 113 | } 114 | 115 | public let name: String 116 | public let parameters: [Parameter] 117 | public private(set) var paths: [Path] 118 | private let plugins: Parameters? 119 | private let optionalDescription: String? 120 | public var description: String { optionalDescription ?? name } 121 | public var pluginData: Parameters? { 122 | plugins 123 | } 124 | 125 | public let replacements: [Replacement] 126 | 127 | public init(name: String, 128 | parameters: [Item.Parameter], 129 | paths: [Item.Path], 130 | plugins: Parameters?, 131 | optionalDescription: String?, 132 | replacements: [Item.Replacement]) { 133 | self.name = name 134 | self.parameters = parameters 135 | self.paths = paths 136 | self.plugins = plugins 137 | self.optionalDescription = optionalDescription 138 | self.replacements = replacements 139 | } 140 | 141 | public init(from decoder: Swift.Decoder) throws { 142 | let container = try decoder.container(keyedBy: CodingKeys.self) 143 | name = try container.decode(String.self, forKey: .name) 144 | parameters = try container.decode([Item.Parameter].self, forKey: .parameters) 145 | paths = try container.decode([Item.Path].self, forKey: .paths) 146 | plugins = try container.decodeIfPresent(Parameters.self, forKey: .plugins) 147 | optionalDescription = try container.decodeIfPresent(String.self, forKey: .optionalDescription) 148 | replacements = try container.decodeIfPresent([Item.Replacement].self, forKey: .replacements) ?? [] 149 | } 150 | } 151 | 152 | extension CodableFile where Object == Item { 153 | func files(with context: Template.Context) throws -> [CodableFile] { 154 | guard let folder = file.parent else { return [] } 155 | return try object 156 | .paths 157 | .map { try .init(file: folder.file(at: $0.from.resolve(with: context))) } 158 | } 159 | 160 | func customParameters() -> JSON { 161 | ["_item": try? object.dictionary()] 162 | } 163 | } 164 | 165 | private extension String { 166 | var filename: String { 167 | components(separatedBy: "/").last ?? self 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/MurrayFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Murrayfile: Hashable, RootFile { 11 | public static var defaultName = "Murrayfile" 12 | 13 | public init(packages: [String], 14 | environment: Parameters, 15 | mainPlaceholder: String? = nil, 16 | plugins: Parameters? = nil) { 17 | self.packages = packages 18 | self.environment = environment 19 | self.mainPlaceholder = mainPlaceholder 20 | self.plugins = plugins 21 | } 22 | 23 | private func string(from date: Date, format: String = "dd/MM/yyyy") -> String { 24 | let dateFormatter = DateFormatter() 25 | dateFormatter.dateFormat = format 26 | return dateFormatter.string(from: date) 27 | } 28 | 29 | private var customParameters: [String: AnyHashable] { 30 | let author = (try? Process().launchBash(with: "git config user.name", 31 | outputHandle: nil))? 32 | .trimmingCharacters(in: .whitespacesAndNewlines) 33 | 34 | return ["_date": string(from: date), 35 | "_dateTime": string(from: date, format: "dd/MM/yyyy HH:mm:ss"), 36 | "_timestamp": String(Int64(Date().timeIntervalSince1970)), 37 | "_time": string(from: date, format: "MM:ss"), 38 | "_year": string(from: date, format: "yyyy"), 39 | "_author": author ?? ""] 40 | } 41 | 42 | private let date: Date = .init() 43 | public private(set) var packages: [String] 44 | private var environment: Parameters 45 | public var enrichedEnvironment: Parameters { 46 | let environmentDictionary = customParameters 47 | .merging(environment.dictionaryValue ?? [:], 48 | uniquingKeysWith: { _, original in original }) 49 | return .init(environmentDictionary) 50 | } 51 | 52 | private var mainPlaceholder: String? 53 | private var plugins: Parameters? 54 | 55 | public var pluginData: Parameters? { 56 | plugins 57 | } 58 | 59 | /// The default parameter used in all commands as main name to be replaced. Defaults to "name" 60 | public var namePlaceholder: String { 61 | mainPlaceholder ?? "name" 62 | } 63 | 64 | public mutating func add(packagePath: String) { 65 | packages.append(packagePath) 66 | } 67 | 68 | public static var empty: Murrayfile = .init(packages: [], environment: nil) 69 | } 70 | 71 | public extension CodableFile where Object == Murrayfile { 72 | func packages() throws -> [CodableFile] { 73 | try object.packages 74 | .compactMap { try file.parent?.file(named: $0) } 75 | .map { try .init(file: $0) } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Package.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Package: Codable, Hashable { 11 | public let name: String 12 | public let description: String 13 | public private(set) var procedures: [Procedure] 14 | 15 | public mutating func add(procedure: Procedure) { 16 | if !procedures.contains(procedure) { 17 | procedures.append(procedure) 18 | } 19 | } 20 | // public let itemPaths: [String] 21 | } 22 | 23 | public extension CodableFile where Object == Package { 24 | // func items() throws -> [CodableFile] { 25 | // try object.itemPaths.compactMap { itemPath in 26 | // try file.parent?.file(at: itemPath) 27 | // } 28 | // .map { try .init(file: $0) } 29 | // } 30 | 31 | func items() throws -> [CodableFile] { 32 | guard let folder = file.parent else { return [] } 33 | let automatic: [CodableFile] = file.parent?.subfolders 34 | .compactMap { folder in 35 | if let file = try? folder.file(named: folder.name) { 36 | return file 37 | } 38 | return CodableFile.Encoding.allValidExtensions 39 | .compactMap { 40 | try? folder.file(named: "\(folder.name).\($0)") 41 | }.first 42 | } 43 | .compactMap { try? .init(file: $0) } ?? [] 44 | 45 | let fromProcedures: [CodableFile] = try Set(object.procedures 46 | .flatMap { $0.itemPaths }) 47 | .map { try folder.file(at: $0) } 48 | .map { try CodableFile(file: $0) } 49 | 50 | return Array(Set(automatic + fromProcedures)).sorted { 51 | $0.object.name < $1.object.name 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Pipeline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pipeline.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Pipeline { 11 | let murrayfile: CodableFile 12 | let procedures: [PackagedProcedure] 13 | public let context: Template.Context 14 | 15 | public init(murrayfile: CodableFile, 16 | procedure: PackagedProcedure, 17 | context: Parameters) throws { 18 | try self.init(murrayfile: murrayfile, procedures: [procedure], context: context) 19 | } 20 | 21 | public init(murrayfile: CodableFile, 22 | procedures: [PackagedProcedure], 23 | context: Parameters) throws { 24 | self.murrayfile = murrayfile 25 | self.procedures = procedures 26 | self.context = Template.Context(context, environment: murrayfile.object.enrichedEnvironment) 27 | } 28 | 29 | public init(murrayfile: CodableFile, 30 | procedure procedureName: String, 31 | context: Parameters) throws { 32 | try self.init(murrayfile: murrayfile, procedures: [procedureName], context: context) 33 | } 34 | 35 | public init(murrayfile: CodableFile, 36 | procedures procedureNames: [String], 37 | context: Parameters) throws { 38 | self.murrayfile = murrayfile 39 | 40 | let packages = try murrayfile.packages() 41 | procedures = try procedureNames.compactMap { procedureName in 42 | let procedures = packages 43 | .compactMap { try? PackagedProcedure(package: $0, procedureName: procedureName) } 44 | switch procedures.count { 45 | case 0: throw Errors.procedureNotFound(name: procedureName) 46 | case 1: return procedures.first 47 | default: throw Errors.multipleProceduresFound(name: procedureName) 48 | } 49 | } 50 | 51 | self.context = Template.Context(context, environment: murrayfile.object.enrichedEnvironment) 52 | } 53 | 54 | public func missingParameters() throws -> [Item.Parameter] { 55 | try requiredParameters() 56 | .filter { self.context[$0.name] == nil } 57 | } 58 | 59 | public func invalidParameters() throws -> [Item.Parameter] { 60 | try allParameters() 61 | .filter { parameter in 62 | if let allowedValues = parameter.values, 63 | let contextValue = self.context[parameter.name]?.description { 64 | return !Set(allowedValues).contains(contextValue) 65 | } else { 66 | return false 67 | } 68 | } 69 | } 70 | 71 | public func requiredParameters() throws -> [Item.Parameter] { 72 | try allParameters().filter { $0.isRequired } 73 | } 74 | 75 | public func allParameters() throws -> [Item.Parameter] { 76 | try procedures.flatMap { procedure in 77 | try procedure.items() 78 | .flatMap { 79 | $0.object.parameters 80 | } 81 | }.uniqued() 82 | } 83 | 84 | public func run() throws { 85 | let missingParameters = try missingParameters() 86 | if !missingParameters.isEmpty { 87 | throw Errors 88 | .missingRequiredParameters(missingParameters) 89 | } 90 | let invalidParameters = try invalidParameters() 91 | if !invalidParameters.isEmpty { 92 | throw Errors 93 | .invalidParameters(invalidParameters) 94 | } 95 | guard let destinationFolder = murrayfile.file.parent else { 96 | // no destination folder provided 97 | throw Errors.unknown 98 | } 99 | let manager = PluginManager.shared 100 | try manager.execute(.init(element: murrayfile.object, 101 | context: context, 102 | phase: .before, 103 | root: destinationFolder)) 104 | try procedures.forEach { procedure in 105 | let procedureContext = context.adding(procedure.customParameters()) 106 | try manager.execute(.init(element: procedure.procedure, 107 | context: procedureContext, 108 | phase: .before, 109 | root: destinationFolder)) 110 | try procedure.items().forEach { item in 111 | 112 | let itemContext = procedureContext.adding(item.customParameters()) 113 | 114 | try manager.execute(.init(element: item.object, 115 | context: itemContext, 116 | phase: .before, 117 | root: destinationFolder)) 118 | 119 | try item.writeableFiles(context: context, 120 | destinationRoot: destinationFolder).forEach { file in 121 | let enrichedContext = file.enrichedContext(from: itemContext) 122 | switch file.reference { 123 | case let path as Item.Path: 124 | let localContext = enrichedContext.adding(path.customParameters()) 125 | try manager.execute(.init(element: path, 126 | context: localContext, 127 | phase: .before, 128 | root: destinationFolder)) 129 | 130 | try file.commit(context: localContext) 131 | 132 | try manager.execute(.init(element: path, 133 | context: localContext, 134 | phase: .after, 135 | root: destinationFolder)) 136 | 137 | case let replacement as Item.Replacement: 138 | let localContext = enrichedContext.adding(replacement.customParameters()) 139 | try manager.execute(.init(element: replacement, 140 | context: localContext, 141 | phase: .before, 142 | root: destinationFolder)) 143 | try file.commit(context: localContext) 144 | try manager.execute(.init(element: replacement, 145 | context: localContext, 146 | phase: .after, 147 | root: destinationFolder)) 148 | 149 | default: 150 | try file.commit(context: enrichedContext) 151 | } 152 | } 153 | 154 | try manager.execute(.init(element: item.object, 155 | context: itemContext, 156 | phase: .after, 157 | root: destinationFolder)) 158 | } 159 | 160 | try manager.execute(.init(element: procedure.procedure, 161 | context: procedureContext, 162 | phase: .after, 163 | root: destinationFolder)) 164 | } 165 | try manager.execute(.init(element: murrayfile.object, 166 | context: context, 167 | phase: .after, 168 | root: destinationFolder)) 169 | } 170 | 171 | public func writeableFiles() throws -> [WriteableFile] { 172 | guard let destinationFolder = murrayfile.file.parent else { 173 | // no destination folder provided 174 | throw Errors.unknown 175 | } 176 | return try procedures.flatMap { 177 | try $0.writeableFiles(context: context, 178 | destinationFolder: destinationFolder) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Procedure/PackagedProcedure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 14/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /** A procedure with reference to parent Package 11 | 12 | A procedure itself is not represented by a single file, as it's merely contained into a Package. 13 | 14 | Therefore, there's no reference to parent folder containing all the procedure's items. 15 | 16 | This structure is used to keep that kind of reference. 17 | 18 | */ 19 | public struct PackagedProcedure: Hashable { 20 | public let package: CodableFile 21 | public let procedure: Procedure 22 | 23 | public init(package: CodableFile, procedure: Procedure) { 24 | self.package = package 25 | self.procedure = procedure 26 | } 27 | 28 | public init(package: CodableFile, 29 | procedureName name: String) throws { 30 | self.package = package 31 | let procedures = package 32 | .object 33 | .procedures 34 | .filter { $0.name == name || name == "\(package.object.name).\($0.name)" } 35 | guard let procedure = procedures.first else { 36 | throw Errors.procedureNotFound(name: name) 37 | } 38 | self.procedure = procedure 39 | } 40 | 41 | public func items() throws -> [CodableFile] { 42 | try procedure.itemPaths.map { try item(at: $0) } 43 | } 44 | 45 | private func item(at path: String) throws -> CodableFile { 46 | guard let file = try package.file.parent?.file(at: path) else { 47 | throw Errors.unparsableFile(path) 48 | } 49 | return try CodableFile(file: file) 50 | } 51 | 52 | public func writeableFiles(context: Template.Context, 53 | destinationFolder: Folder) throws -> [WriteableFile] { 54 | try items().flatMap { 55 | try $0.writeableFiles(context: context, 56 | destinationRoot: destinationFolder) 57 | } 58 | } 59 | 60 | public func customParameters() -> JSON { 61 | ["_procedure": try? procedure.dictionary()] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Procedure/Procedure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | /** Swift model for a procedure 10 | 11 | A procedure represents a sequence of items executed with the same context. 12 | 13 | */ 14 | public struct Procedure: Codable, Hashable { 15 | private enum CodingKeys: String, CodingKey { 16 | case name 17 | case optionalDescription = "description" 18 | case plugins 19 | case itemPaths = "items" 20 | } 21 | 22 | public let name: String 23 | 24 | private let optionalDescription: String? 25 | public var description: String { optionalDescription ?? name } 26 | 27 | private let plugins: Parameters? 28 | public var pluginData: Parameters? { plugins } 29 | 30 | public private(set) var itemPaths: [String] 31 | 32 | internal init(name: String, 33 | description: String?, 34 | plugins: Parameters?, 35 | itemPaths: [String]) { 36 | self.name = name 37 | optionalDescription = description 38 | self.plugins = plugins 39 | self.itemPaths = itemPaths 40 | } 41 | 42 | public mutating func add(itemPath: String) { 43 | itemPaths = itemPaths.filter { $0 != itemPath } + [itemPath] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Repository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Repository.swift 3 | // MurrayKit 4 | // Inspired by Mint, from here: https://github.com/yonaskolb/Mint/blob/master/Sources/MintKit/PackageReference.swift 5 | // 6 | // Created by Stefano Mondino on 07/01/2019. 7 | // 8 | 9 | import Foundation 10 | 11 | public class Repository: Hashable, CustomStringConvertible { 12 | public func hash(into hasher: inout Hasher) { 13 | hasher.combine(repo.hashValue) 14 | } 15 | 16 | public var repo: String 17 | public var version: String 18 | public var package: String 19 | public var description: String { 20 | package 21 | } 22 | 23 | private init(repo: String, version: String = "", package: String? = nil) { 24 | self.repo = repo 25 | self.version = version 26 | self.package = package ?? [repo, version] 27 | .map { $0.trimmingCharacters(in: .whitespaces) } 28 | .filter { !$0.isEmpty } 29 | .joined(separator: "@") 30 | } 31 | 32 | public convenience init(at package: String) { 33 | let packageParts = package.components(separatedBy: "@") 34 | .map { $0.trimmingCharacters(in: .whitespaces) } 35 | 36 | let repo: String 37 | let version: String 38 | if packageParts.count == 3 { 39 | repo = [packageParts[0], packageParts[1]].joined(separator: "@") 40 | version = packageParts[2] 41 | } else if packageParts.count == 2 { 42 | if packageParts[1].contains(":") { 43 | repo = [packageParts[0], packageParts[1]].joined(separator: "@") 44 | version = "" 45 | } else { 46 | repo = packageParts[0] 47 | version = packageParts[1] 48 | } 49 | } else { 50 | repo = package 51 | version = "master" 52 | } 53 | self.init(repo: repo, version: version, package: package) 54 | } 55 | 56 | public var namedVersion: String { 57 | return "\(name) \(version)" 58 | } 59 | 60 | public var name: String { 61 | return repo.components(separatedBy: "/").last!.components(separatedBy: ".").first! 62 | } 63 | } 64 | 65 | extension Repository: Equatable { 66 | public static func == (lhs: Repository, rhs: Repository) -> Bool { 67 | return lhs.repo == rhs.repo && lhs.version == rhs.version 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Resolvable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol Resolvable { 11 | func resolve(with context: Template.Context) throws -> String 12 | } 13 | 14 | extension String: Resolvable { 15 | public func resolve(with context: Template.Context) throws -> String { 16 | try Template(self, context: context) 17 | .resolve(recursive: true) 18 | } 19 | } 20 | 21 | // 22 | // extension CodableFile: Resolvable { 23 | // public func resolve(with context: Template.Context) throws -> String { 24 | // try file.resolve(with: context) 25 | // } 26 | // } 27 | // 28 | // extension File: Resolvable { 29 | // public func resolve(with context: Template.Context) throws -> String { 30 | // try Template(self, context: context).resolve() 31 | // } 32 | // } 33 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/RootFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 26/07/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol RootFile: Codable { 11 | static var defaultName: String { get } 12 | } 13 | 14 | public extension CodableFile where Object: RootFile { 15 | init(in folder: Folder, 16 | defaultName: String = Object.defaultName) throws { 17 | let extensions = ["", ".json", ".yml", ".yaml"] 18 | let names = extensions.map { defaultName + $0 } 19 | let file = try folder.firstFile(named: names) 20 | try self.init(file: file) 21 | } 22 | 23 | func encoding(_: Object.Type = Object.self) -> CodableFile.Encoding { 24 | switch file.extension?.lowercased() ?? "" { 25 | case "yaml", "yml": return .yml 26 | default: return .json 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Skeleton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 25/07/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Skeleton: RootFile, Hashable { 11 | private enum CodingKeys: String, CodingKey { 12 | case scripts 13 | case paths 14 | case initializeGit 15 | } 16 | 17 | public let scripts: [String] 18 | public let paths: [Item.Path] 19 | public let initializeGit: Bool 20 | 21 | public static var defaultName: String { "Skeleton" } 22 | 23 | public static var empty: Skeleton { 24 | .init(scripts: [], 25 | paths: [], 26 | initializeGit: true) 27 | } 28 | 29 | public init(scripts: [String], 30 | paths: [Item.Path], 31 | initializeGit: Bool) { 32 | self.scripts = scripts 33 | self.paths = paths 34 | self.initializeGit = initializeGit 35 | } 36 | 37 | public init(from decoder: Swift.Decoder) throws { 38 | let container = try decoder.container(keyedBy: CodingKeys.self) 39 | scripts = try container.decode([String].self, forKey: .scripts) 40 | paths = try container.decode([Item.Path].self, forKey: .paths) 41 | initializeGit = try container.decodeIfPresent(Bool.self, forKey: .initializeGit) ?? false 42 | } 43 | 44 | public func encode(to encoder: Swift.Encoder) throws { 45 | var container = encoder.container(keyedBy: CodingKeys.self) 46 | try container.encode(scripts, forKey: .scripts) 47 | try container.encode(paths, forKey: .paths) 48 | if initializeGit { 49 | try container.encode(initializeGit, forKey: .initializeGit) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/Template.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | import Stencil 10 | import StencilSwiftKit 11 | 12 | public struct Template { 13 | public struct Context: ExpressibleByDictionaryLiteral, 14 | CustomStringConvertible { 15 | public var description: String { values.description } 16 | 17 | public let values: JSON 18 | 19 | public init(_ parameters: Parameters, environment: Parameters = [:]) { 20 | values = (environment.dictionaryValue ?? [:]) 21 | .merging(parameters.dictionaryValue ?? [:]) { _, other in other } 22 | } 23 | 24 | public init(_ values: JSON) { 25 | self.values = values 26 | } 27 | 28 | public init(dictionaryLiteral elements: (String, AnyHashable)...) { 29 | values = elements.reduce(into: [:]) { $0[$1.0] = $1.1 } 30 | } 31 | 32 | public func adding(_ newValues: JSON) -> Context { 33 | .init(values.merging(newValues) { original, _ in original }) 34 | } 35 | 36 | private func explore(key: String, values: JSON) -> AnyHashable? { 37 | let separator = "." 38 | let split = key.components(separatedBy: separator) 39 | guard let main = split.first, 40 | let value = values[main] 41 | else { 42 | return nil 43 | } 44 | switch value { 45 | case let dictionary as JSON: 46 | return explore(key: split.dropFirst().joined(separator: separator), values: dictionary) 47 | case let array as [JSON]: 48 | guard let mainInteger = Int(key) else { 49 | return nil 50 | } 51 | let indexedValue = array[mainInteger] 52 | let otherKeys = split.dropFirst() 53 | if otherKeys.isEmpty { 54 | return indexedValue 55 | } else { 56 | return explore(key: otherKeys.joined(separator: separator), values: indexedValue) 57 | } 58 | default: return value 59 | } 60 | } 61 | 62 | public subscript(_ key: String) -> AnyHashable? { 63 | explore(key: key, values: values) 64 | } 65 | } 66 | 67 | public let contents: String 68 | public let context: Context 69 | 70 | public init(_ contents: String, context: Context) { 71 | self.contents = contents 72 | self.context = context 73 | } 74 | 75 | public init(_ file: File, context: Context) throws { 76 | contents = try file.readAsString() 77 | self.context = context 78 | } 79 | 80 | public func resolve(recursive: Bool = true) throws -> String { 81 | let ext = Extension() 82 | ext.registerStencilSwiftExtensions() 83 | ext.registerFilter("firstLowercase") { (value: Any?) in 84 | (value as? String)?.firstLowercased() ?? value 85 | } 86 | ext.registerFilter("firstUppercase") { (value: Any?) in 87 | (value as? String)?.firstUppercased() ?? value 88 | } 89 | 90 | ext.registerFilter("snakeCase") { (value: Any?) in 91 | (value as? String)?.camelCaseToSnakeCase() ?? value 92 | } 93 | 94 | let environment = Environment(extensions: [ext]) 95 | do { 96 | let rendered = try environment.renderTemplate(string: contents, context: context.values) 97 | if recursive, rendered != contents { 98 | return try Template(rendered, context: context).resolve(recursive: recursive) 99 | } else { 100 | return rendered 101 | } 102 | } catch { 103 | throw Errors.unresolvableString(string: contents, context: context.values) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Models/WriteableFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct WriteableFile { 11 | public enum Action { 12 | case create // (context: Template.Context) 13 | case edit(placeholder: String) 14 | } 15 | 16 | public let identifier = UUID() 17 | public let content: Content 18 | public let path: String 19 | public let root: Folder 20 | public let action: Action 21 | let reference: Any? 22 | public init(content: Content, 23 | path: String, 24 | destinationRoot: Folder, 25 | action: Action, 26 | reference: Any? = nil) { 27 | self.content = content 28 | self.path = path 29 | root = destinationRoot 30 | self.action = action 31 | self.reference = reference 32 | } 33 | 34 | public func preview(context: Template.Context) throws -> String { 35 | switch action { 36 | case .create: 37 | try Logger.log("File will be created at \(path.resolve(with: context))\n", 38 | level: .normal) 39 | return try resolve(with: context) 40 | case let .edit(placeholder): 41 | try Logger.log("Contents of file at \(path.resolve(with: context)) will be replaced when placeholder: '\(placeholder)' is found", 42 | level: .normal) 43 | return try replace(searching: placeholder, with: context) 44 | } 45 | } 46 | 47 | @discardableResult 48 | public func commit(context: Template.Context) throws -> File { 49 | switch action { 50 | case .create: 51 | try Logger.log("Will create file at \(path.resolve(with: context))\n", 52 | level: .normal) 53 | return try create(with: context) 54 | case let .edit(placeholder): 55 | try Logger.log("Will replace contents of file at \(path.resolve(with: context)), looking for placeholder: '\(placeholder)'", 56 | level: .normal) 57 | return try update(searching: placeholder, with: context) 58 | } 59 | } 60 | 61 | private func create(with context: Template.Context) throws -> File { 62 | let destination = try root.createFileIfNeeded(at: path.resolve(with: context)) 63 | let contents = try resolve(with: context) 64 | try destination.write(contents) 65 | return destination 66 | } 67 | 68 | private func update(searching placeholder: String, 69 | with context: Template.Context) throws -> File { 70 | let destination = try root.file(at: path.resolve(with: context)) 71 | let contents = try replace(searching: placeholder, with: context) 72 | try destination.write(contents) 73 | return destination 74 | } 75 | 76 | private func replace(searching placeholder: String, 77 | with context: Template.Context) throws -> String { 78 | let destination = try root.file(at: path.resolve(with: context)) 79 | let replacement = try content.resolve(with: context) + placeholder 80 | return try destination.readAsString() 81 | .replacingOccurrences(of: placeholder.resolve(with: context), 82 | with: replacement) 83 | } 84 | 85 | public func enrichedContext(from originalContext: Template.Context) -> Template.Context { 86 | let fileContext: JSON = ["_destinationPath": root.path.appendingPathComponent(path), 87 | "_destinationRoot": root.path, 88 | "_destinationFilename": path.components(separatedBy: "/").last] 89 | 90 | return originalContext.adding(fileContext) 91 | } 92 | } 93 | 94 | extension WriteableFile: Resolvable { 95 | public func resolve(with context: Template.Context) throws -> String { 96 | try content.resolve(with: context) 97 | } 98 | } 99 | 100 | public extension CodableFile { 101 | func writeableFiles(for path: Item.Path, 102 | resolveSource: Bool = true, 103 | context: Template.Context, 104 | destinationRoot: Folder) throws -> [WriteableFile] { 105 | let sourcePath: String = resolveSource ? try path.from.resolve(with: context) : path.from 106 | 107 | if let folder = try? file.parent?.subfolder(at: sourcePath) { 108 | return try writeableFiles(in: folder, 109 | context: context, 110 | destinationRoot: destinationRoot, 111 | destinationPath: path.to) 112 | } 113 | 114 | guard let file = try file.parent?.file(at: sourcePath) else { 115 | throw Errors.unparsableFile(sourcePath) 116 | } 117 | 118 | return [WriteableFile(content: .file(file), 119 | path: path.to, 120 | destinationRoot: destinationRoot, 121 | action: .create, 122 | reference: path)] 123 | } 124 | 125 | private func writeableFiles(in folder: Folder, 126 | context: Template.Context, 127 | destinationRoot: Folder, 128 | destinationPath: String) throws -> [WriteableFile] { 129 | let files = try folder.files.map { file -> WriteableFile in 130 | let destinationName = try file.name.resolve(with: context) 131 | let path = destinationPath.appendingPathComponent(destinationName) 132 | return WriteableFile(content: .file(file), 133 | path: path, 134 | destinationRoot: destinationRoot, 135 | action: .create, 136 | reference: Item.Path(from: file.path, to: path)) 137 | } 138 | let subfolders = try folder.subfolders.flatMap { subfolder in 139 | try writeableFiles(in: subfolder, 140 | context: context, 141 | destinationRoot: destinationRoot, 142 | destinationPath: destinationPath.appendingPathComponent(subfolder.name)) 143 | } 144 | return files + subfolders 145 | } 146 | 147 | func writeableFile(for replacement: Item.Replacement, 148 | context _: Template.Context, 149 | destinationRoot: Folder) throws -> WriteableFile { 150 | let content: Content 151 | 152 | if let text = replacement.text { 153 | content = .text(text) 154 | } else if let sourcePath = replacement.source { 155 | guard let file = try file.parent?.file(at: sourcePath) else { 156 | throw Errors.unparsableFile(sourcePath) 157 | } 158 | content = .file(file) 159 | } else { 160 | // this should never happen - replacements always have either a text or a source. 161 | throw Errors.unknown 162 | } 163 | 164 | return WriteableFile(content: content, 165 | path: replacement.destination, 166 | destinationRoot: destinationRoot, 167 | action: .edit(placeholder: replacement.placeholder), 168 | reference: replacement) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Plugin/Plugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol PluginType { 11 | var name: String { get } 12 | func execute(_ execution: PluginExecution) throws 13 | func execute(_ execution: PluginExecution) throws 14 | func execute(_ execution: PluginExecution) throws 15 | func execute(_ execution: PluginExecution) throws 16 | func execute(_ execution: PluginExecution) throws 17 | } 18 | 19 | public protocol Plugin: PluginType { 20 | associatedtype PluginData: Codable 21 | func data(for item: PluginDataContainer) throws -> PluginData? 22 | } 23 | 24 | extension Plugin { 25 | func data(for item: PluginDataContainer) throws -> PluginData? { 26 | guard let pluginData = item.pluginData, 27 | let subJSON = pluginData[name]?.dictionaryValue else { return nil } 28 | let data = try JSONSerialization.data(withJSONObject: subJSON) 29 | return try JSONDecoder().decode(data) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Plugin/PluginDataContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 23/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol PluginDataContainer { 11 | var pluginData: Parameters? { get } 12 | } 13 | 14 | extension Murrayfile: PluginDataContainer {} 15 | extension Item: PluginDataContainer {} 16 | extension Item.Path: PluginDataContainer {} 17 | extension Item.Replacement: PluginDataContainer {} 18 | extension Procedure: PluginDataContainer {} 19 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Plugin/PluginExecution.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 23/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct PluginExecution { 11 | public enum Phase { 12 | case before 13 | case after 14 | } 15 | 16 | let element: Element 17 | let file: WriteableFile? 18 | let phase: Phase 19 | let root: Folder 20 | private let originalContext: Template.Context 21 | 22 | internal init(element: Element, 23 | file: WriteableFile? = nil, 24 | context: Template.Context, 25 | phase: Phase, 26 | root: Folder) { 27 | self.element = element 28 | self.file = file 29 | originalContext = context 30 | self.phase = phase 31 | self.root = root 32 | } 33 | 34 | func context() -> Template.Context { 35 | let fileContext = ["_path": file? 36 | .root 37 | .path 38 | .appendingPathComponent(file?.path ?? ""), 39 | "_root": file?.root.path] 40 | let all = originalContext.values.merging(fileContext) { original, _ in original } 41 | return .init(all) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Plugin/PluginManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 23/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public class PluginManager { 11 | static let shared: PluginManager = { 12 | let manager = PluginManager() 13 | manager.add(plugins: [ShellPlugin(), XcodePlugin()]) 14 | return manager 15 | }() 16 | 17 | private var plugins: [PluginType] = [] 18 | private init() {} 19 | 20 | public func add(plugin: PluginType) { 21 | if !plugins.contains(where: { $0.name == plugin.name }) { 22 | add(plugins: [plugin]) 23 | } 24 | } 25 | 26 | public func add(plugins: [PluginType]) { 27 | self.plugins += plugins 28 | } 29 | 30 | public func execute(_ execution: PluginExecution) throws { 31 | try plugins.forEach { try $0.execute(execution) } 32 | } 33 | 34 | public func execute(_ execution: PluginExecution) throws { 35 | try plugins.forEach { try $0.execute(execution) } 36 | } 37 | 38 | public func execute(_ execution: PluginExecution) throws { 39 | try plugins.forEach { try $0.execute(execution) } 40 | } 41 | 42 | public func execute(_ execution: PluginExecution) throws { 43 | try plugins.forEach { try $0.execute(execution) } 44 | } 45 | 46 | public func execute(_ execution: PluginExecution) throws { 47 | try plugins.forEach { try $0.execute(execution) } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Plugin/ShellPlugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 23/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ShellPlugin: Plugin { 11 | var name: String { "shell" } 12 | 13 | struct PluginData: Codable { 14 | let before: [String]? 15 | let after: [String]? 16 | } 17 | 18 | func execute(_ execution: PluginExecution) throws { 19 | let keyPath: KeyPath 20 | switch execution.phase { 21 | case .before: keyPath = \.before 22 | case .after: keyPath = \.after 23 | } 24 | 25 | guard let data = try data(for: execution.element), 26 | let commands = data[keyPath: keyPath] 27 | else { 28 | return 29 | } 30 | 31 | let context = execution.context() 32 | try commands.map { 33 | try $0.resolve(with: context) 34 | }.forEach { 35 | Logger.log("Executing command: \($0)", level: .verbose) 36 | try Process().launchBash(with: $0) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Plugin/XcodePlugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 31/05/22. 6 | // 7 | 8 | import Foundation 9 | import PathKit 10 | import XcodeProj 11 | 12 | struct XcodePlugin: Plugin { 13 | var name: String { "xcode" } 14 | 15 | struct PluginData: Codable { 16 | let projectPath: String? 17 | let targets: [String] 18 | } 19 | 20 | func execute(_ execution: PluginExecution) throws { 21 | if execution.phase == .before { return } 22 | guard let data = try data(for: execution.element) else { 23 | return 24 | } 25 | let projectFolder: Folder 26 | if let path = data.projectPath { 27 | projectFolder = try execution.root.subfolder(at: path) 28 | } else { 29 | guard let folder = execution.root.subfolders 30 | .filter({ $0.name.contains(".xcodeproj") }) 31 | .first else { return } 32 | projectFolder = folder 33 | } 34 | let context = execution.context() 35 | 36 | let targetNames = try Set(data.targets.map { try $0.resolve(with: context) }) 37 | Logger.log("Required targets: \(targetNames.joined(separator: ", "))", level: .verbose) 38 | guard targetNames.isEmpty == false else { return } 39 | 40 | let files = (try? [execution.element] 41 | .compactMap { try? $0.to.resolve(with: context) } 42 | .compactMap { try execution.root.file(at: $0) }) ?? [] 43 | 44 | let project = try? XcodeProj(pathString: projectFolder.path) 45 | guard let pbx = project?.pbxproj.projects.first else { return } 46 | let targets = pbx.targets.filter { targetNames.contains($0.name) } 47 | Logger.log("Matching targets: \(targets.map { $0.name }.joined(separator: ", "))", level: .verbose) 48 | 49 | files.forEach { file in 50 | let relativeFolder: Folder = projectFolder.parent ?? projectFolder 51 | 52 | let folders = file.parent?.path(relativeTo: relativeFolder) 53 | .components(separatedBy: "/") 54 | .filter { $0.isEmpty == false } ?? [] 55 | 56 | guard let mainGroup = pbx.mainGroup else { return } 57 | let group = folders 58 | .reduce(mainGroup) { group, folder -> PBXGroup? in 59 | group?.group(named: folder) ?? 60 | group?.children 61 | .filter { $0.path == folder } 62 | .compactMap { $0 as? PBXGroup } 63 | .first ?? 64 | (try? group?.addGroup(named: folder).first) 65 | } 66 | 67 | if let addedFile = try? group?.addFile(at: .init(file.path), sourceRoot: .init(projectFolder.parent?.path ?? projectFolder.path)) { 68 | targets.forEach { target in 69 | Logger.log("Adding file \(addedFile.name ?? "n/a") to target \(target.name)", level: .verbose) 70 | do { 71 | // if Path(file.path).extension == "xib" { 72 | addedFile.explicitFileType = nil 73 | // } 74 | _ = try getBuildPhase(for: .init(file.path), 75 | target: target)? 76 | .add(file: addedFile) 77 | 78 | } catch { 79 | Logger.log("Error adding file \(addedFile.name ?? "") to target \(target.name):") 80 | Logger.log(error.localizedDescription) 81 | } 82 | } 83 | } 84 | } 85 | do { 86 | try project?.write(path: .init(projectFolder.path), override: true) 87 | } catch { 88 | Logger.log("Error saving project") 89 | Logger.log(error.localizedDescription) 90 | } 91 | } 92 | 93 | func execute(_: PluginExecution) throws {} 94 | 95 | func execute(_: PluginExecution) throws {} 96 | 97 | func execute(_: PluginExecution) throws {} 98 | 99 | func execute(_: PluginExecution) throws {} 100 | 101 | // adapted from here https://github.com/yonaskolb/XcodeGen/blob/master/Sources/XcodeGenKit/SourceGenerator.swift 102 | private func getBuildPhase(for path: PathKit.Path, 103 | target: PBXTarget) throws -> PBXBuildPhase? { 104 | if path.lastComponent == "Info.plist" { 105 | return nil 106 | } 107 | if let fileExtension = path.extension { 108 | switch fileExtension { 109 | case "swift", 110 | "m", 111 | "mm", 112 | "cpp", 113 | "c", 114 | "cc", 115 | "S", 116 | "xcdatamodeld", 117 | "intentdefinition", 118 | "metal", 119 | "mlmodel", 120 | "rcproject": 121 | return try target.sourcesBuildPhase() 122 | case "h", 123 | "hh", 124 | "hpp", 125 | "ipp", 126 | "tpp", 127 | "hxx", 128 | "def": 129 | return nil 130 | case "modulemap": 131 | return nil 132 | case "framework": 133 | return try target.frameworksBuildPhase() 134 | case "xpc": 135 | return nil 136 | case "xcconfig", 137 | "entitlements", 138 | "gpx", 139 | "lproj", 140 | "xcfilelist", 141 | "apns", 142 | "pch": 143 | return nil 144 | default: 145 | return try target.resourcesBuildPhase() 146 | } 147 | } 148 | return nil 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Utilities/Files+Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Wrapper for the `Files` library 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Files 9 | import Foundation 10 | 11 | public struct File: Hashable { 12 | private let file: Files.File 13 | 14 | public var parent: Folder? { 15 | guard let parent = file.parent else { return nil } 16 | return .init(parent) 17 | } 18 | 19 | public var name: String { file.name } 20 | public var nameExcludingExtension: String { file.nameExcludingExtension } 21 | public var `extension`: String? { file.extension } 22 | public var path: String { file.path } 23 | 24 | init(_ file: Files.File) { 25 | self.file = file 26 | } 27 | 28 | public func hash(into hasher: inout Hasher) { 29 | hasher.combine(path) 30 | } 31 | 32 | public func path(relativeTo folder: Folder) -> String { 33 | file.path(relativeTo: folder.folder) 34 | } 35 | 36 | public func readAsString() throws -> String { 37 | do { 38 | return try file.readAsString() 39 | } catch { 40 | throw Errors.unreadableFile(file.path) 41 | } 42 | } 43 | 44 | public func read() throws -> Data { 45 | do { 46 | return try file.read() 47 | } catch { 48 | throw Errors.unreadableFile(file.path) 49 | } 50 | } 51 | 52 | public func write(_ string: String, encoding: String.Encoding = .utf8) throws { 53 | do { 54 | return try file.write(string, encoding: encoding) 55 | } catch { 56 | throw Errors.unwriteableFile(file.path) 57 | } 58 | } 59 | 60 | public func delete() throws { 61 | do { 62 | try file.delete() 63 | } catch { 64 | throw Errors.deleteFile(path) 65 | } 66 | } 67 | } 68 | 69 | public struct Folder: Hashable, CustomStringConvertible { 70 | fileprivate let folder: Files.Folder 71 | public var description: String { path } 72 | public var path: String { folder.path } 73 | public var name: String { folder.name } 74 | public var files: [File] { 75 | folder.files.map { .init($0) } 76 | } 77 | 78 | public var subfolders: [Folder] { 79 | folder.subfolders.map { .init($0) } 80 | } 81 | 82 | public static var current: Folder { 83 | .init(Files.Folder.current) 84 | } 85 | 86 | public static var home: Folder { 87 | .init(Files.Folder.home) 88 | } 89 | 90 | public static var temporary: Folder { 91 | .init(Files.Folder.temporary) 92 | } 93 | 94 | fileprivate init(_ folder: Files.Folder) { 95 | self.folder = folder 96 | } 97 | 98 | public init(path: String) throws { 99 | do { 100 | folder = try .init(path: path) 101 | } catch { 102 | throw Errors.folderLocationError(path) 103 | } 104 | } 105 | 106 | public func hash(into hasher: inout Hasher) { 107 | hasher.combine(path) 108 | } 109 | 110 | @discardableResult 111 | public func copy(to folder: Folder) throws -> Folder { 112 | do { 113 | return try Folder(self.folder.copy(to: folder.folder)) 114 | } catch { 115 | throw Errors.copyFolder(folder.path) 116 | } 117 | } 118 | 119 | @discardableResult 120 | public func moveContents(to folder: Folder, includeHidden: Bool = false) throws -> Folder { 121 | do { 122 | try self.folder.moveContents(to: folder.folder, includeHidden: includeHidden) 123 | return folder 124 | } catch { 125 | throw Errors.moveFolder(folder.path) 126 | } 127 | } 128 | 129 | @discardableResult 130 | public func createSubfolderIfNeeded(withName name: String) throws -> Folder { 131 | do { 132 | return try Folder(folder.createSubfolderIfNeeded(at: name)) 133 | } catch { 134 | throw Errors.createFolder(name) 135 | } 136 | } 137 | 138 | public func delete() throws { 139 | do { 140 | try folder.delete() 141 | } catch { 142 | throw Errors.deleteFolder(path) 143 | } 144 | } 145 | 146 | public func file(named name: String) throws -> File { 147 | do { 148 | let file = try folder.file(named: name) 149 | return .init(file) 150 | } catch { 151 | throw Errors.fileLocationError(path.appendingPathComponent(name)) 152 | } 153 | } 154 | 155 | public func file(at path: String) throws -> File { 156 | do { 157 | let file = try folder.file(at: path) 158 | return .init(file) 159 | } catch { 160 | throw Errors.fileLocationError(self.path.appendingPathComponent(path)) 161 | } 162 | } 163 | 164 | public func path(relativeTo to: Folder) -> String { 165 | folder.path(relativeTo: to.folder) 166 | } 167 | 168 | @discardableResult 169 | public func createFileIfNeeded(at path: String, 170 | contents: @autoclosure () -> Data? = nil) throws -> File { 171 | do { 172 | return try .init(folder.createFileIfNeeded(at: path, contents: contents())) 173 | 174 | } catch { 175 | throw Errors.unwriteableFile(self.path.appendingPathComponent(path)) 176 | } 177 | } 178 | 179 | public func subfolder(at path: String) throws -> Folder { 180 | do { 181 | return try .init(folder.subfolder(at: path)) 182 | } catch { 183 | throw Errors.folderLocationError(path.appendingPathComponent(path)) 184 | } 185 | } 186 | 187 | public func subfolder(named name: String) throws -> Folder { 188 | do { 189 | return try .init(folder.subfolder(named: name)) 190 | } catch { 191 | throw Errors.folderLocationError(path.appendingPathComponent(name)) 192 | } 193 | } 194 | 195 | public var parent: Folder? { 196 | guard let value = folder.parent else { return nil } 197 | return .init(value) 198 | } 199 | } 200 | 201 | extension Folder { 202 | func firstFile(named names: [String]) throws -> File { 203 | guard let file: File = names 204 | .lazy 205 | .compactMap({ try? file(named: $0) }) 206 | .first 207 | else { 208 | throw Errors.fileLocationError(path) 209 | } 210 | return file 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Sources/MurrayKit/Utilities/Process+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // Inspired by https://github.com/JohnSundell/ShellOut 5 | // Created by Stefano Mondino on 26/07/22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Process { 11 | @discardableResult func launchBash(with command: String, 12 | in folder: Folder = .current, 13 | outputHandle: FileHandle? = .standardOutput, 14 | errorHandle: FileHandle? = nil) throws -> String { 15 | let command = "cd \(folder.path.escapingSpaces) && \(command)" 16 | launchPath = "/bin/bash" 17 | arguments = ["-c", command] 18 | 19 | // Because FileHandle's readabilityHandler might be called from a 20 | // different queue from the calling queue, avoid a data race by 21 | // protecting reads and writes to outputData and errorData on 22 | // a single dispatch queue. 23 | let outputQueue = DispatchQueue(label: "bash-output-queue") 24 | 25 | var outputData = Data() 26 | var errorData = Data() 27 | 28 | let outputPipe = Pipe() 29 | standardOutput = outputPipe 30 | 31 | let errorPipe = Pipe() 32 | standardError = errorPipe 33 | 34 | #if !os(Linux) 35 | outputPipe.fileHandleForReading.readabilityHandler = { handler in 36 | let data = handler.availableData 37 | outputQueue.async { 38 | outputData.append(data) 39 | outputHandle?.write(data) 40 | } 41 | } 42 | 43 | errorPipe.fileHandleForReading.readabilityHandler = { handler in 44 | let data = handler.availableData 45 | outputQueue.async { 46 | errorData.append(data) 47 | errorHandle?.write(data) 48 | } 49 | } 50 | #endif 51 | 52 | launch() 53 | 54 | #if os(Linux) 55 | outputQueue.sync { 56 | outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() 57 | errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 58 | } 59 | #endif 60 | 61 | waitUntilExit() 62 | 63 | if let handle = outputHandle, !handle.isStandard { 64 | handle.closeFile() 65 | } 66 | 67 | if let handle = errorHandle, !handle.isStandard { 68 | handle.closeFile() 69 | } 70 | 71 | #if !os(Linux) 72 | outputPipe.fileHandleForReading.readabilityHandler = nil 73 | errorPipe.fileHandleForReading.readabilityHandler = nil 74 | #endif 75 | 76 | // Block until all writes have occurred to outputData and errorData, 77 | // and then read the data back out. 78 | return try outputQueue.sync { 79 | if terminationStatus != 0 { 80 | throw ShellOutError( 81 | terminationStatus: terminationStatus, 82 | errorData: errorData, 83 | outputData: outputData 84 | ) 85 | } 86 | 87 | return outputData.shellOutput() 88 | } 89 | } 90 | } 91 | 92 | private extension FileHandle { 93 | var isStandard: Bool { 94 | return self === FileHandle.standardOutput || 95 | self === FileHandle.standardError || 96 | self === FileHandle.standardInput 97 | } 98 | } 99 | 100 | private extension Data { 101 | func shellOutput() -> String { 102 | guard let output = String(data: self, encoding: .utf8) else { 103 | return "" 104 | } 105 | 106 | guard !output.hasSuffix("\n") else { 107 | let endIndex = output.index(before: output.endIndex) 108 | return String(output[.. String { 12 | return prefix(1).lowercased() + dropFirst() 13 | } 14 | 15 | func firstUppercased() -> String { 16 | return prefix(1).uppercased() + dropFirst() 17 | } 18 | 19 | func camelCaseToSnakeCase() -> String? { 20 | let acronymPattern = "([A-Z]+)([A-Z][a-z]|[0-9])" 21 | let normalPattern = "([a-z0-9])([A-Z])" 22 | return processCamelCaseRegex(pattern: acronymPattern)? 23 | .processCamelCaseRegex(pattern: normalPattern)?.lowercased() 24 | } 25 | 26 | fileprivate func processCamelCaseRegex(pattern: String) -> String? { 27 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 28 | let range = NSRange(location: 0, length: count) 29 | return regex?.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "$1_$2") 30 | } 31 | } 32 | 33 | extension String { 34 | func appendingPathComponent(_ path: String) -> String { 35 | return (components(separatedBy: "/") + path.components(separatedBy: "/")) 36 | .filter { !$0.isEmpty } 37 | .joined(separator: "/") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | fatalError("Run the tests with `swift test --enable-test-discovery`.") 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/CodableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 01/05/23. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | import Yams 12 | 13 | class CodableTests: TestCase { 14 | func testYAMLFileProducesProperError() throws { 15 | let scenario = Scenario.wrongMurrayfile 16 | let root = try scenario.make() 17 | XCTAssertThrowsError(try CodableFile(in: root).object) { error in 18 | switch error as? Errors ?? .unknown { 19 | case .unparsableFile: return 20 | default: XCTFail("\(error) is not of expected type") 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Commands/CloneTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 26/07/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class CloneTests: TestCase { 13 | private func makeGit(in folder: Folder) throws -> Folder { 14 | try ["git init", 15 | "git config user.email \"somebody@synesthesia.it\"", 16 | "git config user.name \"John Doe\"", 17 | "git add .", 18 | "git commit -m \"test\""] 19 | .forEach { 20 | print("Executing: `\($0)` in \(folder)") 21 | do { 22 | try Process().launchBash(with: $0, in: folder) 23 | } catch { 24 | print((error as? ShellOutError)?.message ?? "") 25 | throw error 26 | } 27 | } 28 | 29 | return folder 30 | } 31 | 32 | private func makeOriginalGit() throws -> Folder { 33 | try makeGit(in: Scenario.cloneOrigin.make()) 34 | } 35 | 36 | private func makeSubfolderGit() throws -> Folder { 37 | return try makeGit(in: Scenario.cloneOriginInSubfolder.make()) 38 | } 39 | 40 | @discardableResult 41 | private func checks(folder: Folder, 42 | projectName: String, 43 | initGitAfterResolution: Bool, 44 | file _: String = #file, 45 | line _: UInt = #line) throws -> Folder { 46 | let projectFolder = try folder.subfolder(named: projectName) 47 | if !initGitAfterResolution { 48 | XCTAssertThrowsError(try projectFolder.subfolder(named: ".git")) 49 | } else { 50 | XCTAssertEqual(try projectFolder.subfolder(named: ".git").name, ".git") 51 | } 52 | 53 | XCTAssertThrowsError(try projectFolder.subfolder(named: "{{name}}")) 54 | 55 | let resolvedFolder = try projectFolder.subfolder(named: projectName) 56 | 57 | XCTAssertEqual(try resolvedFolder.file(named: "HelloLOCALGIT.swift").readAsString(), "Swift LocalGit\n") 58 | 59 | XCTAssertEqual(try projectFolder.file(named: "hello.txt").readAsString(), "Hello\n") 60 | 61 | XCTAssertThrowsError(try projectFolder.file(named: "Skeleton.yml")) 62 | return projectFolder 63 | } 64 | 65 | func testSimpleClone() throws { 66 | let git = try makeOriginalGit() 67 | let folder = try Scenario.folder() 68 | let projectName = "LocalGit" 69 | 70 | try? folder.subfolder(named: projectName).delete() 71 | 72 | let clone = Clone(path: git.path, 73 | folder: folder, 74 | mainPlaceholder: projectName, 75 | copyFromLocalFolder: true) 76 | try clone.execute() 77 | 78 | let projectFolder = try checks(folder: folder, 79 | projectName: projectName, 80 | initGitAfterResolution: false) 81 | XCTAssertEqual(try projectFolder.file(at: "main.swift").readAsString(), "// LocalGit.swift\n") 82 | } 83 | 84 | func testCloneWithSubfolder() throws { 85 | let git = try makeSubfolderGit() 86 | let folder = try Scenario.folder() 87 | let projectName = "LocalGit" 88 | 89 | try? folder.subfolder(named: projectName).delete() 90 | 91 | let clone = Clone(path: git.path, 92 | folder: folder, 93 | subfolderPath: "Subfolder", 94 | mainPlaceholder: projectName, 95 | copyFromLocalFolder: true) 96 | try clone.execute() 97 | 98 | try checks(folder: folder, 99 | projectName: projectName, 100 | initGitAfterResolution: true) 101 | } 102 | 103 | func testCloneWithPath() throws { 104 | let git = try Scenario.cloneOrigin.make() 105 | try git.createFileIfNeeded(at: "uncommitted.txt", 106 | contents: "uncommitted".data(using: .utf8)) 107 | let folder = try Scenario.folder() 108 | let projectName = "LocalGit" 109 | 110 | try? folder.subfolder(named: projectName).delete() 111 | 112 | let clone = Clone(path: git.path, 113 | folder: folder, 114 | mainPlaceholder: projectName, 115 | copyFromLocalFolder: true) 116 | try clone.execute() 117 | 118 | try checks(folder: folder, 119 | projectName: projectName, 120 | initGitAfterResolution: false) 121 | 122 | let projectFolder = try folder.subfolder(named: projectName) 123 | XCTAssertEqual(try projectFolder.file(at: "uncommitted.txt").readAsString(), "uncommitted") 124 | } 125 | 126 | func testRemoteClone() throws {} 127 | } 128 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Commands/CommandTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 21/05/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class CommandTests: TestCase { 13 | 14 | private struct TestCommand: Command { 15 | func execute() throws { 16 | Logger.log("Test", level: .normal) 17 | Logger.log("TestVerbose", level: .verbose) 18 | } 19 | } 20 | 21 | func testLogVerboseWhenSpecified() throws { 22 | TestCommand().executeAndCatch(verbose: true) 23 | XCTAssertEqual(logger.messages, [.message("Test"), .message("TestVerbose")]) 24 | } 25 | func testDontLogVerboseWhenSpecified() throws { 26 | TestCommand().executeAndCatch(verbose: false) 27 | XCTAssertEqual(logger.messages, [.message("Test")]) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Commands/ListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class ListTests: TestCase { 13 | func testSimpleList() throws { 14 | let scenario = Scenario.simpleYaml 15 | let root = try scenario.make() 16 | let murrayfile = try CodableFile(in: root) 17 | let command = List(murrayfile: murrayfile) 18 | let procedures = try command.list() 19 | XCTAssertEqual(procedures.count, 4) 20 | } 21 | 22 | func testListInFolder() throws { 23 | let scenario = Scenario.simpleYaml 24 | let root = try scenario.make() 25 | 26 | let command = try List(folder: root, murrayfileName: "Murrayfile") 27 | let procedures = try command.list() 28 | XCTAssertEqual(procedures.count, 4) 29 | } 30 | 31 | func testCommandExecution() throws { 32 | let scenario = Scenario.simpleYaml 33 | let root = try scenario.make() 34 | 35 | let command = try List(folder: root) 36 | try command.execute() 37 | XCTAssertFalse(logger.messages.isEmpty) 38 | } 39 | 40 | func testCommandFailedExecutionInWrongFolder() throws { 41 | let root = Folder.temporary 42 | XCTAssertThrowsError(try List(folder: root)) { error in 43 | XCTAssertEqual(error as? Errors, Errors.murrayfileNotFound(root.path)) 44 | // XCTAssert(error is LocationError) 45 | // XCTAssertFalse(logger.messages.isEmpty) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Commands/RunTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 19/05/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class RunTests: TestCase { 13 | func testSimpleRun() throws { 14 | let scenario = Scenario.simpleJSON 15 | let root = try scenario.make() 16 | let command = Run(folder: root, 17 | mainPlaceholder: "name", 18 | name: "simpleGroup", 19 | preview: false, 20 | verbose: false, 21 | params: ["name:test"]) 22 | try command.execute() 23 | let file = try root.file(at: "Sources/Files/test/test.swift") 24 | XCTAssertEqual(try file.readAsString(), "test Test \(year)\n") 25 | } 26 | 27 | func testRunWithDuplicatedProcedureName() throws { 28 | let scenario = Scenario.simpleYaml 29 | let root = try scenario.make() 30 | let command = Run(folder: root, 31 | mainPlaceholder: "name", 32 | name: "SimpleCopy.simpleGroup", 33 | preview: false, 34 | verbose: false, 35 | params: ["name:test"]) 36 | try command.execute() 37 | let file = try root.file(at: "Sources/Files/test/test.swift") 38 | XCTAssertEqual(try file.readAsString(), "test Test2\n") 39 | } 40 | 41 | func testRunWithDuplicatedProcedureNameThrowErrorWithoutPackageName() throws { 42 | let scenario = Scenario.simpleYaml 43 | let root = try scenario.make() 44 | let command = Run(folder: root, 45 | mainPlaceholder: "name", 46 | name: "simpleGroup", 47 | preview: false, 48 | verbose: false, 49 | params: ["name:test"]) 50 | XCTAssertThrowsError(try command.execute()) { error in 51 | XCTAssertEqual(error as? Errors, Errors.multipleProceduresFound(name: "simpleGroup")) 52 | } 53 | } 54 | 55 | func testSimpleRunWithPreview() throws { 56 | let scenario = Scenario.simpleJSON 57 | let root = try scenario.make() 58 | let command = Run(folder: root, 59 | mainPlaceholder: "name", 60 | name: "simpleGroup", 61 | preview: true, 62 | verbose: false, 63 | params: ["name:test"]) 64 | try command.execute() 65 | 66 | XCTAssertThrowsError(try root.file(at: "Sources/Files/test/test.swift")) 67 | 68 | XCTAssertFalse(logger.messages.isEmpty) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Commands/ScaffoldTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 20/05/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class ScaffoldTests: TestCase { 13 | func testMurrayfileCreation() throws { 14 | let root = try Folder.emptyTestFolder() 15 | 16 | try Scaffold.murrayfile(encoding: .yml, in: root) 17 | .execute() 18 | let murrayfile = try CodableFile(in: root) 19 | XCTAssertEqual(murrayfile.file.name, "Murrayfile.yml") 20 | } 21 | 22 | func testAddNewPackageInJSONScenario() throws { 23 | let root = try Scenario.simpleJSON.make() 24 | try Scaffold 25 | .package(named: "testPackage", description: "", rootFolder: root) 26 | .execute() 27 | 28 | let murrayfile = try CodableFile(in: root) 29 | XCTAssertEqual(murrayfile.object.packages.last, "Murray/testPackage/testPackage.json") 30 | } 31 | 32 | func testAddNewPackageInYMLScenario() throws { 33 | let root = try Scenario.simpleYaml.make() 34 | try Scaffold 35 | .package(named: "testPackage", description: "", rootFolder: root) 36 | .execute() 37 | 38 | let murrayfile = try CodableFile(in: root) 39 | XCTAssertEqual(murrayfile.object.packages.last, "Murray/testPackage/testPackage.yml") 40 | } 41 | 42 | func testAddNewItemInYMLScenario() throws { 43 | let root = try Scenario.simpleYaml.make() 44 | try Scaffold 45 | .item(named: "newItem", 46 | package: "Simple", 47 | description: "Test description", 48 | rootFolder: root, 49 | files: ["fileA.swift", "fileB.yml"]) 50 | .execute() 51 | 52 | let package = try XCTUnwrap(CodableFile(in: root) 53 | .packages() 54 | .first(where: { $0.object.name == "Simple" })) 55 | 56 | let itemFolder = try XCTUnwrap(package.file.parent?.subfolder(named: "newItem")) 57 | let itemFile = try itemFolder.file(named: "newItem.yml") 58 | let item = try CodableFile(file: itemFile, type: MurrayKit.Item.self) 59 | XCTAssertEqual(item.object.name, "newItem") 60 | XCTAssertEqual(item.object.description, "Test description") 61 | let fileA = try itemFolder.file(named: "fileA.swift") 62 | let fileB = try itemFolder.file(named: "fileB.yml") 63 | XCTAssertEqual(try fileA.readAsString(), "") 64 | XCTAssertEqual(try fileB.readAsString(), "") 65 | } 66 | 67 | func testAddNewItemInJSONScenario() throws { 68 | let root = try Scenario.simpleJSON.make() 69 | try Scaffold 70 | .item(named: "newItem", 71 | package: "simple", 72 | description: "Test description", 73 | rootFolder: root, 74 | files: ["fileA.swift", "fileB.json"]) 75 | .execute() 76 | 77 | let package = try XCTUnwrap(CodableFile(in: root) 78 | .packages() 79 | .first(where: { $0.object.name == "simple" })) 80 | 81 | let packageFolder = try XCTUnwrap(package.file.parent) 82 | let itemFolder = try XCTUnwrap(packageFolder.subfolder(named: "newItem")) 83 | let itemFile = try itemFolder.file(named: "newItem.json") 84 | 85 | let item = try CodableFile(file: itemFile, type: MurrayKit.Item.self) 86 | XCTAssertEqual(item.object.name, "newItem") 87 | XCTAssertEqual(item.object.description, "Test description") 88 | let fileA = try itemFolder.file(named: "fileA.swift") 89 | let fileB = try itemFolder.file(named: "fileB.json") 90 | XCTAssertEqual(try fileA.readAsString(), "") 91 | XCTAssertEqual(try fileB.readAsString(), "") 92 | 93 | let itemRelativePath = itemFile.path(relativeTo: packageFolder) 94 | 95 | let procedure = Procedure(name: item.object.name, 96 | description: item.object.description, 97 | plugins: nil, 98 | itemPaths: [itemRelativePath]) 99 | 100 | XCTAssertTrue(package.object.procedures.contains(procedure)) 101 | } 102 | 103 | func testAddSameItemTwiceFails() throws { 104 | let root = try Scenario.simpleJSON.make() 105 | try Scaffold 106 | .item(named: "newItem", 107 | package: "simple", 108 | description: "Test description", 109 | rootFolder: root, 110 | files: ["fileA.swift", "fileB.json"]) 111 | .execute() 112 | 113 | XCTAssertThrowsError(try Scaffold 114 | .item(named: "newItem", 115 | package: "simple", 116 | description: "Test description", 117 | rootFolder: root, 118 | files: ["fileA.swift", "fileB.json"]) 119 | .execute()) { error in 120 | XCTAssertEqual(error as? Errors, .itemAlreadyExists("newItem")) 121 | } 122 | } 123 | 124 | func testNewItemDoesNotAddProcedureWhenSpecified() throws { 125 | let root = try Scenario.simpleJSON.make() 126 | try Scaffold 127 | .item(named: "newItem", 128 | package: "simple", 129 | description: "Test description", 130 | rootFolder: root, 131 | createProcedure: false, 132 | files: ["fileA.swift", "fileB.json"]) 133 | .execute() 134 | 135 | let package = try XCTUnwrap(CodableFile(in: root) 136 | .packages() 137 | .first(where: { $0.object.name == "simple" })) 138 | let packageFolder = try XCTUnwrap(package.file.parent) 139 | let itemFolder = try XCTUnwrap(packageFolder.subfolder(named: "newItem")) 140 | let itemFile = try itemFolder.file(named: "newItem.json") 141 | 142 | let item = try CodableFile(file: itemFile, type: MurrayKit.Item.self) 143 | let itemRelativePath = itemFile.path(relativeTo: packageFolder) 144 | let procedure = Procedure(name: item.object.name, 145 | description: item.object.description, 146 | plugins: nil, 147 | itemPaths: [itemRelativePath]) 148 | XCTAssertTrue(!package.object.procedures.contains(procedure)) 149 | } 150 | 151 | func testAddNewItemFailsWithMissingPackage() throws { 152 | let root = try Scenario.simpleJSON.make() 153 | XCTAssertThrowsError(try Scaffold 154 | .item(named: "newItem", 155 | package: "notFound", 156 | description: "Test description", 157 | rootFolder: root, 158 | files: ["fileA.swift", "fileB.json"]) 159 | .execute()) { 160 | XCTAssertEqual($0 as? Errors, .invalidPackageName("notFound")) 161 | } 162 | } 163 | 164 | func testAddNewProcedureInInvalidPackage() throws { 165 | let root = try Scenario.simpleYaml.make() 166 | 167 | XCTAssertThrowsError(try Scaffold.procedure(named: "newProcedure", 168 | package: "wrongValue", 169 | description: "theDescription", 170 | rootFolder: root, 171 | itemNames: ["replacementOnly", "simpleItem"]) 172 | .execute()) { error in 173 | XCTAssertEqual(error as? Errors, .invalidPackageName("wrongValue")) 174 | } 175 | } 176 | 177 | func testAddNewProcedureWithMultipleItems() throws { 178 | let root = try Scenario.simpleYaml.make() 179 | 180 | var package = try XCTUnwrap(CodableFile(in: root) 181 | .packages() 182 | .first(where: { $0.object.name == "Simple" })) 183 | 184 | XCTAssertNil(package.object.procedures.first { $0.name == "newProcedure" }) 185 | 186 | try Scaffold.procedure(named: "newProcedure", 187 | package: "Simple", 188 | description: "theDescription", 189 | rootFolder: root, 190 | itemNames: ["replacementOnly", "simpleItem"]) 191 | .execute() 192 | 193 | try package.reload() 194 | XCTAssertNotNil(package.file.parent) 195 | let procedure = try XCTUnwrap(package.object.procedures.first { $0.name == "newProcedure" }) 196 | XCTAssertEqual(procedure.description, "theDescription") 197 | } 198 | 199 | func testFailWhenProcedureAlreadyExists() throws { 200 | let root = try Scenario.simpleYaml.make() 201 | 202 | XCTAssertThrowsError(try Scaffold.procedure(named: "simpleGroup", 203 | package: "Simple", 204 | description: "theDescription", 205 | rootFolder: root, 206 | itemNames: ["replacementOnly", "i do not exist"]) 207 | .execute()) { 208 | XCTAssertEqual($0 as? Errors, .procedureAlreadyExists("simpleGroup")) 209 | } 210 | } 211 | 212 | func testFailWhenItemNameNotFound() throws { 213 | let root = try Scenario.simpleYaml.make() 214 | var package = try XCTUnwrap(CodableFile(in: root) 215 | .packages() 216 | .first(where: { $0.object.name == "Simple" })) 217 | 218 | XCTAssertThrowsError(try Scaffold.procedure(named: "newProcedure", 219 | package: "Simple", 220 | description: "theDescription", 221 | rootFolder: root, 222 | itemNames: ["replacementOnly", "i do not exist"]) 223 | .execute()) { 224 | XCTAssertEqual($0 as? Errors, .itemNotFound("i do not exist")) 225 | } 226 | try package.reload() 227 | XCTAssertNil(package.object.procedures.first { $0.name == "newProcedure" }) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/ItemTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemTests.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | import Yams 12 | 13 | class ItemTests: TestCase { 14 | func testSimpleItem() throws { 15 | let file = try Folder.mock().file(at: "SimpleJSON/Murray/Simple/SimpleItem/SimpleItem.json") 16 | 17 | let item = try CodableFile(file: file).object 18 | XCTAssertEqual(item.name, "simpleItem") 19 | XCTAssertEqual(item.description, "custom description") 20 | XCTAssertEqual(item.paths.count, 1) 21 | 22 | let path = try XCTUnwrap(item.paths.first) 23 | XCTAssertEqual(path.from, "Bone.swift") 24 | XCTAssertEqual(path.to, "Sources/Files/{{ nestedName }}/{{ customName }}.swift") 25 | 26 | let requiredParameter = try XCTUnwrap(item.parameters.first) 27 | XCTAssertEqual(requiredParameter.name, "name") 28 | XCTAssertTrue(requiredParameter.isRequired) 29 | 30 | let optionalParameter = try XCTUnwrap(item.parameters.dropLast().last) 31 | XCTAssertEqual(optionalParameter.name, "type") 32 | XCTAssertFalse(optionalParameter.isRequired) 33 | 34 | let textReplacement = try XCTUnwrap(item.replacements.first) 35 | XCTAssertEqual(textReplacement.destination, "Sources/Files/Default/Test.swift") 36 | XCTAssertEqual(textReplacement.placeholder, "//Murray Placeholder") 37 | XCTAssertEqual(textReplacement.text, "{{ name }}") 38 | XCTAssertNil(textReplacement.source) 39 | 40 | let sourceReplacement = try XCTUnwrap(item.replacements.last) 41 | XCTAssertEqual(sourceReplacement.destination, "Sources/Files/Default/Test2.swift") 42 | XCTAssertEqual(sourceReplacement.placeholder, "//Murray Placeholder") 43 | XCTAssertEqual(sourceReplacement.text, "{{ name }}") 44 | XCTAssertEqual(sourceReplacement.source, "Replacement.swift") 45 | } 46 | 47 | func testInvalidReplacement() { 48 | let replacementYaml = """ 49 | destination: somewhere 50 | placeholder: somePlaceholder 51 | """ 52 | XCTAssertThrowsError(try YAMLDecoder(encoding: .utf8).decode(replacementYaml, of: Item.Replacement.self)) { error in 53 | XCTAssertEqual(error as? Errors, Errors.invalidReplacement) 54 | } 55 | 56 | let replacementJSON = """ 57 | { "destination": "somewhere", "placeholder": "somePlaceholder"} 58 | """ 59 | 60 | XCTAssertThrowsError(try JSONDecoder().decode(replacementJSON, of: Item.Replacement.self)) { error in 61 | XCTAssertEqual(error as? Errors, Errors.invalidReplacement) 62 | } 63 | } 64 | 65 | func testMissingReplacementAllowsMapping() throws { 66 | let yaml = """ 67 | name: simpleItem 68 | description: custom description 69 | paths: 70 | - from: "Bone.swift" 71 | to: "Sources/Files/{{ nestedName }}/{{ customName }}.swift" 72 | plugins: 73 | xcode: 74 | targets: ["App"] 75 | shell: 76 | after: 77 | - "echo {{_destinationFilename}} >> /{{ _destinationPath }}.test" 78 | parameters: 79 | - "name": "name" 80 | "isRequired": true 81 | """ 82 | let item = try YAMLDecoder(encoding: .utf8).decode(yaml, of: Item.self) 83 | XCTAssertEqual(item.replacements, []) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/Folder/Subfolder/AnotherSubfolderWith{{name}}/Object.swift: -------------------------------------------------------------------------------- 1 | {{ name }} Test 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/Folder/Subfolder/AnotherSubfolderWith{{name}}/{{name}}.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/Folder/Subfolder/Bone.swift: -------------------------------------------------------------------------------- 1 | {{ name }} Test 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/Folder/Subfolder/{{name}}.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/Folder/item.yml: -------------------------------------------------------------------------------- 1 | name: "folder" 2 | description: "Folder replacement with nested subfolders" 3 | paths: 4 | - from: "Subfolder" 5 | to: "Sources/Files/{{name}}" 6 | replacements: 7 | - text: "{{name}}" 8 | placeholder: "//Murray Placeholder" 9 | destination: "Sources/Files/Default/TestReplacementOnly.swift" 10 | parameters: 11 | - name: name 12 | isRequired: true 13 | - name: type 14 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/ReplacementOnly/Replacement.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/ReplacementOnly/item.yml: -------------------------------------------------------------------------------- 1 | name: "replacementOnly" 2 | description: "Simple item replacement" 3 | paths: [] 4 | replacements: 5 | - text: {{name}} 6 | placeholder: "//Murray Placeholder" 7 | destination: "Sources/Files/Default/TestReplacementOnly.swift" 8 | parameters: 9 | - name: name 10 | isRequired: true 11 | - name: type 12 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/Simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple", 3 | "description": "Simple bone spec for testing purposes", 4 | "procedures": [ 5 | { 6 | "name": "simpleGroup", 7 | "description": "custom description", 8 | "items": [ 9 | "SimpleItem/SimpleItem.json" 10 | ] 11 | }, 12 | { 13 | "name": "replacementOnly", 14 | "description": "custom description", 15 | "items": [ 16 | "ReplacementOnly/item.yml" 17 | ] 18 | }, 19 | { 20 | "name": "folder", 21 | "description": "custom description", 22 | "items": [ 23 | "Folder/item.yml" 24 | ] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/SimpleItem/Bone.swift: -------------------------------------------------------------------------------- 1 | {{ name }} Test {{ _year }} 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/SimpleItem/Replacement.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murray/Simple/SimpleItem/SimpleItem.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simpleItem", 3 | "description": "custom description", 4 | "paths": [ 5 | { "from": "Bone.swift", 6 | "to": "Sources/Files/{{ nestedName }}/{{ customName }}.swift", 7 | } 8 | ], 9 | "replacements": [ 10 | { 11 | "text": "{{ name }}", 12 | "placeholder": "//Murray Placeholder", 13 | "destination": "Sources/Files/Default/Test.swift" 14 | }, 15 | { 16 | "text": "{{ name }}", 17 | "source": "Replacement.swift", 18 | "placeholder": "//Murray Placeholder", 19 | "destination": "Sources/Files/Default/Test2.swift" 20 | } 21 | ], 22 | "parameters": [ 23 | { 24 | "name": "name", 25 | "isRequired": true 26 | }, 27 | { 28 | "name": "type" 29 | }, 30 | {"name": "nestedValue.innerValue", 31 | "isRequired": true} 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Murrayfile: -------------------------------------------------------------------------------- 1 | { 2 | "environment": { 3 | "author": "Stefano Mondino", 4 | "customName": "{{name}}", 5 | "nestedName": "{{customName}}", 6 | "nestedValue": { 7 | "innerValue": "Test" 8 | } 9 | }, 10 | "mainPlaceholder": "name", 11 | "packages": ["Murray/Simple/Simple.json"], 12 | "plugins" : { 13 | "shell": { "after": ["echo test >> plugin.data"]} 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Sources/Files/Default/Test.swift: -------------------------------------------------------------------------------- 1 | This is a test 2 | //Murray Placeholder 3 | 4 | Enjoy 5 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Sources/Files/Default/Test2.swift: -------------------------------------------------------------------------------- 1 | This is a test 2 | //Murray Placeholder 3 | 4 | Enjoy 5 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleJSON/Sources/Files/Default/TestReplacementOnly.swift: -------------------------------------------------------------------------------- 1 | This is a test 2 | //Murray Placeholder 3 | 4 | Enjoy 5 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/ReplacementOnly/Replacement.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/ReplacementOnly/item.yml: -------------------------------------------------------------------------------- 1 | name: "replacementOnly" 2 | description: "Simple item replacement" 3 | paths: [] 4 | replacements: 5 | - text: "{{name}}" 6 | placeholder: "//Murray Placeholder" 7 | destination: "Sources/Files/Default/TestReplacementOnly.swift" 8 | parameters: 9 | - name: name 10 | isRequired: true 11 | - name: type 12 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/Simple.yml: -------------------------------------------------------------------------------- 1 | name: "Simple" 2 | description: "Simple bone spec for testing purposes" 3 | procedures: 4 | - name: simpleGroup 5 | description: custom description 6 | items: 7 | - "SimpleItem/SimpleItem.yml" 8 | - name: replacementOnly 9 | description: custom description 10 | items: 11 | - "ReplacementOnly/item.yml" 12 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/.gitKeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synesthesia-it/Murray/d5d95da863fa164ad09286b6190cb6ff198bc323/Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/.gitKeep -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/Bone.swift: -------------------------------------------------------------------------------- 1 | {{ name }} Test 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/File: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synesthesia-it/Murray/d5d95da863fa164ad09286b6190cb6ff198bc323/Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/File -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/Replacement.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/Simple/SimpleItem/SimpleItem.yml: -------------------------------------------------------------------------------- 1 | name: simpleItem 2 | description: custom description 3 | paths: 4 | - from: "Bone.swift" 5 | to: "Sources/Files/{{ nestedName }}/{{ customName }}.swift" 6 | plugins: 7 | xcode: 8 | targets: ["App"] 9 | shell: 10 | after: 11 | - "echo {{_destinationFilename}} >> /{{ _destinationPath }}.test" 12 | replacements: 13 | - "text": "{{ name }}" 14 | "placeholder": "//Murray Placeholder" 15 | "destination": "Sources/Files/Default/Test.swift" 16 | - "text": "{{ name }}" 17 | "source": "Replacement.swift" 18 | "placeholder": "//Murray Placeholder" 19 | "destination": "Sources/Files/Default/Test2.swift" 20 | parameters: 21 | - "name": "name" 22 | "description": "The name of the item" 23 | "isRequired": true 24 | - "name": "type" 25 | "description": "The type of the item" 26 | "values": ["valueA", "valueB"] 27 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/SimpleCopy/Simple.yml: -------------------------------------------------------------------------------- 1 | name: "SimpleCopy" 2 | description: "Simple bone spec for testing purposes" 3 | procedures: 4 | - name: simpleGroup 5 | description: custom description 6 | items: 7 | - "SimpleItem/SimpleItem.yml" 8 | - name: replacementOnly 9 | description: custom description 10 | items: 11 | - "ReplacementOnly/item.yml" 12 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/SimpleCopy/SimpleItem/Bone.swift: -------------------------------------------------------------------------------- 1 | {{ name }} Test2 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/SimpleCopy/SimpleItem/Replacement.swift: -------------------------------------------------------------------------------- 1 | testing {{ name }} in place(copy) 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murray/SimpleCopy/SimpleItem/SimpleItem.yml: -------------------------------------------------------------------------------- 1 | name: simpleItem 2 | description: custom description 3 | paths: 4 | - from: "Bone.swift" 5 | to: "Sources/Files/{{ nestedName }}/{{ customName }}.swift" 6 | plugins: 7 | xcode: 8 | targets: ["App"] 9 | shell: 10 | after: 11 | - "echo {{_destinationFilename}} >> /{{ _destinationPath }}.test" 12 | replacements: 13 | - "text": "{{ name }}" 14 | "placeholder": "//Murray Placeholder" 15 | "destination": "Sources/Files/Default/Test.swift" 16 | - "text": "{{ name }}" 17 | "source": "Replacement.swift" 18 | "placeholder": "//Murray Placeholder" 19 | "destination": "Sources/Files/Default/Test2.swift" 20 | parameters: 21 | - "name": "name" 22 | "isRequired": true 23 | - "name": "type" 24 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Murrayfile.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | author: Stefano Mondino 3 | customName: "{{name}}" 4 | nestedName: "{{customName}}" 5 | mainPlaceholder: "name" 6 | packages: 7 | - "Murray/Simple/Simple.yml" 8 | - "Murray/SimpleCopy/Simple.yml" 9 | plugins: 10 | shell: 11 | after: 12 | - "echo test >> plugin.data" 13 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Sources/Files/Default/Test.swift: -------------------------------------------------------------------------------- 1 | This is a test 2 | // Murray Placeholder 3 | 4 | Enjoy 5 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Sources/Files/Default/Test2.swift: -------------------------------------------------------------------------------- 1 | This is a test 2 | // Murray Placeholder 3 | 4 | Enjoy 5 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SimpleYaml/Sources/Files/Default/TestReplacementOnly.swift: -------------------------------------------------------------------------------- 1 | This is a test 2 | //Murray Placeholder 3 | 4 | Enjoy 5 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/Skeleton/Skeleton.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | - from: "{{name}}" 3 | to: "{{name}}" 4 | - from: "main.swift" 5 | to: "main.swift" 6 | initializeGit: false 7 | scripts: 8 | - echo "Hello" >> hello.txt 9 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/Skeleton/Sources/Swift.swift: -------------------------------------------------------------------------------- 1 | Swift 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/Skeleton/main.swift: -------------------------------------------------------------------------------- 1 | // {{name}}.swift 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/Skeleton/{{name}}/Hello{{name|uppercase}}.swift: -------------------------------------------------------------------------------- 1 | Swift {{name}} 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SkeletonInSubfolder/Subfolder/Skeleton.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | - from: "{{name}}" 3 | to: "{{name}}" 4 | initializeGit: true 5 | scripts: 6 | - echo "Hello" >> hello.txt 7 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SkeletonInSubfolder/Subfolder/Sources/Swift.swift: -------------------------------------------------------------------------------- 1 | Swift 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/SkeletonInSubfolder/Subfolder/{{name}}/Hello{{name|uppercase}}.swift: -------------------------------------------------------------------------------- 1 | Swift {{name}} 2 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Mocks/WrongMurrayfile/Murrayfile.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | someProperty: {{name}} but this is probably wrong because is missing surrounding quotes 3 | packages: [] 4 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/MurrayfileTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class MurrayfileTests: TestCase { 13 | override func setUpWithError() throws { 14 | try super.setUpWithError() 15 | } 16 | 17 | func testJSONMurrayfileCreation() throws { 18 | try assertMurrayfile(scenario: .simpleJSON) 19 | } 20 | 21 | func testYAMLMurrayfileCreation() throws { 22 | try assertMurrayfile(scenario: .simpleYaml) 23 | } 24 | 25 | fileprivate func assertMurrayfile(scenario: Scenario, 26 | file _: StaticString = #file, 27 | line _: UInt = #line) throws { 28 | let root = try scenario.make() 29 | let murrayfile = try CodableFile(in: root).object 30 | let packagePath = try XCTUnwrap(murrayfile.packages.first) 31 | let package = try CodableFile(file: root.file(named: packagePath)) 32 | XCTAssertGreaterThan(package.object.procedures.count, 0) 33 | XCTAssertEqual(murrayfile.pluginData?["shell"]?["after"]?.first, "echo test >> plugin.data") 34 | let name: String? = murrayfile.enrichedEnvironment["author"] 35 | XCTAssertEqual(name, "Stefano Mondino") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/PipelineTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PipelineTests.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | import Yams 12 | 13 | class PipelineTests: TestCase { 14 | func testSimpleJSONPipeline() throws { 15 | let root = try Scenario.simpleJSON.make() 16 | let pipeline = try Pipeline(murrayfile: .init(in: root), 17 | procedure: "simpleGroup", 18 | context: ["name": "test"]) 19 | 20 | try pipeline.run() 21 | 22 | let file = try root.file(at: "Sources/Files/test/test.swift") 23 | XCTAssertEqual(try file.readAsString(), "test Test \(year)\n") 24 | // XCTAssertNoThrow(try root.file(at: "Sources/Files/test/.gitKeep")) 25 | } 26 | 27 | func testSimpleJSONPipelineWithMissingParameter() throws { 28 | let root = try Scenario.simpleJSON.make() 29 | let pipeline = try Pipeline(murrayfile: .init(in: root), 30 | procedure: "simpleGroup", 31 | context: [:]) 32 | let missingParameter = Item.Parameter(name: "name") 33 | XCTAssertThrowsError(try pipeline.run()) { 34 | XCTAssertEqual($0 as? Errors, Errors.missingRequiredParameters([missingParameter])) 35 | } 36 | 37 | XCTAssertThrowsError(try root.file(at: "Sources/Files/test/test.swift")) { 38 | XCTAssertEqual($0 as? Errors, Errors.fileLocationError(root.path.appendingPathComponent("Sources/Files/test/test.swift"))) 39 | } 40 | } 41 | 42 | func testFolderReplacementPipeline() throws { 43 | let root = try Scenario.simpleJSON.make() 44 | let pipeline = try Pipeline(murrayfile: .init(in: root), 45 | procedure: "folder", 46 | context: ["name": "test"]) 47 | 48 | try pipeline.run() 49 | 50 | XCTAssertEqual(try root.file(at: "Sources/Files/test/test.swift").readAsString(), 51 | "testing test in place\n") 52 | XCTAssertEqual(try root.file(at: "Sources/Files/test/AnotherSubfolderWithtest/test.swift").readAsString(), 53 | "testing test in place\n") 54 | } 55 | 56 | func testSingleProcedureNotFound() throws { 57 | let root = try Scenario.simpleJSON.make() 58 | XCTAssertThrowsError(try Pipeline(murrayfile: .init(in: root), 59 | procedure: "wrongName", 60 | context: ["name": "test"])) { error in 61 | XCTAssertEqual(error as? Errors, .procedureNotFound(name: "wrongName")) 62 | } 63 | } 64 | 65 | func testSingleProcedureNotFoundInMultipleProcedureSetup() throws { 66 | let root = try Scenario.simpleJSON.make() 67 | XCTAssertThrowsError(try Pipeline(murrayfile: .init(in: root), 68 | procedures: ["simpleGroup", "wrongName"], 69 | context: ["name": "test"])) { error in 70 | XCTAssertEqual(error as? Errors, .procedureNotFound(name: "wrongName")) 71 | } 72 | } 73 | 74 | func testPluginExecutionWithCustomPlaceholders() throws { 75 | let root = try Scenario.simpleYaml.make() 76 | let pipeline = try Pipeline(murrayfile: .init(in: root), 77 | procedure: "Simple.simpleGroup", 78 | context: ["name": "test"]) 79 | 80 | try pipeline.run() 81 | 82 | let file = try root.file(at: "Sources/Files/test/test.swift.test") 83 | XCTAssertEqual(try file.readAsString(), "test.swift\n") 84 | } 85 | 86 | func testExecutionFailsIfProvidedValueIsNotPartOfAllowedList() throws { 87 | let root = try Scenario.simpleYaml.make() 88 | let pipeline = try Pipeline(murrayfile: .init(in: root), 89 | procedure: "Simple.simpleGroup", 90 | context: ["name": "test", "type": "valueC"]) 91 | let invalidParameter = Item.Parameter(name: "type", 92 | description: "The type of the item", 93 | values: ["valueA", "valueB"]) 94 | XCTAssertThrowsError(try pipeline.run()) { error in 95 | XCTAssertEqual(error as? Errors?, Errors.invalidParameters([invalidParameter])) 96 | } 97 | // no file should be created 98 | XCTAssertThrowsError(try root.file(at: "Sources/Files/test/test.swift.test")) 99 | } 100 | 101 | func testXcodePluginAlteringXcodeProject() throws { 102 | let root = try Scenario.simpleYaml.make() 103 | let pipeline = try Pipeline(murrayfile: .init(in: root), 104 | procedure: "Simple.simpleGroup", 105 | context: ["name": "xcodeCustomFile"]) 106 | let xcodeProjPreviousContents = try root.file(at: "Test.xcodeproj/project.pbxproj").readAsString() 107 | try pipeline.run() 108 | let xcodeProjUpdatedContents = try root.file(at: "Test.xcodeproj/project.pbxproj").readAsString() 109 | XCTAssertNotEqual(xcodeProjPreviousContents, xcodeProjUpdatedContents) 110 | // check that xcodeplugin is properly adding newly created file to proper target. This test can be improved a lot. 111 | XCTAssertTrue(xcodeProjUpdatedContents.contains("xcodeCustomFile.swift")) 112 | XCTAssertFalse(xcodeProjPreviousContents.contains("xcodeCustomFile.swift")) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/ProcedureTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | @testable import MurrayKit 11 | import XCTest 12 | 13 | class ProcedureTests: TestCase { 14 | 15 | func testJSONValidProcedure() throws { 16 | let json = """ 17 | { 18 | "name": "simpleGroup", 19 | "description": "custom description", 20 | "items": [ 21 | "SimpleItem/SimpleItem.json" 22 | ] 23 | } 24 | """ 25 | let procedure = try JSONDecoder().decode(json, of: Procedure.self) 26 | XCTAssertEqual(procedure.name, "simpleGroup") 27 | XCTAssertEqual(procedure.description, "custom description") 28 | XCTAssertEqual(procedure.itemPaths.first, "SimpleItem/SimpleItem.json") 29 | } 30 | 31 | func testJSONProcedureWithEmptyDescription() throws { 32 | let json = """ 33 | { 34 | "name": "simpleGroup", 35 | "items": [ 36 | "SimpleItem/SimpleItem.json" 37 | ] 38 | } 39 | """ 40 | let procedure = try JSONDecoder().decode(json, of: Procedure.self) 41 | XCTAssertEqual(procedure.name, "simpleGroup") 42 | XCTAssertEqual(procedure.description, "simpleGroup") 43 | XCTAssertEqual(procedure.itemPaths.first, "SimpleItem/SimpleItem.json") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/TemplateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | import XCTest 11 | 12 | class TemplateTests: TestCase { 13 | private func test(_ text: String, 14 | context: Template.Context, 15 | expected: String, 16 | file: StaticString = #file, 17 | line: UInt = #line) throws { 18 | let template = Template(text, context: context) 19 | XCTAssertEqual(try template.resolve(), expected, file: file, line: line) 20 | } 21 | 22 | func testSimpleStringConversion() throws { 23 | try test("{{name}} is in the house", 24 | context: ["name": "John Doe"], 25 | expected: "John Doe is in the house") 26 | } 27 | 28 | func testAdditionalSwiftContext() throws { 29 | let string = """ 30 | {%- set aVariable %}{{true}}{% endset %} 31 | {%- if aVariable %}{{name}}{%endif%} 32 | """ 33 | try test(string, 34 | context: ["name": "John Doe"], 35 | expected: "John Doe") 36 | } 37 | 38 | func testStringConversionWithCustomFilters() throws { 39 | try test("{{name|uppercase}} is in the house", 40 | context: ["name": "John Doe"], 41 | expected: "JOHN DOE is in the house") 42 | 43 | try test("{{name|lowercase}} is in the house", 44 | context: ["name": "John Doe"], 45 | expected: "john doe is in the house") 46 | 47 | try test("{{name|firstUppercase}}ViewController", 48 | context: ["name": "test"], 49 | expected: "TestViewController") 50 | 51 | try test("{{name|firstLowercase}}ViewController", 52 | context: ["name": "Test"], 53 | expected: "testViewController") 54 | 55 | try test("{{name|snakeCase}}ViewController", 56 | context: ["name": "TestWithSnakeCaseStuff"], 57 | expected: "test_with_snake_case_stuffViewController") 58 | } 59 | 60 | func testNestedParametersConversion() throws { 61 | try test("{{person.firstname}} {{person.lastname|uppercase}}", 62 | context: ["person": ["firstname": "John", "lastname": "Doe"]], 63 | expected: "John DOE") 64 | } 65 | 66 | func testContextWithGlobalEnvironment() throws { 67 | try test("{{person.firstname}} {{person.lastname|uppercase}} ©{{year}}", 68 | context: .init(["person": ["firstname": "John", "lastname": "Doe"]], 69 | environment: ["year": "2022"]), 70 | expected: "John DOE ©2022") 71 | } 72 | 73 | func testSimpleFileConversion() throws { 74 | let file = try Folder.mock().file(at: "SimpleJSON/Murray/Simple/SimpleItem/Bone.swift") 75 | let template = try Template(file, context: ["name": "Some random test", "_year": "2023"]) 76 | XCTAssertEqual(try template.resolve(), "Some random test Test 2023\n") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Utilities/MurrayTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 20/05/22. 6 | // 7 | 8 | import Foundation 9 | import MurrayKit 10 | import XCTest 11 | 12 | class TestCase: XCTestCase { 13 | var logger = TestLogger(logLevel: .normal) 14 | let formatter = DateFormatter() 15 | var year: String { 16 | string(from: .init(), format: "yyyy") 17 | } 18 | 19 | func string(from _: Date, format: String) -> String { 20 | formatter.dateFormat = format 21 | return formatter.string(from: .init()) 22 | } 23 | 24 | override func setUpWithError() throws { 25 | try super.setUpWithError() 26 | logger = TestLogger(logLevel: .normal) 27 | Logger.logger = logger 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/Utilities/Scenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 30/01/22. 6 | // 7 | 8 | import Foundation 9 | @testable import MurrayKit 10 | 11 | extension Folder { 12 | static func mock(at name: String = "") throws -> Folder { 13 | try Folder(path: Bundle.module.resourcePath ?? Bundle.module.bundlePath) 14 | .subfolder(at: "Mocks/\(name)") 15 | } 16 | 17 | static func testFolder() throws -> Folder { 18 | try Folder 19 | .temporary 20 | .createSubfolderIfNeeded(withName: "Murray") 21 | } 22 | 23 | static func emptyTestFolder() throws -> Folder { 24 | try? Folder.temporary.subfolder(named: "emptyTest").delete() 25 | return try Folder 26 | .temporary 27 | .createSubfolderIfNeeded(withName: "emptyTest") 28 | } 29 | } 30 | 31 | struct Scenario { 32 | let name: String 33 | 34 | func make() throws -> Folder { 35 | let origin = try Folder.mock(at: name) 36 | 37 | let destinationParent = try Scenario.folder() 38 | 39 | try? destinationParent 40 | .subfolder(named: name) 41 | .delete() 42 | 43 | try origin 44 | .copy(to: destinationParent) 45 | 46 | let destination = try destinationParent 47 | .subfolder(named: name) 48 | print("Running in \(destination)") 49 | return destination 50 | } 51 | } 52 | 53 | extension Scenario { 54 | static var simpleJSON: Scenario { 55 | .init(name: "SimpleJSON") 56 | } 57 | 58 | static var simpleYaml: Scenario { 59 | .init(name: "SimpleYaml") 60 | } 61 | 62 | static var wrongMurrayfile: Scenario { 63 | .init(name: "WrongMurrayfile") 64 | } 65 | 66 | static var cloneOrigin: Scenario { 67 | .init(name: "Skeleton") 68 | } 69 | 70 | static var cloneOriginInSubfolder: Scenario { 71 | .init(name: "SkeletonInSubfolder") 72 | } 73 | 74 | static func folder() throws -> Folder { 75 | try Folder.testFolder() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/MurrayKitTests/WriteableFileTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stefano Mondino on 11/05/22. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | @testable import MurrayKit 11 | import XCTest 12 | 13 | class WriteableFileTests: TestCase { 14 | 15 | func testFileCreation() throws { 16 | let root = try Scenario.simpleJSON.make() 17 | let context = Template.Context(["name": "TEST"]) 18 | let file = WriteableFile(content: .text("replaced with {{name}}"), 19 | path: "Sources/{{name}}/{{name}}.swift", 20 | destinationRoot: root, 21 | action: .create) 22 | 23 | XCTAssertEqual(try file.preview(context: context), "replaced with TEST") 24 | 25 | let destination = try file.commit(context: context) 26 | 27 | XCTAssertEqual(destination.path(relativeTo: root), "Sources/TEST/TEST.swift") 28 | XCTAssertEqual(try destination.readAsString(), "replaced with TEST") 29 | 30 | } 31 | 32 | func testFileReplacement() throws { 33 | let root = try Scenario.simpleJSON.make() 34 | let context = Template.Context(["name": "TEST", "customizedPath": "Default"]) 35 | let placeholder = "//Murray Placeholder" 36 | 37 | let file = WriteableFile(content: .text("replaced with {{name}}\n"), 38 | path: "Sources/Files/{{customizedPath}}/Test.swift", 39 | destinationRoot: root, 40 | action: .edit(placeholder: placeholder)) 41 | 42 | let expected = """ 43 | This is a test 44 | replaced with TEST 45 | //Murray Placeholder 46 | 47 | Enjoy 48 | 49 | """ 50 | XCTAssertEqual(try file.preview(context: context), expected) 51 | 52 | let destination = try file.commit(context: context) 53 | 54 | XCTAssertEqual(destination.path(relativeTo: root), "Sources/Files/Default/Test.swift") 55 | XCTAssertEqual(try destination.readAsString(), expected) 56 | } 57 | 58 | } 59 | --------------------------------------------------------------------------------