package grammar import ( "bufio" "bytes" "testing" "time" ) func TestFrameRoundTrip(t *testing.T) { var buf bytes.Buffer bodies := [][]byte{ []byte(`{"jsonrpc":"2.0","id":1}`), []byte(`{"method":"textDocument/didOpen"}`), []byte(`{}`), } for _, b := range bodies { if err := writeFrame(&buf, b); err != nil { t.Fatalf("writeFrame: %v", err) } } r := bufio.NewReader(&buf) for i, want := range bodies { got, err := readFrame(r) if err != nil { t.Fatalf("readFrame %d: %v", i, err) } if !bytes.Equal(got, want) { t.Errorf("frame %d = %q, want %q", i, got, want) } } } func TestReadFrameCaseInsensitiveHeader(t *testing.T) { // Some servers vary header casing; the length header must still parse. raw := "content-length: 2\r\n\r\n{}" got, err := readFrame(bufio.NewReader(bytes.NewBufferString(raw))) if err != nil { t.Fatalf("readFrame: %v", err) } if string(got) != "{}" { t.Errorf("got %q, want {}", got) } } func TestUTF16ToRuneCol(t *testing.T) { cases := []struct { line string u16 int want int }{ {"hello", 0, 0}, {"hello", 3, 3}, {"hello", 99, 5}, // clamp past end {"a😀b", 0, 0}, // emoji is 2 UTF-16 units, 1 rune {"a😀b", 1, 1}, // before the emoji {"a😀b", 3, 2}, // after the emoji (1 + 2 units) -> rune col 2 } for _, c := range cases { if got := utf16ToRuneCol(c.line, c.u16); got != c.want { t.Errorf("utf16ToRuneCol(%q, %d) = %d, want %d", c.line, c.u16, got, c.want) } } } func TestLSPRangeToDiagsSingleLine(t *testing.T) { lines := []string{"This is a a sentence."} got := lspRangeToDiags(lines, 0, 8, 0, 11, "Did you mean to repeat this word?", "RepeatedWords") if len(got) != 1 { t.Fatalf("got %d diags, want 1", len(got)) } d := got[0] if d.Line != 0 || d.StartCol != 8 || d.EndCol != 11 { t.Errorf("range = line %d [%d,%d), want line 0 [8,11)", d.Line, d.StartCol, d.EndCol) } if d.Code != "RepeatedWords" { t.Errorf("code = %q", d.Code) } } func TestLSPRangeToDiagsMultiLine(t *testing.T) { lines := []string{"first line", "second", "third line"} got := lspRangeToDiags(lines, 0, 6, 2, 5, "m", "C") if len(got) != 3 { t.Fatalf("got %d diags, want 3 (one per covered line)", len(got)) } // start line: from col 6 to end (10); middle: full; end: 0..5 if got[0].StartCol != 6 || got[0].EndCol != 10 { t.Errorf("start line range [%d,%d), want [6,10)", got[0].StartCol, got[0].EndCol) } if got[1].StartCol != 0 || got[1].EndCol != 6 { t.Errorf("middle line range [%d,%d), want [0,6)", got[1].StartCol, got[1].EndCol) } if got[2].StartCol != 0 || got[2].EndCol != 5 { t.Errorf("end line range [%d,%d), want [0,5)", got[2].StartCol, got[2].EndCol) } } func TestLSPRangeToDiagsSkipsOutOfRangeAndEmpty(t *testing.T) { lines := []string{"only line"} // Line 5 doesn't exist -> skipped, no panic. if got := lspRangeToDiags(lines, 5, 0, 5, 3, "m", "C"); got != nil { t.Errorf("out-of-range line produced %v, want nil", got) } // Zero-width range -> no diag. if got := lspRangeToDiags(lines, 0, 2, 0, 2, "m", "C"); got != nil { t.Errorf("zero-width range produced %v, want nil", got) } } func TestCodeString(t *testing.T) { if got := codeString("RepeatedWords"); got != "RepeatedWords" { t.Errorf("string code = %q", got) } if got := codeString(float64(42)); got != "42" { t.Errorf("number code = %q, want 42", got) } if got := codeString(nil); got != "" { t.Errorf("nil code = %q, want empty", got) } } func TestRuneColToUTF16(t *testing.T) { cases := []struct { line string col int want int }{ {"hello", 0, 0}, {"hello", 3, 3}, {"hello", 99, 5}, // clamp past end {"a😀b", 1, 1}, // before the emoji {"a😀b", 2, 3}, // after the emoji: 1 + 2 UTF-16 units {"a😀b", 3, 4}, // whole string } for _, c := range cases { if got := runeColToUTF16(c.line, c.col); got != c.want { t.Errorf("runeColToUTF16(%q, %d) = %d, want %d", c.line, c.col, got, c.want) } } // Round-trips with utf16ToRuneCol at rune boundaries. for _, line := range []string{"plain", "a😀b😀c"} { for col := 0; col <= len([]rune(line)); col++ { if got := utf16ToRuneCol(line, runeColToUTF16(line, col)); got != col { t.Errorf("round-trip %q col %d -> %d", line, col, got) } } } } func TestParseActions(t *testing.T) { lines := []string{"This is a a test."} // A quickfix replacement plus a bare ignore command and a duplicate title. result := []byte(`[ {"title":"Replace with: \"a\"","kind":"quickfix","edit":{"changes":{"file:///x.md":[ {"range":{"start":{"line":0,"character":8},"end":{"line":0,"character":11}},"newText":"a"}]}}}, {"title":"Ignore Harper error.","command":"HarperIgnoreLint","arguments":["file:///x.md",{}]}, {"title":"Replace with: \"a\"","kind":"quickfix","edit":{"changes":{"file:///x.md":[ {"range":{"start":{"line":0,"character":8},"end":{"line":0,"character":11}},"newText":"a"}]}}} ]`) got, err := parseActions(result, lines) if err != nil { t.Fatalf("parseActions: %v", err) } if len(got) != 1 { t.Fatalf("got %d actions, want 1 (ignore excluded, duplicate collapsed): %+v", len(got), got) } a := got[0] if a.Title != `Replace with: "a"` { t.Errorf("title = %q", a.Title) } if len(a.Edits) != 1 { t.Fatalf("got %d edits, want 1", len(a.Edits)) } e := a.Edits[0] if e.StartLine != 0 || e.StartCol != 8 || e.EndLine != 0 || e.EndCol != 11 || e.NewText != "a" { t.Errorf("edit = %+v, want line0 [8,11) -> \"a\"", e) } } func TestParseActionsEmpty(t *testing.T) { // A response of only ignore/telemetry commands yields no replacement actions. result := []byte(`[{"title":"Ignore Harper error.","command":"HarperIgnoreLint","arguments":[]}]`) got, err := parseActions(result, []string{"x"}) if err != nil { t.Fatalf("parseActions: %v", err) } if len(got) != 0 { t.Errorf("got %d actions, want 0", len(got)) } } // TestLiveHarper exercises the real harper-ls end to end. Skipped when the binary // is absent or under -short, so CI without harper stays green. func TestLiveHarper(t *testing.T) { if testing.Short() { t.Skip("skipping live harper test under -short") } if !Available() { t.Skip("harper-ls not on PATH") } c, err := Start() if err != nil { t.Fatalf("Start: %v", err) } defer c.Close() // "a a" is a repeated word; harper should flag it. c.DidOpen("/tmp/glint-grammar-test.md", "This is a a test.\n") select { case batch := <-c.Diagnostics(): if len(batch) == 0 { t.Fatal("harper returned an empty diagnostic batch") } found := false for _, d := range batch { if d.Line == 0 && d.Message != "" { found = true } } if !found { t.Errorf("no usable diagnostic in batch: %+v", batch) } case <-time.After(10 * time.Second): t.Fatal("timed out waiting for harper diagnostics") } } // TestLiveHarperCodeActions asks the real harper-ls for fixes on a flagged span and // expects at least one replacement action. Gated like TestLiveHarper. func TestLiveHarperCodeActions(t *testing.T) { if testing.Short() { t.Skip("skipping live harper test under -short") } if !Available() { t.Skip("harper-ls not on PATH") } c, err := Start() if err != nil { t.Fatalf("Start: %v", err) } defer c.Close() path := "/tmp/glint-grammar-ca-test.md" c.DidOpen(path, "This is a a test.\n") // "a a" repeated word, runes [8,11) var d Diag select { case batch := <-c.Diagnostics(): if len(batch) == 0 { t.Fatal("empty diagnostic batch") } d = batch[0] case <-time.After(10 * time.Second): t.Fatal("timed out waiting for diagnostics") } actions, err := c.CodeActions(path, d.Line, d.StartCol, d.EndCol) if err != nil { t.Fatalf("CodeActions: %v", err) } if len(actions) == 0 { t.Fatal("harper offered no replacement actions for a flagged span") } for _, a := range actions { if a.Title == "" || len(a.Edits) == 0 { t.Errorf("action missing title or edits: %+v", a) } } }