/** * Copyright (C) 2014 - present by OpenGamma Inc. and the OpenGamma group of companies * * Please see distribution for license. */ package com.opengamma.collect.io; import static java.util.stream.Collectors.toList; import java.io.IOException; import java.io.UncheckedIOException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Stream; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; import com.google.common.io.CharSource; import com.opengamma.collect.ArgChecker; /** * An INI file. * <p> * Represents an INI file together with the ability to parse it from a {@link CharSource}. * <p> * The INI file format used here is deliberately simple. * There are two elements - key-value pairs and sections. * <p> * The basic element is a key-value pair. * The key is separated from the value using the '=' symbol. * Duplicate keys are allowed. * For example 'key = value'. * <p> * All properties are grouped into named sections. * The section name occurs on a line by itself surrounded by square brackets. * Duplicate section names are not allowed. * For example '[section]'. * <p> * Keys, values and section names are trimmed. * Blank lines are ignored. * Whole line comments begin with hash '#' or semicolon ';'. * No escape format is available. * Lookup is case sensitive. * <p> * This example explains the format: * <pre> * # line comment * [foo] * key = value * * [bar] * key = value * month = January * </pre> * <p> * The aim of this class is to parse the basic format. * Interpolation of variables is not supported. */ public final class IniFile { /** * Section name used for chaining. */ private static final String CHAIN_SECTION = "chain"; /** * Property name used for priority. */ private static final String PRIORITY = "priority"; /** * Property name used for chaining. */ private static final String CHAIN_NEXT = "chainNextFile"; /** * Property name used for removing sections. */ private static final String CHAIN_REMOVE = "chainRemoveSections"; /** * The INI sections. */ private final ImmutableMap<String, PropertySet> sectionMap; //------------------------------------------------------------------------- /** * Parses the specified source as an INI file. * <p> * This parses the specified character source expecting an INI file format. * The resulting instance can be queried for each section in the file. * * @param source the INI file resource * @return the INI file * @throws UncheckedIOException if an IO error occurs * @throws IllegalArgumentException if the configuration is invalid */ public static IniFile of(CharSource source) { ArgChecker.notNull(source, "source"); try { Map<String, Multimap<String, String>> parsedIni = parse(source); ImmutableMap.Builder<String, PropertySet> builder = ImmutableMap.builder(); parsedIni.forEach((sectionName, sectionData) -> builder.put(sectionName, PropertySet.of(sectionData))); return new IniFile(builder.build()); } catch (IOException ex) { throw new UncheckedIOException(ex); } } //------------------------------------------------------------------------- /** * Returns a single INI file that is the chained combination of the inputs. * <p> * The result of this method is formed by chaining all the specified files together. * The files are combined using a simple algorithm defined in the '[chain]' section. * Firstly, the 'priority' value is used to sort the files, higher numbers have higher priority * All entries in the highest priority file are used * <p> * Once data from the highest priority file is included, the 'chainNextFile' property is examined. * If 'chainNextFile' is 'true', then the next file in the chain is considered. * The 'chainRemoveSections' property can be used to ignore specific sections from the files lower in the chain. * The chain process continues until the 'chainNextFile' is 'false', or all files have been combined. * * @param sources the INI file sources to read * @return the combined chained INI file * @throws UncheckedIOException if an IO error occurs * @throws IllegalArgumentException if the configuration is invalid */ public static IniFile ofChained(Stream<CharSource> sources) { ArgChecker.notNull(sources, "sources"); List<IniFile> files = sources .map(IniFile::of) .sorted(IniFile::compareByReversePriority) .collect(toList()); // combine files, based on chain flag Map<String, PropertySet> builder = new LinkedHashMap<>(); for (IniFile file : files) { // remove everything from lower priority files if not chaining if (Boolean.parseBoolean(file.getSection(CHAIN_SECTION).getValue(CHAIN_NEXT)) == false) { builder.clear(); } else { // remove sections from lower priority files builder.keySet().removeAll(file.getSection(CHAIN_SECTION).getValueList(CHAIN_REMOVE)); } // add entries, replacing existing data for (String sectionName : file.asMap().keySet()) { if (!sectionName.equals(CHAIN_SECTION)) { builder.merge(sectionName, file.getSection(sectionName), PropertySet::combinedWith); } } } return new IniFile(ImmutableMap.copyOf(builder)); } // sort by priority, lowest first private static int compareByReversePriority(IniFile a, IniFile b) { int priority1 = Integer.parseInt(a.getSection(CHAIN_SECTION).getValue(PRIORITY)); int priority2 = Integer.parseInt(b.getSection(CHAIN_SECTION).getValue(PRIORITY)); return Integer.compare(priority1, priority2); } //------------------------------------------------------------------------- // parses the INI file format private static Map<String, Multimap<String, String>> parse(CharSource source) throws IOException { ImmutableList<String> lines = source.readLines(); Map<String, Multimap<String, String>> ini = new LinkedHashMap<>(); Multimap<String, String> currentSection = null; int lineNum = 0; for (String line : lines) { lineNum++; line = line.trim(); if (line.length() == 0 || line.startsWith("#") || line.startsWith(";")) { continue; } if (line.startsWith("[") && line.endsWith("]")) { String sectionName = line.substring(1, line.length() - 1).trim(); if (ini.containsKey(sectionName)) { throw new IllegalArgumentException("Invalid INI file, duplicate section not allowed, line " + lineNum); } currentSection = ArrayListMultimap.create(); ini.put(sectionName, currentSection); } else if (currentSection == null) { throw new IllegalArgumentException("Invalid INI file, properties must be within a [section], line " + lineNum); } else { int equalsPosition = line.indexOf('='); if (equalsPosition < 0) { throw new IllegalArgumentException("Invalid INI file, expected key=value property, line " + lineNum); } String key = line.substring(0, equalsPosition).trim(); String value = line.substring(equalsPosition + 1).trim(); if (key.length() == 0) { throw new IllegalArgumentException("Invalid INI file, empty key, line " + lineNum); } currentSection.put(key, value); } } return ini; } //------------------------------------------------------------------------- /** * Restricted constructor. * * @param sectionMap the sections */ private IniFile(ImmutableMap<String, PropertySet> sectionMap) { this.sectionMap = sectionMap; } //------------------------------------------------------------------------- /** * Returns the set of keys of this INI file. * * @return the set of keys */ public ImmutableSet<String> keys() { return sectionMap.keySet(); } /** * Returns the INI file as a map. * <p> * The iteration order of the map matches that of the original file. * * @return the INI file sections */ public ImmutableMap<String, PropertySet> asMap() { return sectionMap; } //------------------------------------------------------------------------- /** * Checks if this INI file contains the specified section. * * @param name the section name * @return true if the section exists */ public boolean contains(String name) { ArgChecker.notNull(name, "name"); return sectionMap.containsKey(name); } /** * Gets a single section of this INI file. * <p> * This returns the section associated with the specified name. * If the section does not exist an exception is thrown. * * @param name the section name * @return the INI file section * @throws IllegalArgumentException if the section does not exist */ public PropertySet getSection(String name) { ArgChecker.notNull(name, "name"); if (contains(name) == false) { throw new IllegalArgumentException("Unknown INI file section: " + name); } return sectionMap.get(name); } //------------------------------------------------------------------------- /** * Checks if this INI file equals another. * <p> * The comparison checks the content. * * @param obj the other file, null returns false * @return true if equal */ @Override public boolean equals(Object obj) { if (obj == this) { return true; } if (obj instanceof IniFile) { return sectionMap.equals(((IniFile) obj).sectionMap); } return false; } /** * Returns a suitable hash code for the INI file. * * @return the hash code */ @Override public int hashCode() { return sectionMap.hashCode(); } /** * Returns a string describing the INI file. * * @return the descriptive string */ @Override public String toString() { return sectionMap.toString(); } }