tmf: Remove deprecated HistoryTree constructor
[deliverable/tracecompass.git] / org.eclipse.linuxtools.tmf.core / src / org / eclipse / linuxtools / internal / tmf / core / statesystem / backends / historytree / HistoryTree.java
CommitLineData
a52fde77
AM
1/*******************************************************************************
2 * Copyright (c) 2012 Ericsson
3 * Copyright (c) 2010, 2011 École Polytechnique de Montréal
4 * Copyright (c) 2010, 2011 Alexandre Montplaisir <alexandre.montplaisir@gmail.com>
3b7f5abe 5 *
a52fde77
AM
6 * All rights reserved. This program and the accompanying materials are
7 * made available under the terms of the Eclipse Public License v1.0 which
8 * accompanies this distribution, and is available at
9 * http://www.eclipse.org/legal/epl-v10.html
3b7f5abe 10 *
a52fde77
AM
11 *******************************************************************************/
12
f9a76cac 13package org.eclipse.linuxtools.internal.tmf.core.statesystem.backends.historytree;
a52fde77
AM
14
15import java.io.File;
16import java.io.FileInputStream;
17import java.io.IOException;
18import java.io.PrintWriter;
19import java.nio.ByteBuffer;
20import java.nio.ByteOrder;
3b7f5abe 21import java.nio.channels.ClosedChannelException;
a52fde77
AM
22import java.nio.channels.FileChannel;
23import java.util.Vector;
24
6d08acca 25import org.eclipse.linuxtools.tmf.core.exceptions.TimeRangeException;
a52fde77
AM
26
27/**
28 * Meta-container for the History Tree. This structure contains all the
29 * high-level data relevant to the tree.
3b7f5abe 30 *
a52fde77 31 * @author alexmont
3b7f5abe 32 *
a52fde77
AM
33 */
34class HistoryTree {
35
36 private static final int HISTORY_FILE_MAGIC_NUMBER = 0x05FFA900;
37
dbdc452f 38 /*
a52fde77
AM
39 * File format version. Increment minor on backwards-compatible changes.
40 * Increment major + set minor back to 0 when breaking compatibility.
41 */
42 private static final int MAJOR_VERSION = 3;
43 private static final byte MINOR_VERSION = 0;
44
dbdc452f
AM
45 // ------------------------------------------------------------------------
46 // Tree-specific configuration
47 // ------------------------------------------------------------------------
48
49 /** Container for all the configuration constants */
a52fde77
AM
50 protected final HTConfig config;
51
dbdc452f 52 /** Reader/writer object */
a52fde77
AM
53 private final HT_IO treeIO;
54
dbdc452f
AM
55 // ------------------------------------------------------------------------
56 // Variable Fields (will change throughout the existance of the SHT)
57 // ------------------------------------------------------------------------
58
59 /** Latest timestamp found in the tree (at any given moment) */
a52fde77
AM
60 private long treeEnd;
61
dbdc452f 62 /** How many nodes exist in this tree, total */
a52fde77
AM
63 private int nodeCount;
64
dbdc452f 65 /** "Cache" to keep the active nodes in memory */
a52fde77
AM
66 protected Vector<CoreNode> latestBranch;
67
dbdc452f
AM
68 // ------------------------------------------------------------------------
69 // Constructors/"Destructors"
70 // ------------------------------------------------------------------------
71
a52fde77
AM
72 /**
73 * Create a new State History from scratch, using a SHTConfig object for
74 * configuration
a52fde77 75 */
7453b40e 76 HistoryTree(HTConfig conf) throws IOException {
a52fde77 77 /*
dbdc452f 78 * Simple check to make sure we have enough place in the 0th block
a52fde77
AM
79 * for the tree configuration
80 */
dbdc452f
AM
81 if (conf.blockSize < getTreeHeaderSize()) {
82 throw new IllegalArgumentException();
83 }
a52fde77
AM
84
85 config = conf;
86 treeEnd = conf.treeStart;
87 nodeCount = 0;
88 latestBranch = new Vector<CoreNode>();
89
90 /* Prepare the IO object */
91 treeIO = new HT_IO(this, true);
92
93 /* Add the first node to the tree */
94 CoreNode firstNode = initNewCoreNode(-1, conf.treeStart);
95 latestBranch.add(firstNode);
96 }
97
a52fde77
AM
98 /**
99 * "Reader" constructor : instantiate a SHTree from an existing tree file on
100 * disk
3b7f5abe 101 *
a52fde77
AM
102 * @param existingFileName
103 * Path/filename of the history-file we are to open
104 * @throws IOException
105 */
106 HistoryTree(File existingStateFile) throws IOException {
107 /*
108 * Open the file ourselves, get the tree header information we need,
109 * then pass on the descriptor to the TreeIO object.
110 */
111 int rootNodeSeqNb, res;
112 int bs, maxc;
fb12b0c2 113 long startTime;
a52fde77
AM
114
115 /* Java I/O mumbo jumbo... */
fee997a5
AM
116 if (!existingStateFile.exists()) {
117 throw new IOException("Selected state file does not exist"); //$NON-NLS-1$
118 }
fb12b0c2
AM
119 if (existingStateFile.length() <= 0) {
120 throw new IOException("Invalid state file selected, " + //$NON-NLS-1$
121 "target file is empty"); //$NON-NLS-1$
a52fde77
AM
122 }
123
124 FileInputStream fis = new FileInputStream(existingStateFile);
125 ByteBuffer buffer = ByteBuffer.allocate(getTreeHeaderSize());
126 FileChannel fc = fis.getChannel();
127 buffer.order(ByteOrder.LITTLE_ENDIAN);
128 buffer.clear();
129 fc.read(buffer);
130 buffer.flip();
131
132 /*
133 * Check the magic number,to make sure we're opening the right type of
134 * file
135 */
136 res = buffer.getInt();
137 if (res != HISTORY_FILE_MAGIC_NUMBER) {
6f04204e
AM
138 fc.close();
139 fis.close();
fb12b0c2
AM
140 throw new IOException("Selected file does not" + //$NON-NLS-1$
141 "look like a History Tree file"); //$NON-NLS-1$
a52fde77
AM
142 }
143
144 res = buffer.getInt(); /* Major version number */
145 if (res != MAJOR_VERSION) {
6f04204e
AM
146 fc.close();
147 fis.close();
a52fde77
AM
148 throw new IOException("Select History Tree file is of an older " //$NON-NLS-1$
149 + "format. Please use a previous version of " //$NON-NLS-1$
150 + "the parser to open it."); //$NON-NLS-1$
151 }
152
153 res = buffer.getInt(); /* Minor version number */
154
155 bs = buffer.getInt(); /* Block Size */
156 maxc = buffer.getInt(); /* Max nb of children per node */
157
158 this.nodeCount = buffer.getInt();
159 rootNodeSeqNb = buffer.getInt();
fb12b0c2 160 startTime = buffer.getLong();
a52fde77 161
fb12b0c2 162 this.config = new HTConfig(existingStateFile, bs, maxc, startTime);
6f04204e 163 fc.close();
a52fde77
AM
164 fis.close();
165 /*
166 * FIXME We close fis here and the TreeIO will then reopen the same
167 * file, not extremely elegant. But how to pass the information here to
168 * the SHT otherwise?
169 */
170 this.treeIO = new HT_IO(this, false);
171
172 rebuildLatestBranch(rootNodeSeqNb);
a52fde77 173 this.treeEnd = latestBranch.firstElement().getNodeEnd();
fb12b0c2
AM
174
175 /*
176 * Make sure the history start time we read previously is consistent
177 * with was is actually in the root node.
178 */
179 if (startTime != latestBranch.firstElement().getNodeStart()) {
180 fc.close();
181 fis.close();
182 throw new IOException("Inconsistent start times in the" + //$NON-NLS-1$
183 "history file, it might be corrupted."); //$NON-NLS-1$
184 }
a52fde77
AM
185 }
186
187 /**
188 * "Save" the tree to disk. This method will cause the treeIO object to
189 * commit all nodes to disk and then return the RandomAccessFile descriptor
190 * so the Tree object can save its configuration into the header of the
191 * file.
3b7f5abe 192 *
a52fde77
AM
193 * @param requestedEndTime
194 */
6a1074ce 195 void closeTree(long requestedEndTime) {
a52fde77
AM
196 FileChannel fc;
197 ByteBuffer buffer;
198 int i, res;
199
3b7f5abe 200 /*
6a1074ce
AM
201 * Work-around the "empty branches" that get created when the root node
202 * becomes full. Overwrite the tree's end time with the original wanted
203 * end-time, to ensure no queries are sent into those empty nodes.
3b7f5abe 204 *
6a1074ce
AM
205 * This won't be needed once extended nodes are implemented.
206 */
207 this.treeEnd = requestedEndTime;
208
a52fde77
AM
209 /* Close off the latest branch of the tree */
210 for (i = 0; i < latestBranch.size(); i++) {
211 latestBranch.get(i).closeThisNode(treeEnd);
212 treeIO.writeNode(latestBranch.get(i));
213 }
214
215 /* Only use this for debugging purposes, it's VERY slow! */
216 // this.checkIntegrity();
217
218 fc = treeIO.getFcOut();
219 buffer = ByteBuffer.allocate(getTreeHeaderSize());
220 buffer.order(ByteOrder.LITTLE_ENDIAN);
221 buffer.clear();
222
223 /* Save the config of the tree to the header of the file */
224 try {
225 fc.position(0);
226
227 buffer.putInt(HISTORY_FILE_MAGIC_NUMBER);
228
229 buffer.putInt(MAJOR_VERSION);
230 buffer.putInt(MINOR_VERSION);
231
232 buffer.putInt(config.blockSize);
233 buffer.putInt(config.maxChildren);
234
235 buffer.putInt(nodeCount);
236
237 /* root node seq. nb */
238 buffer.putInt(latestBranch.firstElement().getSequenceNumber());
239
fb12b0c2
AM
240 /* start time of this history */
241 buffer.putLong(latestBranch.firstElement().getNodeStart());
242
a52fde77
AM
243 buffer.flip();
244 res = fc.write(buffer);
245 assert (res <= getTreeHeaderSize());
246 /* done writing the file header */
247
248 } catch (IOException e) {
6f04204e 249 /* We should not have any problems at this point... */
a52fde77 250 e.printStackTrace();
6f04204e
AM
251 } finally {
252 try {
253 fc.close();
254 } catch (IOException e) {
255 e.printStackTrace();
256 }
a52fde77
AM
257 }
258 return;
259 }
260
dbdc452f
AM
261 // ------------------------------------------------------------------------
262 // Accessors
263 // ------------------------------------------------------------------------
ab604305 264
a52fde77
AM
265 long getTreeStart() {
266 return config.treeStart;
267 }
268
269 long getTreeEnd() {
270 return treeEnd;
271 }
272
273 int getNodeCount() {
274 return nodeCount;
275 }
276
277 HT_IO getTreeIO() {
278 return treeIO;
279 }
280
dbdc452f
AM
281 // ------------------------------------------------------------------------
282 // Operations
283 // ------------------------------------------------------------------------
284
a52fde77
AM
285 /**
286 * Rebuild the latestBranch "cache" object by reading the nodes from disk
287 * (When we are opening an existing file on disk and want to append to it,
288 * for example).
3b7f5abe 289 *
a52fde77
AM
290 * @param rootNodeSeqNb
291 * The sequence number of the root node, so we know where to
292 * start
3b7f5abe 293 * @throws ClosedChannelException
a52fde77 294 */
3b7f5abe 295 private void rebuildLatestBranch(int rootNodeSeqNb) throws ClosedChannelException {
a52fde77
AM
296 HTNode nextChildNode;
297
298 this.latestBranch = new Vector<CoreNode>();
299
300 nextChildNode = treeIO.readNodeFromDisk(rootNodeSeqNb);
301 latestBranch.add((CoreNode) nextChildNode);
302 while (latestBranch.lastElement().getNbChildren() > 0) {
303 nextChildNode = treeIO.readNodeFromDisk(latestBranch.lastElement().getLatestChild());
304 latestBranch.add((CoreNode) nextChildNode);
305 }
306 }
307
308 /**
309 * Insert an interval in the tree
3b7f5abe 310 *
a52fde77
AM
311 * @param interval
312 */
313 void insertInterval(HTInterval interval) throws TimeRangeException {
314 if (interval.getStartTime() < config.treeStart) {
315 throw new TimeRangeException();
316 }
317 tryInsertAtNode(interval, latestBranch.size() - 1);
318 }
319
320 /**
321 * Inner method to find in which node we should add the interval.
3b7f5abe 322 *
a52fde77
AM
323 * @param interval
324 * The interval to add to the tree
325 * @param indexOfNode
326 * The index *in the latestBranch* where we are trying the
327 * insertion
328 */
329 private void tryInsertAtNode(HTInterval interval, int indexOfNode) {
330 HTNode targetNode = latestBranch.get(indexOfNode);
331
332 /* Verify if there is enough room in this node to store this interval */
333 if (interval.getIntervalSize() > targetNode.getNodeFreeSpace()) {
334 /* Nope, not enough room. Insert in a new sibling instead. */
335 addSiblingNode(indexOfNode);
336 tryInsertAtNode(interval, latestBranch.size() - 1);
337 return;
338 }
339
340 /* Make sure the interval time range fits this node */
341 if (interval.getStartTime() < targetNode.getNodeStart()) {
342 /*
343 * No, this interval starts before the startTime of this node. We
344 * need to check recursively in parents if it can fit.
345 */
346 assert (indexOfNode >= 1);
347 tryInsertAtNode(interval, indexOfNode - 1);
348 return;
349 }
350
351 /*
352 * Ok, there is room, and the interval fits in this time slot. Let's add
353 * it.
354 */
355 targetNode.addInterval(interval);
356
357 /* Update treeEnd if needed */
358 if (interval.getEndTime() > this.treeEnd) {
359 this.treeEnd = interval.getEndTime();
360 }
361 return;
362 }
363
364 /**
365 * Method to add a sibling to any node in the latest branch. This will add
366 * children back down to the leaf level, if needed.
3b7f5abe 367 *
a52fde77
AM
368 * @param indexOfNode
369 * The index in latestBranch where we start adding
370 */
371 private void addSiblingNode(int indexOfNode) {
372 int i;
373 CoreNode newNode, prevNode;
374 long splitTime = treeEnd;
375
376 assert (indexOfNode < latestBranch.size());
377
378 /* Check if we need to add a new root node */
379 if (indexOfNode == 0) {
380 addNewRootNode();
381 return;
382 }
383
384 /* Check if we can indeed add a child to the target parent */
385 if (latestBranch.get(indexOfNode - 1).getNbChildren() == config.maxChildren) {
386 /* If not, add a branch starting one level higher instead */
387 addSiblingNode(indexOfNode - 1);
388 return;
389 }
390
391 /* Split off the new branch from the old one */
392 for (i = indexOfNode; i < latestBranch.size(); i++) {
393 latestBranch.get(i).closeThisNode(splitTime);
394 treeIO.writeNode(latestBranch.get(i));
395
396 prevNode = latestBranch.get(i - 1);
397 newNode = initNewCoreNode(prevNode.getSequenceNumber(),
398 splitTime + 1);
399 prevNode.linkNewChild(newNode);
400
401 latestBranch.set(i, newNode);
402 }
403 return;
404 }
405
406 /**
407 * Similar to the previous method, except here we rebuild a completely new
408 * latestBranch
409 */
410 private void addNewRootNode() {
411 int i, depth;
412 CoreNode oldRootNode, newRootNode, newNode, prevNode;
413 long splitTime = this.treeEnd;
414
415 oldRootNode = latestBranch.firstElement();
416 newRootNode = initNewCoreNode(-1, config.treeStart);
417
418 /* Tell the old root node that it isn't root anymore */
419 oldRootNode.setParentSequenceNumber(newRootNode.getSequenceNumber());
420
421 /* Close off the whole current latestBranch */
422 for (i = 0; i < latestBranch.size(); i++) {
423 latestBranch.get(i).closeThisNode(splitTime);
424 treeIO.writeNode(latestBranch.get(i));
425 }
426
427 /* Link the new root to its first child (the previous root node) */
428 newRootNode.linkNewChild(oldRootNode);
429
430 /* Rebuild a new latestBranch */
431 depth = latestBranch.size();
432 latestBranch = new Vector<CoreNode>();
433 latestBranch.add(newRootNode);
434 for (i = 1; i < depth + 1; i++) {
435 prevNode = latestBranch.get(i - 1);
436 newNode = initNewCoreNode(prevNode.getParentSequenceNumber(),
437 splitTime + 1);
438 prevNode.linkNewChild(newNode);
439 latestBranch.add(newNode);
440 }
441 }
442
443 /**
444 * Add a new empty node to the tree.
3b7f5abe 445 *
a52fde77
AM
446 * @param parentSeqNumber
447 * Sequence number of this node's parent
448 * @param startTime
449 * Start time of the new node
450 * @return The newly created node
451 */
452 private CoreNode initNewCoreNode(int parentSeqNumber, long startTime) {
453 CoreNode newNode = new CoreNode(this, this.nodeCount, parentSeqNumber,
454 startTime);
455 this.nodeCount++;
456
457 /* Update the treeEnd if needed */
458 if (startTime >= this.treeEnd) {
459 this.treeEnd = startTime + 1;
460 }
461 return newNode;
462 }
463
464 /**
465 * Inner method to select the next child of the current node intersecting
466 * the given timestamp. Useful for moving down the tree following one
467 * branch.
3b7f5abe 468 *
a52fde77
AM
469 * @param currentNode
470 * @param t
471 * @return The child node intersecting t
3b7f5abe
AM
472 * @throws ClosedChannelException
473 * If the file channel was closed while we were reading the tree
a52fde77 474 */
3b7f5abe 475 HTNode selectNextChild(CoreNode currentNode, long t) throws ClosedChannelException {
a52fde77
AM
476 assert (currentNode.getNbChildren() > 0);
477 int potentialNextSeqNb = currentNode.getSequenceNumber();
478
479 for (int i = 0; i < currentNode.getNbChildren(); i++) {
480 if (t >= currentNode.getChildStart(i)) {
481 potentialNextSeqNb = currentNode.getChild(i);
482 } else {
483 break;
484 }
485 }
486 /*
487 * Once we exit this loop, we should have found a children to follow. If
488 * we didn't, there's a problem.
489 */
490 assert (potentialNextSeqNb != currentNode.getSequenceNumber());
491
492 /*
493 * Since this code path is quite performance-critical, avoid iterating
494 * through the whole latestBranch array if we know for sure the next
495 * node has to be on disk
496 */
497 if (currentNode.isDone()) {
498 return treeIO.readNodeFromDisk(potentialNextSeqNb);
499 }
500 return treeIO.readNode(potentialNextSeqNb);
501 }
502
503 /**
504 * Helper function to get the size of the "tree header" in the tree-file The
505 * nodes will use this offset to know where they should be in the file. This
506 * should always be a multiple of 4K.
507 */
508 static int getTreeHeaderSize() {
509 return 4096;
510 }
511
512 long getFileSize() {
513 return config.stateFile.length();
514 }
515
3b7f5abe
AM
516 // ------------------------------------------------------------------------
517 // Test/debugging methods
518 // ------------------------------------------------------------------------
a52fde77
AM
519
520 /* Only used for debugging, shouldn't be externalized */
521 @SuppressWarnings("nls")
522 boolean checkNodeIntegrity(HTNode zenode) {
ab604305 523
a52fde77
AM
524 HTNode otherNode;
525 CoreNode node;
ab604305 526 StringBuffer buf = new StringBuffer();
a52fde77
AM
527 boolean ret = true;
528
529 // FIXME /* Only testing Core Nodes for now */
530 if (!(zenode instanceof CoreNode)) {
531 return true;
532 }
533
534 node = (CoreNode) zenode;
535
3b7f5abe
AM
536 try {
537 /*
538 * Test that this node's start and end times match the start of the
539 * first child and the end of the last child, respectively
540 */
541 if (node.getNbChildren() > 0) {
542 otherNode = treeIO.readNode(node.getChild(0));
543 if (node.getNodeStart() != otherNode.getNodeStart()) {
544 buf.append("Start time of node (" + node.getNodeStart() + ") "
545 + "does not match start time of first child " + "("
546 + otherNode.getNodeStart() + "), " + "node #"
ab604305 547 + otherNode.getSequenceNumber() + ")\n");
a52fde77
AM
548 ret = false;
549 }
3b7f5abe
AM
550 if (node.isDone()) {
551 otherNode = treeIO.readNode(node.getLatestChild());
552 if (node.getNodeEnd() != otherNode.getNodeEnd()) {
553 buf.append("End time of node (" + node.getNodeEnd()
554 + ") does not match end time of last child ("
555 + otherNode.getNodeEnd() + ", node #"
556 + otherNode.getSequenceNumber() + ")\n");
557 ret = false;
558 }
559 }
a52fde77 560 }
a52fde77 561
3b7f5abe
AM
562 /*
563 * Test that the childStartTimes[] array matches the real nodes' start
564 * times
565 */
566 for (int i = 0; i < node.getNbChildren(); i++) {
567 otherNode = treeIO.readNode(node.getChild(i));
568 if (otherNode.getNodeStart() != node.getChildStart(i)) {
569 buf.append(" Expected start time of child node #"
570 + node.getChild(i) + ": " + node.getChildStart(i)
571 + "\n" + " Actual start time of node #"
572 + otherNode.getSequenceNumber() + ": "
573 + otherNode.getNodeStart() + "\n");
574 ret = false;
575 }
a52fde77 576 }
3b7f5abe
AM
577
578 } catch (ClosedChannelException e) {
579 e.printStackTrace();
a52fde77
AM
580 }
581
582 if (!ret) {
583 System.out.println("");
584 System.out.println("SHT: Integrity check failed for node #"
585 + node.getSequenceNumber() + ":");
ab604305 586 System.out.println(buf.toString());
a52fde77
AM
587 }
588 return ret;
589 }
590
591 void checkIntegrity() {
3b7f5abe
AM
592 try {
593 for (int i = 0; i < nodeCount; i++) {
594 checkNodeIntegrity(treeIO.readNode(i));
595 }
596 } catch (ClosedChannelException e) {
597 e.printStackTrace();
a52fde77
AM
598 }
599 }
600
601 /* Only used for debugging, shouldn't be externalized */
602 @SuppressWarnings("nls")
603 @Override
604 public String toString() {
605 return "Information on the current tree:\n\n" + "Blocksize: "
606 + config.blockSize + "\n" + "Max nb. of children per node: "
607 + config.maxChildren + "\n" + "Number of nodes: " + nodeCount
608 + "\n" + "Depth of the tree: " + latestBranch.size() + "\n"
609 + "Size of the treefile: " + this.getFileSize() + "\n"
610 + "Root node has sequence number: "
611 + latestBranch.firstElement().getSequenceNumber() + "\n"
612 + "'Latest leaf' has sequence number: "
613 + latestBranch.lastElement().getSequenceNumber();
614 }
615
616 private int curDepth;
617
618 /**
619 * Start at currentNode and print the contents of all its children, in
620 * pre-order. Give the root node in parameter to visit the whole tree, and
621 * have a nice overview.
622 */
623 @SuppressWarnings("nls")
624 private void preOrderPrint(PrintWriter writer, boolean printIntervals,
625 CoreNode currentNode) {
626 /* Only used for debugging, shouldn't be externalized */
627 int i, j;
628 HTNode nextNode;
629
630 writer.println(currentNode.toString());
631 if (printIntervals) {
632 currentNode.debugPrintIntervals(writer);
633 }
634 curDepth++;
635
3b7f5abe
AM
636 try {
637 for (i = 0; i < currentNode.getNbChildren(); i++) {
638 nextNode = treeIO.readNode(currentNode.getChild(i));
639 assert (nextNode instanceof CoreNode); // TODO temporary
640 for (j = 0; j < curDepth - 1; j++) {
641 writer.print(" ");
642 }
643 writer.print("+-");
644 preOrderPrint(writer, printIntervals, (CoreNode) nextNode);
a52fde77 645 }
3b7f5abe
AM
646 } catch (ClosedChannelException e) {
647 e.printStackTrace();
a52fde77
AM
648 }
649 curDepth--;
650 return;
651 }
652
653 /**
654 * Print out the full tree for debugging purposes
3b7f5abe 655 *
a52fde77
AM
656 * @param writer
657 * PrintWriter in which to write the output
658 * @param printIntervals
659 * Says if you want to output the full interval information
660 */
661 void debugPrintFullTree(PrintWriter writer, boolean printIntervals) {
662 /* Only used for debugging, shouldn't be externalized */
663 curDepth = 0;
664 this.preOrderPrint(writer, false, latestBranch.firstElement());
665
666 if (printIntervals) {
667 writer.println("\nDetails of intervals:"); //$NON-NLS-1$
668 curDepth = 0;
669 this.preOrderPrint(writer, true, latestBranch.firstElement());
670 }
671 writer.println('\n');
672 }
673
674}
This page took 0.0679959999999999 seconds and 5 git commands to generate.