...

Source file src/oss.terrastruct.com/util-go/diff/diff.go

Documentation: oss.terrastruct.com/util-go/diff

     1  // package diff contains diff generation helpers, particularly useful for tests.
     2  //
     3  // - Strings
     4  // - Files
     5  // - Runes
     6  // - JSON
     7  // - Testdata
     8  // - TestdataJSON
     9  package diff
    10  
    11  import (
    12  	"context"
    13  	"errors"
    14  	"fmt"
    15  	"io/ioutil"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"strings"
    20  	"time"
    21  
    22  	"go.uber.org/multierr"
    23  
    24  	"oss.terrastruct.com/util-go/xdefer"
    25  	"oss.terrastruct.com/util-go/xjson"
    26  )
    27  
    28  // Strings diffs exp with got in a git style diff.
    29  //
    30  // The git style diff header will contain real paths to exp and got
    31  // on the file system so that you can easily inspect them.
    32  //
    33  // This behavior is particularly useful for when you need to update
    34  // a test with the new got. You can just copy and paste from the got
    35  // file in the diff header.
    36  //
    37  // It uses Files under the hood.
    38  func Strings(exp, got string) (ds string, err error) {
    39  	defer xdefer.Errorf(&err, "failed to diff text")
    40  
    41  	if exp == got {
    42  		return "", nil
    43  	}
    44  
    45  	d, err := ioutil.TempDir("", "ts_d2_diff")
    46  	if err != nil {
    47  		return "", err
    48  	}
    49  
    50  	expPath := filepath.Join(d, "exp")
    51  	gotPath := filepath.Join(d, "got")
    52  
    53  	err = ioutil.WriteFile(expPath, []byte(exp), 0644)
    54  	if err != nil {
    55  		return "", err
    56  	}
    57  	err = ioutil.WriteFile(gotPath, []byte(got), 0644)
    58  	if err != nil {
    59  		return "", err
    60  	}
    61  
    62  	return Files(expPath, gotPath)
    63  }
    64  
    65  // Files diffs expPath with gotPath and prints a git style diff header.
    66  //
    67  // It uses git under the hood.
    68  func Files(expPath, gotPath string) (ds string, err error) {
    69  	defer xdefer.Errorf(&err, "failed to diff files")
    70  
    71  	_, err = os.Stat(expPath)
    72  	if os.IsNotExist(err) {
    73  		expPath = "/dev/null"
    74  	}
    75  	_, err = os.Stat(gotPath)
    76  	if os.IsNotExist(err) {
    77  		gotPath = "/dev/null"
    78  	}
    79  
    80  	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    81  	defer cancel()
    82  	cmd := exec.CommandContext(ctx, "git", "-c", "diff.color=always", "diff",
    83  		// Use the best diff-algorithm and highlight trailing whitespace.
    84  		"--diff-algorithm=histogram",
    85  		"--ws-error-highlight=all",
    86  		"--no-index",
    87  		expPath, gotPath)
    88  	cmd.Env = append(cmd.Env, "GIT_CONFIG_NOSYSTEM=1", "HOME=")
    89  
    90  	diffBytes, err := cmd.CombinedOutput()
    91  	var ee *exec.ExitError
    92  	if err != nil && !errors.As(err, &ee) {
    93  		return "", fmt.Errorf("git diff failed: out=%q: %w", diffBytes, err)
    94  	}
    95  	ds = string(diffBytes)
    96  
    97  	// Strips the diff header before ---
    98  	//
    99  	// diff --git a/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/exp b/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/got
   100  	// index d48c704b..dbe709e6 100644
   101  	// --- a/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/exp
   102  	// +++ b/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/got
   103  	// @@ -1,5 +1,5 @@
   104  	//
   105  	// becomes:
   106  	//
   107  	// --- a/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/exp
   108  	// +++ b/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/got
   109  	// @@ -1,5 +1,5 @@
   110  	i := strings.Index(ds, "index")
   111  	if i > -1 {
   112  		j := strings.IndexByte(ds[i:], '\n')
   113  		if j > -1 {
   114  			ds = ds[i+j+1:]
   115  		}
   116  	}
   117  	return strings.TrimSpace(ds), nil
   118  }
   119  
   120  // Runes is like Strings but formats exp and got with each unicode codepoint on a separate
   121  // line and generates a diff of that. It's useful for autogenerated UTF-8 with
   122  // xrand.String as Strings won't generate a coherent diff with undisplayable characters.
   123  func Runes(exp, got string) error {
   124  	if exp == got {
   125  		return nil
   126  	}
   127  	expRunes := formatRunes(exp)
   128  	gotRunes := formatRunes(got)
   129  	ds, err := Strings(expRunes, gotRunes)
   130  	if err != nil {
   131  		return err
   132  	}
   133  	if ds != "" {
   134  		return errors.New(ds)
   135  	}
   136  	return nil
   137  }
   138  
   139  func formatRunes(s string) string {
   140  	return strings.Join(strings.Split(fmt.Sprintf("%#v", []rune(s)), ", "), "\n")
   141  }
   142  
   143  // TestdataJSON is for when you have JSON that is too large to easily keep embedded by the
   144  // tests in _test.go files. As well, it makes the acceptance of large changes trivial
   145  // unlike say fs/embed.
   146  //
   147  // TestdataJSON encodes got as JSON and diffs it against the stored json in path.exp.json.
   148  // The got JSON is stored in path.got.json. If the diff is empty, it returns nil.
   149  //
   150  // Otherwise it returns an error containing the diff.
   151  //
   152  // In order to accept changes path.got.json has to become path.exp.json. You can use
   153  // ./ci/testdata/accept.sh to rename all non stale path.got.json files to path.exp.json.
   154  //
   155  // You can scope it to a single test or folder, see ./ci/testdata/accept.sh --help
   156  //
   157  // Also see ./ci/testdata/clean.sh --help for cleaning the repository of all
   158  // path.got.json and path.exp.json files.
   159  //
   160  // You can also use $TESTDATA_ACCEPT=1 to update all path.exp.json files on the fly.
   161  // This is useful when you're regenerating the repository's testdata. You can't easily
   162  // use the accept script without rerunning go test multiple times as go test will return
   163  // after too many test failures and will not continue until they are fixed.
   164  //
   165  // You'll want to use -count=1 to disable go test's result caching if you do use
   166  // $TESTDATA_ACCEPT.
   167  //
   168  // TestdataJSON will automatically create nonexistent directories in path.
   169  //
   170  // Here's an example that you can play with to better understand the behaviour:
   171  //
   172  //     err = diff.TestdataJSON(filepath.Join("testdata", t.Name()), "change me")
   173  //     if err != nil {
   174  //     	t.Fatal(err)
   175  //     }
   176  //
   177  // Normally you want to use t.Name() as path for clarity but you can pass in any string.
   178  // e.g. a single test could persist two json objects into testdata with:
   179  //
   180  //     err = diff.TestdataJSON(filepath.Join("testdata", t.Name(), "1"), "change me 1")
   181  //     if err != nil {
   182  //     	t.Fatal(err)
   183  //     }
   184  //     err = diff.TestdataJSON(filepath.Join("testdata", t.Name(), "2"), "change me 2")
   185  //     if err != nil {
   186  //     	t.Fatal(err)
   187  //     }
   188  //
   189  // These would persist in testdata/${t.Name()}/1.exp.json and testdata/${t.Name()}/2.exp.json
   190  //
   191  // It uses Files under the hood.
   192  //
   193  // note: testdata is the canonical Go directory for such persistent test only files.
   194  //       It is unfortunately poorly documented. See https://pkg.go.dev/cmd/go/internal/test
   195  //       So normally you'd want path to be filepath.Join("testdata", t.Name()).
   196  //       This is also the reason this function is named "TestdataJSON".
   197  func TestdataJSON(path string, got interface{}) error {
   198  	gotb := xjson.Marshal(got)
   199  	gotb = append(gotb, '\n')
   200  	return Testdata(path, ".json", gotb)
   201  }
   202  
   203  // ext includes period like path.Ext()
   204  func Testdata(path, ext string, got []byte) error {
   205  	expPath := fmt.Sprintf("%s.exp%s", path, ext)
   206  	gotPath := fmt.Sprintf("%s.got%s", path, ext)
   207  
   208  	err := os.MkdirAll(filepath.Dir(gotPath), 0755)
   209  	if err != nil {
   210  		return err
   211  	}
   212  	err = ioutil.WriteFile(gotPath, []byte(got), 0600)
   213  	if err != nil {
   214  		return err
   215  	}
   216  
   217  	ds, err := Files(expPath, gotPath)
   218  	if err != nil {
   219  		return err
   220  	}
   221  
   222  	if ds != "" {
   223  		if os.Getenv("TESTDATA_ACCEPT") != "" || os.Getenv("TA") != "" {
   224  			return os.Rename(gotPath, expPath)
   225  		}
   226  		if os.Getenv("NO_DIFF") != "" || os.Getenv("ND") != "" {
   227  			ds = "diff hidden with $NO_DIFF=1 or $ND=1"
   228  		}
   229  		return fmt.Errorf("diff (rerun with $TESTDATA_ACCEPT=1 or $TA=1 to accept):\n%s", ds)
   230  	}
   231  	return os.Remove(gotPath)
   232  }
   233  
   234  func JSON(exp, got interface{}) (string, error) {
   235  	return Strings(string(xjson.Marshal(exp)), string(xjson.Marshal(got)))
   236  }
   237  
   238  func TestdataDir(testName, dir string) (err error) {
   239  	defer xdefer.Errorf(&err, "failed to commit testdata dir %v", dir)
   240  	testdataDir(&err, testName, dir)
   241  	return err
   242  }
   243  
   244  func testdataDir(errs *error, testName, dir string) {
   245  	ea, err := os.ReadDir(dir)
   246  	if err != nil {
   247  		*errs = multierr.Combine(*errs, err)
   248  		return
   249  	}
   250  
   251  	for _, e := range ea {
   252  		if e.IsDir() {
   253  			testdataDir(errs, filepath.Join(testName, e.Name()), filepath.Join(dir, e.Name()))
   254  		} else {
   255  			ext := filepath.Ext(e.Name())
   256  			name := strings.TrimSuffix(e.Name(), ext)
   257  			got, err := os.ReadFile(filepath.Join(dir, e.Name()))
   258  			if err != nil {
   259  				*errs = multierr.Combine(*errs, err)
   260  				continue
   261  			}
   262  			err = Testdata(filepath.Join(testName, name), ext, got)
   263  			if err != nil {
   264  				*errs = multierr.Combine(*errs, err)
   265  			}
   266  		}
   267  	}
   268  }
   269  

View as plain text