Skip to content
Open
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
59 changes: 58 additions & 1 deletion Marksman/CodeActions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,6 +37,16 @@ let createFile newFileUri : WorkspaceEdit =

{ Changes = None; DocumentChanges = Some documentChanges }

type MultiEditAction = { name: string; edits: list<Range * string> }

let multiDocumentEdit (edits: list<Range * string>) (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<int>) (doc: Doc) : DocumentAction option =
match TableOfContents.mk includeLevels (Doc.index doc) with
| Some toc ->
Expand Down Expand Up @@ -108,7 +119,6 @@ let tableOfContentsInner (includeLevels: array<int>) (doc: Doc) : DocumentAction

| _ -> None


let tableOfContents
(_range: Range)
(_context: CodeActionContext)
Expand Down Expand Up @@ -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<MdLinkDef>) = url.text.Equals(def.data.url.text)

let getAction (link: Node<MdLink>) : 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
15 changes: 15 additions & 0 deletions Marksman/Config.fs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ type Config = {
caTocEnable: option<bool>
caTocInclude: option<array<int>>
caCreateMissingFileEnable: option<bool>
caLinkToReferenceEnable: option<bool>
coreMarkdownFileExtensions: option<array<string>>
coreMarkdownGlfmHeadingIdsEnable: option<bool>
coreTextSync: option<TextSync>
Expand All @@ -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
Expand All @@ -175,6 +177,7 @@ type Config = {
caTocEnable = None
caTocInclude = None
caCreateMissingFileEnable = None
caLinkToReferenceEnable = None
coreMarkdownFileExtensions = None
coreMarkdownGlfmHeadingIdsEnable = None
coreTextSync = None
Expand All @@ -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
Expand Down Expand Up @@ -255,6 +263,9 @@ let private configOfTable (table: TomlTable) : LookupResult<Config> =
let! caCreateMissingFileEnable =
getFromTableOpt<bool> table [] [ "code_action"; "create_missing_file"; "enable" ]

let! caLinkToReferenceEnable =
getFromTableOpt<bool> table [] [ "code_action"; "link_to_reference"; "enable" ]

let! coreMarkdownFileExtensions =
getFromTableOpt<array<string>> table [] [ "core"; "markdown"; "file_extensions" ]

Expand Down Expand Up @@ -296,6 +307,7 @@ let private configOfTable (table: TomlTable) : LookupResult<Config> =
caTocEnable = caTocEnable
caTocInclude = caTocInclude
caCreateMissingFileEnable = caCreateMissingFileEnable
caLinkToReferenceEnable = caLinkToReferenceEnable
coreMarkdownFileExtensions = coreMarkdownFileExtensions
coreMarkdownGlfmHeadingIdsEnable = coreMarkdownGlfmHeadingIdsEnable
coreTextSync = coreTextSync
Expand All @@ -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
Expand Down
15 changes: 14 additions & 1 deletion Marksman/Server.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
147 changes: 147 additions & 0 deletions Tests/CodeActionTests.fs
Original file line number Diff line number Diff line change
@@ -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 =
[<Fact>]
Expand Down Expand Up @@ -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 }

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
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)
3 changes: 3 additions & 0 deletions Tests/default.marksman.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down