diff --git a/elm.json b/elm.json index f6b6a57..f1d1bf3 100644 --- a/elm.json +++ b/elm.json @@ -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" } } diff --git a/src/Ansi.elm b/src/Ansi.elm index b566f0f..ba5627a 100644 --- a/src/Ansi.elm +++ b/src/Ansi.elm @@ -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 @@ -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 @@ -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 @@ -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 <| diff --git a/src/Ansi/Log.elm b/src/Ansi/Log.elm index 56a1e15..561a06d 100644 --- a/src/Ansi/Log.elm +++ b/src/Ansi/Log.elm @@ -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 diff --git a/tests/Tests.elm b/tests/Tests.elm index 1d930ba..0bcfc71 100644 --- a/tests/Tests.elm +++ b/tests/Tests.elm @@ -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" <| @@ -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" ] () @@ -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 + ] ] @@ -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" diff --git a/yarn.lock b/yarn.lock index 1b855b2..a8cb855 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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