diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-06 13:19:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-06 13:19:01 +0300 |
| commit | 04f290dbeeee8a6fcbc70fed253a968336bcb2ab (patch) | |
| tree | 3ee23a4ac4bcc5b43b43697cfb0e905735fc6331 /internal/textutil | |
| parent | 5e966f50111adf6e2cb2683fe588f6fe033fa931 (diff) | |
more tests
Diffstat (limited to 'internal/textutil')
| -rw-r--r-- | internal/textutil/textutil.go | 114 | ||||
| -rw-r--r-- | internal/textutil/textutil_test.go | 87 |
2 files changed, 201 insertions, 0 deletions
diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go new file mode 100644 index 0000000..7ef2680 --- /dev/null +++ b/internal/textutil/textutil.go @@ -0,0 +1,114 @@ +package textutil + +import "strings" + +// RenderTemplate performs simple {{var}} replacement in a template string. +func RenderTemplate(t string, vars map[string]string) string { + if t == "" || len(vars) == 0 { + return t + } + out := t + for k, v := range vars { + out = strings.ReplaceAll(out, "{{"+k+"}}", v) + } + return out +} + +// StripCodeFences removes surrounding Markdown triple-backtick fences. +func StripCodeFences(s string) string { + t := strings.TrimSpace(s) + if t == "" { + return t + } + lines := strings.Split(t, "\n") + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + end := len(lines) - 1 + for end >= 0 && strings.TrimSpace(lines[end]) == "" { + end-- + } + if start >= len(lines) || end < 0 || start > end { + return t + } + first := strings.TrimSpace(lines[start]) + last := strings.TrimSpace(lines[end]) + if strings.HasPrefix(first, "```") && last == "```" && end > start { + inner := strings.Join(lines[start+1:end], "\n") + return inner + } + return t +} + +// InstructionFromSelection extracts the first inline instruction and returns +// (instruction, cleanedSelection). It detects markers on the earliest position +// per line in precedence: strict ;text;, /* */, <!-- -->, //, #, --. +func InstructionFromSelection(sel string) (string, string) { + lines := strings.Split(sel, "\n") + for idx, line := range lines { + if instr, cleaned, ok := FindFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { + lines[idx] = cleaned + return instr, strings.Join(lines, "\n") + } + } + return "", sel +} + +// FindFirstInstructionInLine returns (instruction, cleaned, ok) for a single line. +func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) { + type cand struct{ start, end int; text string } + cands := []cand{} + if t, l, r, ok := FindStrictInlineTag(line); ok { + cands = append(cands, cand{start: l, end: r, text: t}) + } + if i := strings.Index(line, "/*"); i >= 0 { + if j := strings.Index(line[i+2:], "*/"); j >= 0 { + start := i + end := i + 2 + j + 2 + text := strings.TrimSpace(line[i+2 : i+2+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + } + } + if i := strings.Index(line, "<!--"); i >= 0 { + if j := strings.Index(line[i+4:], "-->"); j >= 0 { + start := i + end := i + 4 + j + 3 + text := strings.TrimSpace(line[i+4 : i+4+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + } + } + if i := strings.Index(line, "//"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + } + if i := strings.Index(line, "#"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) + } + if i := strings.Index(line, "--"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + } + if len(cands) == 0 { return "", line, false } + best := cands[0] + for _, c := range cands[1:] { + if c.start >= 0 && (best.start < 0 || c.start < best.start) { best = c } + } + cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + return best.text, cleaned, true +} + +// FindStrictInlineTag finds ;text; with no spaces after/before semicolons. +func FindStrictInlineTag(line string) (text string, left, right int, ok bool) { + for i := 0; i < len(line); i++ { + if line[i] != ';' { continue } + if i+1 < len(line) && line[i+1] == ' ' { continue } + for j := i + 1; j < len(line); j++ { + if line[j] == ';' { + if j-1 >= 0 && line[j-1] == ' ' { continue } + inner := strings.TrimSpace(line[i+1 : j]) + if inner != "" { return inner, i, j + 1, true } + } + } + } + return "", -1, -1, false +} + diff --git a/internal/textutil/textutil_test.go b/internal/textutil/textutil_test.go new file mode 100644 index 0000000..3a8cd90 --- /dev/null +++ b/internal/textutil/textutil_test.go @@ -0,0 +1,87 @@ +package textutil + +import ( + "regexp" + "strings" + "testing" +) + +func TestRenderTemplate_Basic(t *testing.T) { + out := RenderTemplate("Hello, {{name}}!", map[string]string{"name": "Hex"}) + if out != "Hello, Hex!" { + t.Fatalf("render failed: %q", out) + } + // No vars + if RenderTemplate("x", nil) != "x" { t.Fatal("nil vars changed output") } +} + +func TestStripCodeFences_Variants(t *testing.T) { + cases := []struct{ in, want string }{ + {"```\ncode\n```", "code"}, + {"```go\npackage x\n```", "package x"}, + {"no fences", "no fences"}, + {"\n\n```\ntrim\n```\n", "trim"}, + } + for _, c := range cases { + if got := StripCodeFences(c.in); got != c.want { + t.Fatalf("strip mismatch: %q != %q", got, c.want) + } + } +} + +func TestInstructionFromSelection_Markers(t *testing.T) { + inputs := []string{ + ";do it;\ncode", + "/* fix */\ncode", + "<!-- doc -->\ncode", + "// change\ncode", + "# tweak\ncode", + "-- op\ncode", + } + for _, in := range inputs { + instr, cleaned := InstructionFromSelection(in) + if strings.TrimSpace(instr) == "" { + t.Fatalf("no instruction for input: %q", in) + } + // cleaned should not contain the instruction token + if strings.Contains(cleaned, instr) { + // Allow coincidence only if separated differently; require not exact match on same line + first := strings.Split(in, "\n")[0] + if strings.Contains(first, instr) { + t.Fatalf("instruction not removed: %q", cleaned) + } + } + } +} + +func TestFindFirstInstructionInLine_EarliestWins(t *testing.T) { + // Both markers present, earliest should win (strict tag first) + line := ";first; // later" + instr, cleaned, ok := FindFirstInstructionInLine(line) + if !ok || instr != "first" { + t.Fatalf("expected 'first', got %q ok=%v", instr, ok) + } + if strings.Contains(cleaned, instr) { + t.Fatalf("expected cleaned line to remove instr: %q", cleaned) + } +} + +func TestFindStrictInlineTag(t *testing.T) { + if txt, l, r, ok := FindStrictInlineTag("pre;do;post"); !ok || txt != "do" || l != 3 || r != 7 { + t.Fatalf("strict tag parse failed: %q %d %d %v", txt, l, r, ok) + } + if _, _, _, ok := FindStrictInlineTag("; spaced ;"); ok { + t.Fatalf("should reject spaced strict tag") + } +} + +// optional: ensure no ANSI codes appear in plain helpers +func TestNoANSIInHelpers(t *testing.T) { + ansi := regexp.MustCompile(`\x1b\[[0-9;]*m`) + if ansi.MatchString(RenderTemplate("x", nil)) { + t.Fatalf("unexpected ansi in RenderTemplate") + } + if ansi.MatchString(StripCodeFences("x")) { + t.Fatalf("unexpected ansi in StripCodeFences") + } +} |
