Sunday, October 4, 2015

Implementing F# interactive intellisense support for Visual Studio

While playing with F# I found that I'm really missing intellisense support for FSI interactive. It is very useful for exploring APIs and language features. I know, "official" suggestion is to write code in script file where full intellisense is available, and then execute it in FSI. But that is less then optimal, at least for me.

Lack of intellisense is even more surprising given that console version of fsi.exe has some limited support for autocompletion. I even considered embedding console window with running FSI in Visual Studio, but that turned our to be problematic.

I googled problem a bit, and it didn't seem to be very hard to fix it. F# is open sourced now, and clearly there are some APIs for autocomplete in FSI: https://github.com/fsharp/fsharp/blob/master/src/fsharp/fsiserver/fsiserver.fs

Adding intellisense support to editor also didn't seem problematic, there is a walkthrough available:
https://msdn.microsoft.com/en-us/library/vstudio/ee372314(v=vs.110).aspx

Then everything I expected to do it taking walkthrough and integrating it with FSI autocompletion APIs. Why should it take longer than couple of evenings? But as usual for software development nothing works as expected from the first attempt.

First I had to find a way to access those FSI APIs. FsiLanguageService is registered as a Visual Studio extensibility service. This is our entry point, everything else can be solved using Reflection. It is obviously a hack and it'll likely break if FSI sources are changed. But it will work, and telling from other's code this is the way extensibility is done for Visual Studio.
In order to access internal properties and fields using C# dynamic keyword we can use ExposedObject. Also I had to modify it a bit because it doesn't support fields declared in nested classes. It is a bit ugly, but does its job:

public FsiLanguageServiceHelper()
{
    fsiAssembly = Assembly.Load("FSharp.VS.FSI, Version=12.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");
    fsiLanguageServiceType = fsiAssembly.GetType("Microsoft.VisualStudio.FSharp.Interactive.FsiLanguageService");
    sessionsType = fsiAssembly.GetType("Microsoft.VisualStudio.FSharp.Interactive.Session.Sessions");
    fsiWindowType = fsiAssembly.GetType(FsiToolWindowClassName);
}
 
private Object GetSession()
{
    try
    {
        var providerGlobal = (IOleServiceProvider)Package.GetGlobalService(typeof(IOleServiceProvider));
        var provider = new ServiceProvider(providerGlobal);
        dynamic fsiLanguageService = ExposedObject.From(provider.GetService(fsiLanguageServiceType));
        dynamic sessions = fsiLanguageService.sessions;
        if (sessions != null)
        { 
            dynamic sessionsValue = ExposedObject.From(sessions).Value;
            dynamic sessionR = ExposedObject.From(sessionsValue).sessionR;
            dynamic sessionRValue = ExposedObject.From(sessionR).Value;
            dynamic sessionRValueValue = ExposedObject.From(sessionRValue).Value;
            dynamic exitedE = ExposedObject.From(sessionRValueValue).exitedE;
 
                MethodInfo methodInfo = fsiAssembly.GetType("Microsoft.VisualStudio.FSharp.Interactive.Session+Session").GetMethod("get_Exited");
                IObservable<EventArgs> exited = methodInfo.Invoke(sessionRValueValue, null);
                IObserver<EventArgs> obsvr = Observer.Create<EventArgs>(
                    x => {
                    //    RegisterAutocompleteServer(); 
                    },
                    ex => { },
                    () => { });
 
                exited.Subscribe(obsvr);
 
        
            return sessionRValueValue;
        }
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex);
    }
 
    return null;
}

The second problem is that for some reason FSI is compiled with #FSI_SERVER_INTELLISENSE flag off. It complicates things a lot. There are 3 options here:

1. Recompile FSI with #FSI_SERVER_INTELLISENSE flag on and use modified fsi.exe file. I tried this, and it worked. Also I didn't like to use hacked FSI, so decided to move further.
2. Implement autocomplete APIs on my own.
3. Try to find an entry point into FSI process, and use reflection to call autocomplete APIs.

It wasn't clear if it is possible to find a way to get into existing FSI process, therefor I decided to start with my own implementation.

FSI interactive binary is designed to be run in 2 modes - as a console application, or as a fsi server. In case of fsi server, std-in and std-out is redirected, and communication with Visual Studio happens over Remoting: https://github.com/fsharp/fsharp/blob/master/src/fsharp/fsiserver/fsiserver.fs

For me it meant that I need to run some code within FSI process, and that I need to establish some intra-process communication. First I tried to use Remoting since it is already used by FSI server, and it almost worked. Once in a while it failed with some cryptic errors which have no sense at all. So I replaced it by WCF service operating over NamedPipes, and it worked much better. Here is my service definition:

[<Serializable>]
[<ServiceContract>]
type AutocompleteService = 
    [<OperationContract>]
    abstract Ping : a:unit -> bool
    [<OperationContract>]
    abstract GetBaseDirectory : b:unit -> String
    [<OperationContract>]
    abstract GetCompletions: prefix:String -> providerType:IntellisenseProviderType -> seq<Completion>

I didn't come up with anything better than adding reference to my dll and then executing command to start my server by piping those commands directly into FSI input. Not perfect solution, but works well.

Implementing basic auto-completion provider wasn't hard. I used reflection APIs to iterate over type system, and a hack similar to the one used in FsEye in order to get access to variable names: https://github.com/SwensenSoftware/fseye/blob/63266a99e59a5eb70173869a05fe9a3bc4b96466/FsEye/Fsi/SessionQueries.fs. Basically variables of FSI sessions are stored in dynamic assembly and names start with FSI_:

let getVariableNames(fsiAssembly:Assembly) =
    fsiAssembly.GetTypes()//FSI types have the name pattern FSI_####, where #### is the order in which they were created
    |> Seq.filter (fun ty -> ty.Name.StartsWith("FSI_"))
    |> Seq.sortBy (fun ty -> ty.Name.Split('_').[1] |> int)
    |> Seq.collect (fun ty -> getPropertyInfosForType(ty))
    |> Seq.map (fun pi -> pi.Name)
    |> Seq.distinct

I also added namespaces opened in F# by default- Microsoft.FSharp.Collections and Microsoft.FSharp.Core.Printf. It helped a bit with usability, but there was another problem.

Autocomplete popup just rejected processing arrow down button. It is critical for navigation buttons to work for autocomplete popup, so I couldn't leave this issue unsolved. One of the biggest problems with Visual Studio extensibility is lack of good documentation. So the best source of information for me were GitHub sources for similar projects, and debugger. It turned out that CommandHandlers for key events in Visual Studio are organized in a hierarchical manner. If top level CommandHandler processes key event, and marks event as processed, there is nothing we can do on lower level CommandHanlder. In case with FSI window, fsiToolWindow processing arrow up/down keypress events for navigating through history, and therefore intercepted those events. And in fact when cursor was not set to the bottom line of FSI those events worked just fine since FSI doesn't process them for navigating history. I guess I tried every reasonable approach to make it work, but it didn't help. So the only way for me to solve it was to make FSI think that it should not iterate over history. Luckily there is a way:

private void SetFsiToolWindowCompletionSetVisibilty(bool displayed)
{
    if (fsiToolWindow != null)
    {
        dynamic source = ExposedObject.From(fsiToolWindow).source;
        if (source != null)
        {
            dynamic completionSet = ExposedObject.From(source).CompletionSet;
            if (completionSet != null)
            {
                dynamic completionSetExp = ExposedObject.From(completionSet);
                completionSetExp.displayed = displayed;
            }
        }
    }
}

Finally everything worked, but after playing with this solution for some time I realized that it is far from perfect.

So I got back to original idea - find a way to hack into running FSI process in order to get access to autocomplete APIs. It is possible to use reflection in order to explore type system of a running process, but we need an entry point in the process - reference to some variable or static field. So I carefully studied FSI source code, but didn't find anything I could use for that purpose.
I tried hard to find a way to find a reference to the class or variable of the running process given that I know that there in only one instance of that class exists in a process. It turned out impossible, or at least I didn't find any reasonable approach to do that. Another approach I investigated is hacking running process itself. There are ways to do that using CLR Profiler APIs, but that is a bit too extreme to be usable. After struggling with this for a while, I've got an idea. The fact that I didn't find any "entry" points into FSI process, doesn't mean that they don't exist. And if they exist, I should be able to see them in memory profiler. So I downloaded CLR profiler, and references view gave me the answer. System.AppDomain is a globally accessible by System.AppDomain.CurrentDomain, and I know that there is only one subscriber to _unhandledException event. So this is our entry point.


Here is how code for executing autocomplete API on a running FSI process end up looking:

let getCompletionsFromFsiSession(prefix:String: seq<String> = 
    try
        let fsiEvaluationSession = System.AppDomain.CurrentDomain 
                                |> getField "_unhandledException" 
                                |> getProperty "Target"
                                |> getField "callback"
                                |> getField "r"
                                |> getField "f"
                                |> getField "x"
 
        let fsiIntellisenseProvider = fsiEvaluationSession |> getField "fsiIntellisenseProvider"
        let istateRef = fsiEvaluationSession |> getField "istateRef"
 
        let getCompletionsFromFsiSession(prefix:String) = 
            fsiIntellisenseProvider |> invokeMethod "CompletionsForPartialLID" [|istateRef |> getProperty "contents"; prefix|] :?> seq<String>
 
        let changeLastLetterCase(prefix:String) = 
            if String.IsNullOrEmpty(prefix) then
                prefix
            else
                let lastChar = prefix.[prefix.Length - 1]
                let updatedLastChar = 
                    if Char.IsLower(lastChar) then
                        Char.ToUpper(lastChar)
                    else if Char.IsUpper(lastChar) then
                        Char.ToLower(lastChar)
                    else
                        lastChar
                prefix.Remove(prefix.Length - 1) + updatedLastChar.ToString()
 
        let changedLastLetterCasePrefix = changeLastLetterCase(prefix)
        let completions = getCompletionsFromFsiSession(prefix)
        let changedLastLetterCaseCompletions = if prefix = changedLastLetterCasePrefix then Seq.empty else getCompletionsFromFsiSession(changedLastLetterCasePrefix)
        
        completions
        |> Seq.append(changedLastLetterCaseCompletions) 
        |> Seq.distinct
        |> Seq.sort
    with 
        | _ -> Seq.empty

Extremely hacky, but not too complicated once you know what to do.

Since this API doesn't tell what kind of completion it returns (variable, method, class, etc.), I merged its results with output of my home grown provider in order to be able to show icons.

And the last piece was configuration, luckily Visual Studio has infrastructure to make it easy:
https://msdn.microsoft.com/en-us/library/bb166195.aspx.

So finally everything worked. Intellisense support is still far from perfect, but much better than nothing. I guess I'll try to integrate it with https://github.com/fsharp/FsAutoComplete later, we'll see.

As a result I've finally got decent Intellisense support for FSI, but it took 10-20 times of my original estimate to get there. Not sure that it was worth of time spent, but it was definitely interesting and unconventional project, so I had some fun while building it. Hope anyone will find it useful. Here is a link to GitHub project: https://github.com/vlasenkoalexey/FSharp.Interactive.Intellisense



Thursday, April 16, 2015

Using FSharp.Data in Windows Store app

Idea to create a dev blog crossed my mind long time ago, but I didn't get to finally do it until now. Hope someone will find my thoughts and experience useful.

Today I want to share my experience of integrating FSharp.Data library into Windows Store application. It took couple of evenings to finally make it work, and few times I thought about dropping this idea and doing everything in good old C# instead. Glad that it finally worked out. So I'd like to share my findings in case someone wants to build something similar.

I started to learn F# in 2011, and the more I got to know it, the more I liked it. Since then I'm trying to use it whenever possible, but unfortunately can't say that I had a lot of success with that.

Recently I decided to rewrite part of  Window Store application I develop as a hobby project, and decided to give F# a try here. Application has HTML parsing component implemented in C# using HTML Agility Pack. Given that there is an amazing FSharp.Data library with HTML provider, it looked promising to rewrite that code in F#.

My first disappointment was that Windows Store does not support F# natively. I couldn't believe that Microsoft allows building Windows Store apps using C++ and JavaScript, but at the same time F# code needs to go into portable class library (PCL). Regardless of all statements about growing F# share and predictions of its bright future this fact makes it look like a niche language in .NET family. It looks even more strange taking into account that Apple have chosen to go functional with Swift, but that is another story. Hope that Microsoft will start taking F# more seriously in the future.

Luckily there is a great blog post describing how to integrate F# into Windows Store application: http://ps-a.blogspot.com/2013/01/windows-store-apps-with-f-part-1-make.html. It helped to setup project and understand how all pieces work together. In the first approach I tried to take maximum advantage out of F# and attempted to reimplement ViewModel. It didn't work nicely. First problem was that ViewModel concept is all about state and .NET events. All that code looks clumsy in F#. Second problem was that I have quite a bit of helper code and ViewModel base class in C# project. It couldn't be referenced from F# unless that code is moved to another C# portable library. That option didn't appeal to me much, so I decided to drop this idea. It might work better in case all ViewModels and related logic in the project are implemented in F#. In fact http://reactiveui.net/ looks like a perfect match here. Unfortunately there is no information available describing how it works with F#. I plan to try it out the next time I build Windows Store app.

So, eventually I decided to keep ViewModel in C# and only port library responsible for HTML parsing and data processing to F#. First I had to figure out how to use FSharp.Data APIs from F# interactive. I installed nuget package, and started exploring.

Install-Package FSharp.Data

It turned out that nuget package contains 2 folders: net40 and portable-net40+sl5+wp8+win8. 
net40 is used for native .NET applications, and portable-net40+sl5+wp8+win8 for platforms which work with portable class library including Windows 8. Therefore we'll refer to
packages\FSharp.Data.2.1.0\lib\net40\FSharp.Data.dll in F# interactive and add reference to packages\FSharp.Data.2.1.0\lib\portable-net40+sl5+wp8+win8\FSharp.Data.dll in Windows Store project.

 1: #r @"C:\Projects\FsharpWindowsStorePrototype\packages\FSharp.Data.2.1.0\lib\net40\FSharp.Data.dll"
 2: 
 3: open System
 4: open FSharp.Data
 5: 
 6: type SampleHtmlProvider = HtmlProvider<"http://www.weather.com/weather/today/l/98033:4:US">
 7: let data = SampleHtmlProvider.Load("http://www.weather.com/weather/today/l/98033:4:US")
 8: let info = data.Tables.Table1.Rows.[0].Column1
 9: 
10: open System
11: open FSharp.Data
12: 
13: type SampleHtmlProvider = HtmlProvider<"http://www.weather.com/weather/today/l/98033:4:US">
14: let data = SampleHtmlProvider.Load("http://www.weather.com/weather/today/l/98033:4:US")
15: let info = data.Tables.Table1.Rows.[0].Column1
namespace System
namespace FSharp
namespace FSharp.Data
type SampleHtmlProvider =
  class
  end

Full name: Snippet.SampleHtmlProvider
val data : 'a
val info : 'a

Ok, it looks like provider works, but it doesn't do very good job in providing strongly typed wrapper around HTML document structure. It exposes basic APIs for iterating over document nodes and doing matching using predicate function. This is just enough in most of cases.

Next I added sample module file, referenced F# project from C# Windows Store application and tried to compile it. It didn't work like that:

2>C:\Program Files (x86)\MSBuild\Microsoft\VisualStudio\v12.0\AppxPackage\Microsoft.AppXPackage.Targets(852,9): error MSB3816: Loading assembly "C:\Projects\FsharpWindowsStorePrototype\FsharpPortableLibrary\bin\Debug\FSharp.Data.dll" failed. System.IO.FileNotFoundException: Could not load file or assembly 'FSharp.Core, Version=2.3.5.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' or one of its dependencies. The system cannot find the file specified.

2>C:\Program Files (x86)\MSBuild\Microsoft\VisualStudio\v12.0\AppxPackage\Microsoft.AppXPackage.Targets(852,9): error MSB3816: File name: 'FSharp.Core, Version=2.3.5.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

After some googling I found out that FSharp.Data doesn't support PCL profile 7. It turned out that there are multiple PCL profiles to target different set of platforms. I spent few hours trying to change profile type to 47. Solution was unexpected, but straightforward:

1. Use legacy Project Library template for .NET Framework 4.0


2. In project settings change target F# runtime to 3.0 (FSharp.Core 2.3.5.0)


Finally I could build solution and do HTTP requests ... almost.

It turned out that web server that hosts site that I'm scraping requires UserAgent to be set on HTTP request. There is a known issue with UserAgent header in Windows Store apps. It is just not possible to set UserAgent header on HttpWebRequest. FSharp.Data provides nice Http helper utility, and I had a hope that it would work:

1: open System
2: open FSharp.Data
3: open FSharp.Data.HttpRequestHeaders
4: 
5: // Run the HTTP web request
6: Http.RequestString
7:   ( "https://www.google.com/search?q=super",
8:     query   = [ "q", "super" ],
9:     headers = [ Accept HttpContentTypes.Any; UserAgent "Mozilla/5.0" ])
namespace System
namespace FSharp
namespace FSharp.Data

And it does work ... but only in F# interactive which uses .NET version of the binary. I thought I'm doing something wrong until I found this code in FSharp.Data souces:

1: #if FX_NO_WEBREQUEST_USERAGENT
2:             | "user-agent" -> if not (req?UserAgent <- value) then try req.Headers.[HeaderEnum.UserAgent] <- value with _ -> ()
3: #else
4:             | "user-agent" -> req.UserAgent <- value
5: #endif

This explained everything. Therefore the only option left was to use Microsoft Client HTTP library.

Here is how my helper method looks like:

 1: open System
 2: open FSharp.Data
 3: open System.Net
 4: open System.IO
 5: open System.Net.Http
 6: open System.Diagnostics
 7: open System.Text.RegularExpressions
 8: 
 9: module DownloadHelpers =
10: 
11:     type DataOrError = 
12:     | Error of Error
13:     | Data of String
14: 
15:     let buildRequest(url:String, httpMethod:String) = 
16:         let request = new HttpRequestMessage(new HttpMethod(httpMethod), url)
17:         request.Headers.UserAgent.ParseAdd("(compatible; MSIE 10.6; Windows NT 6.1; Trident/5.0; InfoPath.2; SLCC1; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; .NET CLR 2.0.50727) 3gpp-gba UNTRUSTED/1.0");
18:         request
19: 
20:     let downloadPageByRequest(request:HttpRequestMessage) = async {
21:         let handler = new HttpClientHandler()
22:         handler.UseCookies <- false
23:         let client = new HttpClient(handler)
24:         try
25:             use! response = Async.AwaitTask <| client.SendAsync(request)
26:             let resultCode = response.StatusCode.ToString();
27:             let! html = Async.AwaitTask <| response.Content.ReadAsStringAsync();
28:             let processedHtml = Regex.Replace(html, @"\</\d+\>", "") // F# data bug
29:             return Data processedHtml 
30:         with ex -> 
31:                 Debug.WriteLine(ex.ToString())
32:                 return Error (Error.Exception ex)
33:     }
34: 
35:     let downloadPage(url:System.String) = async {
36:         return! downloadPageByRequest(buildRequest(url, HttpMethod.Get))
37:     }
namespace System
namespace FSharp
namespace System.Data
namespace System.Net
namespace System.IO
namespace System.Diagnostics
namespace System.Text
namespace System.Text.RegularExpressions
module DownloadHelpers

from Snippet
type DataOrError =
  | Error of obj
  | Data of String

Full name: Snippet.DownloadHelpers.DataOrError

  type: DataOrError
  implements: IEquatable<DataOrError>
  implements: Collections.IStructuralEquatable
union case DataOrError.Error: obj -> DataOrError
Multiple items
union case DataOrError.Data: String -> DataOrError

--------------------
namespace System.Data
type String =
  class
    new : char -> string
    new : char * int * int -> string
    new : System.SByte -> string
    new : System.SByte * int * int -> string
    new : System.SByte * int * int * System.Text.Encoding -> string
    new : char [] * int * int -> string
    new : char [] -> string
    new : char * int -> string
    member Chars : int -> char
    member Clone : unit -> obj
    member CompareTo : obj -> int
    member CompareTo : string -> int
    member Contains : string -> bool
    member CopyTo : int * char [] * int * int -> unit
    member EndsWith : string -> bool
    member EndsWith : string * System.StringComparison -> bool
    member EndsWith : string * bool * System.Globalization.CultureInfo -> bool
    member Equals : obj -> bool
    member Equals : string -> bool
    member Equals : string * System.StringComparison -> bool
    member GetEnumerator : unit -> System.CharEnumerator
    member GetHashCode : unit -> int
    member GetTypeCode : unit -> System.TypeCode
    member IndexOf : char -> int
    member IndexOf : string -> int
    member IndexOf : char * int -> int
    member IndexOf : string * int -> int
    member IndexOf : string * System.StringComparison -> int
    member IndexOf : char * int * int -> int
    member IndexOf : string * int * int -> int
    member IndexOf : string * int * System.StringComparison -> int
    member IndexOf : string * int * int * System.StringComparison -> int
    member IndexOfAny : char [] -> int
    member IndexOfAny : char [] * int -> int
    member IndexOfAny : char [] * int * int -> int
    member Insert : int * string -> string
    member IsNormalized : unit -> bool
    member IsNormalized : System.Text.NormalizationForm -> bool
    member LastIndexOf : char -> int
    member LastIndexOf : string -> int
    member LastIndexOf : char * int -> int
    member LastIndexOf : string * int -> int
    member LastIndexOf : string * System.StringComparison -> int
    member LastIndexOf : char * int * int -> int
    member LastIndexOf : string * int * int -> int
    member LastIndexOf : string * int * System.StringComparison -> int
    member LastIndexOf : string * int * int * System.StringComparison -> int
    member LastIndexOfAny : char [] -> int
    member LastIndexOfAny : char [] * int -> int
    member LastIndexOfAny : char [] * int * int -> int
    member Length : int
    member Normalize : unit -> string
    member Normalize : System.Text.NormalizationForm -> string
    member PadLeft : int -> string
    member PadLeft : int * char -> string
    member PadRight : int -> string
    member PadRight : int * char -> string
    member Remove : int -> string
    member Remove : int * int -> string
    member Replace : char * char -> string
    member Replace : string * string -> string
    member Split : char [] -> string []
    member Split : char [] * int -> string []
    member Split : char [] * System.StringSplitOptions -> string []
    member Split : string [] * System.StringSplitOptions -> string []
    member Split : char [] * int * System.StringSplitOptions -> string []
    member Split : string [] * int * System.StringSplitOptions -> string []
    member StartsWith : string -> bool
    member StartsWith : string * System.StringComparison -> bool
    member StartsWith : string * bool * System.Globalization.CultureInfo -> bool
    member Substring : int -> string
    member Substring : int * int -> string
    member ToCharArray : unit -> char []
    member ToCharArray : int * int -> char []
    member ToLower : unit -> string
    member ToLower : System.Globalization.CultureInfo -> string
    member ToLowerInvariant : unit -> string
    member ToString : unit -> string
    member ToString : System.IFormatProvider -> string
    member ToUpper : unit -> string
    member ToUpper : System.Globalization.CultureInfo -> string
    member ToUpperInvariant : unit -> string
    member Trim : unit -> string
    member Trim : char [] -> string
    member TrimEnd : char [] -> string
    member TrimStart : char [] -> string
    static val Empty : string
    static member Compare : string * string -> int
    static member Compare : string * string * bool -> int
    static member Compare : string * string * System.StringComparison -> int
    static member Compare : string * string * System.Globalization.CultureInfo * System.Globalization.CompareOptions -> int
    static member Compare : string * string * bool * System.Globalization.CultureInfo -> int
    static member Compare : string * int * string * int * int -> int
    static member Compare : string * int * string * int * int * bool -> int
    static member Compare : string * int * string * int * int * System.StringComparison -> int
    static member Compare : string * int * string * int * int * bool * System.Globalization.CultureInfo -> int
    static member Compare : string * int * string * int * int * System.Globalization.CultureInfo * System.Globalization.CompareOptions -> int
    static member CompareOrdinal : string * string -> int
    static member CompareOrdinal : string * int * string * int * int -> int
    static member Concat : obj -> string
    static member Concat : obj [] -> string
    static member Concat<'T> : System.Collections.Generic.IEnumerable<'T> -> string
    static member Concat : System.Collections.Generic.IEnumerable<string> -> string
    static member Concat : string [] -> string
    static member Concat : obj * obj -> string
    static member Concat : string * string -> string
    static member Concat : obj * obj * obj -> string
    static member Concat : string * string * string -> string
    static member Concat : obj * obj * obj * obj -> string
    static member Concat : string * string * string * string -> string
    static member Copy : string -> string
    static member Equals : string * string -> bool
    static member Equals : string * string * System.StringComparison -> bool
    static member Format : string * obj -> string
    static member Format : string * obj [] -> string
    static member Format : string * obj * obj -> string
    static member Format : System.IFormatProvider * string * obj [] -> string
    static member Format : string * obj * obj * obj -> string
    static member Intern : string -> string
    static member IsInterned : string -> string
    static member IsNullOrEmpty : string -> bool
    static member IsNullOrWhiteSpace : string -> bool
    static member Join : string * string [] -> string
    static member Join : string * obj [] -> string
    static member Join<'T> : string * System.Collections.Generic.IEnumerable<'T> -> string
    static member Join : string * System.Collections.Generic.IEnumerable<string> -> string
    static member Join : string * string [] * int * int -> string
  end

Full name: System.String

  type: String
  implements: IComparable
  implements: ICloneable
  implements: IConvertible
  implements: IComparable<string>
  implements: seq<char>
  implements: Collections.IEnumerable
  implements: IEquatable<string>
val buildRequest : String * String -> 'a

Full name: Snippet.DownloadHelpers.buildRequest
val url : String

  type: String
  implements: IComparable
  implements: ICloneable
  implements: IConvertible
  implements: IComparable<string>
  implements: seq<char>
  implements: Collections.IEnumerable
  implements: IEquatable<string>
val httpMethod : String

  type: String
  implements: IComparable
  implements: ICloneable
  implements: IConvertible
  implements: IComparable<string>
  implements: seq<char>
  implements: Collections.IEnumerable
  implements: IEquatable<string>
val request : 'a
val downloadPageByRequest : 'a -> Async<DataOrError>

Full name: Snippet.DownloadHelpers.downloadPageByRequest
val async : AsyncBuilder

Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.async
val handler : 'a
val client : 'a
val response : 'a (requires 'a :> IDisposable)

  type: 'a
  implements: IDisposable
Multiple items
type Async<'T>

Full name: Microsoft.FSharp.Control.Async<_>

--------------------
type Async
with
  static member AsBeginEnd : computation:('Arg -> Async<'T>) -> ('Arg * AsyncCallback * obj -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit)
  static member AwaitEvent : event:IEvent<'Del,'T> * ?cancelAction:(unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate)
  static member AwaitIAsyncResult : iar:IAsyncResult * ?millisecondsTimeout:int -> Async<bool>
  static member AwaitTask : task:Threading.Tasks.Task<'T> -> Async<'T>
  static member AwaitWaitHandle : waitHandle:Threading.WaitHandle * ?millisecondsTimeout:int -> Async<bool>
  static member CancelDefaultToken : unit -> unit
  static member Catch : computation:Async<'T> -> Async<Choice<'T,exn>>
  static member FromBeginEnd : beginAction:(AsyncCallback * obj -> IAsyncResult) * endAction:(IAsyncResult -> 'T) * ?cancelAction:(unit -> unit) -> Async<'T>
  static member FromBeginEnd : arg:'Arg1 * beginAction:('Arg1 * AsyncCallback * obj -> IAsyncResult) * endAction:(IAsyncResult -> 'T) * ?cancelAction:(unit -> unit) -> Async<'T>
  static member FromBeginEnd : arg1:'Arg1 * arg2:'Arg2 * beginAction:('Arg1 * 'Arg2 * AsyncCallback * obj -> IAsyncResult) * endAction:(IAsyncResult -> 'T) * ?cancelAction:(unit -> unit) -> Async<'T>
  static member FromBeginEnd : arg1:'Arg1 * arg2:'Arg2 * arg3:'Arg3 * beginAction:('Arg1 * 'Arg2 * 'Arg3 * AsyncCallback * obj -> IAsyncResult) * endAction:(IAsyncResult -> 'T) * ?cancelAction:(unit -> unit) -> Async<'T>
  static member FromContinuations : callback:(('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T>
  static member Ignore : computation:Async<'T> -> Async<unit>
  static member OnCancel : interruption:(unit -> unit) -> Async<IDisposable>
  static member Parallel : computations:seq<Async<'T>> -> Async<'T []>
  static member RunSynchronously : computation:Async<'T> * ?timeout:int * ?cancellationToken:Threading.CancellationToken -> 'T
  static member Sleep : millisecondsDueTime:int -> Async<unit>
  static member Start : computation:Async<unit> * ?cancellationToken:Threading.CancellationToken -> unit
  static member StartAsTask : computation:Async<'T> * ?taskCreationOptions:Threading.Tasks.TaskCreationOptions * ?cancellationToken:Threading.CancellationToken -> Threading.Tasks.Task<'T>
  static member StartChild : computation:Async<'T> * ?millisecondsTimeout:int -> Async<Async<'T>>
  static member StartChildAsTask : computation:Async<'T> * ?taskCreationOptions:Threading.Tasks.TaskCreationOptions -> Async<Threading.Tasks.Task<'T>>
  static member StartImmediate : computation:Async<unit> * ?cancellationToken:Threading.CancellationToken -> unit
  static member StartWithContinuations : computation:Async<'T> * continuation:('T -> unit) * exceptionContinuation:(exn -> unit) * cancellationContinuation:(OperationCanceledException -> unit) * ?cancellationToken:Threading.CancellationToken -> unit
  static member SwitchToContext : syncContext:Threading.SynchronizationContext -> Async<unit>
  static member SwitchToNewThread : unit -> Async<unit>
  static member SwitchToThreadPool : unit -> Async<unit>
  static member TryCancelled : computation:Async<'T> * compensation:(OperationCanceledException -> unit) -> Async<'T>
  static member CancellationToken : Async<Threading.CancellationToken>
  static member DefaultCancellationToken : Threading.CancellationToken
end

Full name: Microsoft.FSharp.Control.Async
static member Async.AwaitTask : task:Threading.Tasks.Task<'T> -> Async<'T>
val resultCode : 'a
val html : string

  type: string
  implements: IComparable
  implements: ICloneable
  implements: IConvertible
  implements: IComparable<string>
  implements: seq<char>
  implements: Collections.IEnumerable
  implements: IEquatable<string>
val processedHtml : string

  type: string
  implements: IComparable
  implements: ICloneable
  implements: IConvertible
  implements: IComparable<string>
  implements: seq<char>
  implements: Collections.IEnumerable
  implements: IEquatable<string>
type Regex =
  class
    new : string -> System.Text.RegularExpressions.Regex
    new : string * System.Text.RegularExpressions.RegexOptions -> System.Text.RegularExpressions.Regex
    new : string * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> System.Text.RegularExpressions.Regex
    member GetGroupNames : unit -> string []
    member GetGroupNumbers : unit -> int []
    member GroupNameFromNumber : int -> string
    member GroupNumberFromName : string -> int
    member IsMatch : string -> bool
    member IsMatch : string * int -> bool
    member Match : string -> System.Text.RegularExpressions.Match
    member Match : string * int -> System.Text.RegularExpressions.Match
    member Match : string * int * int -> System.Text.RegularExpressions.Match
    member MatchTimeout : System.TimeSpan
    member Matches : string -> System.Text.RegularExpressions.MatchCollection
    member Matches : string * int -> System.Text.RegularExpressions.MatchCollection
    member Options : System.Text.RegularExpressions.RegexOptions
    member Replace : string * string -> string
    member Replace : string * System.Text.RegularExpressions.MatchEvaluator -> string
    member Replace : string * string * int -> string
    member Replace : string * System.Text.RegularExpressions.MatchEvaluator * int -> string
    member Replace : string * string * int * int -> string
    member Replace : string * System.Text.RegularExpressions.MatchEvaluator * int * int -> string
    member RightToLeft : bool
    member Split : string -> string []
    member Split : string * int -> string []
    member Split : string * int * int -> string []
    member ToString : unit -> string
    static val InfiniteMatchTimeout : System.TimeSpan
    static member CacheSize : int with get, set
    static member CompileToAssembly : System.Text.RegularExpressions.RegexCompilationInfo [] * System.Reflection.AssemblyName -> unit
    static member CompileToAssembly : System.Text.RegularExpressions.RegexCompilationInfo [] * System.Reflection.AssemblyName * System.Reflection.Emit.CustomAttributeBuilder [] -> unit
    static member CompileToAssembly : System.Text.RegularExpressions.RegexCompilationInfo [] * System.Reflection.AssemblyName * System.Reflection.Emit.CustomAttributeBuilder [] * string -> unit
    static member Escape : string -> string
    static member IsMatch : string * string -> bool
    static member IsMatch : string * string * System.Text.RegularExpressions.RegexOptions -> bool
    static member IsMatch : string * string * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> bool
    static member Match : string * string -> System.Text.RegularExpressions.Match
    static member Match : string * string * System.Text.RegularExpressions.RegexOptions -> System.Text.RegularExpressions.Match
    static member Match : string * string * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> System.Text.RegularExpressions.Match
    static member Matches : string * string -> System.Text.RegularExpressions.MatchCollection
    static member Matches : string * string * System.Text.RegularExpressions.RegexOptions -> System.Text.RegularExpressions.MatchCollection
    static member Matches : string * string * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> System.Text.RegularExpressions.MatchCollection
    static member Replace : string * string * string -> string
    static member Replace : string * string * System.Text.RegularExpressions.MatchEvaluator -> string
    static member Replace : string * string * string * System.Text.RegularExpressions.RegexOptions -> string
    static member Replace : string * string * System.Text.RegularExpressions.MatchEvaluator * System.Text.RegularExpressions.RegexOptions -> string
    static member Replace : string * string * string * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> string
    static member Replace : string * string * System.Text.RegularExpressions.MatchEvaluator * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> string
    static member Split : string * string -> string []
    static member Split : string * string * System.Text.RegularExpressions.RegexOptions -> string []
    static member Split : string * string * System.Text.RegularExpressions.RegexOptions * System.TimeSpan -> string []
    static member Unescape : string -> string
  end

Full name: System.Text.RegularExpressions.Regex

  type: Regex
  implements: Runtime.Serialization.ISerializable
Regex.Replace(input: string, pattern: string, evaluator: MatchEvaluator) : string
Regex.Replace(input: string, pattern: string, replacement: string) : string
Regex.Replace(input: string, pattern: string, evaluator: MatchEvaluator, options: RegexOptions) : string
Regex.Replace(input: string, pattern: string, replacement: string, options: RegexOptions) : string
Regex.Replace(input: string, pattern: string, evaluator: MatchEvaluator, options: RegexOptions, matchTimeout: TimeSpan) : string
Regex.Replace(input: string, pattern: string, replacement: string, options: RegexOptions, matchTimeout: TimeSpan) : string
val ex : exn

  type: exn
  implements: Runtime.Serialization.ISerializable
  implements: Runtime.InteropServices._Exception
type Debug =
  class
    static member Assert : bool -> unit
    static member Assert : bool * string -> unit
    static member Assert : bool * string * string -> unit
    static member Assert : bool * string * string * obj [] -> unit
    static member AutoFlush : bool with get, set
    static member Close : unit -> unit
    static member Fail : string -> unit
    static member Fail : string * string -> unit
    static member Flush : unit -> unit
    static member Indent : unit -> unit
    static member IndentLevel : int with get, set
    static member IndentSize : int with get, set
    static member Listeners : System.Diagnostics.TraceListenerCollection
    static member Print : string -> unit
    static member Print : string * obj [] -> unit
    static member Unindent : unit -> unit
    static member Write : string -> unit
    static member Write : obj -> unit
    static member Write : string * string -> unit
    static member Write : obj * string -> unit
    static member WriteIf : bool * string -> unit
    static member WriteIf : bool * obj -> unit
    static member WriteIf : bool * string * string -> unit
    static member WriteIf : bool * obj * string -> unit
    static member WriteLine : string -> unit
    static member WriteLine : obj -> unit
    static member WriteLine : string * string -> unit
    static member WriteLine : obj * string -> unit
    static member WriteLine : string * obj [] -> unit
    static member WriteLineIf : bool * string -> unit
    static member WriteLineIf : bool * obj -> unit
    static member WriteLineIf : bool * string * string -> unit
    static member WriteLineIf : bool * obj * string -> unit
  end

Full name: System.Diagnostics.Debug
Debug.WriteLine(value: obj) : unit
Debug.WriteLine(message: string) : unit
Debug.WriteLine(format: string, args: obj []) : unit
Debug.WriteLine(value: obj, category: string) : unit
Debug.WriteLine(message: string, category: string) : unit
Object.ToString() : string
type Exception =
  class
    new : unit -> System.Exception
    new : string -> System.Exception
    new : string * System.Exception -> System.Exception
    member Data : System.Collections.IDictionary
    member GetBaseException : unit -> System.Exception
    member GetObjectData : System.Runtime.Serialization.SerializationInfo * System.Runtime.Serialization.StreamingContext -> unit
    member GetType : unit -> System.Type
    member HResult : int with get, set
    member HelpLink : string with get, set
    member InnerException : System.Exception
    member Message : string
    member Source : string with get, set
    member StackTrace : string
    member TargetSite : System.Reflection.MethodBase
    member ToString : unit -> string
  end

Full name: System.Exception

  type: Exception
  implements: Runtime.Serialization.ISerializable
  implements: Runtime.InteropServices._Exception
val downloadPage : String -> Async<DataOrError>

Full name: Snippet.DownloadHelpers.downloadPage

Note that there is an bug in FSharp.Data 2.1.0, so that any closing tag with number like </1> breaks the parser.

Finally everything worked, but can't say that it was the end of the story.
When I tried to pull project from Git to another computer it refused to build. For some reason F# project was broken. Nuget pulled all dependencies, but for build didn't work.
Googling and experimenting proved that there is an issue with Microsoft.Bcl dependency which is required by Microsoft.Net.Http. By default Nuget tries to pull older version of this library, while Microsoft.Net.Http works only with the most recent one. The solution which worked for me was to clean Nuget cache, remove Microsoft.Bcl and Microsoft.Bcl.Build projects from packages folder, and request Nuget to install those projects manually before installing Microsoft.Net.Http.

Install-Package Microsoft.Bcl

It helped to solve dependency issues.

When I did that recently, I also updated FSharp.Data to version 2.2.0. This update broke my application, and now I have to investigate what changed.

Was it worth messing up with F#? Definitely I've spent way more time trying to fix all issues on the way than I'd spend writing same logic in C#. But it was fun and I'm glad that finally everything worked out.