import std/options import std/os import std/posix import js/error import js/fromjs import js/javascript import js/tojs import types/opt import utils/twtstr const libexecPath {.strdefine.} = "${%CHA_BIN_DIR}/../libexec/chawan" type ChaPath* = distinct string func `$`*(p: ChaPath): string = return string(p) type UnquoteContext = object state: UnquoteState s: string p: string i: int identStr: string subChar: char hasColon: bool terminal: Option[char] UnquoteState = enum STATE_NORMAL, STATE_TILDE, STATE_DOLLAR, STATE_IDENT, STATE_BSLASH, STATE_CURLY_START, STATE_CURLY, STATE_CURLY_HASH, STATE_CURLY_PERC, STATE_CURLY_COLON, STATE_CURLY_EXPAND, STATE_DONE ChaPathError = string ChaPathResult[T] = Result[T, ChaPathError] proc unquote*(p: ChaPath): ChaPathResult[string] proc unquote(p: string, starti: var int, terminal: Option[char]): ChaPathResult[string] proc stateNormal(ctx: var UnquoteContext, c: char) = case c of '$': ctx.state = STATE_DOLLAR of '\\': ctx.state = STATE_BSLASH of '~': if ctx.i == 0: ctx.state = STATE_TILDE else: ctx.s &= c elif ctx.terminal.isSome and ctx.terminal.get == c: ctx.state = STATE_DONE else: ctx.s &= c proc flushTilde(ctx: var UnquoteContext) = if ctx.identStr == "": ctx.s &= getHomeDir() else: let p = getpwnam(cstring(ctx.identStr)) if p != nil: ctx.s &= $p.pw_dir ctx.identStr = "" ctx.state = STATE_NORMAL proc stateTilde(ctx: var UnquoteContext, c: char) = if c != '/': ctx.identStr &= c else: ctx.flushTilde() # Kind of a hack. We special case `\$' (backslash-dollar) in TOML, so that # it produces itself in dquote strings. # Thus by applying stateBSlash we get '\$' -> "$", but also "\$" -> "$". proc stateBSlash(ctx: var UnquoteContext, c: char) = if c != '$': ctx.s &= '\\' ctx.s &= c ctx.state = STATE_NORMAL proc stateDollar(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # $ case c of '$': ctx.s &= $getCurrentProcessId() ctx.state = STATE_NORMAL of '0': # Note: we intentionally use getAppFileName so that any symbolic links # are resolved. ctx.s &= getAppFileName() ctx.state = STATE_NORMAL of '1'..'9': return err("Parameter substitution is not supported") of AsciiAlpha: ctx.identStr = $c ctx.state = STATE_IDENT of '{': ctx.state = STATE_CURLY_START else: # > If an unquoted '$' is followed by a character that is not one of # > the following: [...] the result is unspecified. # just error out here to be safe return err("Invalid dollar substitution") ok() proc flushIdent(ctx: var UnquoteContext) = ctx.s &= getEnv(ctx.identStr) ctx.identStr = "" const BareChars = AsciiAlphaNumeric + {'_'} proc stateIdent(ctx: var UnquoteContext, c: char) = # $ident if c in BareChars: ctx.identStr &= c else: ctx.flushIdent() dec ctx.i ctx.state = STATE_NORMAL proc stateCurlyStart(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # ${ case c of '#': ctx.state = STATE_CURLY_HASH of '%': ctx.state = STATE_CURLY_PERC of BareChars: ctx.state = STATE_CURLY dec ctx.i else: return err("unexpected character in substitution: '" & c & "'") return ok() proc stateCurly(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # ${ident case c of '}': ctx.s &= $getEnv(ctx.identStr) ctx.state = STATE_NORMAL return ok() of '$': # allow $ as first char only if ctx.identStr.len > 0: return err("unexpected dollar sign in substitution") ctx.identStr &= c return ok() of ':', '-', '?', '+': # note: we don't support `=' (assign) if ctx.identStr.len > 0: return err("substitution without parameter name") if c == ':': ctx.state = STATE_CURLY_COLON else: ctx.subChar = c ctx.state = STATE_CURLY_EXPAND return ok() of '1'..'9': return err("Parameter substitution is not supported") of BareChars - {'1'..'9'}: ctx.identStr &= c return ok() else: return err("unexpected character in substitution: '" & c & "'") proc stateCurlyHash(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # ${#ident if c == '}': let s = getEnv(ctx.identStr) ctx.s &= $s.len ctx.identStr = "" ctx.state = STATE_NORMAL return ok() if c == '$': # allow $ as first char only if ctx.identStr.len > 0: return err("unexpected dollar sign in substitution") # fall through elif c notin BareChars: return err("unexpected character in substitution: '" & c & "'") ctx.identStr &= c return ok() proc stateCurlyPerc(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # ${%ident if c == '}': if ctx.identStr == "CHA_BIN_DIR": ctx.s &= getAppFileName().beforeLast('/') elif ctx.identStr == "CHA_LIBEXEC_DIR": ctx.s &= ?ChaPath(libexecPath).unquote() else: return err("Unknown internal variable " & ctx.identStr) ctx.identStr = "" ctx.state = STATE_NORMAL return ok() if c notin BareChars: return err("unexpected character in substitution: '" & c & "'") ctx.identStr &= c return ok() proc stateCurlyColon(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # ${ident: if c notin {'-', '?', '+'}: # Note: we don't support `=' (assign) return err("unexpected character after colon: '" & c & "'") ctx.hasColon = true ctx.subChar = c ctx.state = STATE_CURLY_EXPAND return ok() proc flushCurlyExpand(ctx: var UnquoteContext, word: string): ChaPathResult[void] = case ctx.subChar of '-': if ctx.hasColon: ctx.s &= getEnv(ctx.identStr, word) else: if existsEnv(ctx.identStr): ctx.s &= getEnv(ctx.identStr) else: ctx.s &= word of '?': if ctx.hasColon: let s = getEnv(ctx.identStr) if s.len == 0: return err(word) ctx.s &= s else: if not existsEnv(ctx.identStr): return err(word) ctx.s &= getEnv(ctx.identStr) of '+': if ctx.hasColon: if getEnv(ctx.identStr).len > 0: ctx.s &= word else: if existsEnv(ctx.identStr): ctx.s &= word else: assert false ctx.subChar = '\0' ctx.hasColon = false return ok() proc stateCurlyExpand(ctx: var UnquoteContext, c: char): ChaPathResult[void] = # ${ident:-[word], ${ident:=[word], ${ident:?[word], ${ident:+[word] # word must be unquoted too. let word = ?unquote(ctx.p, ctx.i, some('}')) return ctx.flushCurlyExpand(word) proc unquote(p: string, starti: var int, terminal: Option[char]): ChaPathResult[string] = var ctx = UnquoteContext(p: p, i: starti, terminal: terminal) while ctx.i < p.len: let c = p[ctx.i] case ctx.state of STATE_NORMAL: ctx.stateNormal(c) of STATE_TILDE: ctx.stateTilde(c) of STATE_BSLASH: ctx.stateBSlash(c) of STATE_DOLLAR: ?ctx.stateDollar(c) of STATE_IDENT: ctx.stateIdent(c) of STATE_CURLY_START: ?ctx.stateCurlyStart(c) of STATE_CURLY: ?ctx.stateCurly(c) of STATE_CURLY_HASH: ?ctx.stateCurlyHash(c) of STATE_CURLY_PERC: ?ctx.stateCurlyPerc(c) of STATE_CURLY_COLON: ?ctx.stateCurlyColon(c) of STATE_CURLY_EXPAND: ?ctx.stateCurlyExpand(c) of STATE_DONE: break inc ctx.i case ctx.state of STATE_NORMAL, STATE_DONE: discard of STATE_TILDE: ctx.flushTilde() of STATE_BSLASH: ctx.s &= '\\' of STATE_DOLLAR: ctx.s &= '$' of STATE_IDENT: ctx.flushIdent() of STATE_CURLY_START, STATE_CURLY, STATE_CURLY_HASH, STATE_CURLY_PERC, STATE_CURLY_COLON: return err("} expected") of STATE_CURLY_EXPAND: ?ctx.flushCurlyExpand("") starti = ctx.i return ok(ctx.s) proc unquote(p: string): ChaPathResult[string] = var dummy = 0 return unquote(p, dummy, none(char)) proc toJS*(ctx: JSContext, p: ChaPath): JSValue = toJS(ctx, $p) proc fromJSChaPath*(ctx: JSContext; val: JSValue): JSResult[ChaPath] = return cast[JSResult[ChaPath]](fromJS[string](ctx, val)) proc unquote*(p: ChaPath): ChaPathResult[string] = let s = ?unquote(string(p)) return ok(normalizedPath(s))