├── spec ├── Fixtures │ ├── Sources │ │ └── Fixture.swift │ ├── Package.swift │ ├── Tests │ │ └── TargetTests │ │ │ └── TargetTests.swift │ └── package_description.json ├── parser_spec.lua └── plugin_spec.lua ├── .luacheckrc ├── .editorconfig ├── .busted ├── .github └── workflows │ └── tests.yml ├── neotest-swift-testing-scm-1.rockspec ├── LICENSE ├── README.md └── lua └── neotest-swift-testing ├── logging.lua ├── parser.lua ├── util.lua └── init.lua /spec/Fixtures/Sources/Fixture.swift: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/Fixtures/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | -------------------------------------------------------------------------------- /spec/Fixtures/Tests/TargetTests/TargetTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | struct TargetTests { 4 | @Test 5 | func aTest() {} 6 | } 7 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | ignore = { 2 | "631", -- max_line_length 3 | } 4 | read_globals = { 5 | "vim", 6 | "describe", 7 | "it", 8 | "assert" 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.lua] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.busted: -------------------------------------------------------------------------------- 1 | return { 2 | _all = { 3 | coverage = false, 4 | lpath = "lua/?.lua;lua/?/init.lua", 5 | lua = "nlua", 6 | }, 7 | default = { 8 | verbose = true, 9 | }, 10 | tests = { 11 | verbose = true, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run tests 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | name: Run tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | neovim_version: ['nightly'] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Run tests 20 | uses: nvim-neorocks/nvim-busted-action@v1 21 | with: 22 | nvim_version: ${{ matrix.neovim_version }} 23 | -------------------------------------------------------------------------------- /neotest-swift-testing-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | rockspec_format = "3.0" 2 | package = "neotest-swift-testing" 3 | version = "scm-1" 4 | 5 | description = { 6 | summary = "A plugin for running Swift Test with neotest in Neovim.", 7 | homepage = "https://codeberg.org/mmllr/neotest-swift-testing", 8 | license = "MIT", 9 | labels = { "neovim", "swift", "test", "neotest" }, 10 | } 11 | 12 | source = { 13 | url = "git+https://github.com/mmllr/neotest-swift-testing", 14 | } 15 | 16 | dependencies = { 17 | "lua >= 5.1", 18 | "nvim-nio", 19 | "neotest", 20 | "plenary.nvim", 21 | } 22 | 23 | test_dependencies = { 24 | "nlua", 25 | "busted", 26 | "luassert", 27 | } 28 | 29 | test = { 30 | type = "busted", 31 | } 32 | 33 | build = { 34 | type = "builtin", 35 | copy_directories = { 36 | -- Add runtimepath directories, like 37 | -- 'plugin', 'ftplugin', 'doc' 38 | -- here. DO NOT add 'lua' or 'lib'. 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /spec/Fixtures/package_description.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [], 3 | "manifest_display_name": "MyPackage", 4 | "name": "MyPackage", 5 | "path": "/PathToPackage", 6 | "platforms": [], 7 | "products": [ 8 | { 9 | "name": "MyPackage", 10 | "targets": ["MyPackage"], 11 | "type": { 12 | "library": ["automatic"] 13 | } 14 | } 15 | ], 16 | "targets": [ 17 | { 18 | "c99name": "MyPackageTests", 19 | "module_type": "SwiftTarget", 20 | "name": "MyPackageTests", 21 | "path": "Tests/MyPackageTests", 22 | "sources": ["MyPackageTests.swift"], 23 | "target_dependencies": ["MyPackage"], 24 | "type": "test" 25 | }, 26 | { 27 | "c99name": "MyPackage", 28 | "module_type": "SwiftTarget", 29 | "name": "MyPackage", 30 | "path": "Sources/MyPackage", 31 | "product_memberships": ["MyPackage"], 32 | "sources": ["MyPackage.swift"], 33 | "type": "library" 34 | } 35 | ], 36 | "tools_version": "6.1" 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Markus Müller 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neotest-swift-testing 2 | 3 | This is an adapter for using the [neotest](https://github.com/nvim-neotest/neotest) framework with [Swift Testing](https://github.com/swiftlang/swift-testing). 4 | 5 | The plugin is tested with Xcode 16 only but might work with earlier versions as well. 6 | Thanks to Emmet Murray for his [neotest-swift](https://github.com/ehmurray8/neotest-swift) plugin which I used as a starting point. 7 | I focused on Swift Testing only, legacy XCTest is not supported but might work. 8 | 9 | ## Features 10 | 11 | - [x] - Run Swift Test suites and tests 12 | - [x] - Debug tests cases with neotest dap support 13 | - [ ] - Show parametrized tests in the test list 14 | 15 | ## Neovim DAP configuration for Swift 16 | 17 | Add the following configuration file (e.q. neovim-dap.lua for Lazy) to enable debugging of Swift code with `nvim-dap`: 18 | 19 | ```lua 20 | return { 21 | "mfussenegger/nvim-dap", 22 | optional = true, 23 | dependencies = "williamboman/mason.nvim", 24 | opts = function() 25 | local dap = require("dap") 26 | if not dap.adapters.lldb then 27 | local lldb_dap_path = vim.fn.trim(vim.fn.system("xcrun -f lldb-dap")) 28 | dap.adapters.lldb = { 29 | type = "executable", 30 | command = lldb_dap_path, -- adjust as needed, must be absolute path 31 | name = "lldb", 32 | } 33 | end 34 | 35 | dap.configurations.swift = { 36 | { 37 | name = "Launch file", 38 | type = "lldb", 39 | request = "launch", 40 | program = function() 41 | return vim.fn.input("Path to executable: ", vim.fn.getcwd() .. "/", "file") 42 | end, 43 | cwd = "${workspaceFolder}", 44 | stopOnEntry = false, 45 | }, 46 | } 47 | end, 48 | } 49 | ``` 50 | 51 | Feel free to start a pull request if you have any improvements or bug fixes. 52 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/logging.lua: -------------------------------------------------------------------------------- 1 | -- https://github.com/fredrikaverpil/neotest-golang/blob/main/lua/neotest-golang/logging.lua 2 | 3 | local Logger = {} 4 | local prefix = "[neotest-swift-testing]" 5 | 6 | local logger 7 | 8 | local log_date_format = "%FT%H:%M:%SZ%z" 9 | ---@param opts table 10 | ---@return neotest.Logger 11 | function Logger.new(opts) 12 | opts = opts or {} 13 | if logger then 14 | return logger 15 | end 16 | logger = {} 17 | setmetatable(logger, { __index = Logger }) 18 | logger = logger 19 | 20 | logger._level = opts.log_level or vim.log.levels.DEBUG 21 | 22 | for level, levelnr in pairs(vim.log.levels) do 23 | logger[level:lower()] = function(...) 24 | local argc = select("#", ...) 25 | if levelnr < logger._level then 26 | return false 27 | end 28 | if argc == 0 then 29 | return true 30 | end 31 | local info = debug.getinfo(2, "Sl") 32 | local fileinfo = string.format("%s:%s", info.short_src, info.currentline) 33 | local parts = { 34 | table.concat({ prefix, level, os.date(log_date_format), fileinfo }, "|"), 35 | } 36 | if _G._NEOTEST_IS_CHILD then 37 | table.insert(parts, "CHILD |") 38 | end 39 | for i = 1, argc do 40 | local arg = select(i, ...) 41 | if arg == nil then 42 | table.insert(parts, "") 43 | elseif type(arg) == "string" then 44 | table.insert(parts, arg) 45 | elseif type(arg) == "table" and arg.__tostring then 46 | table.insert(parts, arg.__tostring(arg)) 47 | else 48 | table.insert(parts, vim.inspect(arg)) 49 | end 50 | end 51 | -- TODO: Add a log file 52 | vim.print(table.concat(parts, " ")) 53 | end 54 | end 55 | return logger 56 | end 57 | 58 | function Logger:set_level(level) 59 | self._level = assert( 60 | type(level) == "number" and level or vim.log.levels[tostring(level):upper()], 61 | string.format("Log level must be one of (trace, debug, info, warn, error), got: %q", level) 62 | ) 63 | end 64 | 65 | return Logger.new({}) 66 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/parser.lua: -------------------------------------------------------------------------------- 1 | local logger = require("neotest-swift-testing.logging") 2 | local M = {} 3 | 4 | ---@class SwiftTesting.SourceLocation 5 | ---@field _filePath string 6 | ---@field fileID string 7 | ---@field line number 8 | ---@field column number 9 | 10 | ---@alias SwiftTesting.Instant "absolute" | "since1970" 11 | 12 | ---@alias SwiftTesting.RecordType "test" | "event" 13 | 14 | ---@alias SwiftTesting.TestType "suite" | "function" 15 | 16 | ---@class SwiftTesting.Payload 17 | ---@field id string 18 | ---@field kind SwiftTesting.TestType 19 | ---@field sourceLocation SwiftTesting.SourceLocation 20 | ---@field name string 21 | ---@field diplayName? string 22 | ---@field isParameterized? boolean 23 | 24 | ---@class SwiftTesting.TestRecord 25 | ---@field version number 26 | ---@field kind SwiftTesting.RecordType 27 | ---@field payload SwiftTesting.Payload 28 | 29 | ---@alias SwiftTesting.EventKind "runStarted" | "testStarted" | "testCaseStarted" | "issueRecorded" | "testCaseEnded" | "testEnded" | "testSkipped" | "runEnded" | "valueAttached" 30 | 31 | ---@class SwiftTesting.MessageSymbol "default" | "skip" | "pass" | "passWithKnownIssue" | "fail" | "difference" | "warning" | "details" 32 | 33 | ---@class SwiftTesting.Message 34 | ---@field symbol SwiftTesting.MessageSymbol 35 | ---@field text string 36 | 37 | ---@class SwiftTesting.Issue 38 | ---@field isKnown boolean 39 | ---@field sourceLocation? SwiftTesting.SourceLocation 40 | 41 | ---@class SwiftTesting.Attachment 42 | ---@field path string 43 | 44 | ---@class SwiftTesting.Event 45 | ---@field kind SwiftTesting.EventKind 46 | ---@field instant SwiftTesting.Instant 47 | ---@field messages SwiftTesting.Message[] 48 | ---@field issue? SwiftTesting.Issue 49 | ---@field attachment? SwiftTesting.Attachment 50 | ---@field testID? string 51 | 52 | ---@param line string 53 | ---@return SwiftTesting.TestRecord|SwiftTesting.Event|nil 54 | function M.parse(line) 55 | local status, result = pcall(function() 56 | return vim.json.decode(line) 57 | end) 58 | 59 | if not status then 60 | logger.error(string.format("Failed to parse JSON on line %s", result)) 61 | return nil 62 | end 63 | 64 | return result 65 | end 66 | 67 | setmetatable(M, { 68 | __call = function(_, opts) 69 | if opts.log_level then 70 | logger:set_level(opts.log_level) 71 | end 72 | return M 73 | end, 74 | }) 75 | 76 | return M 77 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/util.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local separator = "::" 3 | 4 | ---Replace the first occurrence of a character in a string. 5 | ---@param str string The string. 6 | ---@param char string The character to replace. 7 | ---@param replacement string The replacement character. 8 | M.replace_first_occurrence = function(str, char, replacement) 9 | return string.gsub(str, char, replacement, 1) 10 | end 11 | 12 | M.table_contains = function(table, value) 13 | for _, v in pairs(table) do 14 | if v == value then 15 | return true 16 | end 17 | end 18 | return false 19 | end 20 | 21 | M.trim_up_to_prefix = function(str, char) 22 | local pattern = "^[^" .. char .. "]*" .. char 23 | return string.gsub(str, pattern, "") 24 | end 25 | 26 | ---Get the prefix of a string. 27 | ---@param str string 28 | ---@param char string 29 | ---@return string 30 | M.get_prefix = function(str, char) 31 | local prefix = string.match(str, "^[^" .. char .. "]*") 32 | return prefix 33 | end 34 | 35 | ---@param list neotest.Position[] 36 | ---@param prefix string 37 | ---@param suffix string 38 | ---@return neotest.Position? 39 | local function find_element_by_id(list, prefix, suffix) 40 | for _, item in ipairs(list) do 41 | local a = vim.startswith(item.id, prefix) 42 | local b = vim.endswith(item.id, suffix) 43 | 44 | if item.type == "test" and a and b then 45 | return item 46 | end 47 | end 48 | return nil 49 | end 50 | 51 | M.collect_tests = function(nested_table) 52 | local flattened_table = {} 53 | 54 | local function recurse(subtable) 55 | for _, item in ipairs(subtable) do 56 | if type(item) == "table" then 57 | if item.type == "test" then 58 | table.insert(flattened_table, item) 59 | else 60 | recurse(item) 61 | end 62 | end 63 | end 64 | end 65 | 66 | recurse(nested_table) 67 | return flattened_table 68 | end 69 | 70 | ---@param list neotest.Position[] 71 | ---@param class_name string 72 | ---@param test_name string 73 | ---@param cwd string 74 | ---@return neotest.Position? 75 | M.find_position = function(list, class_name, test_name, cwd) 76 | local module, class = class_name:match("([^%.]+)%.([^%.]+)") 77 | if not module or not class then 78 | return nil 79 | end 80 | local prefix = cwd .. "/Tests/" .. module 81 | local suffix = separator .. class .. separator .. M.get_prefix(test_name, "(") 82 | 83 | return find_element_by_id(list, prefix, suffix) 84 | end 85 | 86 | return M 87 | -------------------------------------------------------------------------------- /spec/parser_spec.lua: -------------------------------------------------------------------------------- 1 | describe("JSON Lines parser", function() 2 | local sut = require("neotest-swift-testing/parser")({ log_level = vim.log.levels.OFF }) 3 | it("Parses empty lines", function() 4 | local result = sut.parse("") 5 | assert.is_nil(result) 6 | end) 7 | 8 | describe("Discovering", function() 9 | it("can parse test suites", function() end) 10 | local input = [[ 11 | {"kind":"test","payload":{"id":"GPXKitTests.ArrayExtensionsTests","kind":"suite","name":"ArrayExtensionsTests","sourceLocation":{"_filePath":"\/Users\/user\/folder\/GPXKit\/Tests\/GPXKitTests\/CollectionExtensionsTests.swift","column":2,"fileID":"GPXKitTests\/CollectionExtensionsTests.swift","line":11}},"version":0} 12 | ]] 13 | 14 | local result = sut.parse(input) 15 | 16 | ---@type SwiftTesting.TestRecord 17 | local actual = { 18 | kind = "test", 19 | payload = { 20 | id = "GPXKitTests.ArrayExtensionsTests", 21 | kind = "suite", 22 | name = "ArrayExtensionsTests", 23 | sourceLocation = { 24 | _filePath = "/Users/user/folder/GPXKit/Tests/GPXKitTests/CollectionExtensionsTests.swift", 25 | column = 2, 26 | fileID = "GPXKitTests/CollectionExtensionsTests.swift", 27 | line = 11, 28 | }, 29 | }, 30 | version = 0, 31 | } 32 | assert.are.same(actual, result) 33 | end) 34 | 35 | it("can parse test cases", function() 36 | local input = [[ 37 | {"kind":"test","payload":{"id":"GPXKitTests.GPXParserTests\/testTrackPointsDateWithFraction()\/GPXParserTests.swift:235:6","isParameterized":false,"kind":"function","name":"testTrackPointsDateWithFraction()","sourceLocation":{"_filePath":"\/Users\/user\/folder\/GPXKit\/Tests\/GPXKitTests\/GPXParserTests.swift","column":6,"fileID":"GPXKitTests\/GPXParserTests.swift","line":235}},"version":0} 38 | ]] 39 | 40 | local result = sut.parse(input) 41 | local actual = { 42 | kind = "test", 43 | version = 0, 44 | payload = { 45 | id = "GPXKitTests.GPXParserTests/testTrackPointsDateWithFraction()/GPXParserTests.swift:235:6", 46 | isParameterized = false, 47 | kind = "function", 48 | name = "testTrackPointsDateWithFraction()", 49 | sourceLocation = { 50 | _filePath = "/Users/user/folder/GPXKit/Tests/GPXKitTests/GPXParserTests.swift", 51 | column = 6, 52 | fileID = "GPXKitTests/GPXParserTests.swift", 53 | line = 235, 54 | }, 55 | }, 56 | } 57 | assert.are.same(actual, result) 58 | end) 59 | end) 60 | -------------------------------------------------------------------------------- /spec/plugin_spec.lua: -------------------------------------------------------------------------------- 1 | local Tree = require("neotest.types").Tree 2 | local lib = require("neotest.lib") 3 | local async = require("neotest.async") 4 | 5 | local function load_file(filename) 6 | local cwd = vim.fn.getcwd() 7 | local path = cwd .. "/" .. filename 8 | local file = io.open(path, "r") 9 | if not file then 10 | error("Could not open file: " .. path) 11 | end 12 | local content = file:read("*a") 13 | file:close() 14 | return content 15 | end 16 | 17 | describe("Swift testing adapter", function() 18 | local it = async.tests.it 19 | 20 | ---@param id string 21 | ---@param type neotest.PositionType 22 | ---@param name string 23 | ---@param path? string 24 | ---@return neotest.Tree 25 | local function given_tree(id, type, name, path) 26 | ---@type neotest.Position 27 | local pos = { 28 | id = id, 29 | type = type, 30 | name = name, 31 | path = path or "/neotest/client", 32 | range = { 0, 0, 0, 0 }, 33 | } 34 | ---@type neotest.Tree 35 | local tree = Tree.from_list({ pos }, function(p) 36 | return p.id 37 | end) 38 | return tree 39 | end 40 | 41 | ---@param code? integer 42 | ---@param output? string 43 | ---@return neotest.StrategyResult 44 | local function given_strategy_result(code, output) 45 | return { 46 | code = code or 0, 47 | output = output or "", 48 | } 49 | end 50 | 51 | ---@type table 52 | local files = {} 53 | ---@type table 54 | local files_exists = {} 55 | 56 | local function stub_files() 57 | local orig = lib.files.read 58 | 59 | lib.files.read = function(path) 60 | if files[path] ~= nil then 61 | return files[path] 62 | end 63 | return orig(path) 64 | end 65 | local orig_exists = lib.files.exists 66 | lib.files.exists = function(path) 67 | if files_exists[path] ~= nil then 68 | return files_exists[path] 69 | end 70 | return orig_exists(path) 71 | end 72 | end 73 | 74 | ---@type neotest.Adapter 75 | local sut 76 | setup(function() 77 | sut = require("neotest-swift-testing")({ log_level = vim.log.levels.OFF }) 78 | end) 79 | 80 | teardown(function() 81 | sut = nil 82 | end) 83 | 84 | ---@type table 85 | local stubbed_commands 86 | 87 | before_each(function() 88 | stubbed_commands = {} 89 | lib.process.run = function(cmd, opts) 90 | local key = table.concat(cmd, " ") 91 | assert.is_not_nil(stubbed_commands[key], "Expected to find\n" .. key .. "\nin stubbed commands") 92 | local p = stubbed_commands[key] 93 | if p then 94 | stubbed_commands[key] = nil 95 | return p.code, { stdout = p.result } 96 | end 97 | return -1, nil 98 | end 99 | 100 | ---@diagnostic disable-next-line: duplicate-set-field 101 | async.fn.tempname = function() 102 | return "/temporary/path/" 103 | end 104 | files = {} 105 | files_exists = {} 106 | end) 107 | 108 | after_each(function() 109 | assert.are.same({}, stubbed_commands, "Expected all stubbed commands to be invoked. Uninvoked commands:\n" .. vim.inspect(stubbed_commands)) 110 | files = {} 111 | files_exists = {} 112 | end) 113 | 114 | ---Stubs the result for a command. 115 | ---@param cmd string 116 | ---@param result string 117 | ---@param code? integer 118 | local function given(cmd, result, code) 119 | stubbed_commands[cmd] = { result = result, code = code or 0 } 120 | end 121 | 122 | ---Stubs content for lib.files.read 123 | ---@param path string 124 | ---@param content? string 125 | local function given_file(path, content) 126 | files[path] = content 127 | files_exists[path] = content ~= nil 128 | end 129 | 130 | it("Has a name", function() 131 | assert.is_equal("neotest-swift-testing", sut.name) 132 | end) 133 | 134 | it("Has a valid root function", function() 135 | local path = vim.fn.getcwd() .. "/spec/fixtures/Sources" 136 | local expected = vim.fn.getcwd() .. "/spec/fixtures" 137 | local actual = sut.root(path) 138 | 139 | assert.is_equal(expected, actual) 140 | end) 141 | 142 | it("Filters invalid directories", function() 143 | local root = vim.fn.getcwd() .. "/spec/fixtures/Sources" 144 | 145 | local invalid = { "Sources", "build", ".git", ".build", ".git", ".swiftpm" } 146 | 147 | for _, dir in ipairs(invalid) do 148 | local actual = sut.filter_dir(dir, "spec/fixtures/Sources", root) 149 | assert.is_false(actual) 150 | end 151 | end) 152 | 153 | it("Does not filters test directories", function() 154 | assert.is_true(sut.filter_dir("Tests", "spec/fixtures/", vim.fn.getcwd())) 155 | assert.is_false(sut.filter_dir("Sources", "spec/fixtures/", vim.fn.getcwd())) 156 | end) 157 | 158 | it("Accepts test files", function() 159 | for _, name in ipairs({ "Test.swift", "Tests/Test.swift", "FeatureTests.swift" }) do 160 | assert.is_true(sut.is_test_file(name), "expected " .. name .. " to be a test file") 161 | end 162 | end) 163 | 164 | it("Filters non test files", function() 165 | for _, name in ipairs({ 166 | "Source.swift", 167 | "Feature.swift", 168 | "main.swift", 169 | "main.c", 170 | "header.h", 171 | "objc.m", 172 | "Package.swift", 173 | "Makefile", 174 | }) do 175 | assert.is_false(sut.is_test_file(name), "expected " .. name .. " to be a test file") 176 | end 177 | end) 178 | 179 | describe("Build spec", function() 180 | before_each(function() 181 | sut.root = function(p) 182 | return "/project/root" 183 | end 184 | end) 185 | 186 | describe("Integrated strategy", function() 187 | ---@param filter string 188 | ---@return string[] 189 | local function expected_command(filter) 190 | return { 191 | "swift", 192 | "test", 193 | "--enable-swift-testing", 194 | "--disable-xctest", 195 | "-c", 196 | "debug", 197 | "--xunit-output", 198 | "/temporary/path/junit.xml", 199 | "-q", 200 | "--filter", 201 | filter, 202 | } 203 | end 204 | it("Directory filter", function() 205 | ---@type neotest.RunArgs 206 | local args = { 207 | tree = given_tree( 208 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 209 | "dir", 210 | "folderName" 211 | ), 212 | strategy = "integrated", 213 | } 214 | local result = sut.build_spec(args) 215 | 216 | assert.are.same({ 217 | command = expected_command("folderName"), 218 | cwd = "/project/root", 219 | context = { 220 | results_path = "/temporary/path/junit.xml", 221 | }, 222 | }, result) 223 | end) 224 | 225 | it("Test filter", function() 226 | ---@type neotest.RunArgs 227 | local args = { 228 | tree = given_tree( 229 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 230 | "test", 231 | "testName()" 232 | ), 233 | strategy = "integrated", 234 | } 235 | local result = sut.build_spec(args) 236 | 237 | assert.are.same({ 238 | command = expected_command("className.testName"), 239 | cwd = "/project/root", 240 | context = { 241 | results_path = "/temporary/path/junit.xml", 242 | }, 243 | }, result) 244 | end) 245 | 246 | it("Namespace filter", function() 247 | ---@type neotest.RunArgs 248 | local args = { 249 | tree = given_tree( 250 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 251 | "namespace", 252 | "TestSuite" 253 | ), 254 | strategy = "integrated", 255 | } 256 | local result = sut.build_spec(args) 257 | 258 | assert.are.same({ 259 | command = expected_command(".TestSuite$"), 260 | cwd = "/project/root", 261 | context = { 262 | results_path = "/temporary/path/junit.xml", 263 | }, 264 | }, result) 265 | end) 266 | 267 | it("File filter", function() 268 | ---@type neotest.RunArgs 269 | local args = { 270 | tree = given_tree( 271 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 272 | "file", 273 | "filename" 274 | ), 275 | strategy = "integrated", 276 | } 277 | local result = sut.build_spec(args) 278 | 279 | assert.are.same({ 280 | command = expected_command("/filename"), 281 | cwd = "/project/root", 282 | context = { 283 | results_path = "/temporary/path/junit.xml", 284 | }, 285 | }, result) 286 | end) 287 | end) 288 | 289 | describe("DAP support", function() 290 | it("build spec when strategy is dap", function() 291 | given( 292 | "swift package --package-path /project/root describe --type json", 293 | load_file("spec/Fixtures/package_description.json") 294 | ) 295 | given("swift build --build-tests --enable-swift-testing --disable-xctest -c debug", "") 296 | given("xcrun --show-sdk-platform-path", "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform") 297 | given("xcode-select -p", "/Applications/Xcode.App/Contents/Developer") 298 | given("fd swiftpm-testing-helper /Applications/Xcode.App/Contents/Developer", "/path/to/swiftpm-testing-helper") 299 | given("swift build --show-bin-path", "/Users/name/project/.build/arm-apple-macosx/debug") 300 | ---@type neotest.RunArgs 301 | local args = { 302 | tree = given_tree( 303 | "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 304 | "dir", 305 | "folderName" 306 | ), 307 | strategy = "dap", 308 | } 309 | 310 | local result = sut.build_spec(args) 311 | 312 | assert.are.same({ 313 | context = { 314 | is_dap_active = true, 315 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 316 | }, 317 | cwd = "/project/root", 318 | env = { 319 | DYLD_FRAMEWORK_PATH = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks", 320 | }, 321 | }, result) 322 | end) 323 | end) 324 | end) 325 | 326 | describe("Results method", function() 327 | ---@type neotest.Tree 328 | local tree 329 | ---@type neotest.StrategyResult 330 | local strategy_result 331 | before_each(function() 332 | tree = given_tree("/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", "test", "testName()") 333 | strategy_result = given_strategy_result(0, "/outputpath/log") 334 | end) 335 | it("Failed build", function() 336 | ---@type neotest.RunSpec 337 | local spec = { 338 | command = { "swift", "test" }, 339 | context = { 340 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 341 | ---@type neotest.Error[] 342 | errors = { 343 | { 344 | message = "Build error", 345 | line = 42, 346 | }, 347 | }, 348 | }, 349 | } 350 | 351 | assert.are.same({ 352 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 353 | status = "failed", 354 | errors = { 355 | { 356 | message = "Build error", 357 | line = 42, 358 | }, 359 | }, 360 | }, 361 | }, sut.results(spec, strategy_result, tree)) 362 | end) 363 | 364 | it("Skips dap results", function() 365 | ---@type neotest.RunSpec 366 | local spec = { 367 | command = { "swift", "test" }, 368 | context = { 369 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 370 | is_dap_active = true, 371 | }, 372 | } 373 | 374 | assert.are.same({ 375 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 376 | status = "skipped", 377 | }, 378 | }, sut.results(spec, strategy_result, tree)) 379 | end) 380 | 381 | it("Fails when result_path is not found", function() 382 | given_file("/temporary/path/junit-swift-testing.xml", nil) 383 | given_file("/outputpath/log", "Output errors") 384 | stub_files() 385 | local spec = { 386 | command = { "swift", "test" }, 387 | context = { 388 | results_path = "/temporary/path/junit.xml", 389 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 390 | }, 391 | } 392 | 393 | assert.are.same({ 394 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 395 | status = "failed", 396 | output = "/outputpath/log", 397 | short = "Output errors", 398 | }, 399 | }, sut.results(spec, strategy_result, tree)) 400 | end) 401 | 402 | it("Successful test run", function() 403 | local results = [[ 404 | 405 | 406 | 407 | 408 | 409 | 410 | ]] 411 | given_file("/temporary/path/junit.xml", results) 412 | given_file("/outputpath/log", "") 413 | stub_files() 414 | local spec = { 415 | cwd = "/project", 416 | command = { "swift", "test" }, 417 | context = { 418 | results_path = "/temporary/path/junit.xml", 419 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 420 | }, 421 | } 422 | assert.are.same({ 423 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 424 | status = "passed", 425 | }, 426 | }, sut.results(spec, given_strategy_result(0, "/outputpath/log"), tree)) 427 | end) 428 | end) 429 | 430 | describe("Test discovery", function() 431 | it("discovers tests", function() 432 | local path = vim.fn.getcwd() .. "/spec/fixtures/Tests/TargetTests/TargetTests.swift" 433 | local output = "/temporary/path/test-events.jsonl" 434 | local entry = [[ 435 | {"kind":"test","payload":{"id":"MyPackageTests.example()\/MyPackageTests.swift:4:2","isParameterized":false,"kind":"function","name":"example()","sourceLocation":{"_filePath":"\/Users\/user\/MyPackage\/Tests\/MyPackageTests\/MyPackageTests.swift","column":2,"fileID":"MyPackageTests\/MyPackageTests.swift","line":4}},"version":0} 436 | ]] 437 | -- given("swift test list --enable-swift-testing --event-stream-output-path " .. output, "", 0) 438 | -- given_file(output, entry) 439 | 440 | -- assert.are.same({}, sut.discover_positions(path)) 441 | end) 442 | end) 443 | end) 444 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/init.lua: -------------------------------------------------------------------------------- 1 | local lib = require("neotest.lib") 2 | local async = require("neotest.async") 3 | local xml = require("neotest.lib.xml") 4 | local util = require("neotest-swift-testing.util") 5 | local Path = require("plenary.path") 6 | local logger = require("neotest-swift-testing.logging") 7 | local filetype = require("plenary.filetype") 8 | local files = require("neotest.lib.file") 9 | 10 | local M = { 11 | name = "neotest-swift-testing", 12 | root = function(path) 13 | return files.match_root_pattern("Package.swift")(path) 14 | end, 15 | filter_dir = function(name, rel_path, root) 16 | return vim.list_contains({ "Sources", "build", ".git", ".build", ".git", ".swiftpm" }, name) == false 17 | end, 18 | is_test_file = function(file_path) 19 | if not vim.endswith(file_path, ".swift") then 20 | return false 21 | end 22 | local elems = vim.split(file_path, Path.path.sep) 23 | local file_name = elems[#elems] 24 | return vim.endswith(file_name, "Test.swift") or vim.endswith(file_name, "Tests.swift") 25 | end, 26 | } 27 | 28 | -- Add filetype for swift until it gets added to plenary's built-in filetypes 29 | -- See https://github.com/nvim-lua/plenary.nvim?tab=readme-ov-file#plenaryfiletype for more information 30 | if filetype.detect_from_extension("swift") == "" then 31 | filetype.add_table({ 32 | extension = { ["swift"] = "swift" }, 33 | }) 34 | end 35 | 36 | local treesitter_query = [[ 37 | ;; @Suite struct TestSuite 38 | ((class_declaration 39 | (modifiers 40 | (attribute 41 | (user_type 42 | (type_identifier) @annotation (#eq? @annotation "Suite"))))? 43 | name: (type_identifier) @namespace.name) 44 | ) @namespace.definition 45 | 46 | ((class_declaration 47 | name: (user_type 48 | (type_identifier) @namespace.name))) @namespace.definition 49 | 50 | ;; @Test test func 51 | ((function_declaration 52 | (modifiers 53 | (attribute 54 | (user_type 55 | (type_identifier) @annotation (#eq? @annotation "Test")))) 56 | name: (simple_identifier) @test.name)) @test.definition 57 | 58 | ]] 59 | 60 | ---@async 61 | ---@param cmd string[] 62 | ---@return string|nil 63 | local function shell(cmd) 64 | local code, result = lib.process.run(cmd, { stdout = true, stderr = true }) 65 | if code ~= 0 or result.stderr ~= nil or result.stdout == nil then 66 | logger.error("Failed to run command: " .. vim.inspect(cmd) .. " " .. result.stderr) 67 | return nil 68 | end 69 | return result.stdout 70 | end 71 | 72 | ---@param kind SwiftTesting.TestType 73 | ---@return neotest.PositionType 74 | local function position_type(kind) 75 | if kind == "suite" then 76 | return "namespace" 77 | else 78 | return "test" 79 | end 80 | end 81 | 82 | ---@param record SwiftTesting.TestRecord 83 | ---@return neotest.Position|nil 84 | local function position_from_record(record) 85 | if record.kind == "test" and record.payload ~= nil then 86 | ---@type neotest.Position 87 | local pos = { 88 | id = record.payload.id, 89 | name = record.payload.name, 90 | path = record.payload.sourceLocation._filePath, 91 | type = position_type(record.payload.kind), 92 | range = { 93 | record.payload.sourceLocation.line, 94 | record.payload.sourceLocation.column, 95 | record.payload.sourceLocation.line, 96 | record.payload.sourceLocation.column, 97 | }, 98 | } 99 | return pos 100 | end 101 | return nil 102 | end 103 | 104 | ---@async 105 | ---@param file_path string 106 | ---@return neotest.Tree 107 | M.discover_positions = function(file_path) 108 | -- local output = async.fn.tempname() .. "test-events.jsonl" 109 | -- shell({ "swift", "test", "list", "--enable-swift-testing", "--event-stream-output-path", output }) 110 | -- local lines = files.read_lines(output) 111 | -- 112 | -- ---@type neotest.Position[] 113 | -- local positions = {} 114 | -- for _, line in ipairs(lines) do 115 | -- local parsed = parser.parse(line) 116 | -- if parsed ~= nil and parsed.kind == "test" then 117 | -- local pos = position_from_record(parsed) 118 | -- if pos ~= nil then 119 | -- table.insert(positions, pos) 120 | -- end 121 | -- end 122 | -- end 123 | -- local Tree = require("neotest.types").Tree 124 | -- logger.debug("Discovered positions: " .. vim.inspect(positions)) 125 | -- return Tree.from_list(positions, function(p) 126 | return lib.treesitter.parse_positions(file_path, treesitter_query, { nested_tests = true, require_namespaces = true }) 127 | end 128 | 129 | ---Removes new line characters 130 | ---@param str string 131 | ---@return string 132 | local function remove_nl(str) 133 | local trimmed, _ = string.gsub(str, "\n", "") 134 | return trimmed 135 | end 136 | 137 | ---Returns Xcode devoloper path 138 | ---@async 139 | ---@return string|nil 140 | local function get_dap_cmd() 141 | -- TODO: use swiftly 142 | local result = shell({ "xcode-select", "-p" }) 143 | if not result then 144 | return nil 145 | end 146 | result = shell({ "fd", "swiftpm-testing-helper", remove_nl(result) }) 147 | if not result then 148 | return nil 149 | end 150 | return remove_nl(result) 151 | end 152 | 153 | ---@async 154 | ---@return string[]|nil 155 | local function get_test_executable() 156 | local bin_path = shell({ "swift", "build", "--show-bin-path" }) 157 | if not bin_path then 158 | return nil 159 | end 160 | local json_path = remove_nl(bin_path) .. "/description.json" 161 | if not files.exists(json_path) then 162 | return nil 163 | end 164 | local decoded = vim.json.decode(files.read(json_path)) 165 | return decoded.builtTestProducts[1].binaryPath 166 | end 167 | 168 | ---@async 169 | ---@param test_name string 170 | ---@param dap_args? table 171 | ---@return table|nil 172 | local function get_dap_config(test_name, dap_args) 173 | local program = get_dap_cmd() 174 | if program == nil then 175 | logger.error("Failed to get the spm test helper path") 176 | return nil 177 | end 178 | local executable = get_test_executable() 179 | if not executable then 180 | logger.error("Failed to get the test executable path") 181 | return nil 182 | end 183 | return vim.tbl_extend("force", dap_args or {}, { 184 | name = "Swift Test debugger", 185 | type = "lldb", 186 | request = "launch", 187 | program = program, 188 | args = { 189 | "--test-bundle-path", 190 | executable, 191 | "--testing-library", 192 | "swift-testing", 193 | "--enable-swift-test", 194 | "--filter", 195 | test_name, 196 | }, 197 | cwd = "${workspaceFolder}", 198 | stopOnEntry = false, 199 | }) 200 | end 201 | 202 | ---@async 203 | ---@return integer 204 | local function ensure_test_bundle_is_build() 205 | local code, result = lib.process.run({ 206 | "swift", 207 | "build", 208 | "--build-tests", 209 | "--enable-swift-testing", 210 | "--disable-xctest", 211 | "-c", 212 | "debug", 213 | }) 214 | if code ~= 0 then 215 | logger.debug("Failed to build test bundle: " .. result.stderr) 216 | end 217 | return code 218 | end 219 | 220 | ---Finds the test target for a given file in the package directory 221 | ---@async 222 | ---@param package_directory string 223 | ---@param file_name string 224 | ---@return string|nil The test target name or nil if not found 225 | local function find_test_target(package_directory, file_name) 226 | local result = shell({ "swift", "package", "--package-path", package_directory, "describe", "--type", "json" }) 227 | if result == nil then 228 | logger.error("Failed to run swift package describe.") 229 | return nil 230 | end 231 | 232 | local decoded = vim.json.decode(result) 233 | if not decoded then 234 | logger.error("Failed to decode swift package describe output.") 235 | return nil 236 | end 237 | 238 | for _, target in ipairs(decoded.targets or {}) do 239 | if target.type == "test" and target.sources and vim.list_contains(target.sources, file_name) then 240 | return target.name 241 | end 242 | end 243 | return nil 244 | end 245 | 246 | ---@async 247 | ---@param args neotest.RunArgs 248 | ---@return neotest.RunSpec|neotest.RunSpec[]|nil 249 | function M.build_spec(args) 250 | if not args.tree then 251 | logger.error("Unexpectedly did not receive a neotest.Tree.") 252 | return nil 253 | end 254 | local position = args.tree:data() 255 | local junit_folder = async.fn.tempname() 256 | local cwd = assert(M.root(position.path), "could not locate root directory of " .. position.path) 257 | 258 | if args.strategy == "dap" then 259 | -- id pattern /Users/name/project/Tests/ProjectTests/fileName.swift::className::testName 260 | local file_name, class_name, test_name = position.id:match(".*/(.-%.swift)::(.-)::(.*)") 261 | 262 | if file_name == nil or class_name == nil or test_name == nil then 263 | logger.error("Could not extract file, class name and test name from position.id: " .. position.id) 264 | return 265 | end 266 | 267 | local target = find_test_target(cwd, file_name) 268 | if not target then 269 | logger.error("Swift test target not found.") 270 | return 271 | end 272 | 273 | local full_test_name = target .. "." .. class_name .. "/" .. test_name .. "()" 274 | if ensure_test_bundle_is_build() ~= 0 then 275 | logger.error("Failed to build test bundle.") 276 | return nil 277 | end 278 | local path = shell({ "xcrun", "--show-sdk-platform-path" }) or "" 279 | return { 280 | cwd = cwd, 281 | context = { is_dap_active = true, position_id = position.id }, 282 | strategy = get_dap_config(full_test_name), 283 | env = { ["DYLD_FRAMEWORK_PATH"] = remove_nl(path) .. "/Developer/Library/Frameworks" }, 284 | } 285 | end 286 | 287 | local command = { 288 | "swift", 289 | "test", 290 | "--enable-swift-testing", 291 | "--disable-xctest", 292 | "-c", 293 | "debug", 294 | "--xunit-output", 295 | junit_folder .. "junit.xml", 296 | "-q", 297 | } 298 | local filters = {} 299 | if position.type == "file" then 300 | table.insert(filters, "/" .. position.name) 301 | elseif position.type == "namespace" then 302 | table.insert(filters, "." .. position.name .. "$") 303 | elseif position.type == "test" then 304 | local namespace, test = string.match(position.id, ".*::(.-)::(.-)$") 305 | if namespace ~= nil and test ~= nil then 306 | table.insert(filters, namespace .. "." .. test) 307 | end 308 | elseif position.type == "dir" and position.path ~= cwd then 309 | table.insert(filters, position.name) 310 | end 311 | 312 | if #filters > 0 then 313 | table.insert(command, "--filter") 314 | for _, filter in ipairs(filters) do 315 | table.insert(command, filter) 316 | end 317 | end 318 | 319 | return { 320 | command = command, 321 | context = { 322 | results_path = junit_folder .. "junit.xml", 323 | }, 324 | cwd = cwd, 325 | } 326 | end 327 | 328 | ---Parse the output of swift test to get the line number and error message 329 | ---@async 330 | ---@param output string[] The output of the swift test command 331 | ---@param position neotest.Position The position of the test 332 | ---@param test_name string The name of the test 333 | ---@return integer?, string? The line number and error message. nil if not found 334 | local function parse_errors(output, position, test_name) 335 | local pattern = "Test (%w+)%(%) recorded an issue at ([%w-_]+%.swift):(%d+):%d+: (.+)" 336 | local pattern_with_arguments = 337 | "Test (%w+)%b() recorded an issue with 1 argument value → (.+) at ([%w-_]+%.swift):(%d+):%d+: (.+)" 338 | for _, line in ipairs(output) do 339 | local method, file, line_number, message = line:match(pattern) 340 | if method and file and line_number and message then 341 | if test_name == method and vim.endswith(position.path, file) then 342 | return tonumber(line_number) - 1 or nil, message 343 | end 344 | end 345 | method, _, file, line_number, message = line:match(pattern_with_arguments) 346 | if method and file and line_number and message then 347 | if test_name == method and vim.endswith(position.path, file) then 348 | return tonumber(line_number) - 1 or nil, message 349 | end 350 | end 351 | end 352 | return nil, nil 353 | end 354 | 355 | ---@async 356 | ---@param spec neotest.RunSpec 357 | ---@param result neotest.StrategyResult 358 | ---@param tree neotest.Tree 359 | ---@return table 360 | function M.results(spec, result, tree) 361 | local test_results = {} 362 | local nodes = {} 363 | 364 | if spec.context.errors ~= nil and #spec.context.errors > 0 then 365 | -- mark as failed if a non-test error occurred. 366 | test_results[spec.context.position_id] = { 367 | status = "failed", 368 | errors = spec.context.errors, 369 | } 370 | return test_results 371 | elseif spec.context and spec.context.is_dap_active and spec.context.position_id then 372 | -- return early if test result processing is not desired. 373 | test_results[spec.context.position_id] = { 374 | status = "skipped", 375 | } 376 | return test_results 377 | end 378 | 379 | local position = tree:data() 380 | local list = tree:to_list() 381 | local tests = util.collect_tests(list) 382 | if position.type == "test" then 383 | table.insert(nodes, position) 384 | end 385 | 386 | for _, node in ipairs(tests) do 387 | table.insert(nodes, node) 388 | end 389 | local raw_output = files.read_lines(result.output) 390 | 391 | if files.exists(spec.context.results_path) then 392 | local root = xml.parse(files.read(spec.context.results_path)) 393 | 394 | local testsuites 395 | if root.testsuites.testsuite == nil then 396 | testsuites = {} 397 | elseif #root.testsuites.testsuite == 0 then 398 | testsuites = { root.testsuites.testsuite } 399 | else 400 | testsuites = root.testsuites.testsuite 401 | end 402 | for _, testsuite in pairs(testsuites) do 403 | local testcases 404 | 405 | if testsuite.testcase == nil then 406 | testcases = {} 407 | elseif #testsuite.testcase == 0 then 408 | testcases = { testsuite.testcase } 409 | else 410 | testcases = testsuite.testcase 411 | end 412 | 413 | for _, testcase in ipairs(testcases) do 414 | local test_position = util.find_position(nodes, testcase._attr.classname, testcase._attr.name, spec.cwd) 415 | if test_position ~= nil then 416 | if testcase.failure then 417 | local line_number, error_message = 418 | parse_errors(raw_output, test_position, util.get_prefix(testcase._attr.name, "(")) 419 | test_results[test_position.id] = { 420 | status = "failed", 421 | } 422 | if line_number and error_message then 423 | test_results[test_position.id].errors = { 424 | { line = line_number, message = error_message }, 425 | } 426 | end 427 | else 428 | test_results[test_position.id] = { 429 | status = "passed", 430 | } 431 | end 432 | else 433 | logger.debug("Position not found: " .. testcase._attr.classname .. "/" .. testcase._attr.name) 434 | end 435 | end 436 | end 437 | else 438 | if spec.context.position_id ~= nil then 439 | test_results[spec.context.position_id] = { 440 | status = "failed", 441 | output = result.output, 442 | short = table.concat(raw_output, "\n"), 443 | } 444 | end 445 | end 446 | return test_results 447 | end 448 | 449 | setmetatable(M, { 450 | __call = function(_, opts) 451 | if opts.log_level then 452 | logger:set_level(opts.log_level) 453 | end 454 | return M 455 | end, 456 | }) 457 | 458 | return M 459 | --------------------------------------------------------------------------------