< Summary - Neospec Coverage

Line coverage
93%
Covered lines: 184
Uncovered lines: 12
Coverable lines: 196
Total lines: 540
Line coverage: 93.8%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: Discover0%0089.66%
File 1: globDoublestar0%00100%
File 2: New0%00100%
File 2: NewWithDefaultSandbox0%00100%
File 2: parseOutput0%00100%
File 2: parseStatus0%00100%
File 3: luaEscape0%00100%
File 3: buildShim0%0083.02%

File(s)

/home/runner/work/neospec/neospec/internal/adapters/runner/discovery.go

#LineLine coverage
 1// Package runner implements ports.TestRunner. It discovers test files via glob
 2// patterns and executes each one in a headless Neovim subprocess with the
 3// embedded Lua harness.
 4package runner
 5
 6import (
 7  "context"
 8  "errors"
 9  "fmt"
 10  "io/fs"
 11  "path/filepath"
 12  "strings"
 13)
 14
 15// Discover finds all files matching the given glob patterns. It returns
 16// absolute paths in discovery order. Patterns that match no files are
 17// silently skipped. Unreadable directories encountered during a recursive
 18// walk are also silently skipped — files inside them will not appear in
 19// results.
 20//
 21// Patterns may contain "**" to match zero or more directory levels. For
 22// example, "tests/unit/**/*_spec.lua" matches all *_spec.lua files at any
 23// depth under tests/unit/, including files directly in tests/unit/ itself.
 24// Only the first "**" occurrence in a pattern is expanded; patterns with
 25// multiple "**" segments are not supported. A bare "**" pattern with no
 26// trailing segment matches all non-directory files under the base directory.
 27//
 28// Discover respects ctx cancellation and deadline: if the context is done
 29// before all patterns are processed, the function returns ctx.Err().
 1830func Discover(ctx context.Context, patterns []string) ([]string, error) {
 1831  seen := make(map[string]struct{})
 1832  var results []string
 1833
 1834  for _, pattern := range patterns {
 135    if err := ctx.Err(); err != nil {
 136      return nil, err
 137    }
 38
 1739    var matches []string
 1740    var err error
 1741
 1242    if strings.Contains(pattern, "**") {
 1243      matches, err = globDoublestar(ctx, pattern)
 544    } else {
 545      matches, err = filepath.Glob(pattern)
 546    }
 547    if err != nil {
 548      return nil, fmt.Errorf("expanding pattern %q: %w", pattern, err)
 549    }
 50
 1251    for _, m := range matches {
 1452      abs, err := filepath.Abs(m)
 053      if err != nil {
 054        return nil, fmt.Errorf("resolving absolute path for %q: %w", m, err)
 055      }
 256      if _, dup := seen[abs]; dup {
 257        continue
 58      }
 1259      seen[abs] = struct{}{}
 1260      results = append(results, abs)
 61    }
 62  }
 63
 1264  return results, nil
 65}
 66
 67// globDoublestar expands a glob pattern containing "**" by recursively walking
 68// the filesystem. "**" matches zero or more directory levels, so:
 69//
 70//  tests/unit/**/*_spec.lua
 71//
 72// matches *_spec.lua files at any depth under tests/unit/, including files
 73// directly in tests/unit/ itself (zero intervening directories).
 74//
 75// Only the first "**" occurrence is expanded. The sub-pattern after "**" is
 76// matched against each file's base name using filepath.Match, so it must not
 77// itself contain path separators.
 78//
 79// Unreadable directories are silently skipped; their contents will not appear
 80// in results. Context cancellation is checked on every visited path.
 1281func globDoublestar(ctx context.Context, pattern string) ([]string, error) {
 182  if strings.Count(pattern, "**") > 1 {
 183    return nil, fmt.Errorf("pattern %q: multiple ** segments are not supported", pattern)
 184  }
 85
 1186  idx := strings.Index(pattern, "**")
 1187
 1188  // filepath.Clean("") returns "." so an empty prefix means "walk from CWD".
 1189  baseDir := filepath.Clean(pattern[:idx])
 1190
 1191  // Everything after "**" is the file pattern, e.g. "*_spec.lua".
 1192  // Strip a single leading separator so "**/*_spec.lua" → "*_spec.lua".
 1193  filePat := pattern[idx+len("**"):]
 994  if len(filePat) > 0 && (filePat[0] == '/' || filePat[0] == filepath.Separator) {
 995    filePat = filePat[1:]
 996  }
 297  if filePat == "" {
 298    filePat = "*"
 299  }
 100
 11101  var matches []string
 11102  err := filepath.WalkDir(baseDir, func(path string, d fs.DirEntry, werr error) error {
 1103    if ctxErr := ctx.Err(); ctxErr != nil {
 1104      return ctxErr
 1105    }
 5106    if werr != nil {
 4107      if path == baseDir {
 4108        // Surface errors on the root itself — callers need to know when
 4109        // the directory they explicitly provided is unreadable, as this
 4110        // is indistinguishable from "no matches" otherwise.
 4111        return werr
 4112      }
 113      // Skip unreadable subdirectories without aborting the entire walk.
 1114      return nil
 115    }
 12116    if d.IsDir() {
 12117      return nil
 12118    }
 11119    ok, matchErr := filepath.Match(filePat, filepath.Base(path))
 1120    if matchErr != nil {
 1121      return matchErr
 1122    }
 9123    if ok {
 9124      matches = append(matches, path)
 9125    }
 10126    return nil
 127  })
 128
 3129  if errors.Is(err, fs.ErrNotExist) {
 3130    // Base directory does not exist — not an error, just no matches.
 3131    return nil, nil
 3132  }
 8133  return matches, err
 134}

/home/runner/work/neospec/neospec/internal/adapters/runner/executor.go

#LineLine coverage
 1package runner
 2
 3import (
 4  "bytes"
 5  "context"
 6  "encoding/json"
 7  "errors"
 8  "fmt"
 9  "os"
 10  "path/filepath"
 11  "runtime"
 12  "strconv"
 13  "sync"
 14  "time"
 15
 16  "github.com/jedi-knights/neospec/internal/adapters/sandbox"
 17  "github.com/jedi-knights/neospec/internal/domain"
 18  "github.com/jedi-knights/neospec/internal/ports"
 19)
 20
 21// Runner executes test files in headless Neovim subprocesses.
 22type Runner struct {
 23  nvimPath        string
 24  sandboxF        ports.SandboxFactory
 25  exec            ports.CommandRunner
 26  verbose         bool
 27  initFile        string
 28  coverageInclude []string
 29}
 30
 31// New creates a Runner.
 32//   - nvimPath: absolute path to the nvim binary obtained from NeovimProvider.Ensure.
 33//   - sandboxF: factory for creating per-run XDG sandboxes.
 34//   - exec: Strategy for running subprocesses; inject a fake in tests.
 35//   - verbose: whether to pass -V3 to nvim for diagnostic output.
 36//   - initFile: optional path to a Lua file executed before the coverage hook and
 37//     test harness. When non-empty, its dofile() call is the very first line of
 38//     the generated shim so the init file runs outside of coverage instrumentation.
 39//   - coverageInclude: optional list of path substrings. When non-empty, the
 40//     coverage hook only records source files whose path contains at least one
 41//     of these strings, restricting coverage to the plugin's own source tree.
 1642func New(nvimPath string, sandboxF ports.SandboxFactory, exec ports.CommandRunner, verbose bool, initFile string, covera
 1643  return &Runner{
 1644    nvimPath:        nvimPath,
 1645    sandboxF:        sandboxF,
 1646    exec:            exec,
 1647    verbose:         verbose,
 1648    initFile:        initFile,
 1649    coverageInclude: coverageInclude,
 1650  }
 1651}
 52
 53// NewWithDefaultSandbox creates a Runner using the standard XDG sandbox factory
 54// and the real os/exec command runner. Use this in production code.
 155func NewWithDefaultSandbox(nvimPath string, verbose bool, initFile string, coverageInclude []string) *Runner {
 156  return New(nvimPath, sandbox.NewFactory(), realCommandRunner{}, verbose, initFile, coverageInclude)
 157}
 58
 59// Discover satisfies the discovery half of ports.TestRunner.
 60func (r *Runner) Discover(ctx context.Context, patterns []string) ([]string, error) {
 61  return Discover(ctx, patterns)
 62}
 63
 64// Run executes each test file in parallel, aggregates results and coverage, and
 65// returns them in the same order as files. Workers are capped at runtime.NumCPU()
 66// so the test suite uses available cores without oversubscribing.
 67func (r *Runner) Run(ctx context.Context, files []string) (*domain.SuiteResult, *domain.CoverageData, error) {
 68  n := len(files)
 69  if n == 0 {
 70    return &domain.SuiteResult{}, &domain.CoverageData{}, nil
 71  }
 72
 73  type runResult struct {
 74    idx     int
 75    suite   *domain.SuiteResult
 76    cov     *domain.CoverageData
 77    err     error
 78    skipped bool // true when the worker skipped this index due to context cancellation
 79  }
 80
 81  // Feed file indices to workers via a buffered jobs channel.
 82  jobs := make(chan int, n)
 83  for i := range n {
 84    jobs <- i
 85  }
 86  close(jobs)
 87
 88  resultsCh := make(chan runResult, n)
 89
 90  numWorkers := min(runtime.NumCPU(), n)
 91
 92  // Start the timer before launching workers so suite.Duration reflects the
 93  // full wall-clock time including goroutine startup and first-job pickup.
 94  start := time.Now()
 95
 96  var wg sync.WaitGroup
 97  for range numWorkers {
 98    wg.Add(1)
 99    go func() {
 100      defer wg.Done()
 101      for idx := range jobs {
 102        if ctx.Err() != nil {
 103          // Context cancelled — skip remaining jobs as a best-effort early
 104          // exit. Note: this check is non-atomic; runOne may still be called
 105          // for a job that arrives in the window between this check and the
 106          // dispatch below. runOne propagates ctx, so any result it records
 107          // will carry context-cancellation context. Run() surfaces ctx.Err()
 108          // at the return site so callers can distinguish abort from test failure.
 109          // Send a skipped marker so the consumer always receives n results and
 110          // does not need to rely on the (nil, nil) zero-value to detect gaps.
 111          resultsCh <- runResult{idx: idx, skipped: true}
 112          continue
 113        }
 114        suite, cov, err := r.runOne(ctx, files[idx])
 115        resultsCh <- runResult{idx: idx, suite: suite, cov: cov, err: err}
 116      }
 117    }()
 118  }
 119  go func() { wg.Wait(); close(resultsCh) }()
 120
 121  // Collect into an ordered slice so output is deterministic regardless of
 122  // which worker finishes first.
 123  ordered := make([]runResult, n)
 124  for res := range resultsCh {
 125    ordered[res.idx] = res
 126  }
 127
 128  suite := &domain.SuiteResult{}
 129  cov := &domain.CoverageData{}
 130  for _, res := range ordered {
 131    if res.skipped {
 132      // Worker skipped this index due to context cancellation; ctx.Err()
 133      // is returned below so callers know the run was aborted.
 134      continue
 135    }
 136    if res.err != nil {
 137      // Record the error as a test failure rather than aborting the run.
 138      suite.Tests = append(suite.Tests, domain.TestResult{
 139        Name:   files[res.idx],
 140        Status: domain.StatusError,
 141        Error:  res.err.Error(),
 142      })
 143      continue
 144    }
 145    suite.Tests = append(suite.Tests, res.suite.Tests...)
 146    if res.cov != nil {
 147      cov.Files = append(cov.Files, res.cov.Files...)
 148    }
 149  }
 150  suite.Duration = time.Since(start)
 151
 152  // Propagate context cancellation so callers can distinguish "the run was
 153  // aborted" from "all test files failed normally".
 154  if err := ctx.Err(); err != nil {
 155    return suite, cov, err
 156  }
 157  return suite, cov, nil
 158}
 159
 160// runOutput is the JSON structure that the Lua harness writes to stdout.
 161// The Error field is populated by reporter.lua's pcall guard when the
 162// serialisation fails; if non-empty, it indicates a harness-level failure
 163// and parseOutput surfaces it as a Go error rather than silently returning
 164// an empty suite and coverage.
 165type runOutput struct {
 166  Tests    []testJSON     `json:"tests"`
 167  Coverage []coverageJSON `json:"coverage"`
 168  Error    string         `json:"error,omitempty"`
 169}
 170
 171type testJSON struct {
 172  Name       string  `json:"name"`
 173  Status     string  `json:"status"`
 174  DurationMs float64 `json:"duration_ms"`
 175  Output     string  `json:"output"`
 176  Error      string  `json:"error"`
 177}
 178
 179// coverageLines is map[string]int that gracefully handles the case where the
 180// Lua reporter emits an empty JSON array ("[]") instead of an empty JSON object
 181// ("{}"). Lua's built-in table encoder has no way to distinguish between an
 182// empty array and an empty object; rather than crash, we treat "[]" as no data.
 183type coverageLines map[string]int
 184
 185func (cl *coverageLines) UnmarshalJSON(data []byte) error {
 186  if bytes.Equal(bytes.TrimSpace(data), []byte("[]")) {
 187    *cl = nil // empty array → treat as no coverage data
 188    return nil
 189  }
 190  var m map[string]int
 191  if err := json.Unmarshal(data, &m); err != nil {
 192    return err
 193  }
 194  *cl = m
 195  return nil
 196}
 197
 198type coverageJSON struct {
 199  Path  string        `json:"path"`
 200  Lines coverageLines `json:"lines"`
 201}
 202
 203// runOne executes a single test file in a fresh sandbox.
 204func (r *Runner) runOne(ctx context.Context, testFile string) (suite *domain.SuiteResult, cov *domain.CoverageData, retE
 205  sb, err := r.sandboxF.Create(ctx)
 206  if err != nil {
 207    return nil, nil, fmt.Errorf("creating sandbox: %w", err)
 208  }
 209  // Join any close error into retErr so temp-dir cleanup failures surface
 210  // as visible errors rather than being silently discarded.
 211  defer func() {
 212    if cerr := sb.Close(); cerr != nil {
 213      retErr = errors.Join(retErr, fmt.Errorf("closing sandbox: %w", cerr))
 214    }
 215  }()
 216
 217  // Write the combined harness+hook Lua shim into the sandbox.
 218  shimPath := filepath.Join(sb.Dir(), "neospec_run.lua")
 219  shim, err := buildShim(testFile, r.initFile, r.coverageInclude)
 220  if err != nil {
 221    return nil, nil, fmt.Errorf("building shim: %w", err)
 222  }
 223  if err := os.WriteFile(shimPath, shim, 0o644); err != nil {
 224    return nil, nil, fmt.Errorf("writing shim: %w", err)
 225  }
 226
 227  args := []string{"--headless", "-l", shimPath}
 228  if r.verbose {
 229    args = append([]string{"-V3"}, args...)
 230  }
 231
 232  stdout, stderr, err := r.exec.Run(ctx, sb.Env(), r.nvimPath, args...)
 233  if err != nil {
 234    return nil, nil, fmt.Errorf("nvim exited with error: %w (stderr: %.500s)", err, stderr)
 235  }
 236
 237  suite, cov, retErr = parseOutput(stdout)
 238  return
 239}
 240
 241// parseOutput decodes the JSON emitted by the Lua harness.
 15242func parseOutput(data []byte) (*domain.SuiteResult, *domain.CoverageData, error) {
 15243  var out runOutput
 1244  if err := json.Unmarshal(data, &out); err != nil {
 1245    return nil, nil, fmt.Errorf("parsing harness output: %w (raw: %.200s)", err, string(data))
 1246  }
 1247  if out.Error != "" {
 1248    return nil, nil, fmt.Errorf("lua reporter error: %s", out.Error)
 1249  }
 250
 13251  suite := &domain.SuiteResult{}
 12252  for _, t := range out.Tests {
 12253    suite.Tests = append(suite.Tests, domain.TestResult{
 12254      Name:     t.Name,
 12255      Status:   parseStatus(t.Status),
 12256      Duration: time.Duration(t.DurationMs * float64(time.Millisecond)),
 12257      Output:   t.Output,
 12258      Error:    t.Error,
 12259    })
 12260  }
 261
 13262  cov := &domain.CoverageData{}
 8263  for _, fc := range out.Coverage {
 1264    if len(fc.Lines) == 0 {
 1265      // Skip entries with no line data — the Lua reporter may emit
 1266      // "lines":[] for files that were loaded but had no recorded hits.
 1267      continue
 268    }
 7269    fileCov := &domain.FileCoverage{
 7270      Path:  fc.Path,
 7271      Lines: make(map[int]int, len(fc.Lines)),
 7272    }
 7273    for lineStr, count := range fc.Lines {
 8274      lineNo, err := strconv.Atoi(lineStr)
 2275      if err != nil {
 2276        return nil, nil, fmt.Errorf("coverage file %q: invalid line key %q: %w", fc.Path, lineStr, err)
 2277      }
 6278      fileCov.Lines[lineNo] = count
 279    }
 5280    cov.Files = append(cov.Files, fileCov)
 281  }
 282
 11283  return suite, cov, nil
 284}
 285
 18286func parseStatus(s string) domain.TestStatus {
 18287  switch s {
 11288  case "pass":
 11289    return domain.StatusPass
 2290  case "fail":
 2291    return domain.StatusFail
 2292  case "skip":
 2293    return domain.StatusSkip
 3294  default:
 3295    return domain.StatusError
 296  }
 297}

/home/runner/work/neospec/neospec/internal/adapters/runner/shim.go

#LineLine coverage
 1package runner
 2
 3import (
 4  "embed"
 5  "fmt"
 6  "strings"
 7)
 8
 9// luaEscape escapes a string for safe embedding in a Lua double-quoted string
 10// literal. It handles backslash, double-quote, newline, carriage return, and
 11// tab — the characters most likely to appear in file paths and to produce
 12// syntactically broken Lua if left unescaped. NUL bytes are not handled here;
 13// callers must reject paths containing NUL before calling luaEscape (see
 14// buildShim).
 2415func luaEscape(s string) string {
 2416  s = strings.ReplaceAll(s, `\`, `\\`)
 2417  s = strings.ReplaceAll(s, `"`, `\"`)
 2418  s = strings.ReplaceAll(s, "\n", `\n`)
 2419  s = strings.ReplaceAll(s, "\r", `\r`)
 2420  s = strings.ReplaceAll(s, "\t", `\t`)
 2421  return s
 2422}
 23
 24//go:embed lua/*.lua
 25var luaFS embed.FS
 26
 27// buildShim constructs the Lua entry-point that is written into the sandbox
 28// before each Neovim invocation. It concatenates the coverage hook and the
 29// test harness, then appends the dofile() call for the actual test file.
 30//
 31// When initFile is non-empty, a dofile() call for it is prepended before the
 32// coverage hook so that the init file runs before instrumentation starts and
 33// is not itself included in coverage data.
 34//
 35// When coverageInclude is non-empty, a _neospec_coverage_include global is
 36// emitted before the coverage hook. The hook reads this global and skips any
 37// source file whose absolute path does not contain at least one of the listed
 38// substrings, restricting coverage to the plugin's own source tree.
 39//
 40// buildShim returns an error if either path contains a NUL byte. LuaJIT (used
 41// by Neovim) truncates double-quoted strings at NUL, producing a silent
 42// "file not found" rather than a clear diagnostic.
 2443func buildShim(testFile, initFile string, coverageInclude []string) ([]byte, error) {
 144  if testFile == "" {
 145    return nil, fmt.Errorf("test file path must not be empty")
 146  }
 147  if strings.ContainsRune(testFile, 0) {
 148    return nil, fmt.Errorf("test file path contains a NUL byte: %q", testFile)
 149  }
 150  if strings.ContainsRune(initFile, 0) {
 151    return nil, fmt.Errorf("init file path contains a NUL byte: %q", initFile)
 152  }
 53  // The three error branches below are structurally unreachable. The //go:embed
 54  // directive above causes a compile-time error if any of the named Lua files
 55  // are missing from the source tree, so by the time the binary runs the files
 56  // are guaranteed present in luaFS. embed.FS.ReadFile only fails for absent
 57  // paths; the error returns are kept for API correctness only.
 2158  hook, err := luaFS.ReadFile("lua/coverage_hook.lua")
 059  if err != nil {
 060    return nil, fmt.Errorf("reading coverage_hook.lua: %w", err)
 061  }
 62
 2163  harness, err := luaFS.ReadFile("lua/harness.lua")
 064  if err != nil {
 065    return nil, fmt.Errorf("reading harness.lua: %w", err)
 066  }
 67
 2168  reporter, err := luaFS.ReadFile("lua/reporter.lua")
 069  if err != nil {
 070    return nil, fmt.Errorf("reading reporter.lua: %w", err)
 071  }
 72
 73  // Escape the test file path for embedding in a Lua string literal.
 2174  escaped := luaEscape(testFile)
 2175
 2176  var sb strings.Builder
 2177  // Use 2× raw path lengths as an upper bound for escaped output (luaEscape
 2178  // at most doubles the length by escaping every character).
 2179  sb.Grow(len(hook) + len(harness) + len(reporter) + 2*len(initFile) + 2*len(testFile) + 128)
 2180
 181  if initFile != "" {
 182    // fmt.Fprintf on a strings.Builder always returns a nil error (the
 183    // builder's Write never fails). The return is intentionally ignored;
 184    // golangci-lint's errcheck exempts strings.Builder writes for this reason.
 185    fmt.Fprintf(&sb, `dofile("%s")`+"\n", luaEscape(initFile))
 186  }
 87
 188  if len(coverageInclude) > 0 {
 189    sb.WriteString("_neospec_coverage_include = {")
 190    for i, pattern := range coverageInclude {
 191      if i > 0 {
 192        sb.WriteString(", ")
 193      }
 294      fmt.Fprintf(&sb, `"%s"`, luaEscape(pattern))
 95    }
 196    sb.WriteString("}\n")
 97  }
 98
 2199  sb.Write(hook)
 21100  sb.WriteByte('\n')
 21101  sb.Write(harness)
 21102  sb.WriteByte('\n')
 21103  sb.Write(reporter)
 21104  sb.WriteByte('\n')
 21105  fmt.Fprintf(&sb, `dofile("%s")`+"\n", escaped)
 21106  sb.WriteString("_neospec_report()\n")
 21107
 21108  return []byte(sb.String()), nil
 109}