From 5f4c26b334bc52f80a1ef107ac11c03f7d1d85bc Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 13:18:58 +0300 Subject: [PATCH 1/9] feat: convert link to reference Closes #104 --- Marksman/CodeActions.fs | 96 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index 0fd8289..99235ed 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 @@ -108,7 +109,6 @@ let tableOfContentsInner (includeLevels: array) (doc: Doc) : DocumentAction | _ -> None - let tableOfContents (_range: Range) (_context: CodeActionContext) @@ -161,3 +161,97 @@ let createMissingFile // create the file { name = $"Create `{filename}`"; newFileUri = uri } } + +let linkToReference + (range: Range) + (context: CodeActionContext) + (doc: Doc) + : CodeAction option = + let getExistingRefAction + (link: Node, linkDef: Node) + : CodeAction option = + let title = "Replace link with reference #{label}" + let newText = linkDef.data.url.text + let edit = Node.range link + + Some { + Data = None; + Disabled = None; + IsPreferred = None + Command = None; + Title =title; + Kind = Some CodeActionKind.RefactorRewrite; + Diagnostics = None; + Edit = Some { + DocumentChanges = None; + Changes = Some Map[Doc.uri doc, [| + { + NewText = newText; + Range = edit + } + |]]; + } + } + + let getNonExistingRefAction (link: Node) : CodeAction option = + match link.data with + | MdLink.IL(_, url,title) -> + let label = (* convert text to dash case *) + title.Value.text + |> String.toLower + |> String.replace " " "-" + |> String.replace "_" "-" + |> String.replace "." "-" + let refText = $"[{label}]: {url.Value.text}"; + (* a new line at the end of the doc's text *) + let refRange = + let text = Doc.text doc + let line = text.lineMap.NumLines + 1 + Range.Mk(line, 0, line + 1, refText.Length) + Some { + Data = None; + Disabled = None; + IsPreferred = None + Command = None; + Title = $"Convert link to new reference {label}"; + Kind = Some CodeActionKind.RefactorRewrite; + Diagnostics = None; + Edit = Some { + DocumentChanges = None; + Changes = Some Map[Doc.uri doc, [| + { + NewText = $"[{title.Value.text}][{label}]"; + Range = Node.range link + }; + { + NewText = refText; + Range = refRange + } + |]]; + } + } + | _ -> None + + (* get the markdown link at the given range *) + doc.Index.mdLinks + |> Seq.filter(fun x -> x.data.IsIL) + |> Seq.tryFind (fun x -> + let range = Node.range x + range.Start <= range.Start && range.End >= range.End + ) + |> Option.bind (fun link -> + let linkDef = + doc.Index.linkDefs + |> Seq.tryFind ( + fun x -> + match link.data with + | MdLink.IL(url = u) -> + u.Value.text.Equals(x.data.url.text) + | (_) -> false) + match linkDef with + | Some (def) -> getExistingRefAction (link, def) + | None -> getNonExistingRefAction link + ) + + + From 370e3852c0cba83a4e0a30817788ae7cdeb0c77e Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 13:21:01 +0300 Subject: [PATCH 2/9] style: format --- Marksman/CodeActions.fs | 163 ++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 88 deletions(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index 99235ed..c1e9806 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -162,96 +162,83 @@ let createMissingFile { name = $"Create `{filename}`"; newFileUri = uri } } -let linkToReference - (range: Range) - (context: CodeActionContext) - (doc: Doc) - : CodeAction option = - let getExistingRefAction - (link: Node, linkDef: Node) - : CodeAction option = - let title = "Replace link with reference #{label}" - let newText = linkDef.data.url.text - let edit = Node.range link +let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : CodeAction option = + let getExistingRefAction (link: Node, linkDef: Node) : CodeAction option = + let title = "Replace link with reference #{label}" + let newText = linkDef.data.url.text + let edit = Node.range link + + Some { + Data = None + Disabled = None + IsPreferred = None + Command = None + Title = title + Kind = Some CodeActionKind.RefactorRewrite + Diagnostics = None + Edit = + Some { + DocumentChanges = None + Changes = Some Map[Doc.uri doc, [| { NewText = newText; Range = edit } |]] + } + } + + let getNonExistingRefAction (link: Node) : CodeAction option = + match link.data with + | MdLink.IL(_, url, title) -> + let label = (* convert text to dash case *) + title.Value.text + |> String.toLower + |> String.replace " " "-" + |> String.replace "_" "-" + |> String.replace "." "-" + + let refText = $"[{label}]: {url.Value.text}" + (* a new line at the end of the doc's text *) + let refRange = + let text = Doc.text doc + let line = text.lineMap.NumLines + 1 + Range.Mk(line, 0, line + 1, refText.Length) Some { - Data = None; - Disabled = None; + Data = None + Disabled = None IsPreferred = None - Command = None; - Title =title; - Kind = Some CodeActionKind.RefactorRewrite; - Diagnostics = None; - Edit = Some { - DocumentChanges = None; - Changes = Some Map[Doc.uri doc, [| - { - NewText = newText; - Range = edit - } - |]]; - } - } - - let getNonExistingRefAction (link: Node) : CodeAction option = - match link.data with - | MdLink.IL(_, url,title) -> - let label = (* convert text to dash case *) - title.Value.text - |> String.toLower - |> String.replace " " "-" - |> String.replace "_" "-" - |> String.replace "." "-" - let refText = $"[{label}]: {url.Value.text}"; - (* a new line at the end of the doc's text *) - let refRange = - let text = Doc.text doc - let line = text.lineMap.NumLines + 1 - Range.Mk(line, 0, line + 1, refText.Length) + Command = None + Title = $"Convert link to new reference {label}" + Kind = Some CodeActionKind.RefactorRewrite + Diagnostics = None + Edit = Some { - Data = None; - Disabled = None; - IsPreferred = None - Command = None; - Title = $"Convert link to new reference {label}"; - Kind = Some CodeActionKind.RefactorRewrite; - Diagnostics = None; - Edit = Some { - DocumentChanges = None; - Changes = Some Map[Doc.uri doc, [| - { - NewText = $"[{title.Value.text}][{label}]"; - Range = Node.range link - }; - { - NewText = refText; - Range = refRange - } - |]]; - } + DocumentChanges = None + Changes = + Some + Map[Doc.uri doc, + [| + { + NewText = $"[{title.Value.text}][{label}]" + Range = Node.range link + } + { NewText = refText; Range = refRange } + |]] } - | _ -> None - - (* get the markdown link at the given range *) - doc.Index.mdLinks - |> Seq.filter(fun x -> x.data.IsIL) - |> Seq.tryFind (fun x -> - let range = Node.range x - range.Start <= range.Start && range.End >= range.End - ) - |> Option.bind (fun link -> - let linkDef = - doc.Index.linkDefs - |> Seq.tryFind ( - fun x -> - match link.data with - | MdLink.IL(url = u) -> - u.Value.text.Equals(x.data.url.text) - | (_) -> false) - match linkDef with - | Some (def) -> getExistingRefAction (link, def) - | None -> getNonExistingRefAction link - ) - - - + } + | _ -> None + + (* get the markdown link at the given range *) + doc.Index.mdLinks + |> Seq.filter (fun x -> x.data.IsIL) + |> Seq.tryFind (fun x -> + let range = Node.range x + range.Start <= range.Start && range.End >= range.End) + |> Option.bind (fun link -> + let linkDef = + doc.Index.linkDefs + |> Seq.tryFind (fun x -> + match link.data with + | MdLink.IL(url = u) -> u.Value.text.Equals(x.data.url.text) + | (_) -> false) + + match linkDef with + | Some(def) -> getExistingRefAction (link, def) + | None -> getNonExistingRefAction link) From 46d92e50256959bf1896acb501a0aa60cf749314 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 13:22:44 +0300 Subject: [PATCH 3/9] fix: remove nonexistent check --- Marksman/CodeActions.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index c1e9806..3f7beeb 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -227,7 +227,6 @@ let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : Cod (* get the markdown link at the given range *) doc.Index.mdLinks - |> Seq.filter (fun x -> x.data.IsIL) |> Seq.tryFind (fun x -> let range = Node.range x range.Start <= range.Start && range.End >= range.End) From 3358d96f983ba1d1ee9fa2ccd073e63439349aad Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 13:38:27 +0300 Subject: [PATCH 4/9] style: format --- Marksman/CodeActions.fs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index 3f7beeb..f0d59bf 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -225,12 +225,7 @@ let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : Cod } | _ -> None - (* get the markdown link at the given range *) - doc.Index.mdLinks - |> Seq.tryFind (fun x -> - let range = Node.range x - range.Start <= range.Start && range.End >= range.End) - |> Option.bind (fun link -> + let getAction (link: Node) : CodeAction option = let linkDef = doc.Index.linkDefs |> Seq.tryFind (fun x -> @@ -240,4 +235,12 @@ let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : Cod match linkDef with | Some(def) -> getExistingRefAction (link, def) - | None -> getNonExistingRefAction link) + | None -> getNonExistingRefAction link + + let isInRange (range: Range) token = + token.range.Start >= range.Start && token.range.End <= range.End + + (* get the markdown link at the given range *) + doc.Index.mdLinks + |> Seq.tryFind (isInRange range) + |> Option.bind getAction From 1c121003a4abfb3b3f804944408a0ada42381081 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 13:48:43 +0300 Subject: [PATCH 5/9] fix: add config --- Marksman/Config.fs | 15 +++++++++++++++ Marksman/Server.fs | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) 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..98f2503 100644 --- a/Marksman/Server.fs +++ b/Marksman/Server.fs @@ -955,8 +955,15 @@ type MarksmanServer(client: MarksmanClient) = else [||] + let linkToReferenceAction = + if config.CaLinkToReferenceEnable() then + CodeActions.linkToReference opts.Range opts.Context doc + |> Option.toArray + else + [||] + let codeActions: TextDocumentCodeActionResult = - Array.concat [| tocAction; createMissingFileAction |] + Array.concat [| tocAction; createMissingFileAction; linkToReferenceAction |] |> Array.map U2.Second Mutation.output (LspResult.success (Some codeActions)) From 2b17da122433febaaa967ed9a62bf5cc26bd7fe4 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 15:24:34 +0300 Subject: [PATCH 6/9] fix: link to reference adds tests --- Marksman/CodeActions.fs | 124 ++++++++++++++++++++++----------------- Tests/CodeActionTests.fs | 42 +++++++++++++ 2 files changed, 113 insertions(+), 53 deletions(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index f0d59bf..78f0ef1 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -164,50 +164,16 @@ let createMissingFile let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : CodeAction option = let getExistingRefAction (link: Node, linkDef: Node) : CodeAction option = - let title = "Replace link with reference #{label}" - let newText = linkDef.data.url.text - let edit = Node.range link - - Some { - Data = None - Disabled = None - IsPreferred = None - Command = None - Title = title - Kind = Some CodeActionKind.RefactorRewrite - Diagnostics = None - Edit = - Some { - DocumentChanges = None - Changes = Some Map[Doc.uri doc, [| { NewText = newText; Range = edit } |]] - } - } - - let getNonExistingRefAction (link: Node) : CodeAction option = match link.data with - | MdLink.IL(_, url, title) -> - let label = (* convert text to dash case *) - title.Value.text - |> String.toLower - |> String.replace " " "-" - |> String.replace "_" "-" - |> String.replace "." "-" - - let refText = $"[{label}]: {url.Value.text}" - (* a new line at the end of the doc's text *) - let refRange = - let text = Doc.text doc - let line = text.lineMap.NumLines + 1 - Range.Mk(line, 0, line + 1, refText.Length) - + | MdLink.IL(text, _, _) -> Some { + Title = $"Replace link with reference `{linkDef.data.label.text}`" + Kind = Some CodeActionKind.RefactorRewrite + Command = None Data = None + Diagnostics = None Disabled = None IsPreferred = None - Command = None - Title = $"Convert link to new reference {label}" - Kind = Some CodeActionKind.RefactorRewrite - Diagnostics = None Edit = Some { DocumentChanges = None @@ -216,31 +182,83 @@ let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : Cod Map[Doc.uri doc, [| { - NewText = $"[{title.Value.text}][{label}]" Range = Node.range link + NewText = $"[{text.text}][{linkDef.data.label.text}]" } - { NewText = refText; Range = refRange } |]] } } | _ -> None + let getNonExistingRefAction (link: Node) : CodeAction option = + match link.data with + | MdLink.IL(_, url, title) -> + match title with + | Some(Value = t) -> + let label = + t.text + |> String.toLower + |> String.replace " " "-" + |> String.replace "_" "-" + |> String.replace "." "-" + + let refText = $"[{label}]: {url.Value.text}" + (* a new line at the end of the doc's text *) + let refRange = + let text = Doc.text doc + let line = text.lineMap.NumLines + 1 + Range.Mk(line, 0, line + 1, refText.Length) + + Some { + Data = None + Disabled = None + IsPreferred = None + Command = None + Title = $"Convert link to new reference {label}" + Kind = Some CodeActionKind.RefactorRewrite + Diagnostics = None + Edit = + Some { + DocumentChanges = None + Changes = + Some + Map[Doc.uri doc, + [| + { + NewText = $"[{title.Value.text}][{label}]" + Range = Node.range link + } + { NewText = refText; Range = refRange } + |]] + } + } + | None -> None + + | _ -> None + + let hasUrl (url: UrlEncodedNode) (x: Node) = url.text.Equals(x.data.url.text) + let getAction (link: Node) : CodeAction option = - let linkDef = - doc.Index.linkDefs - |> Seq.tryFind (fun x -> - match link.data with - | MdLink.IL(url = u) -> u.Value.text.Equals(x.data.url.text) - | (_) -> false) + match link.data with + | MdLink.IL(_, Some(url), _) -> + let linkDef = doc.Index.linkDefs |> Seq.tryFind (hasUrl url) + + match linkDef with + | Some(def) -> getExistingRefAction (link, def) + | None -> getNonExistingRefAction link + | _ -> None - match linkDef with - | Some(def) -> getExistingRefAction (link, def) - | None -> getNonExistingRefAction link + let isInRange (range: Range) (node: Node) = + range.Start.Line >= node.range.Start.Line + && range.End.Line <= node.range.End.Line - let isInRange (range: Range) token = - token.range.Start >= range.Start && token.range.End <= range.End + let isInlineLink (node: Node) = + match node.data with + | MdLink.IL(_, _, _) -> true + | _ -> false (* get the markdown link at the given range *) doc.Index.mdLinks - |> Seq.tryFind (isInRange range) + |> Seq.filter (isInRange range) + |> Seq.tryFind isInlineLink |> Option.bind getAction diff --git a/Tests/CodeActionTests.fs b/Tests/CodeActionTests.fs index c1baee9..2410103 100644 --- a/Tests/CodeActionTests.fs +++ b/Tests/CodeActionTests.fs @@ -5,6 +5,7 @@ open Xunit open Marksman.Helpers open Marksman.Misc +open Marksman.Doc module CreateMissingFileTests = [] @@ -34,3 +35,44 @@ module CreateMissingFileTests = CodeActions.createMissingFile (Range.Mk(0, 3, 0, 3)) caCtx doc2 folder Assert.Equal(None, ca) + +module LinkToReferenceTests = + [] + let shouldConvertWhenReferenceExits () = + let doc = + FakeDoc.Mk( + [| + "[link][ref]" + "[inline](https://link/)" + "" + "[ref]: https://link/" + |], + path = "doc.md" + ) + + let caCtx = { Diagnostics = [||]; Only = None; TriggerKind = None } + + let ca = CodeActions.linkToReference (Range.Mk(1, 4, 1, 4)) caCtx doc + + let expected = + Some { + Title = "Replace link with reference `ref`" + Kind = Some CodeActionKind.RefactorRewrite + Command = None + Data = None + Diagnostics = None + Disabled = None + IsPreferred = None + Edit = + Some { + DocumentChanges = None + Changes = + Some + Map[Doc.uri doc, + [| + { Range = Range.Mk(1, 0, 1, 23); NewText = "[inline][ref]" } + |]] + } + } + + Assert.Equivalent(expected, ca) From 1a499a5d16a2e86b1c318e4dda5ee804a4f4af7f Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 15:35:18 +0300 Subject: [PATCH 7/9] fix: create new reference --- Marksman/CodeActions.fs | 79 +++++++++++++++++++--------------------- Tests/CodeActionTests.fs | 48 ++++++++++++++++++++++++ Tests/Tests.fsproj | 1 - 3 files changed, 86 insertions(+), 42 deletions(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index 78f0ef1..456273b 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -192,48 +192,45 @@ let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : Cod let getNonExistingRefAction (link: Node) : CodeAction option = match link.data with - | MdLink.IL(_, url, title) -> - match title with - | Some(Value = t) -> - let label = - t.text - |> String.toLower - |> String.replace " " "-" - |> String.replace "_" "-" - |> String.replace "." "-" - - let refText = $"[{label}]: {url.Value.text}" - (* a new line at the end of the doc's text *) - let refRange = - let text = Doc.text doc - let line = text.lineMap.NumLines + 1 - Range.Mk(line, 0, line + 1, refText.Length) - - Some { - Data = None - Disabled = None - IsPreferred = None - Command = None - Title = $"Convert link to new reference {label}" - Kind = Some CodeActionKind.RefactorRewrite - Diagnostics = None - Edit = - Some { - DocumentChanges = None - Changes = - Some - Map[Doc.uri doc, - [| - { - NewText = $"[{title.Value.text}][{label}]" - Range = Node.range link - } - { NewText = refText; Range = refRange } - |]] - } - } - | None -> None + | MdLink.IL(text, url, title) -> + let label = + text.text + |> String.toLower + |> String.replace " " "-" + |> String.replace "_" "-" + |> String.replace "." "-" + + let refText = $"[{label}]: {url.Value.text}" + + (* a new line at the end of the doc's text *) + let refRange = + let text = Doc.text doc + let line = text.lineMap.NumLines + 1 + Range.Mk(line, 0, line + 1, refText.Length) + Some { + Data = None + Disabled = None + IsPreferred = None + Command = None + Title = $"Convert link to new reference `{label}`" + Kind = Some CodeActionKind.RefactorRewrite + Diagnostics = None + Edit = + Some { + DocumentChanges = None + Changes = + Some + Map[Doc.uri doc, + [| + { + Range = Node.range link + NewText = $"[{text.text}][{label}]" + } + { Range = refRange; NewText = refText } + |]] + } + } | _ -> None let hasUrl (url: UrlEncodedNode) (x: Node) = url.text.Equals(x.data.url.text) diff --git a/Tests/CodeActionTests.fs b/Tests/CodeActionTests.fs index 2410103..687c6b1 100644 --- a/Tests/CodeActionTests.fs +++ b/Tests/CodeActionTests.fs @@ -76,3 +76,51 @@ module LinkToReferenceTests = } Assert.Equivalent(expected, ca) + + [] + let shouldCreateWhenReferenceDoesNotExist () = + let doc = + FakeDoc.Mk( + [| + "[link][ref]" + "[inline Link_Thing](https://link/)" + "" + "[ref]: https://other/" + |], + path = "doc.md" + ) + + let caCtx = { Diagnostics = [||]; Only = None; TriggerKind = None } + + let ca = CodeActions.linkToReference (Range.Mk(1, 4, 1, 4)) caCtx doc + + let expected = + Some { + Title = "Convert link to new reference `inline-link-thing`" + Kind = Some CodeActionKind.RefactorRewrite + Command = None + Data = None + Diagnostics = None + Disabled = None + IsPreferred = None + Edit = + Some { + DocumentChanges = None + Changes = + Some + Map[Doc.uri doc, + [| + { + Range = Range.Mk(1, 0, + 1, 34) + NewText = "[inline Link_Thing][inline-link-thing]" + } + { + Range = Range.Mk(5, 0, 6, 34) + NewText = "[inline-link-thing]: https://link/" + } + |]] + } + } + + Assert.Equivalent(expected, ca) diff --git a/Tests/Tests.fsproj b/Tests/Tests.fsproj index c461365..a492b50 100644 --- a/Tests/Tests.fsproj +++ b/Tests/Tests.fsproj @@ -26,7 +26,6 @@ - From 54e07a5fc38d83c18797462334fe363bf64b66f7 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 27 Apr 2025 15:44:08 +0300 Subject: [PATCH 8/9] chore: revert test skip --- Tests/Tests.fsproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/Tests.fsproj b/Tests/Tests.fsproj index a492b50..c461365 100644 --- a/Tests/Tests.fsproj +++ b/Tests/Tests.fsproj @@ -26,6 +26,7 @@ + From 28d546e84f838d9d7e74d409130577a0562fa600 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Tue, 17 Mar 2026 17:59:11 +0200 Subject: [PATCH 9/9] refactor: improve convert-inline-link-to-reference code action - Return domain type (MultiEditAction) instead of LSP CodeAction, matching the pattern used by tableOfContents and createMissingFile - Fix unsafe Option.Value call by proper pattern matching - Fix reference definition range to insert at document end with proper newline separation - Fix range containment check to use ContainsInclusive instead of line-only comparison - Include link title in generated reference definitions - Add default.marksman.toml entry for link_to_reference config - Add comprehensive tests: cursor not on link, cursor on reference link, link without URL, link with title, multiple links, and single-link document Assisted-By: Claude Opus 4.6 (1M context) --- Marksman/CodeActions.fs | 141 ++++++++++++-------------------- Marksman/Server.fs | 6 ++ Tests/CodeActionTests.fs | 159 ++++++++++++++++++++++++------------ Tests/default.marksman.toml | 3 + 4 files changed, 167 insertions(+), 142 deletions(-) diff --git a/Marksman/CodeActions.fs b/Marksman/CodeActions.fs index 456273b..df2c7c4 100644 --- a/Marksman/CodeActions.fs +++ b/Marksman/CodeActions.fs @@ -37,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 -> @@ -162,100 +172,49 @@ let createMissingFile { name = $"Create `{filename}`"; newFileUri = uri } } -let linkToReference (range: Range) (context: CodeActionContext) (doc: Doc) : CodeAction option = - let getExistingRefAction (link: Node, linkDef: Node) : CodeAction option = - match link.data with - | MdLink.IL(text, _, _) -> - Some { - Title = $"Replace link with reference `{linkDef.data.label.text}`" - Kind = Some CodeActionKind.RefactorRewrite - Command = None - Data = None - Diagnostics = None - Disabled = None - IsPreferred = None - Edit = - Some { - DocumentChanges = None - Changes = - Some - Map[Doc.uri doc, - [| - { - Range = Node.range link - NewText = $"[{text.text}][{linkDef.data.label.text}]" - } - |]] - } - } - | _ -> None +let linkToReference (range: Range) (_context: CodeActionContext) (doc: Doc) : MultiEditAction option = + let mkLabel (text: string) = + text + |> String.toLower + |> String.replace " " "-" + |> String.replace "_" "-" + |> String.replace "." "-" - let getNonExistingRefAction (link: Node) : CodeAction option = - match link.data with - | MdLink.IL(text, url, title) -> - let label = - text.text - |> String.toLower - |> String.replace " " "-" - |> String.replace "_" "-" - |> String.replace "." "-" - - let refText = $"[{label}]: {url.Value.text}" - - (* a new line at the end of the doc's text *) - let refRange = - let text = Doc.text doc - let line = text.lineMap.NumLines + 1 - Range.Mk(line, 0, line + 1, refText.Length) - - Some { - Data = None - Disabled = None - IsPreferred = None - Command = None - Title = $"Convert link to new reference `{label}`" - Kind = Some CodeActionKind.RefactorRewrite - Diagnostics = None - Edit = - Some { - DocumentChanges = None - Changes = - Some - Map[Doc.uri doc, - [| - { - Range = Node.range link - NewText = $"[{text.text}][{label}]" - } - { Range = refRange; NewText = refText } - |]] - } - } - | _ -> None - - let hasUrl (url: UrlEncodedNode) (x: Node) = url.text.Equals(x.data.url.text) + let hasUrl (url: UrlEncodedNode) (def: Node) = url.text.Equals(def.data.url.text) - let getAction (link: Node) : CodeAction option = + let getAction (link: Node) : MultiEditAction option = match link.data with - | MdLink.IL(_, Some(url), _) -> - let linkDef = doc.Index.linkDefs |> Seq.tryFind (hasUrl url) - - match linkDef with - | Some(def) -> getExistingRefAction (link, def) - | None -> getNonExistingRefAction link + | 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 - let isInRange (range: Range) (node: Node) = - range.Start.Line >= node.range.Start.Line - && range.End.Line <= node.range.End.Line - - let isInlineLink (node: Node) = - match node.data with - | MdLink.IL(_, _, _) -> true - | _ -> false - - (* get the markdown link at the given range *) - doc.Index.mdLinks - |> Seq.filter (isInRange range) - |> Seq.tryFind isInlineLink + (Doc.index doc).mdLinks + |> Seq.tryFind (fun node -> node.range.ContainsInclusive(range.Start)) |> Option.bind getAction diff --git a/Marksman/Server.fs b/Marksman/Server.fs index 98f2503..127fc25 100644 --- a/Marksman/Server.fs +++ b/Marksman/Server.fs @@ -959,6 +959,12 @@ type MarksmanServer(client: MarksmanClient) = 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 [||] diff --git a/Tests/CodeActionTests.fs b/Tests/CodeActionTests.fs index 687c6b1..356e05c 100644 --- a/Tests/CodeActionTests.fs +++ b/Tests/CodeActionTests.fs @@ -1,5 +1,7 @@ module Marksman.CodeActionTests +open type System.Environment + open Ionide.LanguageServerProtocol.Types open Xunit @@ -37,8 +39,10 @@ module CreateMissingFileTests = Assert.Equal(None, ca) module LinkToReferenceTests = + let caCtx = { Diagnostics = [||]; Only = None; TriggerKind = None } + [] - let shouldConvertWhenReferenceExits () = + let shouldConvertWhenReferenceExists () = let doc = FakeDoc.Mk( [| @@ -50,32 +54,15 @@ module LinkToReferenceTests = path = "doc.md" ) - let caCtx = { Diagnostics = [||]; Only = None; TriggerKind = None } - let ca = CodeActions.linkToReference (Range.Mk(1, 4, 1, 4)) caCtx doc - let expected = + let expected: CodeActions.MultiEditAction option = Some { - Title = "Replace link with reference `ref`" - Kind = Some CodeActionKind.RefactorRewrite - Command = None - Data = None - Diagnostics = None - Disabled = None - IsPreferred = None - Edit = - Some { - DocumentChanges = None - Changes = - Some - Map[Doc.uri doc, - [| - { Range = Range.Mk(1, 0, 1, 23); NewText = "[inline][ref]" } - |]] - } + name = "Replace link with reference `ref`" + edits = [ Range.Mk(1, 0, 1, 23), "[inline][ref]" ] } - Assert.Equivalent(expected, ca) + Assert.Equal(expected, ca) [] let shouldCreateWhenReferenceDoesNotExist () = @@ -90,37 +77,107 @@ module LinkToReferenceTests = path = "doc.md" ) - let caCtx = { Diagnostics = [||]; Only = None; TriggerKind = None } - let ca = CodeActions.linkToReference (Range.Mk(1, 4, 1, 4)) caCtx doc - let expected = + 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 { - Title = "Convert link to new reference `inline-link-thing`" - Kind = Some CodeActionKind.RefactorRewrite - Command = None - Data = None - Diagnostics = None - Disabled = None - IsPreferred = None - Edit = - Some { - DocumentChanges = None - Changes = - Some - Map[Doc.uri doc, - [| - { - Range = Range.Mk(1, 0, - 1, 34) - NewText = "[inline Link_Thing][inline-link-thing]" - } - { - Range = Range.Mk(5, 0, 6, 34) - NewText = "[inline-link-thing]: https://link/" - } - |]] - } + 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.Equivalent(expected, ca) + 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