diff --git a/cmd/matches/extprogram.go b/cmd/matches/extprogram.go
new file mode 100644
index 0000000..cc85121
--- /dev/null
+++ b/cmd/matches/extprogram.go
@@ -0,0 +1,116 @@
+package matches // import  nocternity.net/gomonop/pkg/matches
+
+import (
+	"bufio"
+	"context"
+	"io"
+	"os/exec"
+)
+
+// streamObtainer refers to functions that may return either an input stream or
+// an error.
+type streamObtainer func() (io.ReadCloser, error)
+
+// pipeCopy encapsulates the structures that are used to transfer data from a
+// single stream to the reader.
+type pipeCopy struct {
+	reader     io.ReadCloser // The input stream being read from
+	dataPipe   chan string   // Channel that receives the data we read
+	aborter    chan struct{} // Channel that causes the copy to abort
+	readResult chan error    // Channel that returns results from reading
+}
+
+// startPipeCopy starts a goroutine that copies data from the reader to the
+// dataPipe.
+func startPipeCopy(obtainer streamObtainer, pipes *readerPipes) *pipeCopy {
+	stream, err := obtainer()
+	if err != nil {
+		pipes.done <- err
+		return nil
+	}
+
+	pipeCopyData := &pipeCopy{
+		reader:     stream,
+		dataPipe:   pipes.data,
+		aborter:    make(chan struct{}),
+		readResult: make(chan error),
+	}
+
+	go pipeCopyData.run()
+
+	return pipeCopyData
+}
+
+// run runs the copy operation from the input stream to the data pipe. It is
+// meant to be executed as a goroutine.
+func (pc *pipeCopy) run() {
+	scanner := bufio.NewScanner(pc.reader)
+	for scanner.Scan() {
+		select {
+		case <-pc.aborter:
+			return
+		case pc.dataPipe <- scanner.Text():
+		}
+	}
+	pc.readResult <- scanner.Err()
+}
+
+// close closes the stream and channels used by a copy pipe.
+func (pc *pipeCopy) close() {
+	pc.abort()
+	_ = pc.reader.Close()
+	close(pc.aborter)
+	close(pc.readResult)
+}
+
+// abort causes the pipe copy to be aborted.
+func (pc *pipeCopy) abort() {
+	pc.aborter <- struct{}{}
+}
+
+// readFromProgram starts a goroutine that controls the program, sending lines
+// from both stderr and stdout to the dataPipe.
+func (pluginInst *matchesPlugin) readFromProgram(ctx context.Context, pipes *readerPipes) {
+	go func() {
+		cmd := exec.Command(pluginInst.dataSource) //nolint:gosec // Command is in fact user-provided
+
+		outs := startPipeCopy(func() (io.ReadCloser, error) { return cmd.StdoutPipe() }, pipes)
+		defer outs.close()
+		errs := startPipeCopy(func() (io.ReadCloser, error) { return cmd.StderrPipe() }, pipes)
+		defer errs.close()
+
+		if err := cmd.Start(); err != nil {
+			pipes.done <- err
+			return
+		}
+
+		abort := func(err error) {
+			_ = cmd.Process.Kill()
+			pipes.done <- err
+		}
+
+		errComplete := false
+		outComplete := false
+		for !(errComplete && outComplete) {
+			select {
+			case <-ctx.Done():
+				abort(ctx.Err())
+				return
+			case err := <-outs.readResult:
+				if err != nil {
+					abort(err)
+					return
+				}
+				outComplete = true
+			case err := <-errs.readResult:
+				if err != nil {
+					abort(err)
+					return
+				}
+				errComplete = true
+			}
+		}
+
+		pipes.done <- cmd.Wait()
+	}()
+}
diff --git a/cmd/matches/plugin.go b/cmd/matches/plugin.go
index 761701c..8f56a30 100644
--- a/cmd/matches/plugin.go
+++ b/cmd/matches/plugin.go
@@ -5,17 +5,8 @@
 package matches // import nocternity.net/gomonop/cmd/matches
 
 import (
-	"bufio"
-	"context"
-	"fmt"
-	"io"
-	"os"
-	"os/exec"
 	"regexp"
-	"strconv"
-	"strings"
 
-	"nocternity.net/gomonop/pkg/perfdata"
 	"nocternity.net/gomonop/pkg/plugin"
 	"nocternity.net/gomonop/pkg/results"
 	"nocternity.net/gomonop/pkg/status"
@@ -77,236 +68,6 @@ func (pluginInst *matchesPlugin) CheckArguments() bool {
 	return true
 }
 
-// readFromFile starts a goroutine that reads from the file and sends each line
-// to the dataPipe.
-func (pluginInst *matchesPlugin) readFromFile(ctx context.Context, donePipe chan error, dataPipe chan string) {
-	go func() {
-		file, err := os.Open(pluginInst.dataSource)
-		if err != nil {
-			donePipe <- err
-			return
-		}
-		defer file.Close()
-
-		scanner := bufio.NewScanner(file)
-		for scanner.Scan() {
-			select {
-			case <-ctx.Done():
-				donePipe <- ctx.Err()
-				return
-			case dataPipe <- scanner.Text():
-			}
-		}
-		donePipe <- scanner.Err()
-	}()
-}
-
-// startPipeCopy starts a goroutine that copies data from the reader to the
-// dataPipe.
-func startPipeCopy(reader io.Reader, dataPipe chan string) (aborter chan struct{}, readError chan error) {
-	aborter = make(chan struct{})
-	readError = make(chan error)
-
-	go func() {
-		scanner := bufio.NewScanner(reader)
-		for scanner.Scan() {
-			select {
-			case <-aborter:
-				return
-			case dataPipe <- scanner.Text():
-			}
-		}
-		readError <- scanner.Err()
-	}()
-
-	return
-}
-
-// readFromProgram starts a goroutine that controls the program, sending lines
-// from both stderr and stdout to the dataPipe.
-func (pluginInst *matchesPlugin) readFromProgram(ctx context.Context, donePipe chan error, dataPipe chan string) {
-	go func() {
-		cmd := exec.Command(pluginInst.dataSource) //nolint:gosec // Command is in fact user-provided
-		stdout, err := cmd.StdoutPipe()
-		if err != nil {
-			donePipe <- err
-			return
-		}
-		stderr, err := cmd.StderrPipe()
-		if err != nil {
-			donePipe <- err
-			return
-		}
-		if err := cmd.Start(); err != nil {
-			donePipe <- err
-			return
-		}
-
-		outAborter, outReadError := startPipeCopy(stdout, dataPipe)
-		errAborter, errReadError := startPipeCopy(stderr, dataPipe)
-		defer func() {
-			_ = stdout.Close()
-			_ = stderr.Close()
-			close(outAborter)
-			close(errAborter)
-			close(outReadError)
-			close(errReadError)
-		}()
-		abort := func(err error) {
-			outAborter <- struct{}{}
-			errAborter <- struct{}{}
-			_ = cmd.Process.Kill()
-			donePipe <- err
-		}
-
-		errComplete := false
-		outComplete := false
-		for !(errComplete && outComplete) {
-			select {
-			case <-ctx.Done():
-				abort(ctx.Err())
-				return
-			case err := <-outReadError:
-				if err != nil {
-					abort(err)
-					return
-				}
-				outComplete = true
-			case err := <-errReadError:
-				if err != nil {
-					abort(err)
-					return
-				}
-				errComplete = true
-			}
-		}
-
-		donePipe <- cmd.Wait()
-	}()
-}
-
-// startReading starts reading data from either a file or a program.
-func (pluginInst *matchesPlugin) startReading(ctx context.Context, donePipe chan error, dataPipe chan string) {
-	if pluginInst.isFile {
-		pluginInst.readFromFile(ctx, donePipe, dataPipe)
-	} else {
-		pluginInst.readFromProgram(ctx, donePipe, dataPipe)
-	}
-}
-
-// processLine processes a single line obtained from the program or file.
-func (pluginInst *matchesPlugin) processLine(line string) {
-	hadMatch := false
-	for index := range pluginInst.matches {
-		if pluginInst.matches[index].matches(line) {
-			pluginInst.counters[index]++
-			hadMatch = true
-		}
-	}
-	if !hadMatch {
-		pluginInst.unmatchedLines++
-	}
-}
-
-// processData processes data from either the program or the file.
-func (pluginInst *matchesPlugin) processData() error {
-	ctx, cancel := pluginInst.makeContext()
-	defer cancel()
-
-	donePipe := make(chan error)
-	dataPipe := make(chan string)
-	pluginInst.startReading(ctx, donePipe, dataPipe)
-
-	defer func() {
-		close(donePipe)
-		close(dataPipe)
-	}()
-
-	for {
-		select {
-		case line := <-dataPipe:
-			pluginInst.processLine(line)
-		case err := <-donePipe:
-			return err
-		}
-	}
-}
-
-// checkResults checks the various counters against their configured thresholds,
-// and the strict mode failure count.
-func (pluginInst *matchesPlugin) checkResults(readError error) {
-	nWarns, nCrits, nUnknowns := 0, 0, 0
-	for index := range pluginInst.counters {
-		config := &pluginInst.matches[index]
-
-		var nature string
-		if config.isRegexp {
-			nature = "regexp"
-		} else {
-			nature = "substring"
-		}
-		label := fmt.Sprintf("#%2d : %s %s", index+1, nature, config.matchString)
-		value := strconv.Itoa(pluginInst.counters[index])
-
-		pdat := perfdata.New(label, perfdata.UomNone, value)
-		pdat.SetWarn(config.warn)
-		pdat.SetCrit(config.crit)
-		pluginInst.results.AddPerfData(pdat)
-
-		switch pdat.GetStatus() {
-		case status.StatusCritical:
-			nCrits++
-		case status.StatusWarning:
-			nWarns++
-		case status.StatusUnknown:
-			nUnknowns++
-		}
-	}
-
-	umlPdat := perfdata.New("unmatched", perfdata.UomNone, strconv.Itoa(pluginInst.unmatchedLines))
-	if pluginInst.strict {
-		umlPdat.SetCrit(perfdata.RangeMinMax("~", "0"))
-	}
-	switch umlPdat.GetStatus() {
-	case status.StatusCritical:
-		nCrits++
-	}
-	pluginInst.results.AddPerfData(umlPdat)
-
-	problems := []string{}
-	if nCrits > 0 {
-		problems = append(problems, fmt.Sprintf("%d value(s) critical", nCrits))
-	}
-	if nWarns > 0 {
-		problems = append(problems, fmt.Sprintf("%d value(s) warning", nWarns))
-	}
-	if nUnknowns > 0 {
-		problems = append(problems, fmt.Sprintf("%d value(s) unknown", nUnknowns))
-	}
-	if len(problems) == 0 {
-		problems = append(problems, "No match errors")
-	}
-
-	if readError != nil {
-		pluginInst.results.AddLinef("Error while reading data: %s", readError.Error())
-		nUnknowns++
-	}
-
-	var finalStatus status.Status
-	switch {
-	case nCrits > 0:
-		finalStatus = status.StatusCritical
-	case nWarns > 0:
-		finalStatus = status.StatusWarning
-	case nUnknowns > 0:
-		finalStatus = status.StatusUnknown
-	default:
-		finalStatus = status.StatusOK
-	}
-
-	pluginInst.results.SetState(finalStatus, strings.Join(problems, ", "))
-}
-
 // Run the check.
 func (pluginInst *matchesPlugin) RunCheck() {
 	pluginInst.counters = make([]int, len(pluginInst.matches))
diff --git a/cmd/matches/reader.go b/cmd/matches/reader.go
new file mode 100644
index 0000000..87d15b1
--- /dev/null
+++ b/cmd/matches/reader.go
@@ -0,0 +1,95 @@
+package matches // import  nocternity.net/gomonop/pkg/matches
+
+import (
+	"bufio"
+	"context"
+	"os"
+)
+
+// readerPipes encapsulates the two channels used to communicate data and state
+// from the functions that acquire the data.
+type readerPipes struct {
+	done chan error
+	data chan string
+}
+
+// makeReaderPipes initializes the channels.
+func makeReaderPipes() *readerPipes {
+	return &readerPipes{
+		done: make(chan error),
+		data: make(chan string),
+	}
+}
+
+// close closes the channels.
+func (rdp *readerPipes) close() {
+	close(rdp.done)
+	close(rdp.data)
+}
+
+// readFromFile starts a goroutine that reads from the file and sends each line
+// to the dataPipe.
+func (pluginInst *matchesPlugin) readFromFile(ctx context.Context, pipes *readerPipes) {
+	go func() {
+		file, err := os.Open(pluginInst.dataSource)
+		if err != nil {
+			pipes.done <- err
+			return
+		}
+		defer file.Close()
+
+		scanner := bufio.NewScanner(file)
+		for scanner.Scan() {
+			select {
+			case <-ctx.Done():
+				pipes.done <- ctx.Err()
+				return
+			case pipes.data <- scanner.Text():
+			}
+		}
+		pipes.done <- scanner.Err()
+	}()
+}
+
+// startReading starts reading data from either a file or a program.
+func (pluginInst *matchesPlugin) startReading(ctx context.Context, pipes *readerPipes) {
+	if pluginInst.isFile {
+		pluginInst.readFromFile(ctx, pipes)
+	} else {
+		pluginInst.readFromProgram(ctx, pipes)
+	}
+}
+
+// processLine processes a single line obtained from the program or file.
+func (pluginInst *matchesPlugin) processLine(line string) {
+	hadMatch := false
+	for index := range pluginInst.matches {
+		if pluginInst.matches[index].matches(line) {
+			pluginInst.counters[index]++
+			hadMatch = true
+		}
+	}
+	if !hadMatch {
+		pluginInst.unmatchedLines++
+	}
+}
+
+// processData processes data from either the program or the file.
+func (pluginInst *matchesPlugin) processData() error {
+	ctx, cancel := pluginInst.makeContext()
+	defer cancel()
+
+	pipes := makeReaderPipes()
+	defer pipes.close()
+
+	pluginInst.startReading(ctx, pipes)
+
+	for {
+		select {
+		case line := <-pipes.data:
+			pluginInst.processLine(line)
+		case err := <-pipes.done:
+			return err
+		}
+	}
+}
diff --git a/cmd/matches/results.go b/cmd/matches/results.go
new file mode 100644
index 0000000..57cf4d3
--- /dev/null
+++ b/cmd/matches/results.go
@@ -0,0 +1,125 @@
+package matches // import nocternity.net/gomonop/cmd/matches
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"nocternity.net/gomonop/pkg/perfdata"
+	"nocternity.net/gomonop/pkg/status"
+)
+
+// resultsInfo contains information about results being processed.
+type resultsInfo struct {
+	nWarns, nCrits, nUnknowns uint
+}
+
+// updateResults updates the result counters based on the specified status.
+func (info *resultsInfo) updateFrom(itemStatus status.Status) {
+	switch itemStatus {
+	case status.StatusCritical:
+		info.nCrits++
+	case status.StatusWarning:
+		info.nWarns++
+	case status.StatusUnknown:
+		info.nUnknowns++
+	case status.StatusOK:
+		// do nothing
+	}
+}
+
+// toProblems generate the list of problems found while running the plugin,
+// as a string which can be returned to the monitoring server.
+func (info *resultsInfo) toProblems() string {
+	problems := []string{}
+	if info.nCrits > 0 {
+		problems = append(problems,
+			fmt.Sprintf("%d value(s) critical", info.nCrits),
+		)
+	}
+	if info.nWarns > 0 {
+		problems = append(problems,
+			fmt.Sprintf("%d value(s) warning", info.nWarns),
+		)
+	}
+	if info.nUnknowns > 0 {
+		problems = append(problems,
+			fmt.Sprintf("%d value(s) unknown", info.nUnknowns),
+		)
+	}
+	if len(problems) == 0 {
+		problems = append(problems, "No match errors")
+	}
+	return strings.Join(problems, ", ")
+}
+
+// toStatus computes the final status of the plugin based on the status
+// counters.
+func (info *resultsInfo) toStatus() status.Status {
+	switch {
+	case info.nCrits > 0:
+		return status.StatusCritical
+	case info.nWarns > 0:
+		return status.StatusWarning
+	case info.nUnknowns > 0:
+		return status.StatusUnknown
+	default:
+		return status.StatusOK
+	}
+}
+
+// checkCounters checks the match counters against their configured thresholds,
+// if any, and adds the corresponding perf data to the result. It initializes a
+// resultsInfo structure containing the counts of critical, warning and unknown
+// statuses gathered so far.
+func (pluginInst *matchesPlugin) checkCounters() resultsInfo {
+	info := resultsInfo{}
+
+	for index := range pluginInst.counters {
+		config := &pluginInst.matches[index]
+
+		var nature string
+		if config.isRegexp {
+			nature = "regexp"
+		} else {
+			nature = "substring"
+		}
+		label := fmt.Sprintf("#%2d : %s %s", index+1, nature, config.matchString)
+		value := strconv.Itoa(pluginInst.counters[index])
+
+		pdat := perfdata.New(label, perfdata.UomNone, value)
+		pdat.SetWarn(config.warn)
+		pdat.SetCrit(config.crit)
+		pluginInst.results.AddPerfData(pdat)
+
+		info.updateFrom(pdat.GetStatus())
+	}
+
+	return info
+}
+
+// checkUnmatched checks the unmatched lines counters, applying a limit if
+// strict mode is enabled. The corresponding performance data is added to the
+// result.
+func (pluginInst *matchesPlugin) checkUnmatched(info *resultsInfo) {
+	umlPdat := perfdata.New("unmatched", perfdata.UomNone, strconv.Itoa(pluginInst.unmatchedLines))
+	if pluginInst.strict {
+		umlPdat.SetCrit(perfdata.RangeMinMax("~", "0"))
+	}
+	info.updateFrom(umlPdat.GetStatus())
+	pluginInst.results.AddPerfData(umlPdat)
+}
+
+// checkResults checks the various counters against their configured thresholds,
+// and the strict mode failure count.
+func (pluginInst *matchesPlugin) checkResults(readError error) {
+	info := pluginInst.checkCounters()
+	pluginInst.checkUnmatched(&info)
+
+	if readError != nil {
+		pluginInst.results.AddLinef("Error while reading data: %s", readError.Error())
+		info.nUnknowns++
+	}
+
+	pluginInst.results.SetState(info.toStatus(), info.toProblems())
+}