content: /*
   *  Licensed to the Apache Software Foundation (ASF) under one
   *  or more contributor license agreements.  See the NOTICE file
   *  distributed with this work for additional information
   *  regarding copyright ownership.  The ASF licenses this file
   *  to you under the Apache License, Version 2.0 (the
   *  "License"); you may not use this file except in compliance
   *  with the License.  You may obtain a copy of the License at
   *
   *        http://www.apache.org/licenses/LICENSE-2.0
   *
   *  Unless required by applicable law or agreed to in writing,
   *  software distributed under the License is distributed on an
   *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
   *  KIND, either express or implied.  See the License for the
   *  specific language governing permissions and limitations
   *  under the License.
   */
  package org.apache.causeway.commons.io;
  
  import java.io.BufferedWriter;
  import java.io.File;
  import java.io.FileInputStream;
  import java.io.FileOutputStream;
  import java.io.InputStream;
  import java.io.OutputStreamWriter;
  import java.net.URL;
  import java.nio.charset.Charset;
  import java.util.ArrayList;
  import java.util.Arrays;
  import java.util.Scanner;
  import java.util.function.UnaryOperator;
  import java.util.stream.Collectors;
  import java.util.stream.Stream;
  
  import org.jspecify.annotations.Nullable;
  
  import org.apache.causeway.commons.collections.Can;
  import org.apache.causeway.commons.internal.assertions._Assert;
  import org.apache.causeway.commons.internal.base._Strings;
  import org.apache.causeway.commons.internal.collections._Arrays;
  
  import lombok.AccessLevel;
  import lombok.AllArgsConstructor;
  import lombok.Getter;
  import org.jspecify.annotations.NonNull;
  import lombok.SneakyThrows;
  import lombok.experimental.UtilityClass;
  
  /**
   * Utilities for text processing and text I/O.
   *
   * @since 2.0 {@index}
   */
  @UtilityClass
  public class TextUtils {
  
      // -- LINE READING
  
      /**
       * Converts given {@code text} into a {@link Stream} of lines,
       * removing new line characters {@code \n,\r} in the process.
       * @param text - nullable
       * @return non-null
       * @apiNote Java 11+ provides {@code String.lines()}
       */
      public Stream<String> streamLines(final @Nullable String text){
          return _Strings.splitThenStream(text, "\n")
                  .map(s->s.replace("\r", ""));
      }
  
      /**
       * Converts given {@code text} into a {@link Can} of lines,
       * removing new line characters {@code \n,\r} in the process.
       * @param text - nullable
       * @return non-null
       */
      public Can<String> readLines(final @Nullable String text){
          return Can.ofStream(streamLines(text));
      }
  
      /**
       * Reads content from given {@code input} into a {@link Can} of lines,
       * removing new line characters {@code \n,\r}
       * and BOM file header (if any) in the process.
       * @param input - nullable
       * @return non-null
       */
      public Can<String> readLinesFromInputStream(
              final @Nullable InputStream input,
              final @NonNull  Charset charset){
          if(input==null) {
              return Can.empty();
          }
          var lines = new ArrayList<String>();
          try(Scanner scanner = new Scanner(input, charset.name())){
              scanner.useDelimiter("\\n");
              while(scanner.hasNext()) {
                  var line = scanner.next()
                          .replace("\r", "");
                  if(lines.size()==0) {
                      line = stripBom(line); // special handling of first line
                  }
                  lines.add(line);
              }
          }
          return Can.ofCollection(lines);
      }
  
      /**
       * Reads content from given resource into a {@link Can} of lines,
       * removing new line characters {@code \n,\r}
       * and BOM file header (if any) in the process.
       * @return non-null
       * @see #readLinesFromInputStream(InputStream, Charset)
       */
      @SneakyThrows
      public Can<String> readLinesFromResource(
              final @NonNull Class<?> resourceLocation,
              final @NonNull String resourceName,
              final @NonNull Charset charset) {
          try(var input = resourceLocation.getResourceAsStream(resourceName)){
              return readLinesFromInputStream(input, charset);
          }
      }
  
      /**
       * Reads content from given {@link URL} into a {@link Can} of lines,
       * removing new line characters {@code \n,\r}
       * and BOM file header (if any) in the process.
       * @return non-null
       * @see #readLinesFromInputStream(InputStream, Charset)
       */
      @SneakyThrows
      public Can<String> readLinesFromUrl(
              final @NonNull URL url,
              final @NonNull Charset charset) {
          try(var input = url.openStream()){
              return readLinesFromInputStream(input, charset);
          }
      }
  
      /**
       * Reads content from given {@link File} into a {@link Can} of lines,
       * removing new line characters {@code \n,\r}
       * and BOM file header (if any) in the process.
       * @return non-null
       * @see #readLinesFromInputStream(InputStream, Charset)
       */
      @SneakyThrows
      public Can<String> readLinesFromFile(
              final @NonNull File file,
              final @NonNull Charset charset) {
          try(var input = new FileInputStream(file)){
              return readLinesFromInputStream(input, charset);
          }
      }
  
      /**
       * Reads content from given {@link DataSource} into a {@link Can} of lines,
       * removing new line characters {@code \n,\r}
       * and BOM file header (if any) in the process.
       * @return non-null
       * @see #readLinesFromInputStream(InputStream, Charset)
       */
      public Can<String> readLinesFromDataSource(
              final @NonNull DataSource dataSource,
              final @NonNull Charset charset) {
          return dataSource.tryReadAsLines(charset)
                  .valueAsNonNullElseFail();
      }
  
      // -- WRITING
  
      /**
       * Writes given lines to given {@link File},
       * using new line character {@code \n}.
       */
      @SneakyThrows
      public void writeLinesToFile(
              final @NonNull Iterable<String> lines,
              final @NonNull File file,
              final @NonNull Charset charset) {
  
          try(var bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), charset))) {
              for(var line : lines) {
                  bw.append(line).append("\n");
              }
          }
      }
  
      /**
       * Writes given lines to given {@link DataSink},
       * using new line character {@code \n}.
       */
      public void writeLinesToDataSink(
              final @NonNull Iterable<String> lines,
              final @NonNull DataSink dataSink,
              final @NonNull Charset charset) {
  
          dataSink.writeAll(os->{
              try(var bw = new BufferedWriter(new OutputStreamWriter(os, charset))) {
                  for(var line : lines) {
                      bw.append(line).append("\n");
                  }
              }
          });
      }
  
      // -- STRING DELIMITER
  
      public StringDelimiter delimiter(final @NonNull String delimiter) {
          return StringDelimiter.of(delimiter, new String[0]);
      }
  
      /**
       * Holder of immutable {@link String}[] elements, that provides
       * 'path like' composition and decomposition utilities.
       * <p>
       * Null or empty delimited elements are ignored.
       */
      @AllArgsConstructor(staticName = "of", access = AccessLevel.PRIVATE)
      public final static class StringDelimiter {
  
          @Getter
          private final @NonNull String delimiter;
          private final @NonNull String[] elements;
  
          /**
           * Returns a new {@link StringDelimiter} instance splitting given {@code string}
           * into elements using {@link String} {@link #getDelimiter()} as delimiter.
           * <p>
           * Null or empty delimited elements are ignored.
           * @param string - null-able
           */
          public StringDelimiter parse(final @Nullable String string) {
              return new StringDelimiter(delimiter,
                  _Strings.splitThenStream(string, delimiter)
                      .filter(_Strings::isNotEmpty)
                      .collect(_Arrays.toArray(String.class)));
          }
  
          /**
           * Streams the delimited {@link String} elements, this {@link StringDelimiter} is holding.
           */
          public Stream<String> stream() {
              return elementCount()>0
                      ? Stream.of(elements)
                      : Stream.empty();
          }
  
          /**
           * Returns a new {@link StringDelimiter} instance that has all the
           * delimited {@link String} elements of this and given {@code other}
           * {@link StringDelimiter} (in sequence).
           * @param other - null-able
           */
          public StringDelimiter join(final @Nullable StringDelimiter other) {
              return other!=null
                      ? new StringDelimiter(delimiter,
                              _Arrays.combine(this.elements, other.elements))
                      : this;
          }
  
          /**
           * Number of delimited {@link String} elements this {@link StringDelimiter} is holding.
           */
          public int elementCount() {
              return elements.length;
          }
  
          public String asDelimitedString() {
              return stream().collect(Collectors.joining(delimiter));
          }
  
          @Override
          public String toString() {
              return String.format("StringDelimiter[delimiter=%s,elements=%s]",
                      delimiter,
                      Arrays.asList(elements));
          }
  
          /**
           * Returns a new {@link StringDelimiter} instance that has all the
           * delimited {@link String} elements of this but instead uses given {@code newDelimiter}.
           */
          public StringDelimiter withDelimiter(final @NonNull String newDelimiter) {
              return new StringDelimiter(newDelimiter, elements);
          }
  
      }
  
      // -- STRING CUTTER
  
      public StringCutter cutter(final @NonNull String value) {
          return StringCutter.of(value);
      }
  
      /**
       * Holder of immutable {@link String} value, that provides
       * dropping of characters before or after search and match for
       * a specific character sequence.
       */
      @AllArgsConstructor(staticName = "of", access = AccessLevel.PRIVATE)
      public final static class StringCutter {
  
          @Getter
          private final @NonNull String value;
  
          /**
           * Returns a new {@link StringCutter} holding a the string value as returned by given {@code mapper}.
           */
          public StringCutter map(final @NonNull UnaryOperator<String> mapper) {
              return StringCutter.of(mapper.apply(value));
          }
  
          /**
           * Whether the held string value contains the specified
           * sequence of char values.
           *
           * @param str the sequence to search for
           * @return true if the held string value contains {@code str},
           *  false otherwise (as well as when null or empty)
           */
          public boolean contains(final @Nullable CharSequence str) {
              return _Strings.isNullOrEmpty(str)
                      ? false
                      : value.contains(str);
          }
  
          /**
           * Searches for first occurrence of given {@code str} within
           * held string value and drops any characters that come before
           * (the matching part).
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter dropBefore(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.indexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(index));
              }
              return this;
          }
  
          /**
           * Searches for first occurrence of given {@code str} within
           * held string value and drops any characters that come after
           * (the matching part).
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter dropAfter(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.indexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(0, index + str.length()));
              }
              return this;
          }
  
          /**
           * Searches for last occurrence of given {@code str} within
           * held string value and drops any characters that come before
           * (the matching part).
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter dropBeforeLast(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.lastIndexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(index));
              }
              return this;
          }
  
          /**
           * Searches for last occurrence of given {@code str} within
           * held string value and drops any characters that come after
           * (the matching part).
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter dropAfterLast(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.lastIndexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(0, index + str.length()));
              }
              return this;
          }
  
          /**
           * Searches for first occurrence of given {@code str} within
           * held string value, keeps any characters that come before
           * (the matching part) and drops the rest.
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter keepBefore(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.indexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(0, index));
              }
              return this;
          }
  
          /**
           * Searches for first occurrence of given {@code str} within
           * held string value, keeps any characters that come after
           * (the matching part) and drops the rest.
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter keepAfter(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.indexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(index + str.length()));
              }
              return this;
          }
  
          /**
           * Searches for last occurrence of given {@code str} within
           * held string value, keeps any characters that come before
           * (the matching part) and drops the rest.
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter keepBeforeLast(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.lastIndexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(0, index));
              }
              return this;
          }
  
          /**
           * Searches for last occurrence of given {@code str} within
           * held string value, keeps any characters that come after
           * (the matching part) and drops the rest.
           * <p>
           * If no match, then acts as identity operator.
           */
          public StringCutter keepAfterLast(final @NonNull String str) {
              _Assert.assertNotEmpty(str, this::matcherNotEmptyMsg);
              final int index = value.lastIndexOf(str);
              if(index>-1) {
                  return StringCutter.of(value.substring(index + str.length()));
              }
              return this;
          }
  
          // -- HELPER
  
          private String matcherNotEmptyMsg() {
              return "can only match search and match for non-empty string";
          }
  
      }
  
      // -- HELPER
  
      /**
       * If line has a BOM 65279 (0xFEFF) leading character, strip it.
       * <p>
       * Some UTF-8 formatted files may have a BOM signature at their start.
       */
      private String stripBom(final String line) {
          if(line.length()>0
                  && line.charAt(0)==65279) {
              return line.substring(1);
          }
          return line;
      }
  
  }
  