├── .gitignore ├── UNLICENSE ├── ast.go ├── cmd └── gmip │ └── main.go ├── const.go ├── dprint └── dprint.go ├── go.mod ├── parser.go ├── parser_test.go └── toc.go /.gitignore: -------------------------------------------------------------------------------- 1 | /cmd/gmip/gmip 2 | /gmip 3 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /ast.go: -------------------------------------------------------------------------------- 1 | package gmi 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | _ Line = (*TextLine)(nil) 10 | _ Line = (*LinkLine)(nil) 11 | _ Line = (*PreformatToggleLine)(nil) 12 | _ Line = (*PreformatLine)(nil) 13 | _ Line = (*HeadingLine)(nil) 14 | _ Line = (*UnorderedListLine)(nil) 15 | _ Line = (*QuoteLine)(nil) 16 | ) 17 | 18 | // Line represents a Line in text/gemini in a logical way 19 | type Line interface { 20 | CoreType() LineType // CoreType returns the core type of the line (returns Text Type for advanced line types) 21 | Type() LineType // Type returns the true type of the line 22 | Level() int // Level returns the level of the heading, if it's a HeadingType, or 0 otherwise 23 | 24 | Data() string // Data returns the content data of the line 25 | Meta() string // Meta returns the secondary data of the line (e.g the link for link lines), if any 26 | Prefix() string // Prefix returns the documented prefix to the line, without whitespace 27 | 28 | String() string // String (Stringer) implements a post-formatted representation of the original line, including prefix 29 | } 30 | 31 | // ---- types 32 | 33 | // TextLine represents a text text/gemini line 34 | type TextLine string 35 | 36 | // LinkLine represents a link text/gemini line 37 | type LinkLine struct { 38 | link string 39 | name string 40 | } 41 | 42 | // PreformatToggleLine represents a "```" toggle in text/gemini 43 | type PreformatToggleLine string 44 | 45 | // PreformatLine represents a preformatted text/gemini line 46 | type PreformatLine string 47 | 48 | // HeadingLine represents a heading text/gemini line 49 | type HeadingLine struct { 50 | contents string 51 | level int 52 | } 53 | 54 | // UnorderedListLine represents an unordered list entry in text/gemini 55 | type UnorderedListLine string 56 | 57 | // QuoteLine represents a quoted text/gemini line 58 | type QuoteLine string 59 | 60 | // revive:disable:exported 61 | // implementations are already documented in the interface 62 | 63 | // ---- text line 64 | 65 | func (r TextLine) CoreType() LineType { return TextType } 66 | func (r TextLine) Type() LineType { return TextType } 67 | func (r TextLine) Level() int { return 0 } 68 | func (r TextLine) Data() string { return string(r) } 69 | func (r TextLine) Meta() string { return "" } 70 | func (r TextLine) Prefix() string { return "" } 71 | func (r TextLine) String() string { return string(r) } 72 | 73 | // ---- link line 74 | 75 | func (r LinkLine) CoreType() LineType { return LinkType } 76 | func (r LinkLine) Type() LineType { return LinkType } 77 | func (r LinkLine) Level() int { return 0 } 78 | func (r LinkLine) Data() string { return r.link } 79 | func (r LinkLine) Meta() string { return r.name } 80 | func (r LinkLine) Prefix() string { return "=>" } 81 | func (r LinkLine) String() string { 82 | if len(r.name) > 0 { 83 | return fmt.Sprintf("%s %s %s", r.Prefix(), r.link, r.name) 84 | } 85 | return fmt.Sprintf("%s %s", r.Prefix(), r.link) 86 | } 87 | 88 | // ---- preformat toggle line 89 | 90 | func (r PreformatToggleLine) CoreType() LineType { return PreformatToggleType } 91 | func (r PreformatToggleLine) Type() LineType { return PreformatToggleType } 92 | func (r PreformatToggleLine) Level() int { return 0 } 93 | func (r PreformatToggleLine) Data() string { return string(r) } 94 | func (r PreformatToggleLine) Meta() string { return "" } 95 | func (r PreformatToggleLine) Prefix() string { return "```" } 96 | func (r PreformatToggleLine) String() string { return fmt.Sprintf("%s%s", r.Prefix(), r.Meta()) } 97 | 98 | // ---- preformat line 99 | 100 | func (r PreformatLine) CoreType() LineType { return PreformatType } 101 | func (r PreformatLine) Type() LineType { return PreformatType } 102 | func (r PreformatLine) Level() int { return 0 } 103 | func (r PreformatLine) Data() string { return string(r) } 104 | func (r PreformatLine) Meta() string { return "" } 105 | func (r PreformatLine) Prefix() string { return "" } 106 | func (r PreformatLine) String() string { return string(r) } 107 | 108 | // ---- heading line 109 | 110 | func (r HeadingLine) CoreType() LineType { return TextType } 111 | func (r HeadingLine) Type() LineType { return HeadingType } 112 | func (r HeadingLine) Level() int { return r.level } 113 | func (r HeadingLine) Data() string { return r.contents } 114 | func (r HeadingLine) Meta() string { return "" } 115 | func (r HeadingLine) Prefix() string { return strings.Repeat("#", r.level) } 116 | func (r HeadingLine) String() string { return fmt.Sprintf("%s %s", r.Prefix(), r.Data()) } 117 | 118 | // ---- unordered list line 119 | 120 | func (r UnorderedListLine) CoreType() LineType { return TextType } 121 | func (r UnorderedListLine) Type() LineType { return UnorderedListType } 122 | func (r UnorderedListLine) Level() int { return 0 } 123 | func (r UnorderedListLine) Data() string { return string(r) } 124 | func (r UnorderedListLine) Meta() string { return "" } 125 | func (r UnorderedListLine) Prefix() string { return "*" } // mostly present for CoreType consumers, use bullet points instead 126 | func (r UnorderedListLine) String() string { return fmt.Sprintf("%s %s", r.Prefix(), r.Data()) } 127 | 128 | // ---- quote line 129 | 130 | func (r QuoteLine) CoreType() LineType { return TextType } 131 | func (r QuoteLine) Type() LineType { return QuoteType } 132 | func (r QuoteLine) Level() int { return 0 } 133 | func (r QuoteLine) Data() string { return string(r) } 134 | func (r QuoteLine) Meta() string { return "" } 135 | func (r QuoteLine) Prefix() string { return ">" } 136 | func (r QuoteLine) String() string { return fmt.Sprintf("%s %s", r.Prefix(), r.Data()) } 137 | -------------------------------------------------------------------------------- /cmd/gmip/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "log" 7 | "os" 8 | 9 | "toast.cafe/x/gmi/dprint" 10 | "toast.cafe/x/gmi/html" 11 | ) 12 | 13 | var s struct { 14 | input string 15 | mode string 16 | output string 17 | 18 | in io.Reader 19 | out io.Writer 20 | } 21 | 22 | func main() { 23 | flag.StringVar(&s.mode, "m", "pretty", "output mode (pretty|debug)") 24 | flag.StringVar(&s.output, "o", "", "output file (default: stdout)") 25 | flag.Parse() 26 | s.input = flag.Arg(0) 27 | 28 | var err error 29 | if s.input == "" { 30 | s.in = os.Stdin 31 | } else { 32 | s.in, err = os.Open(s.input) 33 | if err != nil { 34 | log.Fatalf("could not open file: %s", s.input) 35 | } 36 | } 37 | if s.output == "" { 38 | s.out = os.Stdout 39 | } else { 40 | s.out, err = os.Open(s.output) 41 | if err != nil { 42 | log.Fatalf("could not open file: %s", s.output) 43 | } 44 | } 45 | 46 | switch s.mode { 47 | case "pretty": 48 | log.Fatalf("mode %s is not yet implemented", s.mode) 49 | case "debug": 50 | dprint.PrintReader(s.in, s.out) 51 | default: 52 | log.Fatalf("Unknown mode: %s", s.mode) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /const.go: -------------------------------------------------------------------------------- 1 | package gmi 2 | 3 | // LineType represents a text/gemini line type 4 | type LineType int 5 | 6 | // Line Types 7 | const ( 8 | TextType LineType = iota 9 | LinkType 10 | PreformatToggleType 11 | PreformatType 12 | HeadingType 13 | UnorderedListType 14 | QuoteType 15 | ) 16 | -------------------------------------------------------------------------------- /dprint/dprint.go: -------------------------------------------------------------------------------- 1 | package dprint 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | 8 | "toast.cafe/x/gmi" 9 | ) 10 | 11 | // Print will write an AST debug format to the given output writer 12 | func Print(p *gmi.Parser, o io.Writer) { 13 | w := bufio.NewWriter(o) 14 | //toc 15 | toc := p.TOC(false) 16 | for _, v := range toc { 17 | fmt.Fprintf(w, "%s\t%s\n", v.Numbered(), v.H) 18 | } 19 | 20 | fmt.Fprintln(w) 21 | 22 | // content 23 | var pft bool 24 | for _, v := range p.Lines { 25 | switch v.Type() { 26 | case gmi.TextType: 27 | fmt.Fprintf(w, "TEXT: %s\n", v.Data()) 28 | case gmi.LinkType: 29 | fmt.Fprintf(w, "LINK: %s (%s)\n", v.Meta(), v.Data()) 30 | case gmi.PreformatToggleType: 31 | fmt.Fprintf(w, "PFTT: %s", v.Data()) 32 | if pft { 33 | fmt.Fprint(w, "; PFT:") 34 | } 35 | fmt.Fprint(w, "\n") 36 | pft = !pft 37 | case gmi.PreformatType: 38 | if !pft { // should never happen, debug case 39 | fmt.Fprintf(w, "PFT: ") 40 | } else { 41 | fmt.Fprintf(w, "\t") 42 | } 43 | fmt.Fprintf(w, "%s\n", v.Data()) 44 | case gmi.HeadingType: 45 | fmt.Fprintf(w, "H%d: %s\n", v.Level(), v.Data()) 46 | case gmi.UnorderedListType: 47 | fmt.Fprintf(w, "LIST: %s\n", v.Data()) 48 | case gmi.QuoteType: 49 | fmt.Fprintf(w, "QUOT: %s\n", v.Data()) 50 | } 51 | } 52 | w.Flush() 53 | } 54 | 55 | // PrintReader will use the reader to parse the document and then invoke Print on the resulting AST 56 | func PrintReader(r io.Reader, o io.Writer) error { 57 | p := gmi.NewParser(r) 58 | e := p.Parse() 59 | if e != nil { 60 | return e 61 | } 62 | Print(p, o) 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module toast.cafe/x/gmi 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package gmi 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | // Parser handles parsing of a text/gemini document 11 | type Parser struct { 12 | s *bufio.Scanner 13 | Lines []Line 14 | toc []*Heading // see toc.go 15 | pft bool 16 | } 17 | 18 | // NewParser instantiates a new parser from a reader 19 | func NewParser(r io.Reader) *Parser { 20 | return &Parser{bufio.NewScanner(r), nil, nil, false} 21 | } 22 | 23 | // Parse will parse the provided input until termination 24 | // The returned error will be non-nil only if a significant error occured 25 | func (p *Parser) Parse() error { 26 | for p.s.Scan() { 27 | p.Lines = append(p.Lines, p.parseLine(p.s.Text())) 28 | } 29 | return p.s.Err() 30 | } 31 | 32 | func get(s string, i int) (r rune) { 33 | if i >= len(s) { 34 | return 35 | } 36 | for ii, c := range s { 37 | if i == ii { 38 | return c 39 | } 40 | } 41 | return // should never happen 42 | } 43 | 44 | func (p *Parser) parseLine(l string) Line { 45 | a, b, c := get(l, 0), get(l, 1), get(l, 2) 46 | 47 | // preformatted mode 48 | if p.pft { 49 | if a == '`' && b == '`' && c == '`' { 50 | p.pft = false 51 | var s string 52 | return (*PreformatToggleLine)(&s) 53 | } 54 | return (*PreformatLine)(&l) // the only time trailing whitespace is not stripped, though line endings still are 55 | } 56 | 57 | // normal mode 58 | switch a { 59 | case '=': 60 | if b == '>' { 61 | return parseLink(l) 62 | } 63 | case '`': 64 | if b == '`' && c == '`' { 65 | p.pft = true 66 | s := strings.TrimPrefix(l, "```") 67 | s = strings.TrimSpace(s) 68 | return (*PreformatToggleLine)(&s) 69 | } 70 | case '#': 71 | return parseHeading(l) 72 | case '*': 73 | if b == ' ' { 74 | return parseUList(l) 75 | } 76 | case '>': 77 | return parseQuote(l) 78 | } 79 | return (*TextLine)(&l) 80 | } 81 | 82 | // whitespace is removed from the end of the line too 83 | // because the spec doesn't really say what the line endings are meant to be for text/gemini itself 84 | // and whitespace at the end of a line is basically meaningless anyway in the presence of empty text lines 85 | 86 | func parseLink(l string) Line { 87 | l = strings.TrimPrefix(l, "=>") 88 | l = strings.TrimSpace(l) 89 | 90 | // is there whitespace in what's left? 91 | w := strings.IndexFunc(l, unicode.IsSpace) 92 | if w == -1 { 93 | return &LinkLine{l, ""} 94 | } 95 | return &LinkLine{ 96 | l[:w], 97 | strings.TrimLeftFunc(l[w:], unicode.IsSpace), 98 | } 99 | } 100 | 101 | func parseHeading(l string) Line { 102 | s := len(l) 103 | if len(l) > 3 { 104 | s = 3 105 | } 106 | level := strings.Count(l[:s], "#") 107 | l = l[level:] 108 | l = strings.TrimSpace(l) 109 | return &HeadingLine{l, level} 110 | } 111 | 112 | func parseUList(l string) Line { 113 | l = strings.TrimPrefix(l, "*") 114 | l = strings.TrimSpace(l) 115 | return (*UnorderedListLine)(&l) 116 | } 117 | 118 | func parseQuote(l string) Line { 119 | l = strings.TrimPrefix(l, ">") 120 | l = strings.TrimSpace(l) 121 | return (*QuoteLine)(&l) 122 | } 123 | 124 | // For TOC stuff, see toc.go 125 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package gmi 2 | 3 | import "testing" 4 | 5 | func parseSingleLine(s string) Line { 6 | p := NewParser(nil) 7 | return p.parseLine(s) 8 | } 9 | 10 | func equalLines(l1, l2 Line) bool { 11 | return l1.Type() == l2.Type() && 12 | l1.Level() == l2.Level() && 13 | l1.Link() == l2.Link() && 14 | l1.Prefix() == l2.Prefix() && 15 | l1.String() == l2.String() 16 | } 17 | 18 | func TestEmpty(t *testing.T) { 19 | var expect = []struct { 20 | in string 21 | out Line 22 | }{ 23 | {"", TextLine("")}, 24 | {"=>", LinkLine{"", ""}}, 25 | {"=> ", LinkLine{"", ""}}, 26 | {"#", HeadingLine{"", 1}}, 27 | {"##", HeadingLine{"", 2}}, 28 | {"###", HeadingLine{"", 3}}, 29 | {"# ", HeadingLine{"", 1}}, 30 | {"## ", HeadingLine{"", 2}}, 31 | {"### ", HeadingLine{"", 3}}, 32 | {"*", TextLine("*")}, 33 | {"* ", UnorderedListLine("")}, 34 | {">", QuoteLine("")}, 35 | {"> ", QuoteLine("")}, 36 | } 37 | 38 | for _, v := range expect { 39 | out := parseSingleLine(v.in) 40 | if !equalLines(out, v.out) { 41 | t.Errorf("unexpected line for %s", v.in) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /toc.go: -------------------------------------------------------------------------------- 1 | package gmi 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const maxHeading = 3 9 | 10 | // Heading represents a Heading in a TOC context, alongside its associated 3 levels within the overall structure 11 | type Heading struct { 12 | HL [maxHeading]int // current section number, extendable in case we get >3 headings 13 | // for example, {1, 3, 0} is # followed by 3 ##s 14 | H *HeadingLine 15 | } 16 | 17 | // are all entries at index and forward 0? 18 | func followingZero(i [maxHeading]int, index int) bool { 19 | for l := len(i); index < l; index++ { 20 | if i[index] != 0 { 21 | return false 22 | } 23 | index++ 24 | } 25 | return true 26 | } 27 | 28 | // Numbered returns a pretty-printed string representing the numbering component of the heading in the document 29 | func (r Heading) Numbered() string { 30 | var b strings.Builder 31 | fmt.Fprintf(&b, "%d.", r.HL[0]) // always print the first one 32 | for index := 1; index <= maxHeading; index++ { // start at the second one 33 | if followingZero(r.HL, index) { 34 | return b.String() 35 | } 36 | fmt.Fprintf(&b, "%d.", r.HL[index]) 37 | } 38 | return b.String() 39 | } 40 | 41 | func (r Heading) String() string { 42 | return fmt.Sprintf("%s %s", r.Numbered(), r.H.Data()) 43 | } 44 | 45 | // TOC returns the calculated Table of Contents 46 | // The returned structure is the list of just the headings of the parsed output. 47 | // Force forces the recalculation of the TOC if it was already calculated, in case you changed the underlying structure. 48 | func (p *Parser) TOC(force bool) []*Heading { 49 | if p.toc == nil || force { 50 | p.calcTOC() 51 | } 52 | return p.toc 53 | } 54 | 55 | func (p *Parser) calcTOC() { 56 | p.toc = nil // in case of force 57 | var hl [maxHeading]int 58 | for _, v := range p.Lines { 59 | l := v.Level() 60 | if l < 1 || l > maxHeading { // skip invalid 61 | continue 62 | } 63 | 64 | // 1 -> 0++, 1 = 0, 2 = 0 65 | // 2 -> 1++, 2 = 0 66 | // 3 -> 2++ 67 | hl[l-1]++ 68 | for l < maxHeading { 69 | hl[l] = 0 70 | l++ 71 | } 72 | 73 | h := Heading{hl, v.(*HeadingLine)} 74 | p.toc = append(p.toc, &h) 75 | } 76 | } 77 | 78 | // Title will calculate the title from the TOC (without forcing recalculation) and return one is one is found 79 | // Title is considered to be whatever the first '#' heading is 80 | func (p *Parser) Title() string { 81 | toc := p.TOC(false) // don't force calculation 82 | for _, v := range toc { 83 | if v.HL[0] == 1 { // we can't skip 1 since we calculate it 84 | return v.H.Data() 85 | } 86 | } 87 | return "" 88 | } 89 | --------------------------------------------------------------------------------