-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathPSString.hs
More file actions
301 lines (264 loc) · 10.9 KB
/
Copy pathPSString.hs
File metadata and controls
301 lines (264 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
module Language.PureScript.PSString
( PSString
, toUTF16CodeUnits
, decodeString
, decodeStringEither
, decodeStringWithReplacement
, decodeStringEscaping
, prettyPrintStringJS
, mkString
) where
import Control.Exception (evaluate, try)
import Data.Aeson qualified as A
import Data.Aeson.Types qualified as A
import Data.Bits (shiftR, (.&.), (.|.))
import Data.ByteString qualified as BS
import Data.Char qualified as Char
import Data.IntCast (intCast)
import Data.Scientific (toBoundedInteger)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf16BE)
import Data.Vector qualified as V
import Numeric (showHex)
import System.IO.Unsafe (unsafePerformIO)
import Text.Show (Show (..))
import Prelude hiding (show)
{- Note [PSString is UTF-16 code units, not text]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A PSString is a sequence of UTF-16 code units that does not necessarily
represent well-formed UTF-16 text. It may contain lone surrogates: code
units in U+D800..U+DFFF that are not part of a surrogate pair. This is
faithful to PureScript, where a string literal can hold any code-unit
sequence.
Two consequences are load-bearing across the compiler.
Dual JSON encoding. Because JSON parsers disagree on lone surrogates inside
JSON strings, the 'A.ToJSON' instance emits a plain JSON string only when
that is lossless (no lone surrogates) and otherwise falls back to an array
of UTF-16 code units (integers). The 'A.FromJSON' instance therefore accepts
both shapes. CoreFn reading relies on this for record labels and string
literals (see 'Language.PureScript.CoreFn.FromJSON').
Decode-or-escape into Lua. A lone surrogate has no corresponding 'Char', so
code generation never decodes literal data with 'decodeString'. It uses
'decodeStringEscaping', which renders decodable code points directly and
emits a lone surrogate as its WTF-8 bytes, each a \ddd decimal escape. The IR
string and char literal sites and the Lua backend rely on this to avoid
failing on otherwise-legal PureScript strings.
-}
{- | A sequence of UTF-16 code units, not necessarily well-formed UTF-16
text. See Note [PSString is UTF-16 code units, not text].
-}
newtype PSString = PSString {toUTF16CodeUnits ∷ [Word16]}
deriving stock (Eq, Ord, Generic)
deriving newtype (Semigroup, Monoid)
{- | Produces a string literal that would represent the same data if
inserted into a PureScript source file.
-}
instance Show PSString where
show = show . codePoints
{- |
Decode a PSString to a String, representing any lone surrogates as the
reserved code point with that index. Warning: if there are any lone
surrogates, converting the result to Text via Data.Text.pack will result in
loss of information as those lone surrogates will be replaced with U+FFFD
REPLACEMENT CHARACTER. Because this function requires care to use correctly,
we do not export it.
-}
codePoints ∷ PSString → String
codePoints = map (either (Char.chr . intCast) id) . decodeStringEither
{- |
Decode a PSString as UTF-16 text. Lone surrogates will be replaced with
U+FFFD REPLACEMENT CHARACTER
-}
decodeStringWithReplacement ∷ PSString → String
decodeStringWithReplacement = map (fromRight '\xFFFD') . decodeStringEither
{- |
Decode a PSString as UTF-16. Lone surrogates in the input are represented in
the output with the Left constructor; characters which were successfully
decoded are represented with the Right constructor.
-}
decodeStringEither ∷ PSString → [Either Word16 Char]
decodeStringEither = unfoldr decode . toUTF16CodeUnits
where
decode ∷ [Word16] → Maybe (Either Word16 Char, [Word16])
decode (h : l : rest)
| isLead h && isTrail l =
Just (Right (unsurrogate h l), rest)
decode (c : rest) | isSurrogate c = Just (Left c, rest)
decode (c : rest) = Just (Right (toChar c), rest)
decode [] = Nothing
unsurrogate ∷ Word16 → Word16 → Char
unsurrogate h l =
toEnum $
(toInt h - 0xD800) * 0x400
+ (toInt l - 0xDC00)
+ 0x10000
{- |
Attempt to decode a PSString as UTF-16 text. This will fail (returning
Nothing) if the argument contains lone surrogates.
-}
decodeString ∷ PSString → Either UnicodeException Text
decodeString =
decodeEither . BS.pack . concatMap unpair . toUTF16CodeUnits
where
unpair w = [highByte w, lowByte w]
-- Deliberate narrowing: fromIntegral keeps the low 8 bits, which is exactly
-- the low byte we want. intCast would (correctly) reject Word16 → Word8.
lowByte ∷ Word16 → Word8
lowByte = fromIntegral
-- The shiftR 8 leaves a value in 0..255, so the narrowing to Word8 is always
-- exact; intCast cannot see that bound statically, so keep fromIntegral.
highByte ∷ Word16 → Word8
highByte = fromIntegral . (`shiftR` 8)
-- Based on a similar function from Data.Text.Encoding for utf8. This is a
-- safe usage of unsafePerformIO because there are no side effects after
-- handling any thrown UnicodeExceptions.
decodeEither ∷ ByteString → Either UnicodeException Text
decodeEither = unsafePerformIO . try . evaluate . decodeUtf16BE
instance IsString PSString where
fromString a = PSString $ concatMap encodeUTF16 a
where
surrogates ∷ Char → (Word16, Word16)
surrogates c = (toWord (h + 0xD800), toWord (l + 0xDC00))
where
(h, l) = divMod (fromEnum c - 0x10000) 0x400
encodeUTF16 ∷ Char → [Word16]
encodeUTF16 c | fromEnum c > 0xFFFF = [high, low]
where
(high, low) = surrogates c
encodeUTF16 c = [toWord $ fromEnum c]
instance A.ToJSON PSString where
toJSON str =
case rightToMaybe (decodeString str) of
Just t → A.toJSON t
Nothing → A.toJSON (toUTF16CodeUnits str)
instance A.FromJSON PSString where
parseJSON a = jsonString <|> arrayOfCodeUnits
where
jsonString = fromString <$> A.parseJSON a
arrayOfCodeUnits = PSString <$> parseArrayOfCodeUnits a
parseArrayOfCodeUnits ∷ A.Value → A.Parser [Word16]
parseArrayOfCodeUnits =
A.withArray
"array of UTF-16 code units"
(traverse parseCodeUnit . V.toList)
parseCodeUnit ∷ A.Value → A.Parser Word16
parseCodeUnit b =
A.withScientific
"two-byte non-negative integer"
(maybe (A.typeMismatch "" b) return . toBoundedInteger)
b
{- |
Decode a PSString as UTF-16, using PureScript escape sequences.
-}
decodeStringEscaping ∷ PSString → Text
decodeStringEscaping s = foldMap encodeAtom (withNextDigit (decodeStringEither s))
where
-- Lua's decimal escape reads up to three digits after the backslash, so an
-- escape immediately followed by a literal digit character must be
-- zero-padded to three digits or it swallows that digit.
withNextDigit ∷ [Either Word16 Char] → [(Either Word16 Char, Bool)]
withNextDigit atoms = zip atoms (map startsWithAsciiDigit (drop 1 atoms) <> [False])
startsWithAsciiDigit ∷ Either Word16 Char → Bool
startsWithAsciiDigit (Right c) = Char.isDigit c
startsWithAsciiDigit (Left _) = False
encodeAtom ∷ (Either Word16 Char, Bool) → Text
encodeAtom (Left c, padLast) = decimalEscapes padLast (wtf8SurrogateBytes c)
encodeAtom (Right c, padLast)
| c == '\t' = "\\t"
| c == '\r' = "\\r"
| c == '\n' = "\\n"
| c == '"' = "\\\""
| c == '\'' = "\\\'"
| c == '\\' = "\\\\"
| shouldPrint c = T.singleton c
| otherwise = decimalEscapes padLast (BS.unpack (encodeUtf8 (T.singleton c)))
-- Every Lua version, including our 5.1 floor, reads a \ddd decimal escape,
-- unlike \x, which is unsupported in 5.1 and reads a different number of
-- digits in 5.2+/LuaJIT. A code point needing escape becomes one \ddd per
-- UTF-8 byte; only the last byte can abut the next character, so only it is
-- ever zero-padded.
decimalEscapes ∷ Bool → [Word8] → Text
decimalEscapes padLast bytes =
foldMap escapeByte (zip bytes (replicate (length bytes - 1) False <> [padLast]))
where
escapeByte ∷ (Word8, Bool) → Text
escapeByte (b, pad) =
let digits = show b
in "\\"
<> T.pack (if pad then replicate (3 - length digits) '0' <> digits else digits)
-- A lone surrogate has no UTF-8 encoding (it is not a valid Unicode scalar
-- value); WTF-8 extends UTF-8 by encoding it with the same 3-byte pattern
-- UTF-8 uses for any other code point in the same 0x0800-0xFFFF range.
wtf8SurrogateBytes ∷ Word16 → [Word8]
wtf8SurrogateBytes w =
[ 0xE0 .|. fromIntegral (w `shiftR` 12)
, 0x80 .|. fromIntegral ((w `shiftR` 6) .&. 0x3F)
, 0x80 .|. fromIntegral (w .&. 0x3F)
]
-- Note we do not use Data.Char.isPrint here because that includes things
-- like zero-width spaces and combining punctuation marks, which could be
-- confusing to print unescaped.
shouldPrint ∷ Char → Bool
-- The standard space character, U+20 SPACE, is the only space char we should
-- print without escaping
shouldPrint ' ' = True
shouldPrint c =
Char.generalCategory c
`elem` [ Char.UppercaseLetter
, Char.LowercaseLetter
, Char.TitlecaseLetter
, Char.OtherLetter
, Char.DecimalNumber
, Char.LetterNumber
, Char.OtherNumber
, Char.ConnectorPunctuation
, Char.DashPunctuation
, Char.OpenPunctuation
, Char.ClosePunctuation
, Char.InitialQuote
, Char.FinalQuote
, Char.OtherPunctuation
, Char.MathSymbol
, Char.CurrencySymbol
, Char.ModifierSymbol
, Char.OtherSymbol
]
{- |
Pretty print a PSString, using JavaScript escape sequences. Intended for
use in compiled JS output.
-}
prettyPrintStringJS ∷ PSString → Text
prettyPrintStringJS s = "\"" <> foldMap encodeChar (toUTF16CodeUnits s) <> "\""
where
encodeChar ∷ Word16 → Text
encodeChar c | c > 0xFF = "\\u" <> showHex' 4 c
encodeChar c | c > 0x7E || c < 0x20 = "\\x" <> showHex' 2 c
encodeChar c | toChar c == '\b' = "\\b"
encodeChar c | toChar c == '\t' = "\\t"
encodeChar c | toChar c == '\n' = "\\n"
encodeChar c | toChar c == '\v' = "\\v"
encodeChar c | toChar c == '\f' = "\\f"
encodeChar c | toChar c == '\r' = "\\r"
encodeChar c | toChar c == '"' = "\\\""
encodeChar c | toChar c == '\\' = "\\\\"
encodeChar c = T.singleton $ toChar c
showHex' ∷ Enum a ⇒ Int → a → Text
showHex' width c =
let hs = showHex (fromEnum c) ""
in T.pack (replicate (width - length hs) '0' <> hs)
isLead ∷ Word16 → Bool
isLead h = h >= 0xD800 && h <= 0xDBFF
isTrail ∷ Word16 → Bool
isTrail l = l >= 0xDC00 && l <= 0xDFFF
isSurrogate ∷ Word16 → Bool
isSurrogate c = isLead c || isTrail c
toChar ∷ Word16 → Char
toChar = toEnum . intCast
-- Deliberate narrowing: callers (surrogate encoding in 'fromString') guarantee
-- the Int is already in Word16 range, so the truncation never loses data.
toWord ∷ Int → Word16
toWord = fromIntegral
toInt ∷ Word16 → Int
toInt = intCast
mkString ∷ Text → PSString
mkString = fromString . T.unpack