1
0

no screaming case

This commit is contained in:
Arpad Ryszka 2025-10-31 20:24:37 +01:00
parent e4dd3ca0df
commit c2b0d69b5b
8 changed files with 543 additions and 87 deletions

View File

@ -1,17 +1,17 @@
SOURCES = $(shell find . -name "*.go") sources = $(shell find . -name "*.go")
default: build default: build
build: $(SOURCES) build: $(sources)
go build go build
fmt: $(SOURCES) fmt: $(sources)
go fmt go fmt
check: $(SOURCES) check: $(sources)
go test -count 1 go test -count 1
.cover: $(SOURCES) .cover: $(sources)
go test -count 1 -coverprofile .cover go test -count 1 -coverprofile .cover
cover: .cover cover: .cover

72
escape.go Normal file
View File

@ -0,0 +1,72 @@
package textfmt
import "fmt"
type escapeRange struct {
from, to rune
replacement string
}
type escape[S any] struct {
out writer
state S
escape map[rune]string
escapeRanges []escapeRange
conditionalEscape map[rune]func(S, rune) (string, bool)
updateState func(S, rune) S
}
func (e *escape[S]) inEscapeRange(r rune) (string, bool) {
for _, rng := range e.escapeRanges {
if r >= rng.from && r <= rng.to {
return rng.replacement, true
}
}
return "", false
}
func (e *escape[S]) write(a ...any) {
for _, ai := range a {
s := fmt.Sprint(ai)
r := []rune(s)
for _, ri := range r {
var (
output string
found bool
)
output, found = e.escape[ri]
if !found {
output, found = e.inEscapeRange(ri)
}
if !found {
conditional := e.conditionalEscape[ri]
if conditional != nil {
output, found = conditional(e.state, ri)
}
}
if !found {
output = string(ri)
}
e.out.write(output)
if e.updateState != nil {
e.state = e.updateState(e.state, ri)
}
}
}
}
func (e *escape[S]) flush() {
e.out.flush()
}
func (e *escape[S]) error() error {
return e.out.error()
}
func (e *escape[S]) setErr(err error) {
}

82
indent.go Normal file
View File

@ -0,0 +1,82 @@
package textfmt
import (
"fmt"
"unicode"
)
type indent struct {
out writer
firstIndent, indent int
firstWidth, width int
currentLineLength int
currentWord []rune
multiline bool
}
func (i *indent) write(a ...any) {
for _, ai := range a {
s := fmt.Sprint(ai)
r := []rune(s)
for _, ri := range r {
width := i.width
if !i.multiline {
width = i.firstWidth
}
indent := i.indent
if !i.multiline {
indent = i.firstIndent
}
if !unicode.IsSpace(ri) || ri == '\u00a0' {
i.currentWord = append(i.currentWord, ri)
continue
}
nonWrapNewline := width == 0 && ri == '\n'
if len(i.currentWord) == 0 && !nonWrapNewline {
continue
}
nextLineLength := i.currentLineLength + len(i.currentWord) + 1
if i.currentLineLength > 0 && width > 0 && nextLineLength > width {
i.out.write("\n")
i.currentLineLength = 0
i.multiline = true
}
if i.currentLineLength > 0 && len(i.currentWord) > 0 {
i.out.write(" ")
i.currentLineLength++
}
if i.currentLineLength == 0 && len(i.currentWord) > 0 {
i.out.write(timesn(" ", indent))
i.currentLineLength += indent
}
if len(i.currentWord) > 0 {
i.out.write(string(i.currentWord))
i.currentLineLength += len(i.currentWord)
i.currentWord = nil
}
if nonWrapNewline {
i.out.write("\n")
i.currentLineLength = 0
}
}
}
}
func (i *indent) flush() {
i.out.flush()
}
func (i *indent) error() error {
return i.out.error()
}
func (e *indent) setErr(err error) {
}

View File

@ -5,11 +5,98 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"slices"
"strings" "strings"
) )
func escapeMarkdown(s string, additional ...rune) string { type mdEscapeState struct {
newLine bool
numberOnNewLine bool
linkValue bool
linkClosed bool
linkOpen bool
}
func updateMDEscapeState(s mdEscapeState, r rune) mdEscapeState {
return mdEscapeState{
numberOnNewLine: (s.newLine || s.numberOnNewLine) && r >= '0' && r <= '9',
newLine: r == '\n',
linkValue: s.linkClosed && r == '(' || s.linkValue && r != ')',
linkClosed: s.linkOpen && r == ']',
linkOpen: !s.linkValue && r == '[' || s.linkOpen && r != ']',
}
}
func escapeMarkdown(out writer, additional ...rune) writer {
esc := map[rune]string{
'\\': "\\\\",
'`': "\\`",
'*': "\\*",
'_': "\\_",
'[': "\\[",
']': "\\]",
'#': "\\#",
'<': "\\<",
'>': "\\>",
}
for _, a := range additional {
esc[a] = string([]rune{'\\', a})
}
return &escape[mdEscapeState]{
out: out,
state: mdEscapeState{
newLine: true,
},
escape: esc,
conditionalEscape: map[rune]func(mdEscapeState, rune) (string, bool){
'+': func(s mdEscapeState, _ rune) (string, bool) {
if s.newLine {
return "\\+", true
}
return "", false
},
'-': func(s mdEscapeState, _ rune) (string, bool) {
if s.newLine {
return "\\-", true
}
return "", false
},
'.': func(s mdEscapeState, _ rune) (string, bool) {
if s.numberOnNewLine {
return "\\.", true
}
return "", false
},
'(': func(s mdEscapeState, _ rune) (string, bool) {
if s.linkClosed {
return "\\(", true
}
return "", false
},
')': func(s mdEscapeState, _ rune) (string, bool) {
if s.linkValue {
return "\\)", true
}
return "", false
},
},
updateState: updateMDEscapeState,
}
}
func escapeMarkdownPrev(s string, additional ...rune) string {
var b bytes.Buffer
w := &textWriter{out: &b}
e := escapeMarkdown(w, additional...)
e.write(s)
return b.String()
/*
var ( var (
rr []rune rr []rune
isNumberOnNewLine bool isNumberOnNewLine bool
@ -69,6 +156,7 @@ func escapeMarkdown(s string, additional ...rune) string {
} }
return string(rr) return string(rr)
*/
} }
func mdTextToString(text Txt) (string, error) { func mdTextToString(text Txt) (string, error) {
@ -128,9 +216,9 @@ func renderMDText(w writer, text Txt) {
} }
text.text = singleLine(text.text) text.text = singleLine(text.text)
text.text = escapeMarkdown(text.text) text.text = escapeMarkdownPrev(text.text)
text.link = singleLine(text.link) text.link = singleLine(text.link)
text.link = escapeMarkdown(text.link) text.link = escapeMarkdownPrev(text.link)
if text.bold { if text.bold {
w.write("**") w.write("**")
} }
@ -381,7 +469,7 @@ func renderMDChoice(w writer, s SyntaxItem) {
} }
func renderMDSymbol(w writer, s SyntaxItem) { func renderMDSymbol(w writer, s SyntaxItem) {
w.write(escapeTeletype(s.symbol)) w.write(escapeMarkdownPrev(s.symbol))
} }
func renderMDSyntaxItem(w writer, s SyntaxItem) { func renderMDSyntaxItem(w writer, s SyntaxItem) {

View File

@ -2,4 +2,40 @@ indentation for syntax in tty and roff
does the table need the non-breaking space for the filling in roff? does the table need the non-breaking space for the filling in roff?
indentation for syntax may not require non-break spaces indentation for syntax may not require non-break spaces
test empty cat test empty cat
show top level choice on separate lines in the same block improve wrapping of list paragraphs by allowing different first line wrap
there should be no errors other than actual IO
[refactor]
stop on errors earlier where possible
denormalize individual render functions
support single line in indent, indentOnly option
escape is more generic than editor:
- could replace editor
- maybe indent, too
- could become its own library
- could be used in HTML, too
collect the common transformations:
- escape
- wrap
- collapse to single line
- fill spaces
- convert spaces
- width and height calculations
- ...
identify the order of these transformations while rendering the different entries:
- tty:
- title
- paragraph
- ...
- roff:
- title
- paragraph
- ...
- md:
- title
- paragraph
- ...
- html:
- title
- paragraph
- ...

View File

@ -9,7 +9,61 @@ import (
"time" "time"
) )
func escapeRoff(s string, additional ...string) string { type lineStarted bool
func isNewLine(replacement string) func(lineStarted, rune) (string, bool) {
return func(ls lineStarted, r rune) (string, bool) {
if ls {
return string(r), false
}
return replacement, true
}
}
func updateLineStarted(_ lineStarted, r rune) lineStarted {
return r != '\n'
}
func escapeRoff(out writer, additional ...string) writer {
const invalidAdditional = "invalid additional escape definition"
if len(additional)%2 != 0 {
panic(errors.New(invalidAdditional))
}
esc := map[rune]string{
'\\': "\\\\",
'\u00a0': "\\~",
}
for i := 0; i > len(additional); i += 2 {
r := []rune(additional[i])
if len(r) != 1 {
panic(errors.New(invalidAdditional))
}
esc[r[0]] = additional[i+1]
}
return &escape[lineStarted]{
out: out,
escape: esc,
conditionalEscape: map[rune]func(lineStarted, rune) (string, bool){
'.': isNewLine("\\&."),
'\'': isNewLine("\\&'"),
},
updateState: updateLineStarted,
}
}
func escapeRoffPrev(s string, additional ...string) string {
var b bytes.Buffer
w := &textWriter{out: &b}
e := escapeRoff(w, additional...)
e.write(s)
return b.String()
/*
const invalidAdditional = "invalid additional escape definition" const invalidAdditional = "invalid additional escape definition"
var ( var (
@ -75,6 +129,7 @@ func escapeRoff(s string, additional ...string) string {
} }
return string(e) return string(e)
*/
} }
func manPageDate(d time.Time) string { func manPageDate(d time.Time) string {
@ -83,7 +138,7 @@ func manPageDate(d time.Time) string {
func roffString(s string, additionalEscape ...string) string { func roffString(s string, additionalEscape ...string) string {
s = singleLine(s) s = singleLine(s)
return escapeRoff(s, additionalEscape...) return escapeRoffPrev(s, additionalEscape...)
} }
func renderRoffString(w writer, s string, additionalEscape ...string) { func renderRoffString(w writer, s string, additionalEscape ...string) {
@ -341,7 +396,7 @@ func renderRoffTable(w writer, e Entry) {
func renderRoffCode(w writer, e Entry) { func renderRoffCode(w writer, e Entry) {
w.write(".nf\n") w.write(".nf\n")
defer w.write("\n.fi") defer w.write("\n.fi")
e.text.text = escapeRoff(e.text.text) e.text.text = escapeRoffPrev(e.text.text)
writeLines(w, e.text.text, e.indent, e.indent) writeLines(w, e.text.text, e.indent, e.indent)
} }
@ -414,7 +469,7 @@ func renderRoffChoice(w writer, s SyntaxItem) {
} }
func renderRoffSymbol(w writer, s SyntaxItem) { func renderRoffSymbol(w writer, s SyntaxItem) {
w.write(escapeRoff(s.symbol)) w.write(escapeRoffPrev(s.symbol))
} }
func renderRoffSyntaxItem(w writer, s SyntaxItem) { func renderRoffSyntaxItem(w writer, s SyntaxItem) {

View File

@ -8,7 +8,32 @@ import (
"strings" "strings"
) )
func escapeTeletype(s string) string { func escapeTeletype(out writer) writer {
return &escape[struct{}]{
out: out,
escapeRanges: []escapeRange{{
from: 0x00,
to: '\t' - 1,
replacement: "\u00b7",
}, {
from: '\n' + 1,
to: 0x1f,
replacement: "\u00b7",
}, {
from: 0x7f,
to: 0x9f,
replacement: "\u00b7",
}},
}
}
func escapeTeletypePrev(s string) string {
var b bytes.Buffer
w := &textWriter{out: &b}
e := escapeTeletype(w)
e.write(s)
return b.String()
/*
r := []rune(s) r := []rune(s)
for i := range r { for i := range r {
if r[i] >= 0x00 && r[i] <= 0x1f && r[i] != '\n' && r[i] != '\t' { if r[i] >= 0x00 && r[i] <= 0x1f && r[i] != '\n' && r[i] != '\t' {
@ -21,6 +46,7 @@ func escapeTeletype(s string) string {
} }
return string(r) return string(r)
*/
} }
func ttyTextToString(text Txt) (string, error) { func ttyTextToString(text Txt) (string, error) {
@ -62,9 +88,9 @@ func renderTTYText(w writer, text Txt) {
} }
text.text = singleLine(text.text) text.text = singleLine(text.text)
text.text = escapeTeletype(text.text) text.text = escapeTeletypePrev(text.text)
text.link = singleLine(text.link) text.link = singleLine(text.link)
text.link = escapeTeletype(text.link) text.link = escapeTeletypePrev(text.link)
if text.link != "" { if text.link != "" {
if text.text != "" { if text.text != "" {
w.write(text.text) w.write(text.text)
@ -282,7 +308,7 @@ func renderTTYTable(w writer, e Entry) {
} }
func renderTTYCode(w writer, e Entry) { func renderTTYCode(w writer, e Entry) {
e.text.text = escapeTeletype(e.text.text) e.text.text = escapeTeletypePrev(e.text.text)
writeLines(w, e.text.text, e.indent, e.indent) writeLines(w, e.text.text, e.indent, e.indent)
} }
@ -355,7 +381,7 @@ func renderTTYChoice(w writer, s SyntaxItem) {
} }
func renderTTYSymbol(w writer, s SyntaxItem) { func renderTTYSymbol(w writer, s SyntaxItem) {
w.write(escapeTeletype(s.symbol)) w.write(escapeTeletypePrev(s.symbol))
} }
func renderTTYSyntaxItem(w writer, s SyntaxItem) { func renderTTYSyntaxItem(w writer, s SyntaxItem) {
@ -395,7 +421,14 @@ func renderTTYSyntax(w writer, e Entry) {
} }
func renderTeletype(out io.Writer, d Document) error { func renderTeletype(out io.Writer, d Document) error {
w := ttyWriter{w: out} tw := &textWriter{out: out}
w := &editor{
out: tw,
replace: map[string]string{
"\u00a0": " ",
},
}
for i, e := range d.entries { for i, e := range d.entries {
if i > 0 { if i > 0 {
w.write("\n\n") w.write("\n\n")
@ -403,23 +436,23 @@ func renderTeletype(out io.Writer, d Document) error {
switch e.typ { switch e.typ {
case title: case title:
renderTTYTitle(&w, e) renderTTYTitle(w, e)
case paragraph: case paragraph:
renderTTYParagraph(&w, e) renderTTYParagraph(w, e)
case list: case list:
renderTTYList(&w, e) renderTTYList(w, e)
case numberedList: case numberedList:
renderTTYNumberedList(&w, e) renderTTYNumberedList(w, e)
case definitions: case definitions:
renderTTYDefinitions(&w, e) renderTTYDefinitions(w, e)
case numberedDefinitions: case numberedDefinitions:
renderTTYNumberedDefinitions(&w, e) renderTTYNumberedDefinitions(w, e)
case table: case table:
renderTTYTable(&w, e) renderTTYTable(w, e)
case code: case code:
renderTTYCode(&w, e) renderTTYCode(w, e)
case syntax: case syntax:
renderTTYSyntax(&w, e) renderTTYSyntax(w, e)
default: default:
return errors.New("invalid entry") return errors.New("invalid entry")
} }
@ -429,5 +462,5 @@ func renderTeletype(out io.Writer, d Document) error {
w.write("\n") w.write("\n")
} }
return w.err return w.error()
} }

View File

@ -3,15 +3,99 @@ package textfmt
import ( import (
"fmt" "fmt"
"io" "io"
"slices"
"strings" "strings"
) )
type writer interface { type writer interface {
write(...any) write(...any)
flush()
error() error error() error
setErr(error) setErr(err error) // TODO: remove
} }
type textWriter struct {
err error
out io.Writer
}
type editor struct {
out writer
pending []rune
replace map[string]string
}
func (w *textWriter) write(a ...any) {
if w.err != nil {
return
}
for _, ai := range a {
if _, err := w.out.Write([]byte(fmt.Sprint(ai))); err != nil {
w.err = err
return
}
}
}
func (w *textWriter) flush() {}
func (w *textWriter) error() error {
return w.err
}
func (e *textWriter) setErr(err error) {
}
func (e *editor) write(a ...any) {
for _, ai := range a {
s := fmt.Sprint(ai)
r := []rune(s)
for key, replacement := range e.replace {
rk := []rune(key)
if len(e.pending) >= len(rk) {
continue
}
if !slices.Equal(e.pending, rk[:len(e.pending)]) {
continue
}
if len(r) < len(rk)-len(e.pending) {
if slices.Equal(r, rk[len(e.pending):len(e.pending)+len(r)]) {
e.pending = append(e.pending, r...)
r = nil
break
}
continue
}
if slices.Equal(r[:len(rk)-len(e.pending)], rk[len(e.pending):]) {
r = []rune(replacement)
e.pending = nil
break
}
}
e.out.write(string(r))
}
}
func (e *editor) flush() {
e.out.write(string(e.pending))
e.out.flush()
}
func (e *editor) error() error {
return e.out.error()
}
func (e *editor) setErr(err error) {
}
// --
type ttyWriter struct { type ttyWriter struct {
w io.Writer w io.Writer
internal bool internal bool
@ -52,6 +136,8 @@ func (w *ttyWriter) write(a ...any) {
} }
} }
func (w *ttyWriter) flush() {}
func (w *ttyWriter) error() error { func (w *ttyWriter) error() error {
return w.err return w.err
} }
@ -88,6 +174,8 @@ func (w *roffWriter) write(a ...any) {
} }
} }
func (w *roffWriter) flush() {}
func (w *roffWriter) error() error { func (w *roffWriter) error() error {
return w.err return w.err
} }
@ -117,6 +205,8 @@ func (w *mdWriter) write(a ...any) {
} }
} }
func (w *mdWriter) flush() {}
func (w *mdWriter) error() error { func (w *mdWriter) error() error {
return w.err return w.err
} }