current commit
261021.17 · The lexer check takes its variable's name from WORKING_NAME
package main

import (
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"

	"github.com/alecthomas/chroma/v2"
)

// TestOwnLexersMatchChroma compares the program's own lexers with
// chroma's, byte by byte, by the class each byte gets on a page.
// WWW_LEXCHECK names the files and directories to compare, eg
// WWW_LEXCHECK=/usr/lib/python3.11; without it the test is skipped. It
// reports the share of bytes that agree per language and the first lines
// that differ.
func TestOwnLexersMatchChroma(t *testing.T) {
	roots := strings.Fields(os.Getenv(envPrefix + "LEXCHECK"))
	if len(roots) == 0 {
		t.Skip("set " + envPrefix + "LEXCHECK to files or directories to compare")
	}
	type tally struct{ files, bytes, wrong int }
	tallies := map[string]*tally{}
	var shown int
	for _, root := range roots {
		filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
			if err != nil || d.IsDir() {
				return nil
			}
			lexer := codeLexer(p)
			own := ownLexers[lexer]
			if own == nil {
				return nil
			}
			b, err := os.ReadFile(p)
			if err != nil || len(b) > maxCode || !isText(b) {
				return nil
			}
			it, err := chroma.Coalesce(lexer).Tokenise(nil, string(b))
			code := ensureLF(string(b))
			if err != nil {
				return nil
			}
			mine := own(code)
			var text strings.Builder
			for _, tok := range mine {
				text.WriteString(tok.Value)
			}
			if text.String() != code {
				t.Errorf("%s: text not preserved", p)
				return nil
			}
			want, got := classesOf(it.Tokens(), len(code)), classesOf(mine, len(code))
			name := langName(lexer)
			tl := tallies[name]
			if tl == nil {
				tl = &tally{}
				tallies[name] = tl
			}
			tl.files++
			tl.bytes += len(code)
			lines := strings.SplitAfter(code, "\n")
			off := 0
			for n, line := range lines {
				bad := 0
				for k := off; k < off+len(line); k++ {
					if want[k] != got[k] && code[k] != '\n' {
						bad++
					}
				}
				tl.wrong += bad
				if bad > 0 && shown < 40 {
					shown++
					t.Logf("%s:%d\n  %s\n  want %s\n  got  %s", p, n+1, strings.TrimRight(line, "\n"),
						classLine(want[off:off+len(line)]), classLine(got[off:off+len(line)]))
				}
				off += len(line)
			}
			return nil
		})
	}
	names := make([]string, 0, len(tallies))
	for n := range tallies {
		names = append(names, n)
	}
	sort.Strings(names)
	for _, n := range names {
		tl := tallies[n]
		t.Logf("%s: %d files, %d bytes, %d differ (%.4f%%)", n, tl.files, tl.bytes, tl.wrong,
			100*float64(tl.wrong)/float64(max(tl.bytes, 1)))
	}
}

// classesOf gives each byte of the source the class its token gets.
func classesOf(toks []chroma.Token, n int) []string {
	out := make([]string, 0, n)
	for _, tok := range toks {
		c := pandocClass(tok.Type)
		for range len(tok.Value) {
			out = append(out, c)
		}
	}
	for len(out) < n {
		out = append(out, "")
	}
	return out[:n]
}

// classLine shows a line's classes, one letter a byte, . for none.
func classLine(cs []string) string {
	var b strings.Builder
	for _, c := range cs {
		l, ok := classLetters[c]
		if !ok {
			l = "?"
		}
		b.WriteString(l)
	}
	return b.String()
}

var classLetters = map[string]string{"": ".", "kw": "k", "cn": "C", "op": "o", "co": "c", "pp": "p", "st": "s",
	"sc": "e", "ch": "h", "do": "d", "dv": "n", "fl": "f", "bn": "b", "bu": "B", "fu": "F", "va": "v", "dt": "t",
	"at": "a", "er": "E"}

// The own lexers give every byte of these samples the class chroma gives
// it; TestOwnLexersMatchChroma checks whole trees when asked.
func TestOwnLexersSamples(t *testing.T) {
	for _, c := range []struct{ name, src string }{
		{"a.py", "#!/usr/bin/env python3\nfrom . import x as y, z\nimport os.path\n\n@dataclass\nclass A(B):\n" +
			"    \"\"\"Doc.\"\"\"\n    def __init__(self, n=0x1F, f=1_0.5e3j):\n        yield from range(n)\n" +
			"        return f\"a{n!r:>3}b{{c}}\" % self.match + rb'\\d' + '\\x41\\q'  # c\n"},
		{"a.js", "#!/usr/bin/env node\nconst re = /a[/]b/g, n = 0x1F + 1.5e3;\nlet s = `t${a + `n${b}`}u` + \"q\\\"\" + 'x';\n" +
			"if (a) { y = a / 2 } // c\n/* m\n */ function f$_x(a) { return typeof a === 'undefined' ? NaN : this }\n"},
		{"a.sh", "#!/bin/sh\nfor f in *.md; do echo \"$f ${x:-y} $(ls -l) $((1 + 2#10))\"; done\nX+=1 cat <<EOF\nhi $USER\nEOF\n" +
			"if [ -n \"$1\" ]; then exit 2; fi # c\nlocal v=$'a\\n' w='b' `pwd`\n"},
	} {
		l := codeLexer(c.name)
		it, err := chroma.Coalesce(l).Tokenise(nil, c.src)
		if err != nil {
			t.Fatal(err)
		}
		want, got := classLine(classesOf(it.Tokens(), len(c.src))), classLine(classesOf(ownLexers[l](c.src), len(c.src)))
		if want != got {
			t.Errorf("%s:\nwant %s\ngot  %s", c.name, want, got)
		}
	}
}