Skip to content
This repository was archived by the owner on Jul 19, 2022. It is now read-only.

Commit a10f587

Browse files
authored
Merge pull request #199 from unisonweb/support-special-character-fqns
FQN: Add support for special characters
2 parents 82c3d81 + 76f4a7f commit a10f587

File tree

7 files changed

+217
-45
lines changed

7 files changed

+217
-45
lines changed

src/FullyQualifiedName.elm

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ module FullyQualifiedName exposing
66
, fromList
77
, fromParent
88
, fromString
9+
, fromUrlList
910
, fromUrlString
1011
, isSuffixOf
12+
, isValidSegmentChar
13+
, isValidUrlSegmentChar
1114
, namespaceOf
1215
, segments
1316
, toString
17+
, toUrlSegments
1418
, toUrlString
1519
, unqualifiedName
1620
, urlParser
@@ -19,6 +23,7 @@ module FullyQualifiedName exposing
1923
import Json.Decode as Decode
2024
import List.Nonempty as NEL
2125
import String.Extra as StringE
26+
import Url
2227
import Url.Parser
2328

2429

@@ -31,28 +36,39 @@ type FQN
3136

3237

3338
{-| Turn a string, like "base.List.map" into FQN ["base", "List", "map"]
39+
40+
Split text into segments. A smarter version of `Text.split` that handles
41+
the name `.` properly.
42+
3443
-}
3544
fromString : String -> FQN
3645
fromString rawFqn =
46+
let
47+
go s =
48+
case s of
49+
[] ->
50+
[]
51+
52+
"" :: "" :: z ->
53+
"." :: go z
54+
55+
"" :: z ->
56+
go z
57+
58+
x :: y ->
59+
x :: go y
60+
in
3761
rawFqn
3862
|> String.split "."
63+
|> go
3964
|> fromList
4065

4166

4267
fromList : List String -> FQN
4368
fromList segments_ =
44-
let
45-
rootEmptyToDot i s =
46-
if i == 0 && String.isEmpty s then
47-
"."
48-
49-
else
50-
s
51-
in
5269
segments_
5370
|> List.map String.trim
54-
|> List.indexedMap rootEmptyToDot
55-
|> List.filter (\s -> String.length s > 0)
71+
|> List.filter (String.isEmpty >> not)
5672
|> NEL.fromList
5773
|> Maybe.withDefault (NEL.fromElement ".")
5874
|> FQN
@@ -61,8 +77,21 @@ fromList segments_ =
6177
fromUrlString : String -> FQN
6278
fromUrlString str =
6379
str
64-
|> String.replace "/" "."
65-
|> fromString
80+
|> String.split "/"
81+
|> fromUrlList
82+
83+
84+
fromUrlList : List String -> FQN
85+
fromUrlList segments_ =
86+
let
87+
urlDecode s =
88+
-- Let invalid % encoding fall through, since it then must be valid
89+
-- strings
90+
Maybe.withDefault s (Url.percentDecode s)
91+
in
92+
segments_
93+
|> List.map (urlDecode >> urlDecodeSegmentDot)
94+
|> fromList
6695

6796

6897
toString : FQN -> String
@@ -71,6 +100,7 @@ toString (FQN nameParts) =
71100
-- Absolute FQNs start with a dot, so when also
72101
-- joining parts using a dot, we get dot dot (..),
73102
-- which we don't want.
103+
-- TODO: this does mean that we don't support . as a term name on the root...
74104
trimLeadingDot str =
75105
if String.startsWith ".." str then
76106
String.dropLeft 1 str
@@ -84,11 +114,19 @@ toString (FQN nameParts) =
84114
|> trimLeadingDot
85115

86116

117+
toUrlSegments : FQN -> NEL.Nonempty String
118+
toUrlSegments fqn =
119+
fqn
120+
|> segments
121+
|> NEL.map (Url.percentEncode >> urlEncodeSegmentDot)
122+
123+
87124
toUrlString : FQN -> String
88125
toUrlString fqn =
89126
fqn
90-
|> toString
91-
|> String.replace "." "/"
127+
|> toUrlSegments
128+
|> NEL.toList
129+
|> String.join "/"
92130

93131

94132
segments : FQN -> NEL.Nonempty String
@@ -161,3 +199,48 @@ decodeFromParent parentFqn =
161199
decode : Decode.Decoder FQN
162200
decode =
163201
Decode.map fromString Decode.string
202+
203+
204+
isValidSegmentChar : Char -> Bool
205+
isValidSegmentChar c =
206+
let
207+
validSymbols =
208+
String.toList "!$%^&*-=+<>.~\\/:_'"
209+
in
210+
Char.isAlphaNum c || List.member c validSymbols
211+
212+
213+
isValidUrlSegmentChar : Char -> Bool
214+
isValidUrlSegmentChar c =
215+
-- '/' is a segment separator in Urls and
216+
-- should be escaped to %2F, so when
217+
-- unescaped, its not a valid segment
218+
-- character when parsing URLs.
219+
c /= '/' && isValidSegmentChar c
220+
221+
222+
223+
-- INTERNAL HELPERS
224+
225+
226+
{-| URLs can't include a single dot in a path segment like so "base/./docs",
227+
but this is a valid definition name in Unison, the composition operator for
228+
example is named "." To get around this we encode dots as ";." in segments such
229+
that "base...doc" becomes "base/;./doc"
230+
-}
231+
urlEncodeSegmentDot : String -> String
232+
urlEncodeSegmentDot s =
233+
if s == "." then
234+
";."
235+
236+
else
237+
s
238+
239+
240+
urlDecodeSegmentDot : String -> String
241+
urlDecodeSegmentDot s =
242+
if s == ";." then
243+
"."
244+
245+
else
246+
s

src/HashQualified.elm

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ fromString str =
4848
str
4949
|> Hash.fromString
5050
|> Maybe.map HashOnly
51-
|> MaybeE.orElse (hashQualifiedFromString Hash.prefix str)
51+
|> MaybeE.orElse (hashQualifiedFromString FQN.fromString Hash.prefix str)
5252
|> Maybe.withDefault (NameOnly (FQN.fromString str))
5353

5454

@@ -57,7 +57,7 @@ fromUrlString str =
5757
str
5858
|> Hash.fromUrlString
5959
|> Maybe.map HashOnly
60-
|> MaybeE.orElse (hashQualifiedFromString Hash.urlPrefix str)
60+
|> MaybeE.orElse (hashQualifiedFromString FQN.fromUrlString Hash.urlPrefix str)
6161
|> Maybe.withDefault (NameOnly (FQN.fromUrlString str))
6262

6363

@@ -142,8 +142,8 @@ isRawHashQualified str =
142142
not (Hash.isRawHash str) && String.contains Hash.urlPrefix str
143143

144144

145-
hashQualifiedFromString : String -> String -> Maybe HashQualified
146-
hashQualifiedFromString sep str =
145+
hashQualifiedFromString : (String -> FQN) -> String -> String -> Maybe HashQualified
146+
hashQualifiedFromString toFQN sep str =
147147
if isRawHashQualified str then
148148
let
149149
parts =
@@ -161,7 +161,7 @@ hashQualifiedFromString sep str =
161161

162162
name_ :: unprefixedHash :: [] ->
163163
Hash.fromString (Hash.prefix ++ unprefixedHash)
164-
|> Maybe.map (HashQualified (FQN.fromString name_))
164+
|> Maybe.map (HashQualified (toFQN name_))
165165

166166
_ ->
167167
Nothing

src/Route.elm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,13 @@ toUrlString route =
152152
hqToPath hq =
153153
case hq of
154154
NameOnly fqn ->
155-
NEL.toList (FQN.segments fqn)
155+
fqn |> FQN.toUrlSegments |> NEL.toList
156156

157157
HashOnly h ->
158158
[ Hash.toUrlString h ]
159159

160160
HashQualified fqn h ->
161-
String.split "/" (FQN.toUrlString fqn ++ Hash.toUrlString h)
161+
NEL.toList (FQN.toUrlSegments fqn) ++ [ Hash.toUrlString h ]
162162

163163
perspectiveParamsToPath pp includeNamespacesSuffix =
164164
case pp of

src/Route/Parsers.elm

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,21 @@ fqn : Parser FQN
2626
fqn =
2727
let
2828
segment =
29+
Parser.oneOf
30+
-- Special case ;. which is an escaped . (dot), since we also use
31+
-- ';' as the separator character between namespace FQNs and
32+
-- definition FQNs. (';' is not a valid character in FQNs and is
33+
-- safe as a separator/escape character).
34+
[ b (succeed (identity ".") |. s ";.")
35+
, b chompSegment
36+
]
37+
38+
chompSegment =
2939
Parser.getChompedString <|
3040
Parser.succeed ()
31-
|. Parser.chompWhile Char.isAlphaNum
41+
|. Parser.chompWhile FQN.isValidUrlSegmentChar
3242
in
33-
Parser.map FQN.fromList
43+
Parser.map FQN.fromUrlList
3444
(Parser.sequence
3545
{ start = ""
3646
, separator = "/"
@@ -44,7 +54,7 @@ fqn =
4454

4555
fqnEnd : Parser ()
4656
fqnEnd =
47-
Parser.symbol "-"
57+
Parser.symbol ";"
4858

4959

5060
hash : Parser Hash

tests/FullyQualifiedNameTests.elm

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module FullyQualifiedNameTests exposing (..)
22

33
import Expect
44
import FullyQualifiedName as FQN exposing (..)
5+
import List.Nonempty as NEL
56
import Test exposing (..)
67

78

@@ -10,17 +11,20 @@ fromString =
1011
describe "FullyQualifiedName.fromString"
1112
[ test "Creates an FQN from a string" <|
1213
\_ ->
13-
Expect.equal "a.b.c" (FQN.toString (FQN.fromString "a.b.c"))
14+
Expect.equal [ "a", "b", "c" ] (segments (FQN.fromString "a.b.c"))
15+
, test "Creates an FQN from a string where a segment includes a dot (like the composition operatory)" <|
16+
\_ ->
17+
Expect.equal [ "base", "." ] (segments (FQN.fromString "base.."))
1418
, describe "Root"
1519
[ test "Creates a root FQN from \"\"" <|
1620
\_ ->
17-
Expect.equal "." (FQN.toString (FQN.fromString ""))
21+
Expect.equal [ "." ] (segments (FQN.fromString ""))
1822
, test "Creates a root FQN from \" \"" <|
1923
\_ ->
20-
Expect.equal "." (FQN.toString (FQN.fromString " "))
24+
Expect.equal [ "." ] (segments (FQN.fromString " "))
2125
, test "Creates a root FQN from \".\"" <|
2226
\_ ->
23-
Expect.equal "." (FQN.toString (FQN.fromString "."))
27+
Expect.equal [ "." ] (segments (FQN.fromString "."))
2428
]
2529
]
2630

@@ -30,17 +34,44 @@ fromUrlString =
3034
describe "FullyQualifiedName.fromUrlString"
3135
[ test "Creates an FQN from a URL string (segments separate by /)" <|
3236
\_ ->
33-
Expect.equal "a.b.c" (FQN.toString (FQN.fromUrlString "a/b/c"))
37+
Expect.equal [ "a", "b", "c" ] (segments (FQN.fromUrlString "a/b/c"))
38+
, test "Supports . in segments (compose)" <|
39+
\_ ->
40+
Expect.equal [ "a", "b", "." ] (segments (FQN.fromUrlString "a/b/."))
41+
, test "Supports special characters n segments" <|
42+
\_ ->
43+
let
44+
results =
45+
[ segments (FQN.fromUrlString "a/b/+")
46+
, segments (FQN.fromUrlString "a/b/*")
47+
, segments (FQN.fromUrlString "a/b/%2F") -- /
48+
, segments (FQN.fromUrlString "a/b/%25") -- %
49+
, segments (FQN.fromUrlString "a/b/!")
50+
, segments (FQN.fromUrlString "a/b/-")
51+
, segments (FQN.fromUrlString "a/b/==")
52+
]
53+
54+
expects =
55+
[ [ "a", "b", "+" ]
56+
, [ "a", "b", "*" ]
57+
, [ "a", "b", "/" ]
58+
, [ "a", "b", "%" ]
59+
, [ "a", "b", "!" ]
60+
, [ "a", "b", "-" ]
61+
, [ "a", "b", "==" ]
62+
]
63+
in
64+
Expect.equal expects results
3465
, describe "Root"
3566
[ test "Creates a root FQN from \"\"" <|
3667
\_ ->
37-
Expect.equal "." (FQN.toString (FQN.fromUrlString ""))
68+
Expect.equal [ "." ] (segments (FQN.fromUrlString ""))
3869
, test "Creates a root FQN from \" \"" <|
3970
\_ ->
40-
Expect.equal "." (FQN.toString (FQN.fromUrlString " "))
71+
Expect.equal [ "." ] (segments (FQN.fromUrlString " "))
4172
, test "Creates a root FQN from \"/\"" <|
4273
\_ ->
43-
Expect.equal "." (FQN.toString (FQN.fromUrlString "/"))
74+
Expect.equal [ "." ] (segments (FQN.fromUrlString "/"))
4475
]
4576
]
4677

@@ -51,9 +82,9 @@ toString =
5182
[ test "serializes the FQN" <|
5283
\_ ->
5384
Expect.equal "foo.bar" (FQN.toString (FQN.fromString "foo.bar"))
54-
, test "includes root dot when an absolute fqn" <|
85+
, test "it supports . as term names (compose)" <|
5586
\_ ->
56-
Expect.equal ".foo.bar" (FQN.toString (FQN.fromString ".foo.bar"))
87+
Expect.equal "foo.bar.." (FQN.toString (FQN.fromString "foo.bar.."))
5788
]
5889

5990

@@ -63,9 +94,15 @@ toUrlString =
6394
[ test "serializes the FQN with segments separate by /" <|
6495
\_ ->
6596
Expect.equal "foo/bar" (FQN.toUrlString (FQN.fromString "foo.bar"))
66-
, test "includes root dot when an absolute fqn" <|
97+
, test "URL encodes / (divide) segments" <|
98+
\_ ->
99+
Expect.equal "foo/bar/%2F/doc" (FQN.toUrlString (FQN.fromString "foo.bar./.doc"))
100+
, test "URL encodes % segments" <|
67101
\_ ->
68-
Expect.equal "/foo/bar" (FQN.toUrlString (FQN.fromString ".foo.bar"))
102+
Expect.equal "foo/bar/%25/doc" (FQN.toUrlString (FQN.fromString "foo.bar.%.doc"))
103+
, test "URL encodes . segments with a ; prefix" <|
104+
\_ ->
105+
Expect.equal "foo/bar/;./doc" (FQN.toUrlString (FQN.fromString "foo.bar...doc"))
69106
]
70107

71108

@@ -157,3 +194,12 @@ namespaceOf =
157194
in
158195
Expect.equal (Just "base.Map") (FQN.namespaceOf suffix fqn)
159196
]
197+
198+
199+
200+
-- HELPERS
201+
202+
203+
segments : FQN -> List String
204+
segments =
205+
FQN.segments >> NEL.toList

0 commit comments

Comments
 (0)