diff --git a/build.gradle b/build.gradle index 95dfb435..9fd1046e 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,9 @@ configurations { } dependencies { - implementation 'io.nextflow:nf-lang:26.04.3' + // TEMPORARY: locally-built nf-lang from nextflow-io/nextflow#7346 (via mavenLocal). + // Amend this to the released nf-lang version once PR #7346 is merged and released. + implementation 'io.nextflow:nf-lang:26.07.0-edge' implementation 'org.apache.groovy:groovy:4.0.31' implementation 'org.apache.groovy:groovy-json:4.0.31' implementation 'org.apache.groovy:groovy-yaml:4.0.31' diff --git a/src/main/java/nextflow/lsp/NextflowLanguageServer.java b/src/main/java/nextflow/lsp/NextflowLanguageServer.java index 58ae0ff1..53694bda 100644 --- a/src/main/java/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/java/nextflow/lsp/NextflowLanguageServer.java @@ -378,7 +378,8 @@ public CompletableFuture> formatting(DocumentFormatting params.getOptions().isInsertSpaces(), configuration.harshilAlignment(), configuration.maheshForm(), - configuration.sortDeclarations() + configuration.sortDeclarations(), + configuration.maxLineLength() ); log.debug(String.format("textDocument/formatting %s %s %d", relativePath(uri), options.insertSpaces() ? "spaces" : "tabs", options.tabSize())); var service = getLanguageService(uri); @@ -462,6 +463,7 @@ public void didChangeConfiguration(DidChangeConfigurationParams params) { withDefault(JsonUtils.getBoolean(settings, "nextflow.formatting.harshilAlignment"), configuration.harshilAlignment()), withDefault(JsonUtils.getBoolean(settings, "nextflow.formatting.maheshForm"), configuration.maheshForm()), withDefault(JsonUtils.getInteger(settings, "nextflow.completion.maxItems"), configuration.maxCompletionItems()), + withDefault(JsonUtils.getInteger(settings, "nextflow.formatting.maxLineLength"), configuration.maxLineLength()), withDefault(JsonUtils.getString(settings, "nextflow.pluginRegistryUrl"), configuration.pluginRegistryUrl()), withDefault(JsonUtils.getBoolean(settings, "nextflow.formatting.sortDeclarations"), configuration.sortDeclarations()) ); diff --git a/src/main/java/nextflow/lsp/services/FormattingProvider.java b/src/main/java/nextflow/lsp/services/FormattingProvider.java index 0e27fb87..737f009a 100644 --- a/src/main/java/nextflow/lsp/services/FormattingProvider.java +++ b/src/main/java/nextflow/lsp/services/FormattingProvider.java @@ -18,6 +18,7 @@ import java.net.URI; import java.util.List; +import nextflow.script.formatter.CommentReattacher; import nextflow.script.formatter.FormattingOptions; import org.eclipse.lsp4j.TextEdit; @@ -25,4 +26,18 @@ public interface FormattingProvider { List formatting(URI uri, FormattingOptions options); + /** + * Determine whether formatting preserved every comment, as a safety + * check before returning any edit. The lexing mode must match the + * formatting visitor that produced the new text. + * + * @param oldText + * @param newText + * @param configFile whether to lex as a config file instead of a script + */ + static boolean commentsPreserved(String oldText, String newText, boolean configFile) { + return CommentReattacher.commentTexts(oldText, configFile) + .equals(CommentReattacher.commentTexts(newText, configFile)); + } + } diff --git a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java index 14ab0d1f..29817095 100644 --- a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java +++ b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java @@ -18,6 +18,8 @@ import java.util.Collections; import java.util.List; +import nextflow.script.formatter.FormattingOptions; + public record LanguageServerConfiguration( String dagDirection, boolean dagVerbose, @@ -27,6 +29,7 @@ public record LanguageServerConfiguration( boolean harshilAlignment, boolean maheshForm, int maxCompletionItems, + int maxLineLength, String pluginRegistryUrl, boolean sortDeclarations ) { @@ -41,6 +44,7 @@ public static LanguageServerConfiguration defaults() { false, false, 100, + FormattingOptions.DEFAULT_MAX_LINE_LENGTH, "https://registry.nextflow.io/api/", false ); diff --git a/src/main/java/nextflow/lsp/services/config/ConfigFormattingProvider.java b/src/main/java/nextflow/lsp/services/config/ConfigFormattingProvider.java index 8bd90308..42eb82dd 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigFormattingProvider.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigFormattingProvider.java @@ -68,10 +68,18 @@ public List formatting(URI uri, FormattingOptions options) { } var range = new Range(new Position(0, 0), Positions.getPosition(oldText, oldText.length())); - var visitor = new ConfigFormattingVisitor(sourceUnit, options); + var visitor = new ConfigFormattingVisitor(sourceUnit, options, oldText); visitor.visit(); var newText = visitor.toString(); + if( newText.equals(oldText) ) + return Collections.emptyList(); + + if( !FormattingProvider.commentsPreserved(oldText, newText, true) ) { + log.showError("Refusing to format config file because formatting would remove or alter comments (please report this as a bug): " + uri); + return Collections.emptyList(); + } + return List.of( new TextEdit(range, newText) ); } diff --git a/src/main/java/nextflow/lsp/services/script/ScriptFormattingProvider.java b/src/main/java/nextflow/lsp/services/script/ScriptFormattingProvider.java index f3f4ccde..67b21543 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptFormattingProvider.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptFormattingProvider.java @@ -68,10 +68,18 @@ public List formatting(URI uri, FormattingOptions options) { } var range = new Range(new Position(0, 0), Positions.getPosition(oldText, oldText.length())); - var visitor = new ScriptFormattingVisitor(sourceUnit, options); + var visitor = new ScriptFormattingVisitor(sourceUnit, options, oldText); visitor.visit(); var newText = visitor.toString(); + if( newText.equals(oldText) ) + return Collections.emptyList(); + + if( !FormattingProvider.commentsPreserved(oldText, newText, false) ) { + log.showError("Refusing to format script because formatting would remove or alter comments (please report this as a bug): " + uri); + return Collections.emptyList(); + } + return List.of( new TextEdit(range, newText) ); } diff --git a/src/main/java/nextflow/lsp/services/script/ScriptService.java b/src/main/java/nextflow/lsp/services/script/ScriptService.java index 6c8356d8..b31603c4 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptService.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptService.java @@ -158,7 +158,8 @@ private static FormattingOptions formattingOptions(LanguageServerConfiguration c true, configuration.harshilAlignment(), configuration.maheshForm(), - configuration.sortDeclarations() + configuration.sortDeclarations(), + configuration.maxLineLength() ); } diff --git a/src/test/groovy/nextflow/lsp/DiagnosticsReportingTest.groovy b/src/test/groovy/nextflow/lsp/DiagnosticsReportingTest.groovy index 22e9089c..900f6260 100644 --- a/src/test/groovy/nextflow/lsp/DiagnosticsReportingTest.groovy +++ b/src/test/groovy/nextflow/lsp/DiagnosticsReportingTest.groovy @@ -43,7 +43,7 @@ class DiagnosticsReportingTest extends Specification { def configuration = new LanguageServerConfiguration( d.dagDirection(), d.dagVerbose(), mode, d.excludePatterns(), d.extendedCompletion(), d.harshilAlignment(), d.maheshForm(), - d.maxCompletionItems(), d.pluginRegistryUrl(), d.sortDeclarations()) + d.maxCompletionItems(), d.maxLineLength(), d.pluginRegistryUrl(), d.sortDeclarations()) def service = new ConfigService(workspaceRootUri()) service.connect(client) service.initialize(configuration, new PluginSpecCache(configuration.pluginRegistryUrl())) diff --git a/src/test/groovy/nextflow/lsp/TestUtils.groovy b/src/test/groovy/nextflow/lsp/TestUtils.groovy index 152b6362..82edf6ed 100644 --- a/src/test/groovy/nextflow/lsp/TestUtils.groovy +++ b/src/test/groovy/nextflow/lsp/TestUtils.groovy @@ -144,4 +144,30 @@ class TestUtils { service.didOpen(new DidOpenTextDocumentParams(textDocumentItem)) } + /** + * Open a file, also writing it to disk. Tests that issue multiple + * requests against the same file without a document change in between + * must use this instead of open(): the deferred workspace scan re-scans + * from disk and would evict an in-memory-only file from the AST cache. + * + * Delete the file with deleteOnDisk() in the test's cleanup block. + * + * @param service + * @param uri + * @param contents + */ + static void openOnDisk(LanguageService service, String uri, String contents) { + Files.writeString(Path.of(URI.create(uri)), contents.stripIndent()) + open(service, uri, contents) + } + + /** + * Delete a file written by openOnDisk(). + * + * @param uri + */ + static void deleteOnDisk(String uri) { + Files.deleteIfExists(Path.of(URI.create(uri))) + } + } diff --git a/src/test/groovy/nextflow/lsp/ast/ASTNodeStringUtilsTest.groovy b/src/test/groovy/nextflow/lsp/ast/ASTNodeStringUtilsTest.groovy index 861afba8..0798489f 100644 --- a/src/test/groovy/nextflow/lsp/ast/ASTNodeStringUtilsTest.groovy +++ b/src/test/groovy/nextflow/lsp/ast/ASTNodeStringUtilsTest.groovy @@ -74,7 +74,7 @@ class ASTNodeStringUtilsTest extends Specification { then: ASTNodeStringUtils.getLabel(ffn) == '(feature flag) nextflow.enable.strict' - ASTNodeStringUtils.getDocumentation(ffn) == 'When `true`, the pipeline is executed in [strict mode](https://nextflow.io/docs/latest/reference/feature-flags.html).' + ASTNodeStringUtils.getDocumentation(ffn) == 'When `true`, the pipeline is executed in [strict mode](https://docs.seqera.io/nextflow/reference/feature-flags).' } def 'should get the label and docs for a workflow' () { @@ -180,7 +180,7 @@ class ASTNodeStringUtilsTest extends Specification { ASTNodeStringUtils.getDocumentation(node) == ''' Create a channel that emits each argument. - [Read more](https://nextflow.io/docs/latest/reference/channel.html#of) + [Read more](https://docs.seqera.io/nextflow/reference/channel#of) '''.stripIndent(true).trim() } @@ -202,7 +202,7 @@ class ASTNodeStringUtilsTest extends Specification { ASTNodeStringUtils.getDocumentation(node) == ''' The `executor` defines the underlying system where tasks are executed. - [Read more](https://nextflow.io/docs/latest/reference/process.html#executor) + [Read more](https://docs.seqera.io/nextflow/reference/process#executor) '''.stripIndent(true).trim() } diff --git a/src/test/groovy/nextflow/lsp/services/config/ConfigFormattingTest.groovy b/src/test/groovy/nextflow/lsp/services/config/ConfigFormattingTest.groovy index cd61c2cb..30539af5 100644 --- a/src/test/groovy/nextflow/lsp/services/config/ConfigFormattingTest.groovy +++ b/src/test/groovy/nextflow/lsp/services/config/ConfigFormattingTest.groovy @@ -16,63 +16,123 @@ package nextflow.lsp.services.config -import java.nio.file.Files -import java.nio.file.Path - -import nextflow.lsp.TestLanguageClient -import nextflow.lsp.services.LanguageServerConfiguration import nextflow.script.formatter.FormattingOptions -import org.eclipse.lsp4j.DidOpenTextDocumentParams -import org.eclipse.lsp4j.Position -import org.eclipse.lsp4j.TextDocumentItem import spock.lang.Specification +import static nextflow.lsp.TestUtils.* + /** * * @author Ben Sherman */ class ConfigFormattingTest extends Specification { - String openAndFormat(ConfigService service, Path filePath, String contents) { - def uri = filePath.toUri() - def textDocumentItem = new TextDocumentItem(uri.toString(), 'nextflow-config', 1, contents) - service.didOpen(new DidOpenTextDocumentParams(textDocumentItem)) - def textEdits = service.formatting(uri, new FormattingOptions(4, true)) - return textEdits.first().getNewText() + String openAndFormat(ConfigService service, String uri, String contents) { + open(service, uri, contents) + def textEdits = service.formatting(URI.create(uri), new FormattingOptions(4, true)) + // no edits means the document was already formatted + return textEdits ? textEdits.first().getNewText() : contents.stripIndent() + } + + boolean checkFormat(ConfigService service, String uri, String before, String after) { + assert openAndFormat(service, uri, before) == after.stripIndent() + return true + } + + boolean checkRoundTrip(ConfigService service, String uri, String source) { + return checkFormat(service, uri, source, source) } def 'should format a config file' () { given: - def workspaceRoot = Path.of(System.getProperty('user.dir')).resolve('build/test_workspace/') - if( !Files.exists(workspaceRoot) ) - workspaceRoot.toFile().mkdirs() - - def service = new ConfigService(workspaceRoot.toUri().toString()) - def configuration = LanguageServerConfiguration.defaults() - service.connect(new TestLanguageClient()) - service.initialize(configuration) + def service = getConfigService() + def uri = getUri('nextflow.config') - when: - def filePath = workspaceRoot.resolve('nextflow.config') - def contents = '''\ + expect: + checkFormat(service, uri, + '''\ process.cpus = 2 ; process.memory = 8.GB - '''.stripIndent() - then: - openAndFormat(service, filePath, contents) == '''\ + ''', + '''\ process.cpus = 2 process.memory = 8.GB - '''.stripIndent() - - when: - contents = '''\ + ''' + ) + checkRoundTrip(service, uri, + '''\ process.cpus = 2 process.memory = 8.GB - '''.stripIndent() - then: - openAndFormat(service, filePath, contents) == '''\ + ''' + ) + } + + def 'should preserve all comments when formatting a config file' () { + given: + def service = getConfigService() + def uri = getUri('nextflow.config') + + expect: + // trailing comments, dangling comments at the end of a block and at + // the end of the file are all preserved + checkRoundTrip(service, uri, + '''\ + process { + cpus = 2 // trailing comment + // comment after the last option + } + + // comment at the end of the file + ''' + ) + } + + def 'should not format config regions excluded with fmt directives' () { + given: + def service = getConfigService() + def uri = getUri('nextflow.config') + + expect: + checkRoundTrip(service, uri, + '''\ process.cpus = 2 - process.memory = 8.GB + + // fmt: off + env.FOO = 'one' + env.BARBAZ = 'two' + // fmt: on + ''' + ) + } + + def 'should produce identical output when formatting a cached config AST twice' () { + given: + // formatting the same document repeatedly without a document change + // re-derives the comment metadata on the same cached AST -- the + // output must not change; the input is deliberately non-canonical + // so that every request returns an edit + def service = getConfigService() + def uri = getUri('nextflow.config') + def contents = '''\ + // top comment + process { + cpus=2 // trailing comment + // dangling comment + } '''.stripIndent() + def expected = contents.replace('cpus=2', 'cpus = 2') + + when: + openOnDisk(service, uri, contents) + def texts = (1..3).collect { + def edits = service.formatting(URI.create(uri), new FormattingOptions(4, true)) + edits ? edits.first().getNewText() : contents + } + + then: + texts == [expected] * 3 + + cleanup: + deleteOnDisk(uri) } } diff --git a/src/test/groovy/nextflow/lsp/services/script/ConvertScriptStaticTypesTest.groovy b/src/test/groovy/nextflow/lsp/services/script/ConvertScriptStaticTypesTest.groovy index af5db8f0..3a1d6f01 100644 --- a/src/test/groovy/nextflow/lsp/services/script/ConvertScriptStaticTypesTest.groovy +++ b/src/test/groovy/nextflow/lsp/services/script/ConvertScriptStaticTypesTest.groovy @@ -17,6 +17,7 @@ package nextflow.lsp.services.script import nextflow.lsp.services.LanguageServerConfiguration +import nextflow.script.formatter.FormattingOptions import spock.lang.Specification import static nextflow.lsp.TestUtils.* @@ -222,6 +223,51 @@ class ConvertScriptStaticTypesTest extends Specification { checkOutputs(service, 'stdout', 'stdout()') } + def 'should convert a script after formatting the same cached AST' () { + given: + // formatting re-derives comment metadata on the cached AST; a + // subsequent conversion of the same AST must neither crash nor + // duplicate comments into the replacement text + def service = getScriptService() + def uri = getUri('main.nf') + def contents = '''\ + // about this process + process test { + input: + val id + + script: + "true" + } + + workflow { + test(1) + } + '''.stripIndent() + + when: 'format the document so comment metadata is re-derived' + openOnDisk(service, uri, contents) + def edits = service.formatting(URI.create(uri), new FormattingOptions(4, true)) + then: 'the document is already canonical, so formatting returns no edits' + edits.isEmpty() + + when: 'convert the same cached AST to static types' + def response = service.executeCommand('nextflow.server.convertScriptToTyped', [asJson(uri)], LanguageServerConfiguration.defaults()) + def newText = response.applyEdit.getChanges()[uri][0].getNewText() + then: 'the replacement text does not duplicate the leading comment, which stays in the document above the replaced range' + newText == '''\ + process test { + input: + id + + script: + "true" + }'''.stripIndent() + + cleanup: + deleteOnDisk(uri) + } + def 'should convert tuple outputs' () { given: def service = getScriptService() diff --git a/src/test/groovy/nextflow/lsp/services/script/ScriptFormattingTest.groovy b/src/test/groovy/nextflow/lsp/services/script/ScriptFormattingTest.groovy index 6a18d0ec..9253d3dd 100644 --- a/src/test/groovy/nextflow/lsp/services/script/ScriptFormattingTest.groovy +++ b/src/test/groovy/nextflow/lsp/services/script/ScriptFormattingTest.groovy @@ -27,13 +27,27 @@ import static nextflow.lsp.TestUtils.* */ class ScriptFormattingTest extends Specification { - boolean checkFormat(ScriptService service, String uri, String before, String after) { + boolean checkFormat(ScriptService service, String uri, FormattingOptions options, String before, String after) { open(service, uri, before) - def textEdits = service.formatting(URI.create(uri), new FormattingOptions(4, true)) - assert textEdits.first().getNewText() == after.stripIndent() + def textEdits = service.formatting(URI.create(uri), options) + // no edits means the document was already formatted + def newText = textEdits ? textEdits.first().getNewText() : before.stripIndent() + assert newText == after.stripIndent() return true } + boolean checkFormat(ScriptService service, String uri, String before, String after) { + return checkFormat(service, uri, new FormattingOptions(4, true), before, after) + } + + boolean checkRoundTrip(ScriptService service, String uri, FormattingOptions options, String source) { + return checkFormat(service, uri, options, source, source) + } + + boolean checkRoundTrip(ScriptService service, String uri, String source) { + return checkFormat(service, uri, source, source) + } + def 'should format a script' () { given: def service = getScriptService() @@ -93,4 +107,313 @@ class ScriptFormattingTest extends Specification { ) } + def 'should format if-else statements in K&R style' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkFormat(service, uri, + '''\ + workflow { + if( params.a ) { + run_a() + } + else if( params.b ) { + run_b() + } + else { + run_c() + } + } + ''', + '''\ + workflow { + if (params.a) { + run_a() + } else if (params.b) { + run_b() + } else { + run_c() + } + } + ''' + ) + } + + def 'should normalize blank lines' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkFormat(service, uri, + '''\ + include { A } from './a.nf' + include { B } from './b.nf' + params.x = 1 + workflow { + A() + } + ''', + '''\ + include { A } from './a.nf' + include { B } from './b.nf' + + params.x = 1 + + workflow { + A() + } + ''' + ) + checkFormat(service, uri, + '''\ + workflow { + + x = 1 + + + + y = 2 + } + ''', + '''\ + workflow { + x = 1 + + y = 2 + } + ''' + ) + } + + def 'should preserve all comments when formatting' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + // trailing comments, dangling comments at the end of a block and at + // the end of the file are all preserved + checkRoundTrip(service, uri, + '''\ + workflow { + x = 1 // trailing comment + // comment after the last statement + } + + // comment at the end of the file + ''' + ) + // a commented-out process is preserved + checkRoundTrip(service, uri, + '''\ + // process FOO { + // script: + // "true" + // } + + process BAR { + script: + "true" + } + + workflow { + BAR() + } + ''' + ) + } + + def 'should wrap lines that exceed the maximum line length' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + def options = new FormattingOptions(4, true, false, false, false, 60) + + expect: + checkFormat(service, uri, options, + '''\ + workflow { + ALIGN_AND_SORT(samples_channel, reference_genome, annotation_file, params.threads) + } + ''', + '''\ + workflow { + ALIGN_AND_SORT( + samples_channel, + reference_genome, + annotation_file, + params.threads, + ) + } + ''' + ) + } + + def 'should not wrap lines when the maximum line length is zero' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + def options = new FormattingOptions(4, true, false, false, false, 0) + + expect: + checkRoundTrip(service, uri, options, + '''\ + workflow { + ALIGN_AND_SORT(samples_channel, reference_genome, annotation_file, params.threads, extra_arg_one, extra_arg_two, extra_arg_three) + } + ''' + ) + } + + def 'should not format regions excluded with fmt directives' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkFormat(service, uri, + '''\ + workflow { + x = [1, 2, 3] // fmt: skip + y = [4,5] + } + ''', + '''\ + workflow { + x = [1, 2, 3] // fmt: skip + y = [4, 5] + } + ''' + ) + // a fmt: off / fmt: on region round-trips unchanged + checkRoundTrip(service, uri, + '''\ + workflow { + a = 1 + + // fmt: off + matrix = [ + [1, 0], + [0, 1] ] + // fmt: on + + b = 2 + } + ''' + ) + } + + def 'should sort includes when sorting is enabled' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + def options = new FormattingOptions(4, true, false, false, true, 120) + + expect: + checkFormat(service, uri, options, + '''\ + include { ZULU } from './modules/zulu.nf' + include { ALPHA } from './modules/alpha.nf' + + workflow { + ALPHA() + } + ''', + '''\ + include { ALPHA } from './modules/alpha.nf' + include { ZULU } from './modules/zulu.nf' + + workflow { + ALPHA() + } + ''' + ) + } + + def 'should re-indent multi-line strings' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + def options = new FormattingOptions(2, true, false, false, false, 120) + + expect: + checkFormat(service, uri, options, + '''\ + process foo { + script: + """ + echo 'hello world!' + """ + } + ''', + '''\ + process foo { + script: + """ + echo 'hello world!' + """ + } + ''' + ) + } + + def 'should format leading comments in a file with CRLF line endings' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkFormat(service, uri, + "workflow {\r\n // ALIGN reads to reference genome\r\n BWA_ALIGN(sample_id, library_id)\r\n}\r\n", + '''\ + workflow { + // ALIGN reads to reference genome + BWA_ALIGN(sample_id, library_id) + } + ''' + ) + } + + def 'should produce identical output when formatting a cached AST twice' () { + given: + // formatting the same document repeatedly without a document change + // re-derives the comment metadata on the same cached AST -- the + // output must not change, including for comments inside wrapped + // expressions; the input is deliberately non-canonical so that + // every request returns an edit + def service = getScriptService() + def uri = getUri('main.nf') + def contents = '''\ + workflow { + foo( + // leading comment on element + alpha, + beta, // trailing comment on element + ) + data + // comment on chain link + .map { x -> x } + .view() + y=1 + } + '''.stripIndent() + def expected = contents.replace('y=1', 'y = 1') + + when: + openOnDisk(service, uri, contents) + def texts = (1..3).collect { + def edits = service.formatting(URI.create(uri), new FormattingOptions(4, true)) + edits ? edits.first().getNewText() : contents + } + + then: + texts == [expected] * 3 + + cleanup: + deleteOnDisk(uri) + } + }