# See https://www.rfc-editor.org/rfc/rfc1524
import std/osproc
import std/streams
import std/strutils
import types/url
import types/opt
import utils/twtstr
type
MailcapParser = object
stream: Stream
hasbuf: bool
buf: char
line: int
MailcapFlags* = enum
NEEDSTERMINAL = "needsterminal"
COPIOUSOUTPUT = "copiousoutput"
HTMLOUTPUT = "x-htmloutput" # from w3m
ANSIOUTPUT = "x-ansioutput" # Chawan extension
MailcapEntry* = object
mt*: string
subt*: string
cmd*: string
flags*: set[MailcapFlags]
nametemplate*: string
edit*: string
test*: string
Mailcap* = seq[MailcapEntry]
proc has(state: MailcapParser): bool {.inline.} =
return not state.stream.atEnd
proc consume(state: var MailcapParser): char =
if state.hasbuf:
state.hasbuf = false
return state.buf
var c = state.stream.readChar()
if c == '\\' and not state.stream.atEnd:
let c2 = state.stream.readChar()
if c2 == '\n' and not state.stream.atEnd:
inc state.line
c = state.stream.readChar()
if c == '\n':
inc state.line
return c
proc reconsume(state: var MailcapParser; c: char) =
state.buf = c
state.hasbuf = true
proc skipBlanks(state: var MailcapParser; c: var char): bool =
while state.has():
c = state.consume()
if c notin AsciiWhitespace - {'\n'}:
return true
proc skipBlanks(state: var MailcapParser) =
var c: char
if state.skipBlanks(c):
state.reconsume(c)
proc skipLine(state: var MailcapParser) =
while state.has():
let c = state.consume()
if c == '\n':
break
proc consumeTypeField(state: var MailcapParser): Result[string, string] =
var s = ""
# type
while state.has():
let c = state.consume()
if c == '/':
s &= c
break
if c notin AsciiAlphaNumeric + {'-', '*'}:
return err("line " & $state.line & ": invalid character in type field: " &
c)
s &= c.toLowerAscii()
if not state.has():
return err("Missing subtype")
# subtype
while state.has():
let c = state.consume()
if c in AsciiWhitespace + {';'}:
state.reconsume(c)
break
if c notin AsciiAlphaNumeric + {'-', '.', '*', '_', '+'}:
return err("line " & $state.line &
": invalid character in subtype field: " & c)
s &= c.toLowerAscii()
var c: char
if not state.skipBlanks(c) or c != ';':
return err("Semicolon not found")
return ok(s)
proc consumeCommand(state: var MailcapParser): Result[string, string] =
state.skipBlanks()
var quoted = false
var s = ""
while state.has():
let c = state.consume()
if not quoted:
if c == '\r':
continue
if c == ';' or c == '\n':
state.reconsume(c)
return ok(s)
if c == '\\':
quoted = true
continue
if c notin Ascii - Controls:
return err("line " & $state.line & ": invalid character in command: " &
c)
else:
quoted = false
s &= c
return ok(s)
type NamedField = enum
NO_NAMED_FIELD, NAMED_FIELD_TEST, NAMED_FIELD_NAMETEMPLATE, NAMED_FIELD_EDIT
proc parseFieldKey(entry: var MailcapEntry; k: string): NamedField =
case k
of "needsterminal":
entry.flags.incl(NEEDSTERMINAL)
of "copiousoutput":
entry.flags.incl(COPIOUSOUTPUT)
of "x-htmloutput":
entry.flags.incl(HTMLOUTPUT)
of "x-ansioutput":
entry.flags.incl(ANSIOUTPUT)
of "test":
return NAMED_FIELD_TEST
of "nametemplate":
return NAMED_FIELD_NAMETEMPLATE
of "edit":
return NAMED_FIELD_EDIT
return NO_NAMED_FIELD
proc consumeField(state: var MailcapParser; entry: var MailcapEntry):
Result[bool, string] =
state.skipBlanks()
if not state.has():
return ok(false)
var buf = ""
while state.has():
let c = state.consume()
case c
of ';', '\n':
if parseFieldKey(entry, buf) != NO_NAMED_FIELD:
return err("Expected command")
return ok(c == ';')
of '\r':
continue
of '=':
let f = parseFieldKey(entry, buf)
let cmd = ?state.consumeCommand()
case f
of NO_NAMED_FIELD:
discard
of NAMED_FIELD_TEST:
entry.test = cmd
of NAMED_FIELD_NAMETEMPLATE:
entry.nametemplate = cmd
of NAMED_FIELD_EDIT:
entry.edit = cmd
return ok(state.consume() == ';')
else:
if c in Controls:
return err("line " & $state.line & ": invalid character in field: " & c)
buf &= c
proc parseMailcap*(stream: Stream): Result[Mailcap, string] =
var state = MailcapParser(stream: stream, line: 1)
var mailcap: Mailcap
while not stream.atEnd():
let c = state.consume()
if c == '#':
state.skipLine()
continue
state.reconsume(c)
state.skipBlanks()
let c2 = state.consume()
if c2 == '\n' or c2 == '\r':
continue
state.reconsume(c2)
let t = ?state.consumeTypeField()
let mt = t.until('/') #TODO this could be more efficient
let subt = t[mt.len + 1 .. ^1]
var entry = MailcapEntry(
mt: mt,
subt: subt,
cmd: ?state.consumeCommand()
)
if state.consume() == ';':
while ?state.consumeField(entry):
discard
mailcap.add(entry)
return ok(mailcap)
# Mostly based on w3m's mailcap quote/unquote
type UnquoteState = enum
usNormal, usQuoted, usPerc, usAttr, usAttrQuoted, usDollar
type UnquoteResult* = object
canpipe*: bool
cmd*: string
type QuoteState* = enum
qsNormal, qsDoubleQuoted, qsSingleQuoted
proc quoteFile*(file: string; qs: QuoteState): string =
var s = ""
for c in file:
case c
of '$', '`', '"', '\\':
if qs != qsSingleQuoted:
s &= '\\'
of '\'':
if qs == qsSingleQuoted:
s &= "'\\'" # then re-open the quote by appending c
elif qs == qsNormal:
s &= '\\'
# double-quoted: append normally
of AsciiAlphaNumeric, '_', '.', ':', '/':
discard # no need to quote
elif qs == qsNormal:
s &= '\\'
s &= c
return s
proc unquoteCommand*(ecmd, contentType, outpath: string; url: URL;
canpipe: var bool; line = -1): string =
var cmd = ""
var attrname = ""
var state: UnquoteState
var qss = @[qsNormal] # quote state stack. len >1
template qs: var QuoteState = qss[^1]
for c in ecmd:
case state
of usQuoted:
cmd &= c
state = usNormal
of usAttrQuoted:
attrname &= c.toLowerAscii()
state = usAttr
of usNormal, usDollar:
let prev_dollar = state == usDollar
state = usNormal
case c
of '%':
state = usPerc
of '\\':
state = usQuoted
of '\'':
if qs == qsSingleQuoted:
qs = qsNormal
else:
qs = qsSingleQuoted
cmd &= c
of '"':
if qs == qsDoubleQuoted:
qs = qsNormal
else:
qs = qsDoubleQuoted
cmd &= c
of '$':
if qs != qsSingleQuoted:
state = usDollar
cmd &= c
of '(':
if prev_dollar:
qss.add(qsNormal)
cmd &= c
of ')':
if qs != qsSingleQuoted:
if qss.len > 1:
qss.setLen(qss.len - 1)
else:
# mismatched parens; probably an invalid shell command...
qss[0] = qsNormal
cmd &= c
else:
cmd &= c
of usPerc:
case c
of '%': cmd &= c
of 's':
cmd &= quoteFile(outpath, qs)
canpipe = false
of 't':
cmd &= quoteFile(contentType.until(';'), qs)
of 'u': # Netscape extension
if url != nil: # nil in getEditorCommand
cmd &= quoteFile($url, qs)
of 'd': # line; not used in mailcap, only in getEditorCommand
if line != -1: # -1 in mailcap
cmd &= $line
of '{':
state = usAttr
continue
else: discard
state = usNormal
of usAttr:
if c == '}':
let s = contentType.getContentTypeAttr(attrname)
cmd &= quoteFile(s, qs)
attrname = ""
state = usNormal
elif c == '\\':
state = usAttrQuoted
else:
attrname &= c
return cmd
proc unquoteCommand*(ecmd, contentType, outpath: string; url: URL): string =
var canpipe: bool
return unquoteCommand(ecmd, contentType, outpath, url, canpipe)
proc getMailcapEntry*(mailcap: Mailcap; contentType, outpath: string; url: URL):
ptr MailcapEntry =
let mt = contentType.until('/')
if mt.len + 1 >= contentType.len:
return nil
let st = contentType.until(AsciiWhitespace + {';'}, mt.len + 1)
for entry in mailcap:
if not (entry.mt.len == 1 and entry.mt[0] == '*') and entry.mt != mt:
continue
if not (entry.subt.len == 1 and entry.subt[0] == '*') and entry.subt != st:
continue
if entry.test != "":
var canpipe = true
let cmd = unquoteCommand(entry.test, contentType, outpath, url, canpipe)
if not canpipe:
continue
if execCmd(cmd) != 0:
continue
return unsafeAddr entry