1 /* 2 * Copyright 2016 Google Inc. All Rights Reserved. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.google.turbine.lower; 18 19 import static com.google.common.truth.Truth.assertThat; 20 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH; 21 import static java.nio.charset.StandardCharsets.UTF_8; 22 import static org.junit.Assert.fail; 23 24 import com.google.common.base.Joiner; 25 import com.google.common.collect.ImmutableList; 26 import com.google.common.collect.ImmutableMap; 27 import com.google.common.io.ByteStreams; 28 import com.google.turbine.binder.Binder; 29 import com.google.turbine.binder.Binder.BindingResult; 30 import com.google.turbine.binder.ClassPathBinder; 31 import com.google.turbine.binder.bound.SourceTypeBoundClass; 32 import com.google.turbine.binder.env.SimpleEnv; 33 import com.google.turbine.binder.sym.ClassSymbol; 34 import com.google.turbine.binder.sym.FieldSymbol; 35 import com.google.turbine.binder.sym.MethodSymbol; 36 import com.google.turbine.binder.sym.TyVarSymbol; 37 import com.google.turbine.bytecode.ByteReader; 38 import com.google.turbine.bytecode.ConstantPoolReader; 39 import com.google.turbine.diag.TurbineError; 40 import com.google.turbine.model.TurbineConstantTypeKind; 41 import com.google.turbine.model.TurbineFlag; 42 import com.google.turbine.model.TurbineTyKind; 43 import com.google.turbine.parse.Parser; 44 import com.google.turbine.testing.AsmUtils; 45 import com.google.turbine.type.Type; 46 import com.google.turbine.type.Type.ClassTy; 47 import com.google.turbine.type.Type.ClassTy.SimpleClassTy; 48 import com.google.turbine.type.Type.IntersectionTy; 49 import com.google.turbine.type.Type.PrimTy; 50 import com.google.turbine.type.Type.TyVar; 51 import java.io.IOException; 52 import java.io.OutputStream; 53 import java.nio.file.Files; 54 import java.nio.file.Path; 55 import java.util.ArrayList; 56 import java.util.LinkedHashMap; 57 import java.util.List; 58 import java.util.Map; 59 import java.util.Optional; 60 import java.util.jar.JarEntry; 61 import java.util.jar.JarOutputStream; 62 import org.junit.Rule; 63 import org.junit.Test; 64 import org.junit.rules.TemporaryFolder; 65 import org.junit.runner.RunWith; 66 import org.junit.runners.JUnit4; 67 import org.objectweb.asm.AnnotationVisitor; 68 import org.objectweb.asm.ClassReader; 69 import org.objectweb.asm.ClassVisitor; 70 import org.objectweb.asm.ClassWriter; 71 import org.objectweb.asm.FieldVisitor; 72 import org.objectweb.asm.Opcodes; 73 import org.objectweb.asm.TypePath; 74 75 @RunWith(JUnit4.class) 76 public class LowerTest { 77 78 @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); 79 80 @Test hello()81 public void hello() throws Exception { 82 83 ImmutableList<Type> interfaceTypes = 84 ImmutableList.of( 85 ClassTy.create( 86 ImmutableList.of( 87 SimpleClassTy.create( 88 new ClassSymbol("java/util/List"), 89 ImmutableList.of( 90 TyVar.create( 91 new TyVarSymbol(new ClassSymbol("test/Test"), "V"), 92 ImmutableList.of())), 93 ImmutableList.of())))); 94 Type.ClassTy xtnds = Type.ClassTy.OBJECT; 95 ImmutableMap<TyVarSymbol, SourceTypeBoundClass.TyVarInfo> tps = 96 ImmutableMap.of( 97 new TyVarSymbol(new ClassSymbol("test/Test"), "V"), 98 new SourceTypeBoundClass.TyVarInfo( 99 IntersectionTy.create( 100 ImmutableList.of( 101 ClassTy.create( 102 ImmutableList.of( 103 SimpleClassTy.create( 104 new ClassSymbol("test/Test$Inner"), 105 ImmutableList.of(), 106 ImmutableList.of()))))), 107 ImmutableList.of())); 108 int access = TurbineFlag.ACC_SUPER | TurbineFlag.ACC_PUBLIC; 109 ImmutableList<SourceTypeBoundClass.MethodInfo> methods = 110 ImmutableList.of( 111 new SourceTypeBoundClass.MethodInfo( 112 new MethodSymbol(new ClassSymbol("test/Test"), "f"), 113 ImmutableMap.of(), 114 PrimTy.create(TurbineConstantTypeKind.INT, ImmutableList.of()), 115 ImmutableList.of(), 116 ImmutableList.of(), 117 TurbineFlag.ACC_STATIC | TurbineFlag.ACC_PUBLIC, 118 null, 119 null, 120 ImmutableList.of(), 121 null), 122 new SourceTypeBoundClass.MethodInfo( 123 new MethodSymbol(new ClassSymbol("test/Test"), "g"), 124 ImmutableMap.of( 125 new TyVarSymbol(new MethodSymbol(new ClassSymbol("test/Test"), "g"), "V"), 126 new SourceTypeBoundClass.TyVarInfo( 127 IntersectionTy.create( 128 ImmutableList.of( 129 ClassTy.create( 130 ImmutableList.of( 131 SimpleClassTy.create( 132 new ClassSymbol("java/lang/Runnable"), 133 ImmutableList.of(), 134 ImmutableList.of()))))), 135 ImmutableList.of()), 136 new TyVarSymbol(new MethodSymbol(new ClassSymbol("test/Test"), "g"), "E"), 137 new SourceTypeBoundClass.TyVarInfo( 138 IntersectionTy.create( 139 ImmutableList.of( 140 ClassTy.create( 141 ImmutableList.of( 142 SimpleClassTy.create( 143 new ClassSymbol("java/lang/Error"), 144 ImmutableList.of(), 145 ImmutableList.of()))))), 146 ImmutableList.of())), 147 Type.VOID, 148 ImmutableList.of( 149 new SourceTypeBoundClass.ParamInfo( 150 PrimTy.create(TurbineConstantTypeKind.INT, ImmutableList.of()), 151 "foo", 152 ImmutableList.of(), 153 0)), 154 ImmutableList.of( 155 TyVar.create( 156 new TyVarSymbol(new MethodSymbol(new ClassSymbol("test/Test"), "g"), "E"), 157 ImmutableList.of())), 158 TurbineFlag.ACC_PUBLIC, 159 null, 160 null, 161 ImmutableList.of(), 162 null)); 163 ImmutableList<SourceTypeBoundClass.FieldInfo> fields = 164 ImmutableList.of( 165 new SourceTypeBoundClass.FieldInfo( 166 new FieldSymbol(new ClassSymbol("test/Test"), "theField"), 167 Type.ClassTy.asNonParametricClassTy(new ClassSymbol("test/Test$Inner")), 168 TurbineFlag.ACC_STATIC | TurbineFlag.ACC_FINAL | TurbineFlag.ACC_PUBLIC, 169 ImmutableList.of(), 170 null, 171 null)); 172 ClassSymbol owner = null; 173 TurbineTyKind kind = TurbineTyKind.CLASS; 174 ImmutableMap<String, ClassSymbol> children = ImmutableMap.of(); 175 ImmutableMap<String, TyVarSymbol> tyParams = 176 ImmutableMap.of("V", new TyVarSymbol(new ClassSymbol("test/Test"), "V")); 177 178 SourceTypeBoundClass c = 179 new SourceTypeBoundClass( 180 interfaceTypes, 181 xtnds, 182 tps, 183 access, 184 methods, 185 fields, 186 owner, 187 kind, 188 children, 189 tyParams, 190 null, 191 null, 192 null, 193 null, 194 ImmutableList.of(), 195 null, 196 null); 197 198 SourceTypeBoundClass i = 199 new SourceTypeBoundClass( 200 ImmutableList.of(), 201 Type.ClassTy.OBJECT, 202 ImmutableMap.of(), 203 TurbineFlag.ACC_STATIC | TurbineFlag.ACC_PROTECTED, 204 ImmutableList.of(), 205 ImmutableList.of(), 206 new ClassSymbol("test/Test"), 207 TurbineTyKind.CLASS, 208 ImmutableMap.of("Inner", new ClassSymbol("test/Test$Inner")), 209 ImmutableMap.of(), 210 null, 211 null, 212 null, 213 null, 214 ImmutableList.of(), 215 null, 216 null); 217 218 SimpleEnv.Builder<ClassSymbol, SourceTypeBoundClass> b = SimpleEnv.builder(); 219 b.put(new ClassSymbol("test/Test"), c); 220 b.put(new ClassSymbol("test/Test$Inner"), i); 221 222 Map<String, byte[]> bytes = 223 Lower.lowerAll( 224 ImmutableMap.of( 225 new ClassSymbol("test/Test"), c, new ClassSymbol("test/Test$Inner"), i), 226 ImmutableList.of(), 227 TURBINE_BOOTCLASSPATH.env()) 228 .bytes(); 229 230 assertThat(AsmUtils.textify(bytes.get("test/Test"))) 231 .isEqualTo( 232 new String( 233 ByteStreams.toByteArray( 234 LowerTest.class.getResourceAsStream("testdata/golden/outer.txt")), 235 UTF_8)); 236 assertThat(AsmUtils.textify(bytes.get("test/Test$Inner"))) 237 .isEqualTo( 238 new String( 239 ByteStreams.toByteArray( 240 LowerTest.class.getResourceAsStream("testdata/golden/inner.txt")), 241 UTF_8)); 242 } 243 244 @Test innerClassAttributeOrder()245 public void innerClassAttributeOrder() throws IOException { 246 BindingResult bound = 247 Binder.bind( 248 ImmutableList.of( 249 Parser.parse( 250 Joiner.on('\n') 251 .join( 252 "class Test {", // 253 " class Inner {", 254 " class InnerMost {}", 255 " }", 256 "}"))), 257 ClassPathBinder.bindClasspath(ImmutableList.of()), 258 TURBINE_BOOTCLASSPATH, 259 /* moduleVersion=*/ Optional.empty()); 260 Map<String, byte[]> lowered = 261 Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes(); 262 List<String> attributes = new ArrayList<>(); 263 new ClassReader(lowered.get("Test$Inner$InnerMost")) 264 .accept( 265 new ClassVisitor(Opcodes.ASM7) { 266 @Override 267 public void visitInnerClass( 268 String name, String outerName, String innerName, int access) { 269 attributes.add(String.format("%s %s %s", name, outerName, innerName)); 270 } 271 }, 272 0); 273 assertThat(attributes) 274 .containsExactly("Test$Inner Test Inner", "Test$Inner$InnerMost Test$Inner InnerMost") 275 .inOrder(); 276 } 277 278 @Test wildArrayElement()279 public void wildArrayElement() throws Exception { 280 IntegrationTestSupport.TestInput input = 281 IntegrationTestSupport.TestInput.parse( 282 new String( 283 ByteStreams.toByteArray( 284 getClass().getResourceAsStream("testdata/canon_array.test")), 285 UTF_8)); 286 287 Map<String, byte[]> actual = 288 IntegrationTestSupport.runTurbine(input.sources, ImmutableList.of()); 289 290 ByteReader reader = new ByteReader(actual.get("Test"), 0); 291 assertThat(reader.u4()).isEqualTo(0xcafebabe); // magic 292 assertThat(reader.u2()).isEqualTo(0); // minor 293 assertThat(reader.u2()).isEqualTo(52); // major 294 ConstantPoolReader pool = ConstantPoolReader.readConstantPool(reader); 295 assertThat(reader.u2()).isEqualTo(TurbineFlag.ACC_SUPER); // access 296 assertThat(pool.classInfo(reader.u2())).isEqualTo("Test"); // this 297 assertThat(pool.classInfo(reader.u2())).isEqualTo("java/lang/Object"); // super 298 assertThat(reader.u2()).isEqualTo(0); // interfaces 299 assertThat(reader.u2()).isEqualTo(1); // field count 300 assertThat(reader.u2()).isEqualTo(0); // access 301 assertThat(pool.utf8(reader.u2())).isEqualTo("i"); // name 302 assertThat(pool.utf8(reader.u2())).isEqualTo("LA$I;"); // descriptor 303 int attributesCount = reader.u2(); 304 String signature = null; 305 for (int j = 0; j < attributesCount; j++) { 306 String attributeName = pool.utf8(reader.u2()); 307 switch (attributeName) { 308 case "Signature": 309 reader.u4(); // length 310 signature = pool.utf8(reader.u2()); 311 break; 312 default: 313 reader.skip(reader.u4()); 314 break; 315 } 316 } 317 assertThat(signature).isEqualTo("LA<[*>.I;"); 318 } 319 320 @Test typePath()321 public void typePath() throws Exception { 322 BindingResult bound = 323 Binder.bind( 324 ImmutableList.of( 325 Parser.parse( 326 Joiner.on('\n') 327 .join( 328 "import java.lang.annotation.ElementType;", 329 "import java.lang.annotation.Target;", 330 "import java.util.List;", 331 "@Target({ElementType.TYPE_USE}) @interface Anno {}", 332 "class Test {", 333 " public @Anno int[][] xs;", 334 "}"))), 335 ClassPathBinder.bindClasspath(ImmutableList.of()), 336 TURBINE_BOOTCLASSPATH, 337 /* moduleVersion=*/ Optional.empty()); 338 Map<String, byte[]> lowered = 339 Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes(); 340 TypePath[] path = new TypePath[1]; 341 new ClassReader(lowered.get("Test")) 342 .accept( 343 new ClassVisitor(Opcodes.ASM7) { 344 @Override 345 public FieldVisitor visitField( 346 int access, String name, String desc, String signature, Object value) { 347 return new FieldVisitor(Opcodes.ASM7) { 348 @Override 349 public AnnotationVisitor visitTypeAnnotation( 350 int typeRef, TypePath typePath, String desc, boolean visible) { 351 path[0] = typePath; 352 return null; 353 }; 354 }; 355 } 356 }, 357 0); 358 assertThat(path[0].getLength()).isEqualTo(2); 359 assertThat(path[0].getStep(0)).isEqualTo(TypePath.ARRAY_ELEMENT); 360 assertThat(path[0].getStepArgument(0)).isEqualTo(0); 361 assertThat(path[0].getStep(1)).isEqualTo(TypePath.ARRAY_ELEMENT); 362 assertThat(path[0].getStepArgument(1)).isEqualTo(0); 363 } 364 365 @Test invalidConstants()366 public void invalidConstants() throws Exception { 367 Path lib = temporaryFolder.newFile("lib.jar").toPath(); 368 try (OutputStream os = Files.newOutputStream(lib); 369 JarOutputStream jos = new JarOutputStream(os)) { 370 jos.putNextEntry(new JarEntry("Lib.class")); 371 372 ClassWriter cw = new ClassWriter(0); 373 cw.visit(52, Opcodes.ACC_SUPER, "Lib", null, "java/lang/Object", null); 374 cw.visitField(Opcodes.ACC_FINAL | Opcodes.ACC_STATIC, "ZCONST", "Z", null, Integer.MAX_VALUE); 375 cw.visitField(Opcodes.ACC_FINAL | Opcodes.ACC_STATIC, "SCONST", "S", null, Integer.MAX_VALUE); 376 jos.write(cw.toByteArray()); 377 } 378 379 ImmutableMap<String, String> input = 380 ImmutableMap.of( 381 "Test.java", 382 Joiner.on('\n') 383 .join( 384 "class Test {", 385 " static final short SCONST = Lib.SCONST + 0;", 386 " static final boolean ZCONST = Lib.ZCONST || false;", 387 "}")); 388 389 Map<String, byte[]> actual = IntegrationTestSupport.runTurbine(input, ImmutableList.of(lib)); 390 391 Map<String, Object> values = new LinkedHashMap<>(); 392 new ClassReader(actual.get("Test")) 393 .accept( 394 new ClassVisitor(Opcodes.ASM7) { 395 @Override 396 public FieldVisitor visitField( 397 int access, String name, String desc, String signature, Object value) { 398 values.put(name, value); 399 return super.visitField(access, name, desc, signature, value); 400 } 401 }, 402 0); 403 404 assertThat(values).containsEntry("SCONST", -1); 405 assertThat(values).containsEntry("ZCONST", 1); 406 } 407 408 @Test deprecated()409 public void deprecated() throws Exception { 410 BindingResult bound = 411 Binder.bind( 412 ImmutableList.of(Parser.parse("@Deprecated class Test {}")), 413 ClassPathBinder.bindClasspath(ImmutableList.of()), 414 TURBINE_BOOTCLASSPATH, 415 /* moduleVersion=*/ Optional.empty()); 416 Map<String, byte[]> lowered = 417 Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes(); 418 int[] acc = {0}; 419 new ClassReader(lowered.get("Test")) 420 .accept( 421 new ClassVisitor(Opcodes.ASM7) { 422 @Override 423 public void visit( 424 int version, 425 int access, 426 String name, 427 String signature, 428 String superName, 429 String[] interfaces) { 430 acc[0] = access; 431 } 432 }, 433 0); 434 assertThat((acc[0] & Opcodes.ACC_DEPRECATED)).isEqualTo(Opcodes.ACC_DEPRECATED); 435 } 436 437 @Test lazyImports()438 public void lazyImports() throws Exception { 439 ImmutableMap<String, String> sources = 440 ImmutableMap.<String, String>builder() 441 .put( 442 "b/B.java", 443 lines( 444 "package b;", // 445 "public class B {", 446 " public static class A {", 447 " public static final int X = 0;", 448 " }", 449 " public static class C {}", 450 "}")) 451 .put( 452 "anno/Anno.java", 453 lines( 454 "package anno;", // 455 "public @interface Anno {", 456 " int value() default 0;", 457 "}")) 458 .put( 459 "a/A.java", 460 lines( 461 "package a;", // 462 "import b.B;", 463 "import anno.Anno;", 464 "import static b.B.nosuch.A;", 465 "@Anno(A.X)", 466 "public class A extends B {", 467 " public A a;", 468 " public static final int X = 1;", 469 "}")) 470 .put( 471 "a/C.java", 472 lines( 473 "package c;", // 474 "import static b.B.nosuch.C;", 475 "class C {", 476 " C c;", 477 "}")) 478 .build(); 479 480 ImmutableMap<String, String> noImports; 481 { 482 ImmutableMap.Builder<String, String> builder = ImmutableMap.builder(); 483 sources.forEach( 484 (k, v) -> builder.put(k, v.replaceAll("import static b\\.B\\.nosuch\\..*;", ""))); 485 noImports = builder.build(); 486 } 487 488 Map<String, byte[]> expected = IntegrationTestSupport.runJavac(noImports, ImmutableList.of()); 489 Map<String, byte[]> actual = IntegrationTestSupport.runTurbine(sources, ImmutableList.of()); 490 assertThat(IntegrationTestSupport.dump(IntegrationTestSupport.sortMembers(actual))) 491 .isEqualTo(IntegrationTestSupport.dump(IntegrationTestSupport.canonicalize(expected))); 492 } 493 494 @Test missingOuter()495 public void missingOuter() throws Exception { 496 497 Map<String, byte[]> lib = 498 IntegrationTestSupport.runJavac( 499 ImmutableMap.of( 500 "A.java", 501 lines( 502 "interface A {", // 503 " interface M {", 504 " interface I {}", 505 " } ", 506 "}"), 507 "B.java", 508 lines( 509 "interface B extends A {", 510 " interface BM extends M {", 511 " interface BI extends I {}", 512 " }", 513 "}")), 514 ImmutableList.of()); 515 516 Path libJar = temporaryFolder.newFile("lib.jar").toPath(); 517 try (OutputStream os = Files.newOutputStream(libJar); 518 JarOutputStream jos = new JarOutputStream(os)) { 519 jos.putNextEntry(new JarEntry("A$M.class")); 520 jos.write(lib.get("A$M")); 521 jos.putNextEntry(new JarEntry("A$M$I.class")); 522 jos.write(lib.get("A$M$I")); 523 jos.putNextEntry(new JarEntry("B.class")); 524 jos.write(lib.get("B")); 525 jos.putNextEntry(new JarEntry("B$BM.class")); 526 jos.write(lib.get("B$BM")); 527 jos.putNextEntry(new JarEntry("B$BM$BI.class")); 528 jos.write(lib.get("B$BM$BI")); 529 } 530 531 ImmutableMap<String, String> sources = 532 ImmutableMap.<String, String>builder() 533 .put( 534 "Test.java", 535 lines( 536 "public class Test extends B.BM {", // 537 " I i;", 538 "}")) 539 .build(); 540 541 try { 542 IntegrationTestSupport.runTurbine(sources, ImmutableList.of(libJar)); 543 fail(); 544 } catch (TurbineError error) { 545 assertThat(error) 546 .hasMessageThat() 547 .contains("Test.java: error: could not locate class file for A"); 548 } 549 } 550 551 @Test missingOuter2()552 public void missingOuter2() throws Exception { 553 554 Map<String, byte[]> lib = 555 IntegrationTestSupport.runJavac( 556 ImmutableMap.of( 557 "A.java", 558 lines( 559 "class A {", // 560 " class M { ", 561 " class I {} ", 562 " } ", 563 "}"), 564 "B.java", 565 lines( 566 "class B extends A { ", 567 " class BM extends M { ", 568 " class BI extends I {} ", 569 " } ", 570 "}")), 571 ImmutableList.of()); 572 573 Path libJar = temporaryFolder.newFile("lib.jar").toPath(); 574 try (OutputStream os = Files.newOutputStream(libJar); 575 JarOutputStream jos = new JarOutputStream(os)) { 576 jos.putNextEntry(new JarEntry("A$M.class")); 577 jos.write(lib.get("A$M")); 578 jos.putNextEntry(new JarEntry("A$M$I.class")); 579 jos.write(lib.get("A$M$I")); 580 jos.putNextEntry(new JarEntry("B.class")); 581 jos.write(lib.get("B")); 582 jos.putNextEntry(new JarEntry("B$BM.class")); 583 jos.write(lib.get("B$BM")); 584 jos.putNextEntry(new JarEntry("B$BM$BI.class")); 585 jos.write(lib.get("B$BM$BI")); 586 } 587 588 ImmutableMap<String, String> sources = 589 ImmutableMap.<String, String>builder() 590 .put( 591 "Test.java", 592 lines( 593 "public class Test extends B {", // 594 " class M extends BM {", 595 " I i;", 596 " }", 597 "}")) 598 .build(); 599 600 try { 601 IntegrationTestSupport.runTurbine(sources, ImmutableList.of(libJar)); 602 fail(); 603 } catch (TurbineError error) { 604 assertThat(error) 605 .hasMessageThat() 606 .contains( 607 "Test.java:3: error: could not locate class file for A\n" 608 + " I i;\n" 609 + " ^"); 610 } 611 } 612 613 // If an element incorrectly has multiple visibility modifiers, pick one, and rely on javac to 614 // report a diagnostic. 615 @Test multipleVisibilities()616 public void multipleVisibilities() throws Exception { 617 ImmutableMap<String, String> sources = 618 ImmutableMap.of("Test.java", "public protected class Test {}"); 619 620 Map<String, byte[]> lowered = 621 IntegrationTestSupport.runTurbine(sources, /* classpath= */ ImmutableList.of()); 622 int[] testAccess = {0}; 623 new ClassReader(lowered.get("Test")) 624 .accept( 625 new ClassVisitor(Opcodes.ASM7) { 626 @Override 627 public void visit( 628 int version, 629 int access, 630 String name, 631 String signature, 632 String superName, 633 String[] interfaces) { 634 testAccess[0] = access; 635 } 636 }, 637 0); 638 assertThat((testAccess[0] & TurbineFlag.ACC_PUBLIC)).isEqualTo(TurbineFlag.ACC_PUBLIC); 639 assertThat((testAccess[0] & TurbineFlag.ACC_PROTECTED)).isNotEqualTo(TurbineFlag.ACC_PROTECTED); 640 } 641 lines(String... lines)642 static String lines(String... lines) { 643 return Joiner.on("\n").join(lines); 644 } 645 } 646