    001/*
    002 * Copyright (c) 2016 The EUROMAP63.jp Project.
    003 *
    004 * Licensed under the Apache License, Version 2.0 (the "License");
    005 * you may not use this file except in compliance with the License.
    006 * You may obtain a copy of the License at
    007 *
    008 *     http://www.apache.org/licenses/LICENSE-2.0
    009 *
    010 * Unless required by applicable law or agreed to in writing, software
    011 * distributed under the License is distributed on an "AS IS" BASIS,
    012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    013 * either express or implied. See the License for the specific language
    014 * governing permissions and limitations under the License.
    015 */
    016package org.opengion.fukurou.fileexec;
    017
    018import java.util.function.Consumer;
    019import java.util.Locale;
    020import java.util.List;
    021import java.util.ArrayList;
    022import java.nio.file.Path;
    023import java.nio.charset.Charset;
    024import static java.nio.charset.StandardCharsets.UTF_8;
    025
    026/**
    027 * LineSplitter は、１行分のデータを順次分割するクラスです。
    028 *
    029 *<pre>
    030 * ファイルは、『改行』で行分割して、カンマかタブでカラム分割します。
    031 *
    032 *  応答ファイルの解析処理を簡素化するため、以下のルール(禁止事項)を定めます。
    033 *      １．ダブルクオートの中に、ダブルクオート、改行、を含まないこと。
    034 *          （カンマとスペースは含めることが出来ます。）
    035 *      ２．１行の定義は、『改行』とします。
    036 *      ３．スペース分割時は、複数スペースの場合でも、１つの区切り文字として扱います。
    037 *          （A B    C D → ｢A｣、｢B｣、｢C｣、｢D｣ に分割されます。）
    038 *      ４．カンマ分割は、ダブルクオート間のカンマは分解しません。
    039 *          混在した場合でも、最初に見つけた方が優先されます。
    040 *      ５．カンマ分割時は、複数カンマの場合は、それぞれ空文字列に分割されます。
    041 *          カンマ分割後、それぞれの文字列は、前後スペースを削除(trim)します。
    042 *          （A, B , ,  C , D → ｢A｣、｢B｣、｢｣、｢C｣、｢D｣ に分割されます。）
    043 *      ６．カラム分解後の、ダブルクオートは、削除します。
    044 *          （（A, B , ,  "CC C,C" , D → ｢A｣、｢B｣、｢｣、｢CC C,C｣、｢D｣ に分割されます。）
    045 *
    046 *  処理手順
    047 *      １．ファイルより、１行づつ（改行コードで分割）読み込みます。
    048 *      ２．読み込んだ１行について、先頭が、『#』の行はコメント行としてスキップします。
    049 *      ３．先頭から、区切り文字(スペースかカンマかタブ)が見つかるまでを、１カラムとして取得します。
    050 *      ４．その間、ダブルクオートが見つかったら、次のダブルクオートまで、取り込みます。
    051 *      ５．カラム分割された単語の前後スペースと、前後ダブルクオートを削除します。
    052 *          trim()が先で、ダブルクオートの削除は、後から行います。(ダブルクオート内のtrim()は行いません。)
    053 *      ６．個々のカラムを配列にして返します。
    054 *      ７．これを、ファイルが終了するまで繰り返します。
    055 *
    056 * 並行性
    057 *  このクラスは、staticメソッドのみのユーティリティークラスのため、スレッドに対して、安全です。
    058 *  また、ﾌｧｲﾙの読み取りに関して、FileChannelのtryLockを行っています。
    059 *</pre>
    060 *
    061 * @og.rev 1.0.0 (2016/04/28) 新規作成
    062 *
    063 * @version  1.0
    064 * @author   Kazuhiko Hasegawa
    065 * @since    JDK1.8,
    066 */
    067public final class LineSplitter {
    068        private static final XLogger LOGGER= XLogger.getLogger( LineSplitter.class.getSimpleName() );           // ログ出力
    069
    070        private final Charset chset ;                           // ファイルのエンコード
    071        private String[] clms = new String[0];          // オリジナルのカラム列(ゼロ文字列も含む)
    072
    073        private static final String NAME_KEY = "#NAME";
    074        private static final int    NAME_LEN = NAME_KEY.length();
    075
    076        /**
    077         * デフォルトコンストラクター
    078         *
    079         * ファイル読み取りのCharsetは、UTF-8になります。
    080         * @see java.nio.charset.StandardCharsets#UTF_8
    081         */
    082        public LineSplitter() {
    083                this( UTF_8 , null );
    084        }
    085
    086        /**
    087         * Charsetに対応した文字列を指定して、オブジェクトを作成します。
    088         *
    089         * @param chStr         ファイルを読み取るときのCharset文字列
    090         * @param inClms        外部指定カラム文字列(CSV形式)
    091         */
    092        public LineSplitter( final String chStr , final String inClms ) {
    093                this( Charset.forName( chStr ) , inClms );
    094        }
    095
    096        /**
    097         * Charsetを指定して、オブジェクトを作成します。
    098         *
    099         * @param chObj         ファイルを読み取るときのCharsetオブジェクト
    100         * @param inClms        外部指定カラム文字列(CSV形式)
    101         */
    102        public LineSplitter( final Charset chObj , final String inClms ) {
    103                chset = chObj;
    104                if( inClms != null && !inClms.isEmpty() ) {
    105                        clms = inClms.split( "," );                     // CSV形式のカラム列を一旦分解します。
    106                }
    107                LOGGER.debug( () -> "[LineSplitter] Charset=" + chObj + " , inClms=" + inClms );
    108        }
    109
    110        /**
    111         * #NAME が存在すれば、そこから名前配列を返します。
    112         *
    113         * ここでは、オリジナルのカラム列(ゼロ文字列も含む)ではなく、
    114         * 存在するカラム名だけのカラム列を返します。
    115         *
    116         * 外部指定カラムがあれば、そちらを優先します。
    117         * 無ければ、長さゼロの配列 が返されます。
    118         *
    119         * @return あれば名前配列、無ければ、長さゼロの配列
    120         */
    121        public String[] getColumns() {
    122                final List<String> clmList = new ArrayList<>();         // １行分の分割したトークンのリスト
    123                for( final String clm : clms ) {
    124                        if( !clm.isEmpty() ) { clmList.add( clm ); }    // ゼロ文字列以外のカラムのみ登録します。
    125                }
    126                return clmList.toArray( new String[clmList.size()] );
    127        }
    128
    129        /**
    130         * １行づつ処理を行った結果のトークンをConsumerにセットする繰り返しメソッドです。
    131         * １行単位に、Consumer#action が呼ばれます。
    132         * セットされるリストは、１行をトークンに分割したリストで、空行の場合は、SKIPします。
    133         * また、オリジナルのカラム列がゼロ文字列の場合は、その列データを返しません。
    134         * つまり、存在するカラム名だけの値列を返します。
    135         *
    136         * ファイルを順次読み込むため、内部メモリを圧迫しません。
    137         *
    138         * @param inPath        処理対象のPathオブジェクト
    139         * @param action        行を区切り文字で分割した文字列のリストを引数に取るConsumerオブジェクト
    140         * @throws      RuntimeException ファイル読み込み時にエラーが発生した場合
    141         * @see         FileUtil#lockForEach(Path,Consumer)
    142         */
    143        public void forEach( final Path inPath , final Consumer<List<String>> action ) {
    144                FileUtil.lockForEach(
    145                        inPath ,
    146                        chset  ,
    147                        line -> {
    148                                final List<String> list = split( line );                        // 行末カット、コメントカット、trim されます。
    149                                if( !list.isEmpty() ) { action.accept( list ); }        // 空のリストオブジェクトの場合、SKIPします。
    150                        }
    151                );
    152        }
    153
    154        /**
    155         * １行分の分割したトークンのリストを返します。
    156         *
    157         * ファイルの読み込みを、単独または、別に行った場合に、１行データとして、処理できます。
    158         * このクラスの特徴である、先頭が、『#』の行は、コメントとみなして、削除します。
    159         * １行分をtrim()する処理も、行います。
    160         * trim()の結果が、空文字列のみの場合は、空のリストオブジェクトを返します。
    161         *
    162         * @og.rev 7.2.1.0 (2020/03/13) カラム列がデータより少ない場合の対応
    163         *
    164         * @param       orgLine １行データ(オリジナル)
    165         * @return      １行分の分割したトークンのリスト(行末、コメント、trim処理済み)
    166         */
    167        public List<String> split( final String orgLine ) {
    168                final List<String> list = new ArrayList<>();                    // １行分の分割したトークンのリスト
    169
    170                final String line = cmntCut( orgLine );                                 // 行末カット、コメントカット、trim されます。
    171                if( line.isEmpty() ) { return list; }                                   // Zero文字列の場合、空のリストオブジェクトを返します。
    172
    173                final int maxPosition = line.length();
    174                int currentPosition = 0;
    175                int listNo = 0;
    176
    177                // "<" では末尾の項目が空(カンマで1行が終わる)場合、正しく処理できない。
    178                while( currentPosition <= maxPosition ) {
    179        //              boolean fstSpace = true;                // 先頭にスペースがある場合は、削除します
    180                        boolean inquote  = false;               // ダブルクオート内部のカンマは、スキップする。
    181        //              boolean inkakko  = false;               // 『[』と『]』の間のカンマは、スキップする。
    182
    183                        int position = currentPosition;
    184                        final int from = position;
    185
    186                        char ch = 0;
    187                        while( position < maxPosition ) {
    188                                ch = line.charAt( position );
    189        //                      if( fstSpace ) {
    190        //                              if( ch <= ' ' ) { position++ ; continue; }              // UTF-8 で、' ' より小さい文字は、空白文字(trim対象)です。
    191        //                              fstSpace = false;
    192        //                              from = position ;               // 最初に見つけた、空白文字以外の文字の位置
    193        //                      }
    194
    195        //                      if( !inquote && !inkakko && ( ch == ',' || ch <= ' ') ) { break; }
    196                                if( !inquote && ( ch == ',' || ch <= ' ') ) { break; }
    197                                else if( '"' == ch ) { inquote = !inquote; }                                    // 『"』クオート処理を行う
    198        //                      else if( '[' == ch && !inkakko ) { inkakko = true;  }                   // 『[』の開始処理
    199        //                      else if( ']' == ch && inkakko  ) { inkakko = false; }                   // 『]』の終了処理
    200                                position++;
    201                        }
    202
    203                        // 分割トークン
    204                        String token = line.substring( from,position );
    205
    206                        // トークンの前後が '"' なら、削除します。
    207                        final int len = token.length();
    208
    209                        if( len >=2 && token.charAt(0) == '"' && token.charAt(len-1) == '"' ) {
    210                                token = token.substring( 1,len-1 );
    211                        }
    212
    213                        // 超特殊処理。tokenにnullは含まれないが、文字列が、"null"の場合は、ゼロ文字列と置き換えます。
    214                        if( "null".equalsIgnoreCase( token ) ) { token = ""; }
    215
    216                        //  #NAME 列を削除します。       ゼロカラム列を削除します。
    217//                      if( !clms[listNo].isEmpty() ) {
    218                        if( listNo < clms.length && !clms[listNo].isEmpty() ) {         // 7.2.1.0 (2020/03/13)
    219                                list.add( token );
    220                        }
    221                        listNo++ ;
    222
    223                        // ch の終わり方で、スペースの場合、CSV形式かも知れないので、もう少し様子を見る。
    224                        while( ch <= ' ' && ++position < maxPosition ) {
    225                                ch = line.charAt( position );
    226                        }
    227
    228                        // 最後がカンマでなければ、進めすぎている。
    229                        currentPosition = ch == ',' || position == maxPosition ? position+1 : position ;
    230                }
    231
    232                return list;
    233        }
    234
    235        /**
    236         * 先頭文字が、'#' の行を削除した文字列を返します。
    237         *
    238         * このメソッド上で、#NAME があれば、カラム配列を作成します。
    239         * カラム配列は、最初の一度のみ、セット可能とします。
    240         *
    241         * @param       line １行分の文字列(not null)
    242         * @return      コメント削除後の行
    243         * @throws      NullPointerException 引数lineが、nullの場合。
    244         */
    245        public String cmntCut( final String line ) {
    246                final boolean isCmnt = !line.isEmpty() && line.charAt(0) == '#';
    247
    248                // カラム配列は、最初の一度のみ、セット可能とします。
    249                if( isCmnt && clms.length == 0 && line.toUpperCase( Locale.JAPAN ).startsWith( NAME_KEY ) ) {
    250                        final String sep = line.substring( NAME_LEN,NAME_LEN+1 );               // 区切り文字。タブかカンマ
    251                        clms = line.split( sep );                                                                               // 区切り文字で配列化します。
    252                        clms[0] = "";                                                                                                   // 統一的に処理を行うため、#NAME を、ゼロ文字列化します。
    253                }
    254
    255                return isCmnt ? "" : line;
    256        }
    257}