/* * [Ini.java] * * Summary: Validate and tidy INI files. * * Copyright: (c) 2006-2017 Roedy Green, Canadian Mind Products, http://mindprod.com * * Licence: This software may be copied and used freely for any purpose but military. * http://mindprod.com/contact/nonmil.html * * Requires: JDK 1.8+ * * Created with: JetBrains IntelliJ IDEA IDE http://www.jetbrains.com/idea/ * * Version History: * 1.1 2006-01-01 just changes the pad structure. * 1.2 2006-03-06 tidy * 1.3 2012-02-11 check for dup sections, better error on IOException. Fix problem with *.old does not exist. * 1.4 2012-04-27 treat [xxx;xxx] as section, not comment, to handle Opera handlers.ini, add finals. */ package com.mindprod.ini; import com.mindprod.common18.EIO; import com.mindprod.common18.ST; import com.mindprod.hunkio.HunkIO; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static java.lang.System.*; /** * Validate and tidy INI files. * * @author Roedy Green, Canadian Mind Products * @version 1.4 2012-04-27 treat [xxx;xxx] as section, not comment, to handle Opera handlers.ini, add finals. * @since 2006 */ public final class Ini { /** * true if want extra debugging output */ private static final boolean DEBUGGING = false; private static final int FIRST_COPYRIGHT_YEAR = 2006; /** * undisplayed copyright notice */ private static final String EMBEDDED_COPYRIGHT = "Copyright: (c) 2006-2017 Roedy Green, Canadian Mind Products, http://mindprod.com"; /** * used to make dummy section sort to the very end */ private static final String maxKey = String.valueOf( ( char ) 0xffff ); private static final String RELEASE_DATE = "2012-04-27"; /** * embedded version string. */ private static final String VERSION_STRING = "1.4"; /** * comments prior to a section or an item we save up. */ private static final ArrayList pendingLeadComments = new ArrayList<>( 40 ); // see http://mindprod.com/jgloss/ini.html // for notes on the format of an INI file. It looks roughly like this: // ; a comment // [section1] // key1=value1 // key2=value2 ; another comment // // [section2] // key1=value1 // key2=value2 // // [We want to sort by section, and within that by key. // Where keys are partly alpha and partly numeric // we wanto sort them as if they were separate fields. /** * section we are collecting items for */ private static Section currentSection = null; /** * which section of the ini file we are processing
true in initial header
false in sectionless items or the * body of sections and items. */ private static boolean inHeader = true; /** * line of the original file we are parsing */ private static String line; /** * line number in the original file we are parsing. */ private static int lineNumber = 0; /** * get the alphabetic front part of the name to use in sorting * * @param s name we are going to sort on alphanumerically. * * @return just the front alphabetic part of the String so sort alphabetically. */ private static String extractAlphaKey( final String s ) { // count how many trailing digits there are int digits = 0; for ( int i = s.length() - 1; i >= 0; i-- ) { // scan backwards to find first non-digit from the right. char c = s.charAt( i ); if ( ( '0' <= c && c <= '9' ) ) { digits++; } else { break; } } // we can't deal with more than 14 digits, so we treat excess as alpha. digits = Math.min( digits, 14 ); if ( digits <= 0 ) { return s; } else { return s.substring( 0, s.length() - digits ); } } /** * get the numeric front part of the name to use in sorting * * @param s name we are going to sort on alphanumerically. * * @return just the tail numeric to numerically, -1 if there is on numeric tail. */ private static long extractNumericKey( final String s ) { // count how many trailing digits there are int digits = 0; for ( int i = s.length() - 1; i >= 0; i-- ) { // scan backwards to find first non-digit from the right. char c = s.charAt( i ); if ( ( '0' <= c && c <= '9' ) ) { digits++; } else { break; } } // we can't deal with more than 14 digits, so we treat excess as alpha. digits = Math.min( digits, 14 ); if ( digits <= 0 ) { return -1; } else { return Long.parseLong( s.substring( s.length() - digits ) ); } } /** * @param fromFile ini file to read * @param encoding encoding to read the file with. Cannot be void. * * @throws IOException if problem reading file or decoding it. */ private static void parse( final File fromFile, final Charset encoding ) throws IOException { // O P E N ; final BufferedReader br = EIO.getBufferedReader( fromFile, 4 * 1024, encoding ); // accumulate comments leading up to section or item while ( true ) { // R E A D line = br.readLine(); // line == null means EOF if ( line == null ) { break; } lineNumber++; line = line.trim(); if ( line.length() == 0 ) { pendingLeadComments.add( null ); } else { if ( inHeader ) { parseHeaderLine(); } else { parseBodyLine(); } } } // end while // deal with any comments tacked on the tail end of the file. if ( pendingLeadComments.size() > 0 ) { currentSection = new Section( pendingLeadComments, maxKey, null, lineNumber ); // ignore it unless it is actually carrying comments. if ( currentSection.leadComments != null ) { Section.addSection( currentSection ); } } // C L O S E br.close(); } // end parse /** * parse a line in the middle of the ini file */ private static void parseBodyLine() { char firstChar = line.charAt( 0 ); switch ( firstChar ) { case '[': int closeBracketPosition = line.indexOf( ']' ); if ( closeBracketPosition < 1 ) { throw new IllegalArgumentException( "missing ] on line " + lineNumber + " : " + line ); } final String sectionName = line.substring( 1, closeBracketPosition ); final String tailComment; final int semiPosition = line.lastIndexOf( ";" ); if ( semiPosition > closeBracketPosition ) { // include ; tailComment = line.substring( semiPosition ); } else { tailComment = null; } currentSection = new Section( pendingLeadComments, sectionName, tailComment, lineNumber ); pendingLeadComments.clear(); Section.addSection( currentSection ); break; case ';': // comment pendingLeadComments.add( line ); break; default: // item key=value final int equalsPosition = line.indexOf( '=' ); if ( equalsPosition < 0 ) { // pure key no equals Item item = new Item( pendingLeadComments, line, null, null, lineNumber ); currentSection.addItem( item ); pendingLeadComments.clear(); } else if ( equalsPosition == 0 ) { throw new IllegalArgumentException( "missing key on line " + lineNumber + " : " + line ); } // missing value is ok. else { // normal key = value final String key = line.substring( 0, equalsPosition ).trim(); String value = line.substring( equalsPosition + 1 ).trim(); final String itemTailComment; // ignore embedded ;, treat as part of value final int isolatedSemiPosition = value.lastIndexOf( " ;" ); if ( isolatedSemiPosition >= 0 ) { // include ; itemTailComment = value.substring( isolatedSemiPosition + 1 ).trim(); value = value.substring( 0, isolatedSemiPosition ).trim(); } else { itemTailComment = null; } final Item item = new Item( pendingLeadComments, key, value, itemTailComment, lineNumber ); // guaranteed by parseHeaderLine to be non-null. currentSection.addItem( item ); pendingLeadComments.clear(); } break; } // end switch } // end parseBodyLine /** * parse the first line after the header commentary. */ private static void parseFirstBodyLine() { inHeader = false; // dummy Section to hold items without a section and initial // commentary // will sort to top. currentSection = new Section( pendingLeadComments, "", "", lineNumber ); // if it is carrying no comments, we throw it away. if ( currentSection.leadComments != null ) { Section.addSection( currentSection ); } // treat lead comments as belonging to dummy not first // section. pendingLeadComments.clear(); parseBodyLine(); } /** * parse a header commentary line of the ini file */ private static void parseHeaderLine() { // accumulate leading lines, as if there were comments. // up until first key= or [ final char firstChar = line.charAt( 0 ); switch ( firstChar ) { case '[': parseFirstBodyLine(); break; case ';': pendingLeadComments.add( line ); break; default: if ( line.indexOf( "=" ) > 0 ) { parseFirstBodyLine(); } else { // pure keyword, treat as header commentary pendingLeadComments.add( line ); } break; } // end switch } // end parseHeaderLine /** * remove leading and trailing blank comments.
Trim each indivdiual commment too. * * @param leadComments comments ahead of a section or item * * @return pruned Strings */ private static ArrayList pruneComments( final List leadComments ) { if ( leadComments == null ) { return null; } else { ArrayList tidiedLeadComments = new ArrayList<>( leadComments ); // trim each comment int size = tidiedLeadComments.size(); for ( int i = 0; i < size; i++ ) { final String comment = tidiedLeadComments.get( i ); if ( comment != null ) { tidiedLeadComments.set( i, comment.trim() ); } } // prune trailing blank lines while ( ( size = tidiedLeadComments.size() ) > 0 && ST.isEmpty( tidiedLeadComments.get( size - 1 ) ) ) { tidiedLeadComments.remove( size - 1 ); } // prune leading blank line while ( tidiedLeadComments.size() > 0 && ST.isEmpty( tidiedLeadComments.get( 0 ) ) ) { tidiedLeadComments.remove( 0 ); } if ( tidiedLeadComments.size() == 0 ) { tidiedLeadComments = null; } return tidiedLeadComments; } // end else } // end pruneComments /** * command line utility. Tidies the file and renames the original to xxx.ini.old. * * @param args name of ini file to validate/tidy, followed by the encoding. */ public static void main( final String[] args ) { if ( !( 1 <= args.length && args.length <= 2 ) ) { err.println( "Ini needs filename and optional encoding on the command line." ); System.exit( 2 ); } final String filename = args[ 0 ]; if ( !filename.endsWith( ".ini" ) ) { err.println( "Ini only works on *.ini files, not ones like " + filename ); System.exit( 2 ); } final String encoding; if ( args.length >= 2 ) { encoding = args[ 1 ]; } else { encoding = Charset.defaultCharset().name(); } final String lineSeparator = System.getProperty( "line.separator" ); try { parse( new File( filename ), Charset.forName( encoding ) ); if ( DEBUGGING ) { out.println( "parsed" ); } Section.sortSections(); if ( DEBUGGING ) { out.println( "sorted" ); } Section.dupCheckSections(); // figure out which sections and items need a blank line in front of // them. Section.calcNeedsLeadBlankLines(); if ( DEBUGGING ) { out.println( "where blank lines go calculated" ); } // emit sorted sections each with its items, including dummy at // front. Write on top of original file. final File original = new File( filename ); // rename the original to xxx.ini.old final File old = new File( filename + ".old" ); HunkIO.deleteAndRename( original, old ); // write out ini file on top of old one, using requested encoding // and system default line separators. // now we have saved original, we can write directly to original name. final FileOutputStream fos = new FileOutputStream( original ); final OutputStreamWriter eosw = new OutputStreamWriter( fos, encoding ); final BufferedWriter bw = new BufferedWriter( eosw, 64 * 1024/* buffsize */ ); for ( Section section : Section.sections ) { if ( lineSeparator.equals( "\n" ) ) { // \n is fine as is bw.write( section.toString() ); } else { // convert \n back to platform, \r\n for windows bw.write( section.toString().replace( "\n", lineSeparator ) ); } } bw.close(); } catch ( IllegalArgumentException e ) { err.println(); e.printStackTrace( err ); err.println( "Malformed " + filename + " left as is." ); err.println(); System.exit( 1 ); } catch ( UnsupportedEncodingException e ) { err.println( "Encoding " + encoding + " is not supported." ); System.exit( 1 ); } catch ( IOException e ) { err.println( "Problems reading file : " + filename ); err.println( e.getMessage() ); System.exit( 1 ); } out.println( filename + " successfully tidied." ); System.exit( 0 ); } // end main /** * represents a key=value pair with possible tail comment *

* created with Intellij Idea * * @author Roedy Green, Canadian Mind Products */ static final class Item implements Comparable { /** * commments prior to this item.
Possibly null. May contain "" blanks lines and null to represent blank * lines.
Strings do not contain any line terminators.
Comment lines retain lead # if there is one. If * there are no comments this will be null. */ final ArrayList leadComments; /** * lead alphabetic part of the Item key for sort. If none is "". */ final String alphaKey; /** * trimmed key, without = */ final String key; /** * trimmed comment including ; but no \n. Possibly empty or null. */ final String tailComment; /** * trimmed value */ final String value; /** * where in source this section key-value from */ final int lineNumber; /** * trailing numeric part of the Item key for sort. If none is -1. */ final long numericKey; /** * true if this item needs a leading blank line when printed */ boolean needsLeadBlankLine = false; /** * constructor * * @param leadComments possibly null. comments prior to itime . Strings do not contain line terminators * @param key name of key, will be trimmed. * @param value value associated with key, will be trimed. Null means was no prior = empty means was * prior = but nothing following. * @param tailComment without trailing \n, possible empty or null contains lead ; * @param lineNumber line number in original source where Item found */ Item( final List leadComments, final String key, final String value, final String tailComment, final int lineNumber ) { this.leadComments = pruneComments( leadComments ); this.key = key.trim(); // key keys to help us sort. this.alphaKey = extractAlphaKey( this.key ); this.numericKey = extractNumericKey( this.key ); if ( value == null ) { this.value = null; } else { this.value = value.trim(); } if ( tailComment == null || tailComment.length() == 0 ) { this.tailComment = null; } else { this.tailComment = tailComment.trim(); } this.lineNumber = lineNumber; } /** * Compare alphabetically with numeric tie breaker. * Defines default the sort order for Item Objects. * Compare this Item with another Item. * Compares alphaKey then numericKey. * Informally, returns (this-other) or +ve if this is more positive than other. * * @param other other Item to compare with this one * * @return +ve if this>other, 0 if this==other, -ve if this<other */ public final int compareTo( final Item other ) { final int diff = this.alphaKey.compareToIgnoreCase( other.alphaKey ); if ( diff != 0 ) { return diff; } return Long.signum( this.numericKey - other.numericKey ); } /** * Produce a string that represents this Item * * @return tidied up item */ public String toString() { final StringBuilder sb = new StringBuilder( 200 ); // Ideally would start with a blank line if this item had a comment // OR the preceding item had a comment. // There is no easy way to discover the previous comment. if ( needsLeadBlankLine ) { sb.append( '\n' ); } if ( leadComments != null ) { // items with comments are separated with extra blank line for ( String comment : leadComments ) { if ( comment != null ) { sb.append( comment );// includes ; but not final \n } sb.append( '\n' ); } } sb.append( key ); if ( value != null ) { sb.append( '=' ); sb.append( value ); } if ( tailComment != null ) { sb.append( ' ' ); sb.append( tailComment );// includes ; } sb.append( '\n' ); return sb.toString(); } } // end Item /** * describes one section of the ini file. *

* created with Intellij Idea * * @author Roedy Green, Canadian Mind Products */ static final class Section implements Comparable

{ /** * initial size for Arraylist of all sections. */ static final int ESTIMATED_MAX_KEYS_PER_SECTION = 50; /** * initial size for ArrayL9ist of all keys in a section. */ static final int ESTIMATED_MAX_SECTIONS = 30; /** * all sections, eventually sorted in order. */ static final ArrayList
sections = new ArrayList<>( ESTIMATED_MAX_SECTIONS ); /** * dummy item used in deduping. Should not match anything. */ static final Item dummyItem = new Item( null, "", "", "", 0 ); /** * dummy seciton used in deduping. Should not match anything. */ static final Section dummySection = new Section( null, "", null, 0 ); /** * key-value pairs for this section, eventually sorted in order. */ final ArrayList items = new ArrayList<>( ESTIMATED_MAX_KEYS_PER_SECTION ); /** * commments prior to this section.
Possibly null. May contain "" blanks lines and null to represent blank * lines.
Strings do not contain any line terminators.
Comment lines retain lead # if there is one. */ final ArrayList leadComments; /** * lead alphabetic part of the section name for sort. If none is "". */ final String alphaSectionName; /** * trimmed name between [] not including [] */ final String sectionName; /** * trimmed comment including ; but no \n. Possibly empty or null. */ final String tailComment; /** * where in source this section came from */ final int lineNumber; /** * trailing numeric part of the section name for sort. If none is -1. */ final long numericSectionName; /** * true if this section needs a leading blank line when printed. All but the first do. */ boolean needsLeadBlankLine = true; /** * constructor * * @param leadComments possibly null. comments prior to section. Strings do not contain line terminators * @param sectionName name of section * @param tailComment without trailing \n, * possible empty or null contains lead ; * @param lineNumber line number in file of the [section] */ Section( final List leadComments, final String sectionName, final String tailComment, final int lineNumber ) { this.leadComments = pruneComments( leadComments ); this.sectionName = sectionName.trim(); // key keys to help us sort. this.alphaSectionName = extractAlphaKey( this.sectionName ); this.numericSectionName = extractNumericKey( this.sectionName ); if ( DEBUGGING ) { out.println( this.sectionName + ":" + this.alphaSectionName + ":" + this.numericSectionName ); } if ( tailComment == null || tailComment.length() == 0 ) { this.tailComment = null; } else { this.tailComment = tailComment.trim(); } this.lineNumber = lineNumber; } /** * add a section * * @param section Section to add to list of all sections */ static void addSection( final Section section ) { /* * don't worry about dups until sorted. */ sections.add( section ); } /** * calc whether we need blank lines prior to each section. */ static void calcNeedsLeadBlankLines() { // all lines need a blank, except the first sections.get( 0 ).needsLeadBlankLine = false; // rest are already set true // Item gets lead blank if this or previous had comments. for ( Section section : sections ) { // treat [section] as if it were an item without lead comments boolean prevHadComments = false; for ( Item item : section.items ) { boolean hasComments = item.leadComments != null; item.needsLeadBlankLine = hasComments || prevHadComments; prevHadComments = hasComments; } } } /** * check for duplicate sections/duplicate keys * * @return true if all went ok. */ static boolean dupCheckSections() { boolean howSuccessful = true; Section prevSection = dummySection; for ( Section section : sections ) { if ( section.equals( prevSection ) ) { err.println( section.lineNumber + " Duplicate section : [" + section.sectionName + "]" ); howSuccessful = false; } else if ( section.sectionName.equalsIgnoreCase( prevSection.sectionName ) ) { err.println( section.lineNumber + " Near duplicate Section : [" + section.sectionName + "]" ); } prevSection = section; Item prevItem = dummyItem; // inner for each item in section for ( Item item : section.items ) { if ( item.key.equals( prevItem.key ) ) { err.println( item.lineNumber + " Duplicate item : [" + section.sectionName + "] " + item.key ); howSuccessful = false; } else if ( item.key.equalsIgnoreCase( prevItem.key ) ) { err.println( item.lineNumber + " Near duplicate item : [" + section.sectionName + "] " + item.key ); } prevItem = item; } // end inner for } // end outer for return howSuccessful; } /** * sort sections, and within each section, sort the keys */ static void sortSections() { Collections.sort( sections ); for ( Section section : sections ) { Collections.sort( section.items ); } } // end sortSections /** * add an item to this section * * @param item item to add to this section */ void addItem( final Item item ) { /* * don't worry about dups until sorted. */ items.add( item ); } /** * Sort by section name, with a numeric tie breaker. * Defines default the sort order for Section Objects. * Compare this Section with another Section. * Compares alphaSectionName then numericSectionName. * Informally, returns (this-other) or +ve if this is more positive than other. * * @param other other Section to compare with this one * * @return +ve if this>other, 0 if this==other, -ve if this<other */ public final int compareTo( final Section other ) { final int diff = this.alphaSectionName.compareToIgnoreCase( other.alphaSectionName ); if ( diff != 0 ) { return diff; } return Long.signum( this.numericSectionName - other.numericSectionName ); } /** * Produce a string that represents this Section and all its child Items * * @return tidied up item */ public String toString() { final StringBuilder sb = new StringBuilder( 2000 ); // start with a blank line for all but the first Section if ( needsLeadBlankLine ) { sb.append( '\n' ); } if ( leadComments != null ) { for ( String comment : leadComments ) { if ( comment != null ) { sb.append( comment );// includes ; but not final \n } sb.append( '\n' ); } } if ( !ST.isEmpty( sectionName ) && !sectionName.equals( maxKey ) ) { sb.append( '[' ); sb.append( sectionName ); sb.append( "]" ); if ( tailComment != null ) { sb.append( ' ' ); sb.append( tailComment );// includes ; } sb.append( '\n' ); } for ( Item item : items ) { sb.append( item.toString() ); } return sb.toString(); } } // end Section } // end ini