diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index 0fd8289..df2c7c4 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -9,6 +9,7 @@ open Ionide.LanguageServerProtocol.Logging open Marksman.Misc open Marksman.Paths open Marksman.Names +open Marksman.Cst open Marksman.Doc open Marksman.Index open Marksman.Folder @@ -36,6 +37,16 @@ let createFile newFileUri : WorkspaceEdit = { Changes = None; DocumentChanges = Some documentChanges } +type MultiEditAction = { name: string; edits: list } + +let multiDocumentEdit (edits: list) (documentUri: DocumentUri) : WorkspaceEdit = + let textEdits = + edits + |> List.map (fun (range, text) -> { NewText = text; Range = range }) + |> Array.ofList + + { Changes = Some(Map.ofList [ documentUri, textEdits ]); DocumentChanges = None } + let tableOfContentsInner (includeLevels: array) (doc: Doc) : DocumentAction option = match TableOfContents.mk includeLevels (Doc.index doc) with | Some toc -> @@ -108,7 +119,6 @@ let tableOfContentsInner (includeLevels: array) (doc: Doc) : DocumentAction | _ -> None - let tableOfContents (_range: Range) (_context: CodeActionContext) @@ -161,3 +171,50 @@ let createMissingFile // create the file { name = $"Create `{filename}`"; newFileUri = uri } } + +let linkToReference (range: Range) (_context: CodeActionContext) (doc: Doc) : MultiEditAction option = + let mkLabel (text: string) = + text + |> String.toLower + |> String.replace " " "-" + |> String.replace "_" "-" + |> String.replace "." "-" + + let hasUrl (url: UrlEncodedNode) (def: Node) = url.text.Equals(def.data.url.text) + + let getAction (link: Node) : MultiEditAction option = + match link.data with + | MdLink.IL(text, Some url, title) -> + let existingDef = (Doc.index doc).linkDefs |> Seq.tryFind (hasUrl url) + + match existingDef with + | Some def -> + Some { + name = $"Replace link with reference `{def.data.label.text}`" + edits = [ Node.range link, $"[{text.text}][{def.data.label.text}]" ] + } + | None -> + let label = mkLabel text.text + + let titleSuffix = + match title with + | Some t -> $" \"{t.text}\"" + | None -> "" + + let refDefText = $"[{label}]: {url.text}{titleSuffix}" + + let numLines = (Doc.text doc).lineMap.NumLines + let refRange = Range.Mk(numLines, 0, numLines, 0) + + Some { + name = $"Convert link to new reference `{label}`" + edits = [ + Node.range link, $"[{text.text}][{label}]" + refRange, $"{NewLine}{refDefText}" + ] + } + | _ -> None + + (Doc.index doc).mdLinks + |> Seq.tryFind (fun node -> node.range.ContainsInclusive(range.Start)) + |> Option.bind getAction diff --git a/Marksman/Config.fs b/Marksman/Config.fs index a609f34..9ae097a 100644 --- a/Marksman/Config.fs +++ b/Marksman/Config.fs @@ -147,6 +147,7 @@ type Config = { caTocEnable: option caTocInclude: option> caCreateMissingFileEnable: option + caLinkToReferenceEnable: option coreMarkdownFileExtensions: option> coreMarkdownGlfmHeadingIdsEnable: option coreTextSync: option @@ -161,6 +162,7 @@ type Config = { caTocEnable = Some true caTocInclude = Some [| 1; 2; 3; 4; 5; 6 |] caCreateMissingFileEnable = Some true + caLinkToReferenceEnable = Some true coreMarkdownFileExtensions = Some [| "md"; "markdown" |] coreMarkdownGlfmHeadingIdsEnable = Some true coreTextSync = Some Full @@ -175,6 +177,7 @@ type Config = { caTocEnable = None caTocInclude = None caCreateMissingFileEnable = None + caLinkToReferenceEnable = None coreMarkdownFileExtensions = None coreMarkdownGlfmHeadingIdsEnable = None coreTextSync = None @@ -195,6 +198,11 @@ type Config = { |> Option.orElse Config.Default.caTocInclude |> Option.get + member this.CaLinkToReferenceEnable() = + this.caLinkToReferenceEnable + |> Option.orElse Config.Default.caLinkToReferenceEnable + |> Option.get + member this.CaCreateMissingFileEnable() = this.caCreateMissingFileEnable |> Option.orElse Config.Default.caCreateMissingFileEnable @@ -255,6 +263,9 @@ let private configOfTable (table: TomlTable) : LookupResult = let! caCreateMissingFileEnable = getFromTableOpt table [] [ "code_action"; "create_missing_file"; "enable" ] + let! caLinkToReferenceEnable = + getFromTableOpt table [] [ "code_action"; "link_to_reference"; "enable" ] + let! coreMarkdownFileExtensions = getFromTableOpt> table [] [ "core"; "markdown"; "file_extensions" ] @@ -296,6 +307,7 @@ let private configOfTable (table: TomlTable) : LookupResult = caTocEnable = caTocEnable caTocInclude = caTocInclude caCreateMissingFileEnable = caCreateMissingFileEnable + caLinkToReferenceEnable = caLinkToReferenceEnable coreMarkdownFileExtensions = coreMarkdownFileExtensions coreMarkdownGlfmHeadingIdsEnable = coreMarkdownGlfmHeadingIdsEnable coreTextSync = coreTextSync @@ -313,6 +325,9 @@ module Config = let merge hi low = { caTocEnable = hi.caTocEnable |> Option.orElse low.caTocEnable caTocInclude = hi.caTocInclude |> Option.orElse low.caTocInclude + caLinkToReferenceEnable = + hi.caLinkToReferenceEnable + |> Option.orElse low.caLinkToReferenceEnable caCreateMissingFileEnable = hi.caCreateMissingFileEnable |> Option.orElse low.caCreateMissingFileEnable diff --git a/Marksman/Server.fs b/Marksman/Server.fs index 0ff978e..127fc25 100644 --- a/Marksman/Server.fs +++ b/Marksman/Server.fs @@ -955,8 +955,21 @@ type MarksmanServer(client: MarksmanClient) = else [||] + let linkToReferenceAction = + if config.CaLinkToReferenceEnable() then + CodeActions.linkToReference opts.Range opts.Context doc + |> Option.toArray + |> Array.map (fun ca -> + let wsEdit = + CodeActions.multiDocumentEdit ca.edits opts.TextDocument.Uri + + let caKind = Some CodeActionKind.RefactorRewrite + codeAction ca.name caKind wsEdit) + else + [||] + let codeActions: TextDocumentCodeActionResult = - Array.concat [| tocAction; createMissingFileAction |] + Array.concat [| tocAction; createMissingFileAction; linkToReferenceAction |] |> Array.map U2.Second Mutation.output (LspResult.success (Some codeActions)) diff --git a/Tests/CodeActionTests.fs b/Tests/CodeActionTests.fs index c1baee9..356e05c 100644 --- a/Tests/CodeActionTests.fs +++ b/Tests/CodeActionTests.fs @@ -1,10 +1,13 @@ module Marksman.CodeActionTests +open type System.Environment + open Ionide.LanguageServerProtocol.Types open Xunit open Marksman.Helpers open Marksman.Misc +open Marksman.Doc module CreateMissingFileTests = [] @@ -34,3 +37,147 @@ module CreateMissingFileTests = CodeActions.createMissingFile (Range.Mk(0, 3, 0, 3)) caCtx doc2 folder Assert.Equal(None, ca) + +module LinkToReferenceTests = + let caCtx = { Diagnostics = [||]; Only = None; TriggerKind = None } + + [] + let shouldConvertWhenReferenceExists () = + let doc = + FakeDoc.Mk( + [| + "[link][ref]" + "[inline](https://link/)" + "" + "[ref]: https://link/" + |], + path = "doc.md" + ) + + let ca = CodeActions.linkToReference (Range.Mk(1, 4, 1, 4)) caCtx doc + + let expected: CodeActions.MultiEditAction option = + Some { + name = "Replace link with reference `ref`" + edits = [ Range.Mk(1, 0, 1, 23), "[inline][ref]" ] + } + + Assert.Equal(expected, ca) + + [] + let shouldCreateWhenReferenceDoesNotExist () = + let doc = + FakeDoc.Mk( + [| + "[link][ref]" + "[inline Link_Thing](https://link/)" + "" + "[ref]: https://other/" + |], + path = "doc.md" + ) + + let ca = CodeActions.linkToReference (Range.Mk(1, 4, 1, 4)) caCtx doc + + let expected: CodeActions.MultiEditAction option = + Some { + name = "Convert link to new reference `inline-link-thing`" + edits = [ + Range.Mk(1, 0, 1, 34), "[inline Link_Thing][inline-link-thing]" + Range.Mk(4, 0, 4, 0), $"{NewLine}[inline-link-thing]: https://link/" + ] + } + + Assert.Equal(expected, ca) + + [] + let shouldReturnNoneWhenCursorNotOnLink () = + let doc = FakeDoc.Mk([| "some plain text"; "[inline](https://link/)" |], path = "doc.md") + + let ca = CodeActions.linkToReference (Range.Mk(0, 5, 0, 5)) caCtx doc + + Assert.Equal(None, ca) + + [] + let shouldReturnNoneWhenCursorOnReferenceLink () = + let doc = + FakeDoc.Mk( + [| "[link][ref]"; ""; "[ref]: https://link/" |], + path = "doc.md" + ) + + let ca = CodeActions.linkToReference (Range.Mk(0, 3, 0, 3)) caCtx doc + + Assert.Equal(None, ca) + + [] + let shouldReturnNoneWhenInlineLinkHasNoUrl () = + let doc = FakeDoc.Mk([| "[text]()" |], path = "doc.md") + + let ca = CodeActions.linkToReference (Range.Mk(0, 3, 0, 3)) caCtx doc + + Assert.Equal(None, ca) + + [] + let shouldIncludeTitleInNewReference () = + let doc = + FakeDoc.Mk( + [| "[My Link](https://example.com \"My Title\")" |], + path = "doc.md" + ) + + let ca = CodeActions.linkToReference (Range.Mk(0, 3, 0, 3)) caCtx doc + + let expected: CodeActions.MultiEditAction option = + Some { + name = "Convert link to new reference `my-link`" + edits = [ + Range.Mk(0, 0, 0, 41), "[My Link][my-link]" + Range.Mk(1, 0, 1, 0), + $"{NewLine}[my-link]: https://example.com \"My Title\"" + ] + } + + Assert.Equal(expected, ca) + + [] + let shouldConvertCorrectLinkWhenMultipleExist () = + let doc = + FakeDoc.Mk( + [| + "[first](https://first.com)" + "[second](https://second.com)" + |], + path = "doc.md" + ) + + // Cursor on the second link + let ca = CodeActions.linkToReference (Range.Mk(1, 5, 1, 5)) caCtx doc + + let expected: CodeActions.MultiEditAction option = + Some { + name = "Convert link to new reference `second`" + edits = [ + Range.Mk(1, 0, 1, 28), "[second][second]" + Range.Mk(2, 0, 2, 0), $"{NewLine}[second]: https://second.com" + ] + } + + Assert.Equal(expected, ca) + + [] + let shouldConvertSingleLinkDocument () = + let doc = FakeDoc.Mk([| "[text](https://url.com)" |], path = "doc.md") + + let ca = CodeActions.linkToReference (Range.Mk(0, 3, 0, 3)) caCtx doc + + let expected: CodeActions.MultiEditAction option = + Some { + name = "Convert link to new reference `text`" + edits = [ + Range.Mk(0, 0, 0, 23), "[text][text]" + Range.Mk(1, 0, 1, 0), $"{NewLine}[text]: https://url.com" + ] + } + + Assert.Equal(expected, ca) diff --git a/Tests/default.marksman.toml b/Tests/default.marksman.toml index 37b4022..768c841 100644 --- a/Tests/default.marksman.toml +++ b/Tests/default.marksman.toml @@ -44,6 +44,9 @@ toc.include = [1, 2, 3, 4, 5, 6] # Enable/disable "Create missing linked file" code action create_missing_file.enable = true +# Enable/disable "Convert inline link to reference" code action +link_to_reference.enable = true + [completion] # The maximum number of candidates returned for a completion candidates = 50