comparison src/main/kotlin/name/blackcap/passman/Shplitter.kt @ 19:7d80cbcb67bb

add shlex-style splitter and tests
author David Barts <n5jrn@me.com>
date Sun, 30 Jun 2024 20:37:36 -0700
parents
children 4391afcf6bd0
comparison
equal deleted inserted replaced
18:8f3ddebb4295 19:7d80cbcb67bb
1 package name.blackcap.passman
2
3 // A state is represented by a state-executing function (see below).
4 typealias State = (Char) -> Boolean
5
6 // Support for simplified *nix shell style splitting into tokens. We are
7 // focused on splitting things into tokens only. No variable expansion,
8 // no ~home expansion, etc. Backslash quotes the next character. Single
9 // quotes quote everything literally up to the next closing single quote.
10 // Double quotes quote to the closing double quote but honor backslashes
11 // (i.e. "\"\nice\"" -> "nice"). No \t, \n, \r etc. backslash escapes
12 // supported (KISS).
13 class Shplitter() {
14 private val QUOTING = setOf<State>(::inSingle, ::inDouble)
15 private val WHITESPACE = setOf<Char>(' ', '\t', '\n')
16 private var oldStates = mutableListOf<State>()
17 private var state: State = ::space
18 private var accum = mutableListOf<String>()
19 private var current = StringBuilder()
20
21 val complete: Boolean
22 get() = state == ::space || state == ::nonspace
23
24 // Feeds more input into this tokenizer
25 fun feed(input: String) : Unit {
26 for (ch in input) {
27 while (state(ch))
28 ;
29 }
30 }
31
32 fun split(): Iterable<String> {
33 if (complete) {
34 if (current.isNotEmpty()) {
35 accum.add(current.toString())
36 current.clear()
37 }
38 if (state == ::nonspace) {
39 popState()
40 }
41 return accum
42 } else {
43 throw IllegalStateException("incomplete quoted expression")
44 }
45 }
46
47 // State transitions
48
49 private fun pushState(newState: State): Unit {
50 oldStates.add(state)
51 state = newState
52 }
53
54 private fun popState(): Unit {
55 state = oldStates.removeLast()
56 }
57
58 private fun lastState(): State = oldStates.last()
59
60 private fun endQuote(): Unit {
61 if (lastState() == ::space) {
62 accum.add(current.toString())
63 current.clear()
64 }
65 popState()
66 }
67
68 // States. A state is represented by a function that accepts the
69 // character currently being processed, and returns whether it should
70 // immediately transition to the next state without reading a new
71 // character.
72
73 private fun space(ch: Char): Boolean =
74 when (ch) {
75 in WHITESPACE -> { false }
76 '\'' -> { pushState(::inSingle); false }
77 '"' -> { pushState(::inDouble); false }
78 '\\' -> { pushState(::backslash); false }
79 else -> { pushState(::nonspace); true }
80 }
81
82 private fun nonspace(ch: Char): Boolean =
83 when (ch) {
84 in WHITESPACE -> {
85 accum.add(current.toString())
86 current.clear()
87 popState()
88 false
89 }
90 '\'' -> {
91 pushState(::inSingle)
92 false
93 }
94 '"' -> {
95 pushState(::inDouble)
96 false
97 }
98 '\\' -> {
99 pushState(::backslash)
100 false
101 }
102 else -> {
103 current.append(ch)
104 false
105 }
106 }
107
108 private fun inSingle(ch: Char): Boolean =
109 when (ch) {
110 '\'' -> { endQuote(); false }
111 else -> { current.append(ch); false }
112 }
113
114 private fun inDouble(ch: Char): Boolean =
115 when (ch) {
116 '\\' -> { pushState(::backslash); false }
117 '"' -> { endQuote(); false }
118 else -> { current.append(ch); false }
119 }
120
121 private fun backslash(ch: Char): Boolean {
122 val last = lastState()
123 if (ch == '\n' && last !in QUOTING) {
124 // if not quoting, \\n makes a normal whitespace out of command terminator
125 popState()
126 return true
127 } else if (last == ::space) {
128 // start a new unquoted string no matter what
129 current.append(ch)
130 state = ::nonspace
131 return false
132 } else {
133 // continue existing string no matter what
134 current.append(ch)
135 popState()
136 return false
137 }
138 }
139 }