diff --git a/README.md b/README.md index b6b1c37..6e39cf2 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,17 @@ # Annot -Annotate a string line with arrows leading to a description. +Annotate a string line leading to a description. ``` -The quick brown fox jumps over the lazy dog - ↑ ↑ ↑ - │ └─ adjective └─ adjective - │ - └─ adjective +The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge. + ↑ └──┬───┘ └───┬───┘ ↑ + │ └─ adjective │ └─ comma + │ │ + └─ article └─ facts, information, and skills acquired + through experience or education; + the theoretical or practical understanding + of a subject. ``` ## Installation @@ -30,11 +33,17 @@ import ( ) func main() { - fmt.Println("The quick brown fox jumps over the lazy dog") + fmt.Println("The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge.") fmt.Println(annot.String( - &annot.Annot{Col: 6, Lines: []string{"adjective"}}, - &annot.Annot{Col: 12, Lines: []string{"adjective"}}, - &annot.Annot{Col: 36, Lines: []string{"adjective"}}, + &annot.Annot{Col: 1, Lines: []string{"article"}}, + &annot.Annot{Col: 4, ColEnd: 11, Lines: []string{"adjective"}}, + &annot.Annot{Col: 22, ColEnd: 30, Lines: []string{ + "facts, information, and skills acquired", + "through experience or education;", + "the theoretical or practical understanding", + "of a subject.", + }}, + &annot.Annot{Col: 48, Lines: []string{"comma"}}, )) } ``` @@ -42,9 +51,12 @@ func main() { Output: ``` -The quick brown fox jumps over the lazy dog - ↑ ↑ ↑ - │ └─ adjective └─ adjective - │ - └─ adjective +The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge. + ↑ └──┬───┘ └───┬───┘ ↑ + │ └─ adjective │ └─ comma + │ │ + └─ article └─ facts, information, and skills acquired + through experience or education; + the theoretical or practical understanding + of a subject. ``` \ No newline at end of file diff --git a/annot.go b/annot.go index 99d7221..8ab6a77 100644 --- a/annot.go +++ b/annot.go @@ -16,9 +16,15 @@ type Annot struct { // E.g. 0 draws an arrow to the first character in a line. Col int + // ColEnd needs to be higher than Col. If ColEnd is set a + // range is annotated. + ColEnd int + // Lines is the text of the annotation represented in one or more lines. Lines []string + pipeColIdx int + row int lines []*line pipeLeadingSpaces []int @@ -109,7 +115,18 @@ func Write(w io.Writer, annots ...*Annot) error { return a.Col - b.Col }) - for _, a := range annots { + for aIdx, a := range annots { + if a.ColEnd != 0 { + if a.Col >= a.ColEnd { + return newColExceedsColEndError(aIdx+1, a.Col, a.ColEnd) + } + a.pipeColIdx = (a.Col + a.ColEnd) / 2 + } else { + a.pipeColIdx = a.Col + } + if aIdx > 0 && annots[aIdx-1].ColEnd != 0 && annots[aIdx-1].ColEnd >= a.Col { + return newOverlapError(annots[aIdx-1].ColEnd, aIdx, a.Col) + } a.createLines() } @@ -127,13 +144,13 @@ func Write(w io.Writer, annots ...*Annot) error { func (a *Annot) createLines() { if len(a.Lines) == 0 { a.lines = make([]*line, 1) - a.lines[0] = &line{} + a.lines[0] = &line{leadingSpaces: a.pipeColIdx} return } a.lines = make([]*line, len(a.Lines)) for i := range a.Lines { - leadingSpaces := a.Col + leadingSpaces := a.pipeColIdx if i > 0 { leadingSpaces += 3 } @@ -166,9 +183,9 @@ func setSpace(rowBefore int, a *Annot, rightAnnots []*Annot) { closestA, s := closestAnnot(rowBefore, rightAnnots, 0) switch s { case above: - closestA.pipeLeadingSpaces[rowBefore] = closestA.Col + s.colPosShift() - a.Col - 1 + closestA.pipeLeadingSpaces[rowBefore] = closestA.pipeColIdx + s.colPosShift() - a.pipeColIdx - 1 case lineOne, lineTwo, linesAfterSecond: - closestA.lines[rowBefore-closestA.row].leadingSpaces = closestA.Col + s.colPosShift() - a.Col - 1 + closestA.lines[rowBefore-closestA.row].leadingSpaces = closestA.pipeColIdx + s.colPosShift() - a.pipeColIdx - 1 case noAnnot, trailingSpaceLines: // Do nothing } @@ -195,11 +212,11 @@ func checkLineAndSetSpace(row, aLineIdx int, a *Annot, rightAnnots []*Annot) boo // 3 for "└─ " or " " (indentation) lineLength := 3 + a.lines[aLineIdx].length - remainingSpaces := closestA.Col + s.colPosShift() - a.Col - lineLength + remainingSpaces := closestA.pipeColIdx + s.colPosShift() - a.pipeColIdx - lineLength if remainingSpaces-s.space() < 0 { a.row++ - a.pipeLeadingSpaces = append(a.pipeLeadingSpaces, a.Col) + a.pipeLeadingSpaces = append(a.pipeLeadingSpaces, a.pipeColIdx) return false } @@ -213,7 +230,7 @@ func checkLineAndSetSpace(row, aLineIdx int, a *Annot, rightAnnots []*Annot) boo if s2 == noAnnot { return true } - leadingSpaces2 := closestA2.Col + s2.colPosShift() - a.Col - lineLength + leadingSpaces2 := closestA2.pipeColIdx + s2.colPosShift() - a.pipeColIdx - lineLength if s2 == above { closestA2.pipeLeadingSpaces[rowPlusLineIdx] = leadingSpaces2 break @@ -248,19 +265,15 @@ func closestAnnot(row int, rightAnnots []*Annot, trailingVerticalSpaceLinesCount } func write(writer io.Writer, annots []*Annot) error { - lastColPos := 0 rowCount := 0 + for _, a := range annots { + rowCount = max(rowCount, a.row+len(a.lines)) + } b := &strings.Builder{} - for _, a := range annots { - b.WriteString(strings.Repeat(" ", a.Col-lastColPos)) - b.WriteString("↑") - lastColPos = a.Col + 1 + b.WriteString(arrowOrRangeString(annots)) - // Also use this loop to calculate rowCount - rowCount = max(rowCount, a.row+len(a.lines)) - } b.WriteString("\n") _, err := fmt.Fprint(writer, b.String()) if err != nil { @@ -295,3 +308,31 @@ func write(writer io.Writer, annots []*Annot) error { return nil } + +func arrowOrRangeString(annots []*Annot) string { + widthWritten := 0 + + b := &strings.Builder{} + + for _, a := range annots { + if a.ColEnd == 0 { + b.WriteString(strings.Repeat(" ", a.pipeColIdx-widthWritten)) + b.WriteString("↑") + widthWritten = a.pipeColIdx + 1 + continue + } + + b.WriteString(strings.Repeat(" ", a.Col-widthWritten)) + if a.Col == a.pipeColIdx { + b.WriteString("├") + } else { + b.WriteString("└") + b.WriteString(strings.Repeat("─", a.pipeColIdx-a.Col-1)) + b.WriteString("┬") + } + b.WriteString(strings.Repeat("─", a.ColEnd-a.pipeColIdx-1)) + b.WriteString("┘") + widthWritten = a.ColEnd + 1 + } + return b.String() +} diff --git a/annot_test.go b/annot_test.go index 8a89e26..0f68318 100644 --- a/annot_test.go +++ b/annot_test.go @@ -2,6 +2,7 @@ package annot import ( "bytes" + "errors" "testing" ) @@ -10,7 +11,7 @@ func TestWrite(t *testing.T) { name string annots []*Annot wantW string - wantErr bool + wantErr error }{ { name: "utilise every space to the limit", @@ -30,7 +31,6 @@ func TestWrite(t *testing.T) { 5000000 6000000000000 `, - wantErr: false, }, { name: "empty annotation", @@ -41,7 +41,6 @@ func TestWrite(t *testing.T) { ↑ └─ `, - wantErr: false, }, { name: "two empty annotations", @@ -54,7 +53,6 @@ func TestWrite(t *testing.T) { │└─ └─ `, - wantErr: false, }, { name: "annotation without a column", @@ -65,7 +63,6 @@ func TestWrite(t *testing.T) { ↑ └─ line1 `, - wantErr: false, }, { name: "annotation without a line", @@ -76,7 +73,6 @@ func TestWrite(t *testing.T) { ↑ └─ `, - wantErr: false, }, { name: "annotation with one line", @@ -87,7 +83,6 @@ func TestWrite(t *testing.T) { ↑ └─ line1 `, - wantErr: false, }, { name: "annotation with five lines", @@ -102,7 +97,6 @@ func TestWrite(t *testing.T) { line4 line5 `, - wantErr: false, }, { name: "utf8, japanese characters and emojis", @@ -115,7 +109,6 @@ func TestWrite(t *testing.T) { └─ ⭐️漢 └─ æñŶǼNJ æñ🥏NJ 字ñŶǼNJ `, - wantErr: false, }, { name: "next to each other with enough distance and second annotation has more lines", @@ -130,7 +123,6 @@ func TestWrite(t *testing.T) { line3 line4 `, - wantErr: false, }, { name: "next to each other with enough distance and first annotation has more lines", @@ -145,7 +137,6 @@ func TestWrite(t *testing.T) { line3 line4 `, - wantErr: false, }, { name: "annots are close", @@ -161,7 +152,6 @@ func TestWrite(t *testing.T) { └─ line1 line2 `, - wantErr: false, }, { name: "annots have correct vertical distance", @@ -181,7 +171,6 @@ func TestWrite(t *testing.T) { └─ line1 line2 `, - wantErr: false, }, { name: "first annotation is at the height of the pipes of the second annotation", @@ -198,7 +187,6 @@ func TestWrite(t *testing.T) { └─ line1 line2 `, - wantErr: false, }, { name: "correct vertical and horizontal distance", @@ -215,7 +203,6 @@ func TestWrite(t *testing.T) { └─ line1 line2 `, - wantErr: false, }, { name: "correct horizontal distance", @@ -228,7 +215,6 @@ func TestWrite(t *testing.T) { └─ line1 └─ line1 line2 line2 `, - wantErr: false, }, { name: "allow one space more at the edge", @@ -243,7 +229,6 @@ func TestWrite(t *testing.T) { └─ line1 line2 `, - wantErr: false, }, { name: "allow one space more at lines after the second line", @@ -258,7 +243,6 @@ func TestWrite(t *testing.T) { └─ line1 line3 line2 line4 `, - wantErr: false, }, { name: "long line of first annotation uses available space", @@ -274,7 +258,6 @@ func TestWrite(t *testing.T) { line2 line3long `, - wantErr: false, }, { name: "last annotation shares row with first annotation", @@ -288,7 +271,6 @@ func TestWrite(t *testing.T) { └─ line1 └─ line1 └─ line1 line2 line2 `, - wantErr: false, }, { name: "complex annotation arrangement", @@ -311,7 +293,6 @@ func TestWrite(t *testing.T) { line7 line2 line8verylongverylongverylong line3 `, - wantErr: false, }, { name: "last annotation shares row with first annotation and first annotation uses available space", @@ -328,7 +309,6 @@ func TestWrite(t *testing.T) { line2 line3verylongverylong `, - wantErr: false, }, { name: "first annotation uses indentation space of second annotation", @@ -342,7 +322,87 @@ func TestWrite(t *testing.T) { │ line2 └─ line1 line3 `, - wantErr: false, + }, + { + name: "one ranged annotation", + annots: []*Annot{ + {Col: 0, ColEnd: 2}, + }, + wantW: ` +└┬┘ + └─ +`, + }, + { + name: "one narrow ranged annotation", + annots: []*Annot{ + {Col: 0, ColEnd: 1}, + }, + wantW: ` +├┘ +└─ +`, + }, + { + name: "two ranged annotation", + annots: []*Annot{ + {Col: 0, ColEnd: 2}, + {Col: 6, ColEnd: 10}, + }, + wantW: ` +└┬┘ └─┬─┘ + └─ └─ +`, + }, + { + name: "mix ranged and arrowed annotation", + annots: []*Annot{ + {Col: 0, ColEnd: 2}, + {Col: 4}, + {Col: 5}, + {Col: 7, ColEnd: 11}, + {Col: 13}, + {Col: 14}, + }, + wantW: ` +└┬┘ ↑↑ └─┬─┘ ↑↑ + │ ││ │ │└─ + │ ││ │ └─ + │ ││ └─ + │ │└─ + │ └─ + └─ +`, + }, + { + name: "overlapping annotation", + annots: []*Annot{ + {Col: 0, ColEnd: 1}, + {Col: 1, ColEnd: 2}, + }, + wantErr: &OverlapError{}, + }, + { + name: "col is equal to col end", + annots: []*Annot{ + {Col: 1, ColEnd: 1}, + }, + wantErr: &ColExceedsColEndError{}, + }, + { + name: "col is higher than col end", + annots: []*Annot{ + {Col: 2, ColEnd: 1}, + }, + wantErr: &ColExceedsColEndError{}, + }, + { + name: "col exceeds col end error occurs before overlapping error", + annots: []*Annot{ + {Col: 1, ColEnd: 1}, + {Col: 1, ColEnd: 2}, + }, + wantErr: &ColExceedsColEndError{}, }, { name: "remove second annotation with same column position", @@ -354,15 +414,16 @@ func TestWrite(t *testing.T) { ↑ └─ line1 `, - wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} err := Write(w, tt.annots...) - if (err != nil) != tt.wantErr { - t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr != nil { + if !errors.Is(tt.wantErr, err) { + t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) + } return } if gotW := "\n" + w.String(); gotW != tt.wantW { diff --git a/error.go b/error.go new file mode 100644 index 0000000..ffe6cf6 --- /dev/null +++ b/error.go @@ -0,0 +1,42 @@ +package annot + +import ( + "errors" + "fmt" +) + +type OverlapError struct { + colEnd, firstAnnotPos, secondAnnotCol int +} + +func newOverlapError(colEnd, firstAnnotPos, secondAnnotCol int) *OverlapError { + return &OverlapError{colEnd, firstAnnotPos, secondAnnotCol} +} + +func (e *OverlapError) Error() string { + return fmt.Sprintf("annot: ColEnd %d of %d. annotation overlaps with Col %d of %d. annotation", + e.colEnd, e.firstAnnotPos, e.secondAnnotCol, e.firstAnnotPos+1) +} + +func (e *OverlapError) Is(target error) bool { + var overlapError *OverlapError + return errors.As(target, &overlapError) +} + +type ColExceedsColEndError struct { + annotPos, col, colEnd int +} + +func newColExceedsColEndError(annotPos, col, colEnd int) *ColExceedsColEndError { + return &ColExceedsColEndError{annotPos, col, colEnd} +} + +func (e *ColExceedsColEndError) Error() string { + return fmt.Sprintf("annot: in %d. annotation Col %d needs to be lower than ColEnd %d", + e.annotPos, e.col, e.colEnd) +} + +func (e *ColExceedsColEndError) Is(target error) bool { + var overlapError *ColExceedsColEndError + return errors.As(target, &overlapError) +} diff --git a/example_test.go b/example_test.go index 98fd000..28d0f06 100644 --- a/example_test.go +++ b/example_test.go @@ -7,16 +7,25 @@ import ( ) func Example() { - fmt.Println("The quick brown fox jumps over the lazy dog") + fmt.Println("The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge.") fmt.Println(annot.String( - &annot.Annot{Col: 6, Lines: []string{"adjective"}}, - &annot.Annot{Col: 12, Lines: []string{"adjective"}}, - &annot.Annot{Col: 36, Lines: []string{"adjective"}}, + &annot.Annot{Col: 1, Lines: []string{"article"}}, + &annot.Annot{Col: 4, ColEnd: 11, Lines: []string{"adjective"}}, + &annot.Annot{Col: 22, ColEnd: 30, Lines: []string{ + "facts, information, and skills acquired", + "through experience or education;", + "the theoretical or practical understanding", + "of a subject.", + }}, + &annot.Annot{Col: 48, Lines: []string{"comma"}}, )) // Output: - // The quick brown fox jumps over the lazy dog - // ↑ ↑ ↑ - // │ └─ adjective └─ adjective - // │ - // └─ adjective + // The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge. + // ↑ └──┬───┘ └───┬───┘ ↑ + // │ └─ adjective │ └─ comma + // │ │ + // └─ article └─ facts, information, and skills acquired + // through experience or education; + // the theoretical or practical understanding + // of a subject. }