Skip to content
Merged
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
26 changes: 26 additions & 0 deletions src/Verso.FSharp/Kernel/FSharpCheckerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ public void Initialize()
return null;
}

/// <summary>
/// Lists the files FSI compiles for the given <c>#load</c>ed scripts, in the order it
/// compiles them: each script after the scripts it loads itself.
/// </summary>
/// <remarks>
/// The closure is the one the checker works out for a script holding just those
/// <c>#load</c> lines, so a nested <c>#load</c> resolves against the file it is in, as it
/// does in FSI. The script itself is the last file of the closure and is left out.
/// </remarks>
public async Task<IReadOnlyList<string>> GetLoadClosureAsync(
string scriptFileName, IEnumerable<string> loadedPaths, CancellationToken ct = default)
{
if (_checker is null)
throw new InvalidOperationException("Checker has not been initialized.");

var script = string.Join("\n", loadedPaths.Select(p => $"#load @\"{p.Replace("\"", "\"\"")}\""));
var asyncOp = _checker.GetProjectOptionsFromScript(
scriptFileName, SourceText.ofString(script),
caret: null, previewEnabled: null, loadedTimeStamp: null, otherFlags: null,
useFsiAuxLib: null, useSdkRefs: null, assumeDotNetFramework: null, sdkDirOverride: null,
optionsStamp: null, userOpName: null);

var (options, _) = await StartAsTask(asyncOp, ct).ConfigureAwait(false);
return options.SourceFiles.Take(options.SourceFiles.Length - 1).ToArray();
}

/// <summary>
/// Triggers a background parse-and-check to pre-warm the checker cache.
/// Errors are silently swallowed.
Expand Down
24 changes: 13 additions & 11 deletions src/Verso.FSharp/Kernel/FSharpKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,17 +259,6 @@ public async Task<IReadOnlyList<CellOutput>> ExecuteAsync(string code, IExecutio
_projectContext?.AddReference(path);
}

// Add #load file contents to IntelliSense context
foreach (var loadedPath in _scriptDirectiveProcessor.LoadedFilePaths)
{
try
{
var loadedSource = File.ReadAllText(loadedPath);
_projectContext?.AppendExecutedCode(loadedSource);
}
catch { /* best effort — FSI will report its own error */ }
}

// --- Snapshot store for Variables.Set detection ---
_variableBridge!.SnapshotStore(context.Variables);

Expand Down Expand Up @@ -403,6 +392,19 @@ public async Task<IReadOnlyList<CellOutput>> ExecuteAsync(string code, IExecutio
_projectContext?.AppendExecutedCode(code);
if (_projectContext is not null && _checkerManager is not null)
{
// A loaded script is checked as a file of its own, as FSI compiles it, so later
// cells see it under the module name FSI gives it.
if (_scriptDirectiveProcessor.LoadedFilePaths.Count > 0)
{
try
{
var loadedFiles = await _checkerManager.GetLoadClosureAsync(
VirtualFileName, _scriptDirectiveProcessor.LoadedFilePaths).ConfigureAwait(false);
_projectContext.AddLoadedFiles(loadedFiles);
}
catch { /* best effort, the cell itself has run */ }
}

var (sourceText, _, options) = _projectContext.BuildDocument("");
_checkerManager.TriggerBackgroundCheck(VirtualFileName, sourceText, options);
}
Expand Down
70 changes: 62 additions & 8 deletions src/Verso.FSharp/Kernel/FSharpProjectContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text;
using FSharp.Compiler.CodeAnalysis;
using FSharp.Compiler.Tokenization;
using Microsoft.FSharp.Collections;
using Microsoft.FSharp.Core;

Expand All @@ -19,9 +20,10 @@ internal sealed class FSharpProjectContext
"fsi.exe", "--noninteractive", "--nologo", "--gui-"
};

private readonly List<string> _executedSources = new();
private readonly List<string> _executedFragments = new();
private readonly IReadOnlyList<string> _defaultOpens;
private readonly List<string> _additionalReferences = new();
private readonly List<string> _loadedFiles = new();
private readonly string[] _sessionArgs;

/// <summary>
Expand All @@ -40,7 +42,26 @@ public FSharpProjectContext(IReadOnlyList<string> defaultOpens, string[] session
/// </summary>
public void AppendExecutedCode(string code)
{
_executedSources.Add(code);
var fragment = new StringBuilder();
AppendFragment(fragment, $"{ExecutedCellModulePrefix}{_executedFragments.Count + 1}", code);
_executedFragments.Add(fragment.ToString());
}

/// <summary>
/// Records the files an executed <c>#load</c> compiled, in the order FSI compiled them.
/// </summary>
/// <remarks>
/// FSI compiles a loaded script as a file of its own, named for its module or namespace
/// header, or without one for its file name (<c>lib.fsx</c> is <c>Lib</c>), and does not
/// open it. The checker does the same when the files are part of the project ahead of the
/// notebook, so they are kept as files rather than copied into the document. A file loaded
/// again moves to the end, since its latest load is the one later cells see.
/// </remarks>
public void AddLoadedFiles(IEnumerable<string> filePaths)
{
var files = filePaths.ToList();
_loadedFiles.RemoveAll(f => files.Contains(f, StringComparer.OrdinalIgnoreCase));
_loadedFiles.AddRange(files);
}

/// <summary>
Expand Down Expand Up @@ -78,9 +99,9 @@ public void AddReference(string assemblyPath)
}

// Add previously executed sources, one fragment each
for (var i = 0; i < _executedSources.Count; i++)
foreach (var fragment in _executedFragments)
{
AppendFragment(prefixBuilder, $"{ExecutedCellModulePrefix}{i + 1}", _executedSources[i]);
prefixBuilder.Append(fragment);
}

var prefix = prefixBuilder.ToString();
Expand Down Expand Up @@ -119,21 +140,33 @@ public static string HideExecutedCellModules(string text) =>
private static readonly string[] ScriptDirectives =
{ "#r", "#load", "#I", "#nowarn", "#warnon", "#time", "#help", "#quit", "#i" };

// FSI defines INTERACTIVE, so the branches it compiled are the ones the tokenizer keeps.
private static readonly FSharpList<string> InteractiveDefines = ListModule.OfArray(new[] { "INTERACTIVE" });

private static void AppendFragment(StringBuilder builder, string moduleName, string source)
{
var lines = source.Replace("\r\n", "\n").Split('\n');
var body = new StringBuilder();
var opens = new List<string>();
var tokenizer = new FSharpSourceTokenizer(
InteractiveDefines, FSharpOption<string>.None, FSharpOption<string>.None, FSharpOption<bool>.None);
var state = FSharpTokenizerLexState.Initial;

foreach (var line in lines)
{
if (IsScriptDirective(line))
// A line that starts inside a multi-line string or comment, or in a branch #if
// leaves out, is not code, whatever it starts with: it is neither a directive to
// lift nor an open to repeat.
var startsInCode = StartsInCode(state);
state = ScanLine(tokenizer, line, state);

if (startsInCode && IsScriptDirective(line))
{
builder.AppendLine(line);
continue;
}

if (line.StartsWith("open ", StringComparison.Ordinal))
if (startsInCode && line.StartsWith("open ", StringComparison.Ordinal))
opens.Add(line);

body.Append(" ").AppendLine(line);
Expand All @@ -149,6 +182,26 @@ private static void AppendFragment(StringBuilder builder, string moduleName, str
builder.AppendLine(open);
}

private static bool StartsInCode(FSharpTokenizerLexState state)
{
var color = FSharpLineTokenizer.ColorStateOfLexState(state);
return color == FSharpTokenizerColorState.InitialState
|| color == FSharpTokenizerColorState.Token
|| color == FSharpTokenizerColorState.EndLineThenToken;
}

private static FSharpTokenizerLexState ScanLine(FSharpSourceTokenizer tokenizer, string line, FSharpTokenizerLexState state)
{
var lineTokenizer = tokenizer.CreateLineTokenizer(line);
while (true)
{
var (token, next) = lineTokenizer.ScanToken(state);
state = next;
if (token is null)
return state;
}
}

private static bool IsScriptDirective(string line)
{
foreach (var directive in ScriptDirectives)
Expand All @@ -167,8 +220,9 @@ private static bool IsScriptDirective(string line)
/// </summary>
public void Reset()
{
_executedSources.Clear();
_executedFragments.Clear();
_additionalReferences.Clear();
_loadedFiles.Clear();
}

private FSharpProjectOptions BuildProjectOptions()
Expand All @@ -191,7 +245,7 @@ private FSharpProjectOptions BuildProjectOptions()
return new FSharpProjectOptions(
projectFileName: VirtualFileName,
projectId: FSharpOption<string>.None,
sourceFiles: new[] { VirtualFileName },
sourceFiles: _loadedFiles.Append(VirtualFileName).ToArray(),
otherOptions: otherOptions.ToArray(),
referencedProjects: Array.Empty<FSharpReferencedProject>(),
isIncompleteTypeCheckEnvironment: false,
Expand Down
6 changes: 4 additions & 2 deletions src/Verso.FSharp/NuGet/ScriptDirectiveProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal sealed class ScriptDirectiveProcessor
RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex LoadRegex = new(
@"^#load\s+""([^""]+)""",
@"^#load\s+@?""([^""]+)""",
RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex IncludePathRegex = new(
Expand All @@ -36,7 +36,8 @@ internal sealed class ScriptDirectiveProcessor
public IReadOnlyList<string> ResolvedAssemblyPaths => _resolvedAssemblyPaths;

/// <summary>
/// File paths loaded via <c>#load "script.fsx"</c> directives, for IntelliSense context.
/// File paths the code last processed loads via <c>#load "script.fsx"</c> directives, so the
/// kernel can record them for IntelliSense once that code has run.
/// </summary>
public IReadOnlyList<string> LoadedFilePaths => _loadedFilePaths;

Expand All @@ -53,6 +54,7 @@ internal sealed class ScriptDirectiveProcessor
public string ProcessDirectives(string code, INotebookMetadata? metadata)
{
var processed = code;
_loadedFilePaths.Clear();

// Process #r "assembly.dll" (non-NuGet)
processed = AssemblyRefRegex.Replace(processed, match =>
Expand Down
2 changes: 1 addition & 1 deletion src/Verso.Testing/Stubs/StubExecutionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public sealed class StubExecutionContext : IExecutionContext
public IThemeContext Theme { get; } = new StubThemeContext();
public LayoutCapabilities LayoutCapabilities { get; set; } = LayoutCapabilities.None;
public IExtensionHostContext ExtensionHost { get; set; } = new StubExtensionHostContext(() => Array.Empty<ILanguageKernel>());
public INotebookMetadata NotebookMetadata { get; } = new NotebookMetadataContext(new NotebookModel());
public INotebookMetadata NotebookMetadata { get; set; } = new NotebookMetadataContext(new NotebookModel());
public INotebookOperations Notebook { get; set; } = new StubNotebookOperations();

/// <summary>
Expand Down
57 changes: 57 additions & 0 deletions tests/Verso.FSharp.Tests/Kernel/ExecutedCellsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,62 @@ public async Task RunCellWithTripleQuotedString_StillChecks()
Assert.AreEqual(0, diagnostics.Count, Describe(diagnostics));
}

[TestMethod]
public async Task OpenLineInsideATripleQuotedString_IsNotOpenedForLaterCells()
{
await _kernel.ExecuteAsync("let text = \"\"\"\nopen System.Text\n\"\"\"", _context);

var diagnostics = await _kernel.GetDiagnosticsAsync("let sb = StringBuilder()");

Assert.IsTrue(diagnostics.Any(d => d.Code == "FS0039"), Describe(diagnostics));
}

[TestMethod]
public async Task OpenLineInsideAVerbatimString_IsNotOpenedForLaterCells()
{
await _kernel.ExecuteAsync("let text = @\"\nopen System.Text\n\"", _context);

var diagnostics = await _kernel.GetDiagnosticsAsync("let sb = StringBuilder()");

Assert.IsTrue(diagnostics.Any(d => d.Code == "FS0039"), Describe(diagnostics));
}

[TestMethod]
public async Task OpenLineInsideABlockComment_IsNotOpenedForLaterCells()
{
await _kernel.ExecuteAsync("(*\nopen System.Text\n*)\nlet value = 1", _context);

var diagnostics = await _kernel.GetDiagnosticsAsync("let sb = StringBuilder()");

Assert.IsTrue(diagnostics.Any(d => d.Code == "FS0039"), Describe(diagnostics));
}

[TestMethod]
public async Task OpenInABranchFsiLeavesOut_IsNotOpenedForLaterCells()
{
await _kernel.ExecuteAsync("#if INTERACTIVE\nlet mode = \"fsi\"\n#else\nopen System.Text\n#endif", _context);

var diagnostics = await _kernel.GetDiagnosticsAsync("let m : string = mode\nlet sb = StringBuilder()");

Assert.AreEqual(1, diagnostics.Count, Describe(diagnostics));
Assert.AreEqual("FS0039", diagnostics[0].Code, Describe(diagnostics));
Assert.AreEqual(1, diagnostics[0].StartLine, Describe(diagnostics));
}

[TestMethod]
public void DirectiveLineInsideAMultiLineString_StaysInTheString()
{
var context = new FSharpProjectContext(Array.Empty<string>(), Array.Empty<string>());
context.AppendExecutedCode("let text = \"\"\"\n#r \"nothing.dll\"\nopen Nowhere\n\"\"\"");

var (sourceText, _, _) = context.BuildDocument("");
var lines = sourceText.Replace("\r\n", "\n").Split('\n');

CollectionAssert.Contains(lines, " #r \"nothing.dll\"", sourceText);
CollectionAssert.DoesNotContain(lines, "#r \"nothing.dll\"", sourceText);
CollectionAssert.DoesNotContain(lines, "open Nowhere", sourceText);
}

[TestMethod]
public async Task Completions_DoNotOfferTheExecutedCellModules()
{
Expand All @@ -118,6 +174,7 @@ public async Task Completions_DoNotOfferTheExecutedCellModules()
&& c.Kind == "Module"),
string.Join(", ", completions.Where(c => c.Kind == "Module").Select(c => c.DisplayText)));
}

[TestMethod]
public async Task HoverAndDiagnostics_DoNotNameTheExecutedCellModules()
{
Expand Down
Loading
Loading