Repository: bwplotka/mdox (go)

Issue:
Add config option to skip local link checks
We currently can only ignore remote links based on regex, but there are use cases where users might also want to ignore local links.

Pull request:
Extend validator support to local links
This PR extends validator support for local links.

Fixes #87.

Patch:
diff --git a/pkg/mdformatter/linktransformer/config.go b/pkg/mdformatter/linktransformer/config.go
index 925e792..ec6aa3e 100644
--- a/pkg/mdformatter/linktransformer/config.go
+++ b/pkg/mdformatter/linktransformer/config.go
@@ -20,9 +20,10 @@ import (
 type Config struct {
 	Version int
 
-	Validators  []ValidatorConfig `yaml:"validators"`
-	Timeout     string            `yaml:"timeout"`
-	Parallelism int               `yaml:"parallelism"`
+	ExplicitLocalValidators bool              `yaml:"explicitLocalValidators"`
+	Validators              []ValidatorConfig `yaml:"validators"`
+	Timeout                 string            `yaml:"timeout"`
+	Parallelism             int               `yaml:"parallelism"`
 	// HostMaxConns has to be a pointer because a zero value means no limits
 	// and we have to tell apart 0 from not-present configurations.
 	HostMaxConns *int   `yaml:"host_max_conns"`
diff --git a/pkg/mdformatter/linktransformer/link.go b/pkg/mdformatter/linktransformer/link.go
index 861aeac..5139275 100644
--- a/pkg/mdformatter/linktransformer/link.go
+++ b/pkg/mdformatter/linktransformer/link.go
@@ -378,6 +378,28 @@ func (v *validator) Close(ctx mdformatter.SourceContext) error {
 	return merr.Err()
 }
 
+func (v *validator) checkLocal(k futureKey) bool {
+	v.l.localLinksChecked.Inc()
+	// Check if link is email address.
+	if email := strings.TrimPrefix(k.dest, "mailto:"); email != k.dest {
+		if isValidEmail(email) {
+			return true
+		}
+		v.destFutures[k].resultFn = func() error { return errors.Errorf("provided mailto link is not a valid email, got %v", k.dest) }
+		return false
+	}
+
+	// Relative or absolute path. Check if exists.
+	newDest := absLocalLink(v.anchorDir, k.filepath, k.dest)
+
+	// Local link. Check if exists.
+	if err := v.localLinks.Lookup(newDest); err != nil {
+		v.destFutures[k].resultFn = func() error { return errors.Wrapf(err, "link %v, normalized to", k.dest) }
+		return false
+	}
+	return true
+}
+
 func (v *validator) visit(filepath string, dest string, lineNumbers string) {
 	v.futureMu.Lock()
 	defer v.futureMu.Unlock()
@@ -387,28 +409,14 @@ func (v *validator) visit(filepath string, dest string, lineNumbers string) {
 		return
 	}
 	v.destFutures[k] = &futureResult{cases: 1, resultFn: func() error { return nil }}
-	matches := remoteLinkPrefixRe.FindAllStringIndex(dest, 1)
-	if matches == nil {
-		v.l.localLinksChecked.Inc()
-		// Check if link is email address.
-		if email := strings.TrimPrefix(dest, "mailto:"); email != dest {
-			if isValidEmail(email) {
-				return
-			}
-			v.destFutures[k].resultFn = func() error { return errors.Errorf("provided mailto link is not a valid email, got %v", dest) }
+	if !v.validateConfig.ExplicitLocalValidators {
+		matches := remoteLinkPrefixRe.FindAllStringIndex(dest, 1)
+		if matches == nil {
+			v.checkLocal(k)
 			return
 		}
-
-		// Relative or absolute path. Check if exists.
-		newDest := absLocalLink(v.anchorDir, filepath, dest)
-
-		// Local link. Check if exists.
-		if err := v.localLinks.Lookup(newDest); err != nil {
-			v.destFutures[k].resultFn = func() error { return errors.Wrapf(err, "link %v, normalized to", dest) }
-		}
-		return
+		v.l.remoteLinksChecked.Inc()
 	}
-	v.l.remoteLinksChecked.Inc()
 
 	validator := v.validateConfig.GetValidatorForURL(dest)
 	if validator != nil {
diff --git a/pkg/mdformatter/linktransformer/validator.go b/pkg/mdformatter/linktransformer/validator.go
index b9c824b..ab72657 100644
--- a/pkg/mdformatter/linktransformer/validator.go
+++ b/pkg/mdformatter/linktransformer/validator.go
@@ -32,8 +32,14 @@ func (v GitHubPullsIssuesValidator) IsValid(k futureKey, r *validator) (bool, er
 	return false, nil
 }
 
-// RoundTripValidator.IsValid returns true if url is checked by colly.
+// RoundTripValidator.IsValid returns true if url is checked by colly or it is a valid local link.
 func (v RoundTripValidator) IsValid(k futureKey, r *validator) (bool, error) {
+	matches := remoteLinkPrefixRe.FindAllStringIndex(k.dest, 1)
+	if matches == nil && r.validateConfig.ExplicitLocalValidators {
+		r.l.localLinksChecked.Inc()
+		return r.checkLocal(k), nil
+	}
+
 	r.l.roundTripLinks.Inc()
 	// Result will be in future.
 	r.destFutures[k].resultFn = func() error { return r.remoteLinks[k.dest] }


Test patch:
diff --git a/pkg/mdformatter/linktransformer/link_test.go b/pkg/mdformatter/linktransformer/link_test.go
index 96c01f1..0920818 100644
--- a/pkg/mdformatter/linktransformer/link_test.go
+++ b/pkg/mdformatter/linktransformer/link_test.go
@@ -354,4 +354,23 @@ func TestValidator_TransformDestination(t *testing.T) {
 		))
 		testutil.Ok(t, err)
 	})
+
+	t.Run("check invalid local links with ignore validate config", func(t *testing.T) {
+		testFile := filepath.Join(tmpDir, "repo", "docs", "test", "invalid-local-links.md")
+		testutil.Ok(t, ioutil.WriteFile(testFile, []byte(`# yolo
+
+[1](.) [2](#yolo) [3](../test/invalid-local-links.md#wrong) [4](../test/invalid-local-links.md#not-yolo)
+`), os.ModePerm))
+
+		diff, err := mdformatter.IsFormatted(context.TODO(), logger, []string{testFile})
+		testutil.Ok(t, err)
+
+		testutil.Equals(t, 0, len(diff), diff.String())
+
+		_, err = mdformatter.IsFormatted(context.TODO(), logger, []string{testFile}, mdformatter.WithLinkTransformer(
+			MustNewValidator(logger, []byte("version: 1\n\nexplicitLocalValidators: true\n\nvalidators:\n  - regex: '^\\.\\.\\/test\\/invalid-local-links\\.md[^\\n ]*$'\n    type: 'ignore'\n"), anchorDir),
+		))
+
+		testutil.Ok(t, err)
+	})
 }