v0.7.0 45bfc5b

text.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"errors"
7	"fmt"
8	"unicode"
9	"unicode/utf8"
10)
11
12// Limits of the plain text of a merge request (ADR 0017), in characters.
13const (
14	// MaxTitleLen is the longest merge request title.
15	MaxTitleLen = 120
16	// MaxTextLen is the longest description or comment.
17	MaxTextLen = 8000
18)
19
20// ErrInvalidText means a text breaks the rules of ParseMRTitle or
21// ParsePlainText.
22var ErrInvalidText = errors.New("invalid text")
23
24// TextError says why a text is invalid. It matches ErrInvalidText.
25type TextError struct {
26	// Reason completes the sentence "the text ...", for example "is not valid
27	// UTF-8".
28	Reason string
29}
30
31// Error implements error.
32func (e *TextError) Error() string { return "text " + e.Reason }
33
34// Unwrap makes errors.Is(err, ErrInvalidText) true.
35func (e *TextError) Unwrap() error { return ErrInvalidText }
36
37// MRTitle is a validated merge request title: one line of plain text, at most
38// MaxTitleLen characters. Only ParseMRTitle builds a non-zero value.
39type MRTitle struct{ s string }
40
41// ParseMRTitle validates s: not blank, valid UTF-8, one line, at most
42// MaxTitleLen characters, no control character and no bidirectional override
43// or isolate character.
44func ParseMRTitle(s string) (MRTitle, error) {
45	if reason := textProblem(s, false, MaxTitleLen); reason != "" {
46		return MRTitle{}, &TextError{Reason: reason}
47	}
48	if isBlank(s) {
49		return MRTitle{}, &TextError{Reason: "is empty"}
50	}
51	return MRTitle{s: s}, nil
52}
53
54// String returns the title.
55func (t MRTitle) String() string { return t.s }
56
57// IsZero reports whether t is the zero value.
58func (t MRTitle) IsZero() bool { return t.s == "" }
59
60// PlainText is a validated multi-line plain text, such as a description or a
61// comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,
62// no other control character, no U+2028 or U+2029, no bidirectional mark,
63// override or isolate character.
64// The zero value is the empty text.
65type PlainText struct{ s string }
66
67// ParsePlainText validates s. The empty text is valid: a caller that needs
68// text checks IsBlank.
69func ParsePlainText(s string) (PlainText, error) {
70	if reason := textProblem(s, true, MaxTextLen); reason != "" {
71		return PlainText{}, &TextError{Reason: reason}
72	}
73	return PlainText{s: s}, nil
74}
75
76// String returns the text.
77func (t PlainText) String() string { return t.s }
78
79// IsBlank reports whether the text is empty or only white space.
80func (t PlainText) IsBlank() bool { return isBlank(t.s) }
81
82func isBlank(s string) bool {
83	for _, r := range s {
84		if !unicode.IsSpace(r) {
85			return false
86		}
87	}
88	return true
89}
90
91// textProblem returns why s breaks the rules, or "".
92func textProblem(s string, multiline bool, maxLen int) string {
93	if !utf8.ValidString(s) {
94		return "is not valid UTF-8"
95	}
96	if n := utf8.RuneCountInString(s); n > maxLen {
97		return fmt.Sprintf("is longer than %d characters (%d)", maxLen, n)
98	}
99	for _, r := range s {
100		switch {
101		case r == '\n':
102			if !multiline {
103				return "must be one line"
104			}
105		case unicode.IsControl(r):
106			return fmt.Sprintf("contains the control character U+%04X", r)
107		case r == '\u2028' || r == '\u2029':
108			return fmt.Sprintf("contains the line or paragraph separator U+%04X", r)
109		case isBidiControl(r):
110			return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)
111		}
112	}
113	return ""
114}
115
116// isBidiControl reports whether r is a bidirectional mark, embedding, override
117// or isolate character (U+200E, U+200F, U+061C, U+202A to U+202E, U+2066 to
118// U+2069), which can make text read differently from how it is stored.
119func isBidiControl(r rune) bool {
120	return r == 0x200e || r == 0x200f || r == 0x061c ||
121		(r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
122}
123
124// FieldViolation names a request field that breaks a rule.
125type FieldViolation struct {
126	// Field is the name of the field in the API contract, for example
127	// "title".
128	Field string
129	// Reason says what is wrong with it.
130	Reason string
131}
132
133// InvalidFieldsError is a request rejected whole because of its fields. It
134// matches ErrInvalid.
135type InvalidFieldsError struct {
136	Fields []FieldViolation
137}
138
139// Error implements error.
140func (e *InvalidFieldsError) Error() string {
141	msg := "invalid argument"
142	for i, f := range e.Fields {
143		sep := "; "
144		if i == 0 {
145			sep = ": "
146		}
147		msg += sep + f.Field + " " + f.Reason
148	}
149	return msg
150}
151
152// Unwrap makes errors.Is(err, ErrInvalid) true.
153func (e *InvalidFieldsError) Unwrap() error { return ErrInvalid }
154
155// Violations collects the violations of a request.
156type Violations struct{ list []FieldViolation }
157
158// Add records that field breaks a rule.
159func (v *Violations) Add(field, reason string) {
160	v.list = append(v.list, FieldViolation{Field: field, Reason: reason})
161}
162
163// AddErr records an error of a parser for field: the reason of a *TextError,
164// else the text of the error.
165func (v *Violations) AddErr(field string, err error) {
166	var te *TextError
167	if errors.As(err, &te) {
168		v.Add(field, te.Reason)
169		return
170	}
171	v.Add(field, err.Error())
172}
173
174// Err returns nil without violations, else an *InvalidFieldsError.
175func (v *Violations) Err() error {
176	if len(v.list) == 0 {
177		return nil
178	}
179	return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close