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 は、1行分のデータを順次分割するクラスです。
028 *
029 *<pre>
030 * ファイルは、『改行』で行分割して、カンマかタブでカラム分割します。
031 *
032 *  応答ファイルの解析処理を簡素化するため、以下のルール(禁止事項)を定めます。
033 *      1.ダブルクオートの中に、ダブルクオート、改行、を含まないこと。
034 *          (カンマとスペースは含めることが出来ます。)
035 *      2.1行の定義は、『改行』とします。
036 *      3.スペース分割時は、複数スペースの場合でも、1つの区切り文字として扱います。
037 *          (A B    C D → 「A」、「B」、「C」、「D」 に分割されます。)
038 *      4.カンマ分割は、ダブルクオート間のカンマは分解しません。
039 *          混在した場合でも、最初に見つけた方が優先されます。
040 *      5.カンマ分割時は、複数カンマの場合は、それぞれ空文字列に分割されます。
041 *          カンマ分割後、それぞれの文字列は、前後スペースを削除(trim)します。
042 *          (A, B , ,  C , D → 「A」、「B」、「」、「C」、「D」 に分割されます。)
043 *      6.カラム分解後の、ダブルクオートは、削除します。
044 *          ((A, B , ,  "CC C,C" , D → 「A」、「B」、「」、「CC C,C」、「D」 に分割されます。)
045 *
046 *  処理手順
047 *      1.ファイルより、1行づつ(改行コードで分割)読み込みます。
048 *      2.読み込んだ1行について、先頭が、『#』の行はコメント行としてスキップします。
049 *      3.先頭から、区切り文字(スペースかカンマかタブ)が見つかるまでを、1カラムとして取得します。
050 *      4.その間、ダブルクオートが見つかったら、次のダブルクオートまで、取り込みます。
051 *      5.カラム分割された単語の前後スペースと、前後ダブルクオートを削除します。
052 *          trim()が先で、ダブルクオートの削除は、後から行います。(ダブルクオート内のtrim()は行いません。)
053 *      6.個々のカラムを配列にして返します。
054 *      7.これを、ファイルが終了するまで繰り返します。
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<>();         // 1行分の分割したトークンのリスト
123                for( final String clm : clms ) {
124                        if( !clm.isEmpty() ) { clmList.add( clm ); }    // ゼロ文字列以外のカラムのみ登録します。
125                }
126//              return clmList.toArray( new String[clmList.size()] );
127                return clmList.toArray( new String[0] );        // 8.5.4.2 (2024/01/12) PMD 7.0.0 OptimizableToArrayCall 対応
128        }
129
130        /**
131         * 1行づつ処理を行った結果のトークンをConsumerにセットする繰り返しメソッドです。
132         * 1行単位に、Consumer#action が呼ばれます。
133         * セットされるリストは、1行をトークンに分割したリストで、空行の場合は、SKIPします。
134         * また、オリジナルのカラム列がゼロ文字列の場合は、その列データを返しません。
135         * つまり、存在するカラム名だけの値列を返します。
136         *
137         * ファイルを順次読み込むため、内部メモリを圧迫しません。
138         *
139         * @param inPath        処理対象のPathオブジェクト
140         * @param action        行を区切り文字で分割した文字列のリストを引数に取るConsumerオブジェクト
141         * @throws      RuntimeException ファイル読み込み時にエラーが発生した場合
142         * @see         FileUtil#lockForEach(Path,Consumer)
143         */
144        public void forEach( final Path inPath , final Consumer<List<String>> action ) {
145                FileUtil.lockForEach(
146                        inPath ,
147                        chset  ,
148                        line -> {
149                                final List<String> list = split( line );                        // 行末カット、コメントカット、trim されます。
150                                if( !list.isEmpty() ) { action.accept( list ); }        // 空のリストオブジェクトの場合、SKIPします。
151                        }
152                );
153        }
154
155        /**
156         * 1行分の分割したトークンのリストを返します。
157         *
158         * ファイルの読み込みを、単独または、別に行った場合に、1行データとして、処理できます。
159         * このクラスの特徴である、先頭が、『#』の行は、コメントとみなして、削除します。
160         * 1行分をtrim()する処理も、行います。
161         * trim()の結果が、空文字列のみの場合は、空のリストオブジェクトを返します。
162         *
163         * @og.rev 7.2.1.0 (2020/03/13) カラム列がデータより少ない場合の対応
164         *
165         * @param       orgLine 1行データ(オリジナル)
166         * @return      1行分の分割したトークンのリスト(行末、コメント、trim処理済み)
167         */
168        public List<String> split( final String orgLine ) {
169                final List<String> list = new ArrayList<>();                    // 1行分の分割したトークンのリスト
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 1行分の文字列(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}