diff --git a/src/PowerShellEditorServices/Workspace/ScriptFile.cs b/src/PowerShellEditorServices/Workspace/ScriptFile.cs index 9d3b4aead..b34985476 100644 --- a/src/PowerShellEditorServices/Workspace/ScriptFile.cs +++ b/src/PowerShellEditorServices/Workspace/ScriptFile.cs @@ -114,7 +114,7 @@ public Token[] ScriptTokens } /// - /// Gets the array of filepaths dot sourced in this ScriptFile + /// Gets the array of filepaths dot sourced in this ScriptFile /// public string[] ReferencedFiles { @@ -227,7 +227,7 @@ public static IList GetLines(string text) } /// - /// Deterines whether the supplied path indicates the file is an "untitled:Unitled-X" + /// Deterines whether the supplied path indicates the file is an "untitled:Unitled-X" /// which has not been saved to file. /// /// The path to check. @@ -311,12 +311,38 @@ public void ValidatePosition(BufferPosition bufferPosition) /// The 1-based column to be validated. public void ValidatePosition(int line, int column) { - int maxLine = this.FileLines.Count; + ValidatePosition(line, column, isInsertion: false); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. If the position is for an insertion (an applied change) + /// the index may be 1 past the end of the file, which is just appended. + /// + /// The 1-based line to be validated. + /// The 1-based column to be validated. + /// If true, the position to validate is for an applied change. + public void ValidatePosition(int line, int column, bool isInsertion) + { + // If new content is being added, VSCode sometimes likes to add it at (FileLines.Count + 1), + // which used to crash EditorServices. Now we append it on to the end of the file. + // See https://github.com/PowerShell/vscode-powershell/issues/1283 + int maxLine = isInsertion ? this.FileLines.Count + 1 : this.FileLines.Count; if (line < 1 || line > maxLine) { throw new ArgumentOutOfRangeException($"Position {line}:{column} is outside of the line range of 1 to {maxLine}."); } + // If we are inserting at the end of the file, the column should be 1 + if (isInsertion && line == maxLine) + { + if (column != 1) + { + throw new ArgumentOutOfRangeException($"Insertion at the end of a file must occur at column 1"); + } + return; + } + // The maximum column is either **one past** the length of the string // or 1 if the string is empty. string lineString = this.FileLines[line - 1]; @@ -347,51 +373,65 @@ public void ApplyChange(FileChange fileChange) } else { - this.ValidatePosition(fileChange.Line, fileChange.Offset); - this.ValidatePosition(fileChange.EndLine, fileChange.EndOffset); - - // Get the first fragment of the first line - string firstLineFragment = - this.FileLines[fileChange.Line - 1] - .Substring(0, fileChange.Offset - 1); - - // Get the last fragment of the last line - string endLine = this.FileLines[fileChange.EndLine - 1]; - string lastLineFragment = - endLine.Substring( - fileChange.EndOffset - 1, - (this.FileLines[fileChange.EndLine - 1].Length - fileChange.EndOffset) + 1); - - // Remove the old lines - for (int i = 0; i <= fileChange.EndLine - fileChange.Line; i++) - { - this.FileLines.RemoveAt(fileChange.Line - 1); - } + this.ValidatePosition(fileChange.Line, fileChange.Offset, isInsertion: true); + this.ValidatePosition(fileChange.EndLine, fileChange.EndOffset, isInsertion: true); - // Build and insert the new lines - int currentLineNumber = fileChange.Line; - for (int changeIndex = 0; changeIndex < changeLines.Length; changeIndex++) + // VSCode sometimes likes to give the change start line as (FileLines.Count + 1). + // This used to crash EditorServices, but we now treat it as an append. + // See https://github.com/PowerShell/vscode-powershell/issues/1283 + if (fileChange.Line == this.FileLines.Count + 1) { - // Since we split the lines above using \n, make sure to - // trim the ending \r's off as well. - string finalLine = changeLines[changeIndex].TrimEnd('\r'); - - // Should we add first or last line fragments? - if (changeIndex == 0) + foreach (string addedLine in changeLines) { - // Append the first line fragment - finalLine = firstLineFragment + finalLine; + string finalLine = addedLine.TrimEnd('\r'); + this.FileLines.Add(finalLine); } - if (changeIndex == changeLines.Length - 1) + } + // Otherwise, the change needs to go between existing content + else + { + // Get the first fragment of the first line + string firstLineFragment = + this.FileLines[fileChange.Line - 1] + .Substring(0, fileChange.Offset - 1); + + // Get the last fragment of the last line + string endLine = this.FileLines[fileChange.EndLine - 1]; + string lastLineFragment = + endLine.Substring( + fileChange.EndOffset - 1, + (this.FileLines[fileChange.EndLine - 1].Length - fileChange.EndOffset) + 1); + + // Remove the old lines + for (int i = 0; i <= fileChange.EndLine - fileChange.Line; i++) { - // Append the last line fragment - finalLine = finalLine + lastLineFragment; + this.FileLines.RemoveAt(fileChange.Line - 1); } - this.FileLines.Insert(currentLineNumber - 1, finalLine); - currentLineNumber++; - } + // Build and insert the new lines + int currentLineNumber = fileChange.Line; + for (int changeIndex = 0; changeIndex < changeLines.Length; changeIndex++) + { + // Since we split the lines above using \n, make sure to + // trim the ending \r's off as well. + string finalLine = changeLines[changeIndex].TrimEnd('\r'); + // Should we add first or last line fragments? + if (changeIndex == 0) + { + // Append the first line fragment + finalLine = firstLineFragment + finalLine; + } + if (changeIndex == changeLines.Length - 1) + { + // Append the last line fragment + finalLine = finalLine + lastLineFragment; + } + + this.FileLines.Insert(currentLineNumber - 1, finalLine); + currentLineNumber++; + } + } } // Parse the script again to be up-to-date diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index ccaed4ce3..620454bb1 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -13,7 +13,7 @@ namespace PSLanguageService.Test { public class ScriptFileChangeTests { - private static readonly Version PowerShellVersion = new Version("5.0"); + private static readonly Version PowerShellVersion = new Version("5.0"); [Fact] public void CanApplySingleLineInsert() @@ -127,6 +127,22 @@ public void CanApplyMultiLineDelete() }); } + [Fact] + public void CanApplyEditsToEndOfFile() + { + this.AssertFileChange( + "line1\r\nline2\r\nline3\r\n\r\n", + "line1\r\nline2\r\nline3\r\n\r\n\r\n\r\n", + new FileChange + { + Line = 5, + EndLine = 5, + Offset = 1, + EndOffset = 1, + InsertString = "\r\n\r\n" + }); + } + [Fact] public void FindsDotSourcedFiles() { @@ -139,7 +155,7 @@ public void FindsDotSourcedFiles() using (StringReader stringReader = new StringReader(exampleScriptContents)) { - ScriptFile scriptFile = + ScriptFile scriptFile = new ScriptFile( "DotSourceTestFile.ps1", "DotSourceTestFile.ps1", @@ -178,7 +194,7 @@ internal static ScriptFile CreateScriptFile(string initialString) using (StringReader stringReader = new StringReader(initialString)) { // Create an in-memory file from the StringReader - ScriptFile fileToChange = + ScriptFile fileToChange = new ScriptFile( "TestFile.ps1", "TestFile.ps1",