v0.35.1 2aca1df

text.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"errors"
7	"fmt"
8	"strings"
9	"unicode"
10	"unicode/utf8"
11
12	"golang.org/x/text/unicode/norm"
13)
14
15// Limits of the plain text of a merge request (ADR 0017), in characters.
16const (
17	// MaxTitleLen is the longest merge request title.
18	MaxTitleLen = 120
19	// MaxTextLen is the longest description or comment.
20	MaxTextLen = 8000
21)
22
23// ErrInvalidText means a text breaks the rules of ParseMRTitle or
24// ParsePlainText.
25var ErrInvalidText = errors.New("invalid text")
26
27// TextError says why a text is invalid. It matches ErrInvalidText.
28type TextError struct {
29	// Reason completes the sentence "the text ...", for example "is not valid
30	// UTF-8".
31	Reason string
32}
33
34// Error implements error.
35func (e *TextError) Error() string { return "text " + e.Reason }
36
37// Unwrap makes errors.Is(err, ErrInvalidText) true.
38func (e *TextError) Unwrap() error { return ErrInvalidText }
39
40// MRTitle is a validated merge request title: one line of plain text, at most
41// MaxTitleLen characters. Only ParseMRTitle builds a non-zero value.
42type MRTitle struct{ s string }
43
44// ParseMRTitle validates s: not blank, valid UTF-8, one line, at most
45// MaxTitleLen characters, no control character and no bidirectional override
46// or isolate character.
47func ParseMRTitle(s string) (MRTitle, error) {
48	if reason := textProblem(s, false, MaxTitleLen); reason != "" {
49		return MRTitle{}, &TextError{Reason: reason}
50	}
51	if isBlank(s) {
52		return MRTitle{}, &TextError{Reason: "is empty"}
53	}
54	return MRTitle{s: s}, nil
55}
56
57// String returns the title.
58func (t MRTitle) String() string { return t.s }
59
60// IsZero reports whether t is the zero value.
61func (t MRTitle) IsZero() bool { return t.s == "" }
62
63// PlainText is a validated multi-line plain text, such as a description or a
64// comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,
65// no other control character, no U+2028 or U+2029, no bidirectional mark,
66// override or isolate character.
67// The zero value is the empty text.
68type PlainText struct{ s string }
69
70// ParsePlainText validates s. The empty text is valid: a caller that needs
71// text checks IsBlank.
72func ParsePlainText(s string) (PlainText, error) {
73	if reason := textProblem(s, true, MaxTextLen); reason != "" {
74		return PlainText{}, &TextError{Reason: reason}
75	}
76	return PlainText{s: s}, nil
77}
78
79// String returns the text.
80func (t PlainText) String() string { return t.s }
81
82// IsBlank reports whether the text is empty or only white space.
83func (t PlainText) IsBlank() bool { return isBlank(t.s) }
84
85func isBlank(s string) bool {
86	for _, r := range s {
87		if !unicode.IsSpace(r) {
88			return false
89		}
90	}
91	return true
92}
93
94// textProblem returns why s breaks the rules, or "".
95func textProblem(s string, multiline bool, maxLen int) string {
96	if !utf8.ValidString(s) {
97		return "is not valid UTF-8"
98	}
99	if n := utf8.RuneCountInString(s); n > maxLen {
100		return fmt.Sprintf("is longer than %d characters (%d)", maxLen, n)
101	}
102	for _, r := range s {
103		switch {
104		case r == '\n':
105			if !multiline {
106				return "must be one line"
107			}
108		case unicode.IsControl(r):
109			return fmt.Sprintf("contains the control character U+%04X", r)
110		case r == '\u2028' || r == '\u2029':
111			return fmt.Sprintf("contains the line or paragraph separator U+%04X", r)
112		case isBidiControl(r):
113			return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)
114		}
115	}
116	return ""
117}
118
119// isBidiControl reports whether r is a bidirectional mark, embedding, override
120// or isolate character (U+200E, U+200F, U+061C, U+202A to U+202E, U+2066 to
121// U+2069), which can make text read differently from how it is stored.
122func isBidiControl(r rune) bool {
123	return r == 0x200e || r == 0x200f || r == 0x061c ||
124		(r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
125}
126
127// FieldViolation names a request field that breaks a rule.
128type FieldViolation struct {
129	// Field is the name of the field in the API contract, for example
130	// "title".
131	Field string
132	// Reason says what is wrong with it.
133	Reason string
134}
135
136// InvalidFieldsError is a request rejected whole because of its fields. It
137// matches ErrInvalid.
138type InvalidFieldsError struct {
139	Fields []FieldViolation
140}
141
142// Error implements error.
143func (e *InvalidFieldsError) Error() string {
144	msg := "invalid argument"
145	for i, f := range e.Fields {
146		sep := "; "
147		if i == 0 {
148			sep = ": "
149		}
150		msg += sep + f.Field + " " + f.Reason
151	}
152	return msg
153}
154
155// Unwrap makes errors.Is(err, ErrInvalid) true.
156func (e *InvalidFieldsError) Unwrap() error { return ErrInvalid }
157
158// Violations collects the violations of a request.
159type Violations struct{ list []FieldViolation }
160
161// Add records that field breaks a rule.
162func (v *Violations) Add(field, reason string) {
163	v.list = append(v.list, FieldViolation{Field: field, Reason: reason})
164}
165
166// AddErr records an error of a parser for field: the reason of a *TextError,
167// else the text of the error.
168func (v *Violations) AddErr(field string, err error) {
169	var te *TextError
170	if errors.As(err, &te) {
171		v.Add(field, te.Reason)
172		return
173	}
174	v.Add(field, err.Error())
175}
176
177// Err returns nil without violations, else an *InvalidFieldsError.
178func (v *Violations) Err() error {
179	if len(v.list) == 0 {
180		return nil
181	}
182	return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
183}
184
185// Limits of the text of an issue, in code points.
186const (
187	// MaxIssueTitleLen is the longest issue title.
188	MaxIssueTitleLen = 120
189	// MaxIssueBodyLen is the longest issue body or issue comment.
190	MaxIssueBodyLen = 10000
191	// MaxAuthorLabelLen is the longest author label.
192	MaxAuthorLabelLen = 40
193)
194
195// IssueTitle is a validated issue title: one line, 1 to MaxIssueTitleLen code
196// points, normalized. Only ParseIssueTitle builds a non-zero value.
197type IssueTitle struct{ s string }
198
199// String returns the title.
200func (t IssueTitle) String() string { return t.s }
201
202// IsZero reports whether t is the zero value.
203func (t IssueTitle) IsZero() bool { return t.s == "" }
204
205// IssueBody is a validated issue body or issue comment: at most
206// MaxIssueBodyLen code points, normalized, line breaks and tabs allowed. The
207// zero value is the empty text.
208type IssueBody struct{ s string }
209
210// String returns the text.
211func (b IssueBody) String() string { return b.s }
212
213// IsBlank reports whether the text is empty or only white space.
214func (b IssueBody) IsBlank() bool { return isBlank(b.s) }
215
216// ParseIssueTitle cleans s (see cleanText) and validates it: one line, not
217// blank, at most MaxIssueTitleLen code points. A line break is refused, not
218// removed, so that two lines are never glued into one.
219func ParseIssueTitle(s string) (IssueTitle, error) {
220	if !utf8.ValidString(s) {
221		return IssueTitle{}, &TextError{Reason: "is not valid UTF-8"}
222	}
223	if strings.ContainsAny(s, "\r\n\u2028\u2029") {
224		return IssueTitle{}, &TextError{Reason: "must be one line"}
225	}
226	c := strings.TrimSpace(cleanText(s, false))
227	if c == "" {
228		return IssueTitle{}, &TextError{Reason: "is empty"}
229	}
230	if n := utf8.RuneCountInString(c); n > MaxIssueTitleLen {
231		return IssueTitle{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueTitleLen, n)}
232	}
233	return IssueTitle{s: c}, nil
234}
235
236// ParseIssueBody cleans s (see cleanText, line breaks and tabs are kept) and
237// validates it: at most MaxIssueBodyLen code points. The empty text is valid.
238func ParseIssueBody(s string) (IssueBody, error) {
239	if !utf8.ValidString(s) {
240		return IssueBody{}, &TextError{Reason: "is not valid UTF-8"}
241	}
242	c := cleanText(s, true)
243	if n := utf8.RuneCountInString(c); n > MaxIssueBodyLen {
244		return IssueBody{}, &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxIssueBodyLen, n)}
245	}
246	return IssueBody{s: c}, nil
247}
248
249// ParseAuthorLabel cleans s like a title (one line) and validates it: at most
250// MaxAuthorLabelLen code points. The empty label is valid.
251func ParseAuthorLabel(s string) (string, error) {
252	if !utf8.ValidString(s) {
253		return "", &TextError{Reason: "is not valid UTF-8"}
254	}
255	if strings.ContainsAny(s, "\r\n\u2028\u2029") {
256		return "", &TextError{Reason: "must be one line"}
257	}
258	c := strings.TrimSpace(cleanText(s, false))
259	if n := utf8.RuneCountInString(c); n > MaxAuthorLabelLen {
260		return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxAuthorLabelLen, n)}
261	}
262	return c, nil
263}
264
265// cleanText normalizes s to NFC and removes the control characters (C0, C1 and
266// DEL; with multiline, line feeds and tabs stay) and the bidirectional marks,
267// embeddings, overrides and isolates. s must be valid UTF-8.
268func cleanText(s string, multiline bool) string {
269	s = norm.NFC.String(s)
270	if multiline {
271		s = strings.ReplaceAll(s, "\r\n", "\n")
272	}
273	return strings.Map(func(r rune) rune {
274		switch {
275		case multiline && (r == '\n' || r == '\t'):
276			return r
277		case unicode.IsControl(r), isBidiControl(r), r == '\u2028' || r == '\u2029':
278			return -1
279		}
280		return r
281	}, s)
282}

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