Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion elm.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
},
"test-dependencies": {
"elm/regex": "1.0.0 <= v < 2.0.0",
"elm-explorations/test": "1.2.0 <= v < 2.0.0"
"elm-explorations/test": "2.2.0 <= v < 3.0.0"
}
}
40 changes: 22 additions & 18 deletions src/Ansi.elm
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,14 @@ processHyperlinkParts params uri =

processedUri =
if String.any (\c -> Char.toCode c > 127) uri then
Url.percentEncode uri
String.toList uri
|> List.map (\c ->
if Char.toCode c > 127 then
Url.percentEncode (String.fromChar c)
else
String.fromChar c
)
|> String.join ""
else
uri
in
Expand All @@ -341,14 +348,21 @@ parseOSCWithState hasActiveLink str =
([], False)
-- Check for hyperlink start sequence "8;params;uri"
else if String.startsWith "8;" str then
-- Parse a standard hyperlink format
-- Split only on first 2 semicolons to preserve semicolons in URI
case String.split ";" str of
-- Should be ["8", params, uri]
"8" :: params :: uri :: [] ->
"8" :: params :: rest ->
-- URI might contain semicolons, so rejoin remaining parts
let
actions = processHyperlinkParts params uri
uri = String.join ";" rest
in
(actions, not (List.isEmpty actions))
if String.isEmpty uri then
-- Invalid format: no URI provided
([], hasActiveLink)
else
let
actions = processHyperlinkParts params uri
in
(actions, not (List.isEmpty actions))
_ ->
([], hasActiveLink)
else
Expand Down Expand Up @@ -391,17 +405,7 @@ parseChar char parser =
Parser (OSC "") hasLink model update

"\\" ->
case parser of
Parser (OSCTerminating chars) _ _ _ ->
-- Complete the OSC sequence (ESC+backslash terminator)
let
(actions, newHasLink) = parseOSCWithState hasLink chars
in
completeBracketed parser newHasLink actions

_ ->
-- Not an OSC terminator, just backslash
Parser (Unescaped "\\") hasLink model update
Parser (Unescaped "\\") hasLink model update

_ ->
Parser (Unescaped char) hasLink model update
Expand Down Expand Up @@ -469,7 +473,7 @@ parseChar char parser =

"G" ->
completeBracketed parser hasLink
[ CursorColumn (Maybe.withDefault 0 currentCode) ]
[ CursorColumn (max 0 (Maybe.withDefault 1 currentCode - 1)) ]

"H" ->
completeBracketed parser hasLink <|
Expand Down
2 changes: 1 addition & 1 deletion src/Ansi/Log.elm
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ handleAction action model =
Ansi.EraseToBeginning ->
let
chunk =
Chunk (String.repeat model.position.column " ") model.style model.currentLinkParams model.currentLinkUrl
Chunk (String.repeat model.position.column " ") model.style [] Nothing

updatedChunk =
writeChunk 0 chunk
Expand Down
182 changes: 176 additions & 6 deletions tests/Tests.elm
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ parsing =
, Ansi.CursorColumn 0
, Ansi.CursorUp 5
, Ansi.CursorColumn 0
, Ansi.CursorColumn 1
, Ansi.CursorColumn 50
, Ansi.CursorColumn 0
, Ansi.CursorColumn 49
]
(Ansi.parse "\u{001B}[E\u{001B}[5F\u{001B}[1G\u{001B}[50G")
, test "cursor position save/restore" <|
Expand Down Expand Up @@ -372,9 +372,18 @@ hyperlinkTests =
List.any (\chunk -> chunk.linkParams == ["id=test"]) idChunks
in
Expect.all
[ \_ -> Expect.true "Should contain a chunk with the link URL" hasLink
, \_ -> Expect.true "Should contain a red-styled link" hasStyledLink
, \_ -> Expect.true "Should contain a chunk with id parameter" hasIdParam
[ \_ ->
hasLink
|> Expect.equal True
|> Expect.onFail "Should contain a chunk with the link URL"
, \_ ->
hasStyledLink
|> Expect.equal True
|> Expect.onFail "Should contain a red-styled link"
, \_ ->
hasIdParam
|> Expect.equal True
|> Expect.onFail "Should contain a chunk with id parameter"
]
()

Expand All @@ -396,6 +405,167 @@ hyperlinkTests =
in
Expect.equal 2 (List.length linkChunks)
]

, describe "URL Handling"
[ test "URL with semicolons in query string" <|
\() ->
let
result = Ansi.parse "\u{001B}]8;;http://example.com?a=1;b=2;c=3\u{0007}link\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart [] "http://example.com?a=1;b=2;c=3"
, Ansi.Print "link"
, Ansi.HyperlinkEnd
]
result

, test "URL with semicolons and parameters" <|
\() ->
let
result = Ansi.parse "\u{001B}]8;id=test;http://example.com?foo=bar;baz=qux\u{0007}link\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart ["id=test"] "http://example.com?foo=bar;baz=qux"
, Ansi.Print "link"
, Ansi.HyperlinkEnd
]
result

, test "URL with multiple semicolons in path and query" <|
\() ->
let
result = Ansi.parse "\u{001B}]8;;http://example.com/path;with;semicolons?q=a;b;c\u{0007}link\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart [] "http://example.com/path;with;semicolons?q=a;b;c"
, Ansi.Print "link"
, Ansi.HyperlinkEnd
]
result

, test "URL with non-ASCII characters" <|
\() ->
let
result = Ansi.parse "\u{001B}]8;;http://example.com/café\u{0007}link\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart [] "http://example.com/caf%C3%A9"
, Ansi.Print "link"
, Ansi.HyperlinkEnd
]
result

, test "URL with already percent-encoded content should not double-encode" <|
\() ->
let
-- URL already has %20, should not become %2520
result = Ansi.parse "\u{001B}]8;;http://example.com/file%20name/café\u{0007}link\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart [] "http://example.com/file%20name/caf%C3%A9"
, Ansi.Print "link"
, Ansi.HyperlinkEnd
]
result

, test "URL with mixed ASCII, percent-encoded, and non-ASCII" <|
\() ->
let
result = Ansi.parse "\u{001B}]8;;http://example.com/path%20one/中文/file%20two\u{0007}link\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart [] "http://example.com/path%20one/%E4%B8%AD%E6%96%87/file%20two"
, Ansi.Print "link"
, Ansi.HyperlinkEnd
]
result
]

, describe "Hyperlink State Management"
[ test "erased content should not inherit hyperlink state" <|
\() ->
let
model =
List.foldl Ansi.Log.update (Ansi.Log.init Ansi.Log.Raw)
[ "\u{001B}]8;;http://example.com\u{0007}link text here"
, "\u{001B}[1K" -- Erase to beginning
]

firstLine =
Array.get 0 model.lines |> Maybe.withDefault ([], 0)

chunks =
Tuple.first firstLine

-- Find chunks that are just spaces (erased content)
spaceChunks =
List.filter (\chunk -> String.all (\c -> c == ' ') chunk.text) chunks

-- Erased spaces should NOT have link URLs
spacesHaveNoLinks =
List.all (\chunk -> chunk.linkUrl == Nothing) spaceChunks
in

spacesHaveNoLinks
|> Expect.equal True
|> Expect.onFail "Erased spaces should not be hyperlinks"


, test "line erase with active link should clear link from erased area" <|
\() ->
let
model =
List.foldl Ansi.Log.update (Ansi.Log.init Ansi.Log.Raw)
[ "prefix\u{001B}]8;;http://example.com\u{0007}link"
, "\u{001B}[6D" -- Move cursor back 6
, "\u{001B}[1K" -- Erase to beginning
]

firstLine =
Array.get 0 model.lines |> Maybe.withDefault ([], 0)

chunks =
Tuple.first firstLine

-- All space chunks should have no links
allSpacesNoLinks =
chunks
|> List.filter (\chunk -> String.all (\c -> c == ' ') chunk.text)
|> List.all (\chunk -> chunk.linkUrl == Nothing)
in

allSpacesNoLinks
|> Expect.equal True
|> Expect.onFail "Erased content should not be hyperlinked"

, test "multiple sequential links work correctly" <|
\() ->
let
result = Ansi.parse "\u{001B}]8;;http://link1.com\u{0007}first\u{001B}]8;;\u{0007} text \u{001B}]8;;http://link2.com\u{0007}second\u{001B}]8;;\u{0007}"
in
Expect.equal
[ Ansi.HyperlinkStart [] "http://link1.com"
, Ansi.Print "first"
, Ansi.HyperlinkEnd
, Ansi.Print " text "
, Ansi.HyperlinkStart [] "http://link2.com"
, Ansi.Print "second"
, Ansi.HyperlinkEnd
]
result

, test "hyperlink end without active link is ignored" <|
\() ->
let
result = Ansi.parse "text\u{001B}]8;;\u{0007} more text"
in
Expect.equal
[ Ansi.Print "text"
-- HyperlinkEnd should be silently ignored
, Ansi.Print " more text"
]
result
]
]


Expand Down Expand Up @@ -593,7 +763,7 @@ log =
, test "cursor movement (not ANSI.SYS)" <|
\() ->
assertWindowRendersAs Ansi.Log.Raw
"\u{001B}[0mONE\u{000D}\n\u{001B}[0mtwo\u{000D}\n\u{001B}[0mxyree\u{000D}\n\u{001B}[0mfour\u{000D}\n\u{001B}[0mxyz"
"\u{001B}[0mONE\u{000D}\n\u{001B}[0mtwo\u{000D}\n\u{001B}[0myhree\u{000D}\n\u{001B}[0mfour\u{000D}\n\u{001B}[0mxyz"
[ "one\u{000D}\ntwo\u{000D}\nthree\u{000D}\nfour\u{000D}\nxyz\u{000D}"
, "\u{001B}[4FONE"
, "\u{001B}[2Ex"
Expand Down
86 changes: 59 additions & 27 deletions yarn.lock
Original file line number Diff line number Diff line change
@@ -1,33 +1,65 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!

__metadata:
version: 8
cacheKey: 10c0

"@elm_binaries/darwin_arm64@0.19.1-0":
version "0.19.1-0"
resolved "https://registry.yarnpkg.com/@elm_binaries/darwin_arm64/-/darwin_arm64-0.19.1-0.tgz#ea09fca7cdfedad3fa517267099d78d180f01098"
integrity sha512-mjbsH7BNHEAmoE2SCJFcfk5fIHwFIpxtSgnEAqMsVLpBUFoEtAeX+LQ+N0vSFJB3WAh73+QYx/xSluxxLcL6dA==
"@elm_binaries/darwin_arm64@npm:0.19.1-0":
version: 0.19.1-0
resolution: "@elm_binaries/darwin_arm64@npm:0.19.1-0"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard

"@elm_binaries/darwin_x64@0.19.1-0":
version "0.19.1-0"
resolved "https://registry.yarnpkg.com/@elm_binaries/darwin_x64/-/darwin_x64-0.19.1-0.tgz#8c3254590804c35ee7b97b4d8a21a63318511c44"
integrity sha512-QGUtrZTPBzaxgi9al6nr+9313wrnUVHuijzUK39UsPS+pa+n6CmWyV/69sHZeX9qy6UfeugE0PzF3qcUiy2GDQ==
"@elm_binaries/darwin_x64@npm:0.19.1-0":
version: 0.19.1-0
resolution: "@elm_binaries/darwin_x64@npm:0.19.1-0"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard

"@elm_binaries/linux_x64@0.19.1-0":
version "0.19.1-0"
resolved "https://registry.yarnpkg.com/@elm_binaries/linux_x64/-/linux_x64-0.19.1-0.tgz#95440341eee8f6bee2d88859b3bdd99f33ca88f0"
integrity sha512-T1ZrWVhg2kKAsi8caOd3vp/1A3e21VuCpSG63x8rDie50fHbCytTway9B8WHEdnBFv4mYWiA68dzGxYCiFmU2w==
"@elm_binaries/linux_x64@npm:0.19.1-0":
version: 0.19.1-0
resolution: "@elm_binaries/linux_x64@npm:0.19.1-0"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard

"@elm_binaries/win32_x64@0.19.1-0":
version "0.19.1-0"
resolved "https://registry.yarnpkg.com/@elm_binaries/win32_x64/-/win32_x64-0.19.1-0.tgz#3f8decfd6f5fbc46906f3f5d50561b6e5b33ac3a"
integrity sha512-yDleiXqSE9EcqKtd9SkC/4RIW8I71YsXzMPL79ub2bBPHjWTcoyyeBbYjoOB9SxSlArJ74HaoBApzT6hY7Zobg==
"@elm_binaries/win32_x64@npm:0.19.1-0":
version: 0.19.1-0
resolution: "@elm_binaries/win32_x64@npm:0.19.1-0"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard

elm@0.19.1-6:
version "0.19.1-6"
resolved "https://registry.yarnpkg.com/elm/-/elm-0.19.1-6.tgz#b6f51d0add8877de70592097a8f6efc4588c476a"
integrity sha512-mKYyierHICPdMx/vhiIacdPmTPnh889gjHOZ75ZAoCxo3lZmSWbGP8HMw78wyctJH0HwvTmeKhlYSWboQNYPeQ==
optionalDependencies:
"@elm_binaries/darwin_arm64" "0.19.1-0"
"@elm_binaries/darwin_x64" "0.19.1-0"
"@elm_binaries/linux_x64" "0.19.1-0"
"@elm_binaries/win32_x64" "0.19.1-0"
"elm@npm:0.19.1-6":
version: 0.19.1-6
resolution: "elm@npm:0.19.1-6"
dependencies:
"@elm_binaries/darwin_arm64": "npm:0.19.1-0"
"@elm_binaries/darwin_x64": "npm:0.19.1-0"
"@elm_binaries/linux_x64": "npm:0.19.1-0"
"@elm_binaries/win32_x64": "npm:0.19.1-0"
dependenciesMeta:
"@elm_binaries/darwin_arm64":
optional: true
"@elm_binaries/darwin_x64":
optional: true
"@elm_binaries/linux_x64":
optional: true
"@elm_binaries/win32_x64":
optional: true
bin:
elm: bin/elm
checksum: 10c0/8fe067b18d66afcc6d843d49ed7fbce7ac93bbdaf9b53fafb57c3396a763ae23b6c8a69346143b6d59bf97e2e57d207a578bd6d4600952babff9934b8643c4c4
languageName: node
linkType: hard

"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
elm: "npm:0.19.1-6"
languageName: unknown
linkType: soft