Download
169 lines 5.4 KiBGoModified 2026-10-18 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
package highlight

import (
	"bytes"
	"html"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

const toyLexer = `<lexer>
  <config>
    <name>toy</name>
    <alias>toy</alias>
    <filename>*.toy</filename>
  </config>
  <rules>
    <state name="root">
      <rule pattern="\bzap\b"><token type="Keyword"/></rule>
      <rule pattern="\d+"><token type="LiteralNumber"/></rule>
      <rule pattern="\s+"><token type="Text"/></rule>
      <rule pattern="."><token type="Text"/></rule>
    </state>
  </rules>
</lexer>`

func TestCustomLexerAndParity(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "toy.xml"), []byte(toyLexer), 0o644); err != nil {
		t.Fatal(err)
	}
	r, err := New([]string{filepath.Join(dir, "missing"), dir}, []Custom{{Name: "toy", Globs: []string{"*.tm"}, Aliases: []string{"tm"}}}, "tokyonight-night")
	if err != nil {
		t.Fatal(err)
	}
	src := []byte("zap 42\n")
	byPath := r.HTML(src, r.ByPath("dir/x.tm"))
	byAlias := r.HTML(src, r.ByName("TM"))
	byName := r.HTML(src, r.ByName("toy"))
	if byPath != byAlias || byPath != byName {
		t.Errorf("lexer resolution differs:\n%s\n%s\n%s", byPath, byAlias, byName)
	}
	want := `<code class="chroma"><span class="k">zap</span> <span class="m">42</span>
</code>`
	if string(byPath) != want {
		t.Errorf("got %q\nwant %q", byPath, want)
	}
	if Name(r.ByPath("a.c")) != "C" || Name(r.ByName("python")) != "Python" {
		t.Error("builtin lexers not found")
	}
	if !r.IsPlain(r.ByPath("no.such.ext")) || !r.IsPlain(r.ByName("nonsense")) {
		t.Error("unknown should be plain")
	}
	if _, err := New([]string{dir}, []Custom{{Name: "nothere"}}, "tokyonight-night"); err == nil {
		t.Error("missing lexer accepted")
	}
	if _, err := New([]string{dir}, nil, "no-such-style"); err == nil {
		t.Error("bad style accepted")
	}
}

func TestEscapingAndLines(t *testing.T) {
	r, err := New(nil, nil, "github")
	if err != nil {
		t.Fatal(err)
	}
	out := string(r.HTML([]byte("<a>&\"'\n"), r.ByName("")))
	if strings.Contains(out, "<a>") || !strings.Contains(out, "&lt;a&gt;&amp;") {
		t.Errorf("not escaped: %s", out)
	}
	for src, want := range map[string]int{"": 0, "a": 1, "a\n": 1, "a\nb": 2, "a\nb\n": 2, "\n\n": 2} {
		if got := LineCount([]byte(src)); got != want {
			t.Errorf("LineCount(%q) = %d, want %d", src, got, want)
		}
	}
	if !IsBinary([]byte("ab\x00c")) || IsBinary([]byte("plain text")) {
		t.Error("IsBinary")
	}
	var css strings.Builder
	if err := r.WriteCSS(&css); err != nil || !strings.Contains(css.String(), ".chroma .k ") {
		t.Errorf("css: %v %s", err, css.String()[:80])
	}
}

// TestThemedCSSIsExclusive: with a light style, dark and light rules must
// never both apply to a token, or properties one style does not set (token
// backgrounds, bold) leak into the other.
func TestThemedCSSIsExclusive(t *testing.T) {
	hl, err := New(nil, nil, "tokyonight-night")
	if err != nil {
		t.Fatal(err)
	}
	var plain bytes.Buffer
	if err := hl.WriteCSS(&plain); err != nil {
		t.Fatal(err)
	}
	if strings.Contains(plain.String(), "data-theme") {
		t.Error("without a light style the CSS should be unscoped")
	}
	if err := hl.SetLight("github"); err != nil {
		t.Fatal(err)
	}
	var out bytes.Buffer
	if err := hl.WriteCSS(&out); err != nil {
		t.Fatal(err)
	}
	for line := range strings.SplitSeq(out.String(), "\n") {
		trimmed := strings.TrimSpace(line)
		if strings.Contains(trimmed, "{") && strings.Contains(trimmed, ".chroma") && !strings.HasPrefix(trimmed, ":root") {
			t.Fatalf("unscoped rule would apply to both themes: %s", line)
		}
	}
	for _, want := range []string{`:root[data-theme="dark"] .chroma .k {`, `:root[data-theme="light"] .chroma .k {`,
		"@media (prefers-color-scheme: dark)", "@media (prefers-color-scheme: light)", `:root:not([data-theme]) .chroma .k {`} {
		if !strings.Contains(out.String(), want) {
			t.Errorf("missing %q", want)
		}
	}
	if err := hl.SetLight("no-such-style"); err == nil {
		t.Error("unknown light style accepted")
	}
}

func TestByFileShebang(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "toy.xml"), []byte(toyLexer), 0o644); err != nil {
		t.Fatal(err)
	}
	r, err := New([]string{dir}, []Custom{{Name: "toy", Aliases: []string{"tm"}}}, "")
	if err != nil {
		t.Fatal(err)
	}
	cases := []struct{ path, src, want string }{
		{"run", "#!/bin/sh\necho hi\n", "Bash"},
		{"run", "#!/usr/bin/env bash\n", "Bash"},
		{"run", "#!/usr/bin/env -S python3.12 -u\n", "Python"},
		{"run", "#! /usr/bin/python3\n", "Python"},
		{"run", "#!/usr/bin/env LANG=C tm\n", "toy"},
		{"run", "#!/usr/bin/nonesuch\n", "plaintext"},
		{"LICENSE", "Permission is hereby granted\n", "plaintext"},
		{"x.c", "#!/bin/sh\n", "C"}, // the name wins when it knows the language
	}
	for _, c := range cases {
		if got := Name(r.ByFile(c.path, []byte(c.src))); got != c.want {
			t.Errorf("ByFile(%q, %q) = %s, want %s", c.path, c.src, got, c.want)
		}
	}
}

// TestBrokenLexers: chroma's Jungle and JSONata lexers loop forever on
// these inputs and its YANG lexer panics; HTML must give up and show them
// plain.
func TestBrokenLexers(t *testing.T) {
	r, err := New(nil, nil, "github")
	if err != nil {
		t.Fatal(err)
	}
	for name, src := range map[string]string{"x.jungle": "a \"b<\n", "x.jsonata": "\x01<", "x.yang": "/*/*0<"} {
		lx := r.ByPath(name)
		if r.IsPlain(lx) {
			t.Fatalf("%s: no lexer", name)
		}
		want := `<code class="chroma">` + html.EscapeString(src) + `</code>`
		if got := string(r.HTML([]byte(src), lx)); got != want {
			t.Errorf("%s: got %q, want %q", name, got, want)
		}
	}
}