]> source.dussan.org Git - sonarqube.git/commitdiff
Fix some quality flaws
authorJulien HENRY <julien.henry@sonarsource.com>
Wed, 16 Mar 2016 16:15:30 +0000 (17:15 +0100)
committerJulien HENRY <julien.henry@sonarsource.com>
Wed, 16 Mar 2016 16:25:16 +0000 (17:25 +0100)
19 files changed:
plugins/sonar-xoo-plugin/pom.xml
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/coverage/AbstractCoverageSensor.java
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/lang/MeasureSensor.java
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/lang/SymbolReferencesSensor.java
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/lang/SyntaxHighlightingSensor.java
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/scm/XooBlameCommand.java
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/test/CoveragePerTestSensor.java
plugins/sonar-xoo-plugin/src/main/java/org/sonar/xoo/test/TestExecutionSensor.java
server/sonar-server/src/main/java/org/sonar/server/batch/ProjectDataLoader.java
sonar-batch/src/main/java/org/sonar/batch/bootstrap/BatchExtensionDictionnary.java
sonar-batch/src/main/java/org/sonar/batch/profiling/PhaseProfiling.java
sonar-batch/src/main/java/org/sonar/batch/scan/report/ConsoleReport.java
sonar-batch/src/main/java/org/sonar/batch/scan/report/HtmlReport.java
sonar-batch/src/main/java/org/sonar/batch/scm/ScmConfiguration.java
sonar-batch/src/main/java/org/sonar/batch/util/ProgressReport.java
sonar-core/src/main/java/org/sonar/core/platform/ComponentContainer.java
sonar-db/src/main/java/org/sonar/db/version/v50/FileSourceDto.java
sonar-plugin-api/src/main/java/org/sonar/api/batch/sensor/cpd/internal/DefaultCpdTokens.java
sonar-plugin-api/src/test/java/org/sonar/api/batch/sensor/cpd/internal/DefaultCpdTokensTest.java [new file with mode: 0644]

index 9b163554fade15579eee4c60b01e78bd5c6e6369..6b48224d77d677ebc811fc35ee873c7ecd34572e 100644 (file)
     <dependency>
       <groupId>com.google.guava</groupId>
       <artifactId>guava</artifactId>
-      <version>18.0</version>
     </dependency>
     <dependency>
       <groupId>commons-io</groupId>
       <artifactId>commons-io</artifactId>
-      <version>2.4</version>
     </dependency>
     <dependency>
-      <groupId>org.apache.commons</groupId>
-      <artifactId>commons-lang3</artifactId>
-      <version>3.3.2</version>
+      <groupId>commons-lang</groupId>
+      <artifactId>commons-lang</artifactId>
     </dependency>
     <dependency>
       <groupId>com.google.code.findbugs</groupId>
       <artifactId>jsr305</artifactId>
-      <version>3.0.0</version>
       <scope>provided</scope>
     </dependency>
     <dependency>
index f3b663c2b043fa14202f6cc886e9bcfad0aaad5c..2683e054b53e749fb85311c1d2af5d559af1ac65 100644 (file)
 package org.sonar.xoo.coverage;
 
 import com.google.common.base.Splitter;
+import java.io.File;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
 import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.api.batch.fs.InputFile;
 import org.sonar.api.batch.sensor.Sensor;
 import org.sonar.api.batch.sensor.SensorContext;
@@ -32,11 +36,6 @@ import org.sonar.api.utils.log.Logger;
 import org.sonar.api.utils.log.Loggers;
 import org.sonar.xoo.Xoo;
 
-import java.io.File;
-import java.io.IOException;
-import java.util.Iterator;
-import java.util.List;
-
 public abstract class AbstractCoverageSensor implements Sensor {
   private static final Logger LOG = Loggers.get(AbstractCoverageSensor.class);
 
index c740b87696051f313ec58dc80dea2dea985fea77..9562f300fb1e773a48dd464ac13c814795415eb1 100644 (file)
@@ -24,7 +24,7 @@ import java.io.IOException;
 import java.io.Serializable;
 import java.util.List;
 import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.api.batch.fs.InputFile;
 import org.sonar.api.batch.measure.MetricFinder;
 import org.sonar.api.batch.sensor.Sensor;
index 760d865d6cfd83154ec280dc1034571ed7760379..7eb71225771181ac801252f5306be4001c03ea72 100644 (file)
@@ -25,7 +25,7 @@ import java.io.IOException;
 import java.util.Iterator;
 import java.util.List;
 import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.api.batch.fs.InputFile;
 import org.sonar.api.batch.sensor.Sensor;
 import org.sonar.api.batch.sensor.SensorContext;
index e75ec0ecfe080ec7ecd3ccc00049336f611371c5..e0d2832838218c34f0c2c214593c1efbe7ac584a 100644 (file)
@@ -25,7 +25,7 @@ import java.io.IOException;
 import java.util.Iterator;
 import java.util.List;
 import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.api.batch.fs.InputFile;
 import org.sonar.api.batch.sensor.Sensor;
 import org.sonar.api.batch.sensor.SensorContext;
index e4c04397d6833e97d39c3ca05aaa670d5e6f9961..4f5d0e6b169d744bbf00c8c4fef4a5ecdc1f3a5d 100644 (file)
 package org.sonar.xoo.scm;
 
 import com.google.common.annotations.VisibleForTesting;
-import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
-import org.sonar.api.batch.fs.InputFile;
-import org.sonar.api.batch.scm.BlameCommand;
-import org.sonar.api.batch.scm.BlameLine;
-import org.sonar.api.utils.DateUtils;
-
 import java.io.File;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang.StringUtils;
+import org.sonar.api.batch.fs.InputFile;
+import org.sonar.api.batch.scm.BlameCommand;
+import org.sonar.api.batch.scm.BlameLine;
+import org.sonar.api.utils.DateUtils;
 
 public class XooBlameCommand extends BlameCommand {
 
index ef533827b2879b81414e4c352e64dd5911681d05..f7853cb0717be6973ee5a5452a256505973cca72 100644 (file)
 package org.sonar.xoo.test;
 
 import com.google.common.base.Splitter;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
 import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.api.batch.DependsUpon;
 import org.sonar.api.batch.fs.FilePredicates;
 import org.sonar.api.batch.fs.FileSystem;
@@ -38,12 +43,6 @@ import org.sonar.api.utils.log.Logger;
 import org.sonar.api.utils.log.Loggers;
 import org.sonar.xoo.Xoo;
 
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
 /**
  * Parse files *.xoo.testcoverage
  */
index 7e3db1977b0bb8b060504d1bf123ad43edee04d9..be19c052fbbe7298d9992bc7983a4d71fb3c4f9a 100644 (file)
 package org.sonar.xoo.test;
 
 import com.google.common.base.Splitter;
+import java.io.File;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
 import org.apache.commons.io.FileUtils;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.api.batch.DependedUpon;
 import org.sonar.api.batch.fs.FilePredicates;
 import org.sonar.api.batch.fs.FileSystem;
@@ -38,11 +42,6 @@ import org.sonar.api.utils.log.Logger;
 import org.sonar.api.utils.log.Loggers;
 import org.sonar.xoo.Xoo;
 
-import java.io.File;
-import java.io.IOException;
-import java.util.Iterator;
-import java.util.List;
-
 /**
  * Parse files *.xoo.test
  */
index 91f0fd59be4a2aae396a6826e1ae3d53bdaf3666..5bf3879210d588c7820e1095a7b92b89e1959955 100644 (file)
@@ -93,8 +93,7 @@ public class ProjectDataLoader {
   }
 
   private List<FilePathWithHashDto> searchFilesWithHashAndRevision(DbSession session, ComponentDto module) {
-    return module.isRootProject() ?
-      dbClient.componentDao().selectEnabledFilesFromProject(session, module.uuid())
+    return module.isRootProject() ? dbClient.componentDao().selectEnabledFilesFromProject(session, module.uuid())
       : dbClient.componentDao().selectEnabledDescendantFiles(session, module.uuid());
   }
 
@@ -129,7 +128,7 @@ public class ProjectDataLoader {
     }
   }
 
-  private void addSettingsToChildrenModules(ProjectRepositories ref, String moduleKey, Map<String, String> parentProperties, TreeModuleSettings treeModuleSettings,
+  private static void addSettingsToChildrenModules(ProjectRepositories ref, String moduleKey, Map<String, String> parentProperties, TreeModuleSettings treeModuleSettings,
     boolean hasScanPerm) {
     Map<String, String> currentParentProperties = newHashMap();
     currentParentProperties.putAll(parentProperties);
index e2f074bc0855d93f004a836d4c32b24c18fc8184..cfebad6cbb50de45bf3878b55b767d805f72a83c 100644 (file)
@@ -202,7 +202,7 @@ public class BatchExtensionDictionnary {
     return results;
   }
 
-  private void evaluateClass(Class extensionClass, Class annotationClass, List<Object> results) {
+  private static void evaluateClass(Class extensionClass, Class annotationClass, List<Object> results) {
     Annotation annotation = extensionClass.getAnnotation(annotationClass);
     if (annotation != null) {
       if (annotation.annotationType().isAssignableFrom(DependsUpon.class)) {
index 330750e5411ccc92679b63a7a003ec4cf9b6f115..5b13f1d4ee12e8c1fc07a5330f2d6c5eac2a0e16 100644 (file)
@@ -87,7 +87,7 @@ public class PhaseProfiling extends AbstractTimeProfiling {
    * @param o
    * @return
    */
-  private String toStringOrSimpleName(Object o) {
+  private static String toStringOrSimpleName(Object o) {
     String toString = o.toString();
     if (toString == null || toString.startsWith(o.getClass().getName())) {
       return o.getClass().getSimpleName();
index eb8e31f18bcc8623104be406a7812c461ac01ede..7e37b789c712343c5922e6cdd41013758469e97e 100644 (file)
@@ -19,8 +19,6 @@
  */
 package org.sonar.batch.scan.report;
 
-import org.sonar.batch.issue.tracking.TrackedIssue;
-
 import com.google.common.annotations.VisibleForTesting;
 import org.apache.commons.lang.StringUtils;
 import org.sonar.api.Properties;
@@ -31,11 +29,12 @@ import org.sonar.api.rule.Severity;
 import org.sonar.api.utils.log.Logger;
 import org.sonar.api.utils.log.Loggers;
 import org.sonar.batch.issue.IssueCache;
+import org.sonar.batch.issue.tracking.TrackedIssue;
 import org.sonar.batch.scan.filesystem.InputPathCache;
 
 @Properties({
-  @Property(key = ConsoleReport.CONSOLE_REPORT_ENABLED_KEY, name = "Enable console report", description = "Set this to true to generate a report in console output",
-    type = PropertyType.BOOLEAN, defaultValue = "false")})
+  @Property(key = ConsoleReport.CONSOLE_REPORT_ENABLED_KEY, defaultValue = "false", name = "Enable console report",
+    description = "Set this to true to generate a report in console output", type = PropertyType.BOOLEAN)})
 public class ConsoleReport implements Reporter {
 
   @VisibleForTesting
@@ -122,7 +121,7 @@ public class ConsoleReport implements Reporter {
     LOG.info(sb.toString());
   }
 
-  private void printNewIssues(Report r, StringBuilder sb) {
+  private static void printNewIssues(Report r, StringBuilder sb) {
     int newIssues = r.totalNewIssues;
     if (newIssues > 0) {
       sb.append(StringUtils.leftPad("+" + newIssues, LEFT_PAD)).append(" issue" + (newIssues > 1 ? "s" : "")).append("\n\n");
index 369ba2b5e91071dd48b5d5ac8fd33fc5ba729971..8111abd467e399dba4acfd5927385a91528a441c 100644 (file)
@@ -21,16 +21,6 @@ package org.sonar.batch.scan.report;
 
 import com.google.common.collect.Maps;
 import freemarker.template.Template;
-import org.apache.commons.io.FileUtils;
-import org.apache.commons.io.IOUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.api.Properties;
-import org.sonar.api.Property;
-import org.sonar.api.PropertyType;
-import org.sonar.api.batch.fs.FileSystem;
-import org.sonar.api.config.Settings;
-
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
@@ -40,19 +30,26 @@ import java.io.OutputStreamWriter;
 import java.io.Writer;
 import java.net.URISyntaxException;
 import java.util.Map;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.api.Properties;
+import org.sonar.api.Property;
+import org.sonar.api.PropertyType;
+import org.sonar.api.batch.fs.FileSystem;
+import org.sonar.api.config.Settings;
 
 @Properties({
-  @Property(key = HtmlReport.HTML_REPORT_ENABLED_KEY, name = "Enable HTML report", description = "Set this to true to generate an HTML report",
-    type = PropertyType.BOOLEAN, defaultValue = "false"),
-  @Property(key = HtmlReport.HTML_REPORT_LOCATION_KEY, name = "HTML Report location",
-    description = "Location of the generated report. Can be absolute or relative to working directory",
-    type = PropertyType.STRING, defaultValue = HtmlReport.HTML_REPORT_LOCATION_DEFAULT, global = false, project = false),
-  @Property(key = HtmlReport.HTML_REPORT_NAME_KEY, name = "HTML Report name",
-    description = "Name of the generated report. Will be suffixed by .html or -light.html",
-    type = PropertyType.STRING, defaultValue = HtmlReport.HTML_REPORT_NAME_DEFAULT, global = false, project = false),
-  @Property(key = HtmlReport.HTML_REPORT_LIGHTMODE_ONLY, name = "Html report in light mode only", project = true,
-    description = "Set this to true to only generate the new issues report (light report)",
-    type = PropertyType.BOOLEAN, defaultValue = "false")})
+  @Property(key = HtmlReport.HTML_REPORT_ENABLED_KEY, defaultValue = "false", name = "Enable HTML report", description = "Set this to true to generate an HTML report",
+    type = PropertyType.BOOLEAN),
+  @Property(key = HtmlReport.HTML_REPORT_LOCATION_KEY, defaultValue = HtmlReport.HTML_REPORT_LOCATION_DEFAULT, name = "HTML Report location",
+    description = "Location of the generated report. Can be absolute or relative to working directory", project = false, global = false, type = PropertyType.STRING),
+  @Property(key = HtmlReport.HTML_REPORT_NAME_KEY, defaultValue = HtmlReport.HTML_REPORT_NAME_DEFAULT, name = "HTML Report name",
+    description = "Name of the generated report. Will be suffixed by .html or -light.html", project = false, global = false, type = PropertyType.STRING),
+  @Property(key = HtmlReport.HTML_REPORT_LIGHTMODE_ONLY, defaultValue = "false", name = "Html report in light mode only",
+    description = "Set this to true to only generate the new issues report (light report)", project = true, type = PropertyType.BOOLEAN)})
 public class HtmlReport implements Reporter {
   private static final Logger LOG = LoggerFactory.getLogger(HtmlReport.class);
 
@@ -111,7 +108,7 @@ public class HtmlReport implements Reporter {
     if (!reportFileDir.isAbsolute()) {
       reportFileDir = new File(fs.workDir(), reportFileDirStr);
     }
-    if (reportFileDirStr.endsWith(".html")) {
+    if (StringUtils.endsWith(reportFileDirStr, ".html")) {
       LOG.warn(HTML_REPORT_LOCATION_KEY + " should indicate a directory. Using parent folder.");
       reportFileDir = reportFileDir.getParentFile();
     }
index 20be830f8aa0cfdfde928d9da8d0906d9ab04ff3..f6a88bf44657cbd94ad9948fc7fb54850eb3ee48 100644 (file)
@@ -22,6 +22,7 @@ package org.sonar.batch.scm;
 import com.google.common.base.Joiner;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import org.apache.commons.lang.StringUtils;
 import org.picocontainer.Startable;
 import org.sonar.api.CoreProperties;
 import org.sonar.api.Properties;
@@ -40,13 +41,13 @@ import org.sonar.batch.scan.ImmutableProjectReactor;
   @Property(
     key = ScmConfiguration.FORCE_RELOAD_KEY,
     defaultValue = "false",
-    type = PropertyType.BOOLEAN,
     name = "Force reloading of SCM information for all files",
     description = "By default only files modified since previous analysis are inspected. Set this parameter to true to force the reloading.",
-    module = false,
+    category = CoreProperties.CATEGORY_SCM,
     project = false,
+    module = false,
     global = false,
-    category = CoreProperties.CATEGORY_SCM)
+    type = PropertyType.BOOLEAN)
 })
 @InstantiationStrategy(InstantiationStrategy.PER_BATCH)
 @BatchSide
@@ -103,7 +104,7 @@ public final class ScmConfiguration implements Startable {
     if (providerPerKey.containsKey(forcedProviderKey)) {
       this.provider = providerPerKey.get(forcedProviderKey);
     } else {
-      String supportedProviders = providerPerKey.isEmpty() ? "No SCM provider installed" : "Supported SCM providers are " + Joiner.on(",").join(providerPerKey.keySet());
+      String supportedProviders = providerPerKey.isEmpty() ? "No SCM provider installed" : ("Supported SCM providers are " + Joiner.on(",").join(providerPerKey.keySet()));
       throw new IllegalArgumentException("SCM provider was set to \"" + forcedProviderKey + "\" but no SCM provider found for this key. " + supportedProviders);
     }
   }
@@ -111,7 +112,7 @@ public final class ScmConfiguration implements Startable {
   private void considerOldScmUrl() {
     if (settings.hasKey(CoreProperties.LINKS_SOURCES_DEV)) {
       String url = settings.getString(CoreProperties.LINKS_SOURCES_DEV);
-      if (url.startsWith("scm:")) {
+      if (StringUtils.startsWith(url, "scm:")) {
         String[] split = url.split(":");
         if (split.length > 1) {
           setProviderIfSupported(split[1]);
index f6fb4cf17528901017022b3a82efec4e607657ae..f2ea0406a01d06085ad4239d53d8ca09082464e3 100644 (file)
@@ -69,7 +69,7 @@ public class ProgressReport implements Runnable {
     }
   }
 
-  private void log(String message) {
+  private static void log(String message) {
     synchronized (LOG) {
       LOG.info(message);
       LOG.notifyAll();
index 6e7d1161bb422d16822a066c0363d8c0db2eeb89..5162e6a8d409e0143ccad8b79e21e864bd0f2dcc 100644 (file)
@@ -244,7 +244,7 @@ public class ComponentContainer implements ContainerPopulator.Container {
     return this;
   }
 
-  private String getName(Object extension) {
+  private static String getName(Object extension) {
     if (extension instanceof Class) {
       return ((Class<?>) extension).getName();
     }
index d4135d069e6112c3fe2787f4cf14510b74eafeb2..e2a2d5640f8fc350e57addeab5ad381b9182f48d 100644 (file)
@@ -139,7 +139,7 @@ class FileSourceDto {
     return result;
   }
 
-  private void addBlock(int blockId, Block block, Map<Integer, StringBuilder> dupPerLine) {
+  private static void addBlock(int blockId, Block block, Map<Integer, StringBuilder> dupPerLine) {
     int currentLine = block.start;
     for (int i = 0; i < block.length; i++) {
       if (dupPerLine.get(currentLine) == null) {
index 47a62b823996651fe9d281e4cc1120332d1fe759..a177a554fa12f7dbff242e4cfe353c5e7460fc22 100644 (file)
@@ -56,11 +56,12 @@ public class DefaultCpdTokens extends DefaultStorable implements NewCpdTokens {
   }
 
   @Override
-  public NewCpdTokens addToken(TextRange range, String image) {
+  public DefaultCpdTokens addToken(TextRange range, String image) {
     Preconditions.checkNotNull(range, "Range should not be null");
+    Preconditions.checkNotNull(image, "Image should not be null");
     Preconditions.checkState(inputFile != null, "Call onFile() first");
-    Preconditions.checkState(lastRange == null || lastRange.end().compareTo(range.start()) >= 0,
-      "Tokens of file %s should be provided in order. \nPrevious token: %s\nLast token: %s", inputFile, lastRange, range);
+    Preconditions.checkState(lastRange == null || lastRange.end().compareTo(range.start()) <= 0,
+      "Tokens of file %s should be provided in order.\nPrevious token: %s\nLast token: %s", inputFile, lastRange, range);
 
     String value = image;
 
@@ -72,6 +73,7 @@ public class DefaultCpdTokens extends DefaultStorable implements NewCpdTokens {
     }
     currentIndex++;
     sb.append(value);
+    lastRange = range;
 
     return this;
   }
diff --git a/sonar-plugin-api/src/test/java/org/sonar/api/batch/sensor/cpd/internal/DefaultCpdTokensTest.java b/sonar-plugin-api/src/test/java/org/sonar/api/batch/sensor/cpd/internal/DefaultCpdTokensTest.java
new file mode 100644 (file)
index 0000000..5f09cf0
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.api.batch.sensor.cpd.internal;
+
+import org.junit.Test;
+import org.sonar.api.batch.fs.internal.DefaultInputFile;
+import org.sonar.api.batch.sensor.internal.SensorStorage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class DefaultCpdTokensTest {
+
+  private static final DefaultInputFile INPUT_FILE = new DefaultInputFile("foo", "src/Foo.java")
+    .setLines(2)
+    .setOriginalLineOffsets(new int[] {0, 50})
+    .setLastValidOffset(100);
+
+  @Test
+  public void save_no_tokens() {
+    SensorStorage sensorStorage = mock(SensorStorage.class);
+    DefaultCpdTokens tokens = new DefaultCpdTokens(sensorStorage)
+      .onFile(INPUT_FILE);
+
+    tokens.save();
+
+    verify(sensorStorage).store(tokens);
+
+    assertThat(tokens.inputFile()).isEqualTo(INPUT_FILE);
+  }
+
+  @Test
+  public void save_one_token() {
+    SensorStorage sensorStorage = mock(SensorStorage.class);
+    DefaultCpdTokens tokens = new DefaultCpdTokens(sensorStorage)
+      .onFile(INPUT_FILE)
+      .addToken(INPUT_FILE.newRange(1, 2, 1, 5), "foo");
+
+    tokens.save();
+
+    verify(sensorStorage).store(tokens);
+
+    assertThat(tokens.getTokenLines()).extracting("value", "startLine", "hashCode", "startUnit", "endUnit").containsExactly(tuple("foo", 1, "foo".hashCode(), 1, 1));
+  }
+
+  @Test
+  public void save_many_tokens() {
+    SensorStorage sensorStorage = mock(SensorStorage.class);
+    DefaultCpdTokens tokens = new DefaultCpdTokens(sensorStorage)
+      .onFile(INPUT_FILE)
+      .addToken(INPUT_FILE.newRange(1, 2, 1, 5), "foo")
+      .addToken(INPUT_FILE.newRange(1, 6, 1, 10), "bar")
+      .addToken(INPUT_FILE.newRange(1, 20, 1, 25), "biz")
+      .addToken(INPUT_FILE.newRange(2, 1, 2, 10), "next");
+
+    tokens.save();
+
+    verify(sensorStorage).store(tokens);
+
+    assertThat(tokens.getTokenLines())
+      .extracting("value", "startLine", "hashCode", "startUnit", "endUnit")
+      .containsExactly(
+        tuple("foobarbiz", 1, "foobarbiz".hashCode(), 1, 3),
+        tuple("next", 2, "next".hashCode(), 4, 4));
+  }
+
+  @Test
+  public void basic_validation() {
+    SensorStorage sensorStorage = mock(SensorStorage.class);
+    DefaultCpdTokens tokens = new DefaultCpdTokens(sensorStorage);
+    try {
+      tokens.save();
+      fail("Expected exception");
+    } catch (Exception e) {
+      assertThat(e).hasMessage("Call onFile() first");
+    }
+    try {
+      tokens.addToken(INPUT_FILE.newRange(1, 2, 1, 5), "foo");
+      fail("Expected exception");
+    } catch (Exception e) {
+      assertThat(e).hasMessage("Call onFile() first");
+    }
+    try {
+      tokens.addToken(null, "foo");
+      fail("Expected exception");
+    } catch (Exception e) {
+      assertThat(e).hasMessage("Range should not be null");
+    }
+    try {
+      tokens.addToken(INPUT_FILE.newRange(1, 2, 1, 5), null);
+      fail("Expected exception");
+    } catch (Exception e) {
+      assertThat(e).hasMessage("Image should not be null");
+    }
+  }
+
+  @Test
+  public void validate_tokens_order() {
+    SensorStorage sensorStorage = mock(SensorStorage.class);
+    DefaultCpdTokens tokens = new DefaultCpdTokens(sensorStorage)
+      .onFile(INPUT_FILE)
+      .addToken(INPUT_FILE.newRange(1, 6, 1, 10), "bar");
+
+    try {
+      tokens.addToken(INPUT_FILE.newRange(1, 2, 1, 5), "foo");
+      fail("Expected exception");
+    } catch (Exception e) {
+      assertThat(e).hasMessage("Tokens of file [moduleKey=foo, relative=src/Foo.java, basedir=null] should be provided in order.\n" +
+        "Previous token: Range[from [line=1, lineOffset=6] to [line=1, lineOffset=10]]\n" +
+        "Last token: Range[from [line=1, lineOffset=2] to [line=1, lineOffset=5]]");
+    }
+  }
+
+}