001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.core;
020
021import org.jpos.iso.ISOUtil;
022import org.jpos.util.Loggeable;
023import org.yaml.snakeyaml.Yaml;
024import org.yaml.snakeyaml.scanner.ScannerException;
025
026import java.io.*;
027import java.util.*;
028import java.util.concurrent.atomic.AtomicReference;
029import java.util.stream.Collectors;
030
031/**
032 * Manages environment-specific configuration for jPOS applications.
033 *
034 * <p>Environment provides property resolution with support for:
035 * <ul>
036 *     <li>YAML ({@code .yml}) and properties ({@code .cfg}) configuration files</li>
037 *     <li>Property expressions: {@code ${property.name}}</li>
038 *     <li>Default values: {@code ${property.name:default}}</li>
039 *     <li>Equality tests: {@code ${property.name=expected}}</li>
040 *     <li>Boolean negation: {@code ${!property.name}}</li>
041 *     <li>Prefix-specific lookups:
042 *         <ul>
043 *             <li>{@code $env{VAR}} - OS environment variable only</li>
044 *             <li>{@code $sys{prop}} - Java system property only</li>
045 *             <li>{@code $cfg{prop}} - Configuration file only</li>
046 *             <li>{@code $verb{text}} - Verbatim (no expansion)</li>
047 *         </ul>
048 *     </li>
049 *     <li>Nested expressions: {@code ${outer:${inner:default}}}</li>
050 * </ul>
051 *
052 * <p>The default property resolution order (for {@code ${prop}}) is:
053 * <ol>
054 *     <li>OS environment variable ({@code prop})</li>
055 *     <li>OS environment variable ({@code PROP} with dots replaced by underscores)</li>
056 *     <li>Java system property</li>
057 *     <li>Configuration file property</li>
058 * </ol>
059 *
060 * <p>Configuration is loaded from the directory specified by {@code jpos.envdir}
061 * (default: "cfg") with the filename from {@code jpos.env} (default: "default").
062 *
063 * @see EnvironmentProvider
064 */
065public class Environment implements Loggeable {
066    private static final String DEFAULT_ENVDIR = "cfg";         // default dir for the env file (relative to cwd), overridable with sys prop "jpos.envdir"
067
068    private static final String CFG_PREFIX = "cfg";
069    private static final String SYSTEM_PREFIX = "sys";
070    private static final String ENVIRONMENT_PREFIX = "env";
071
072    private static Environment INSTANCE;
073
074    private String name;
075    private String envDir;
076    private AtomicReference<Properties> propRef = new AtomicReference<>(new Properties());
077    private static String SP_PREFIX = "system.property.";
078    private static int SP_PREFIX_LENGTH = SP_PREFIX.length();
079    private String errorString;
080    private ServiceLoader<EnvironmentProvider> serviceLoader;
081    // Sentinel used to protect verbatim '$' so it is not expanded in later passes.
082    private static final String VERB_DOLLAR_SENTINEL = "\u0000$VERB_DOLLAR$\u0000";
083
084
085    static {
086        try {
087            INSTANCE = new Environment();
088        } catch (Throwable e) {
089            e.printStackTrace();
090            throw new RuntimeException(e);
091        }
092    }
093
094    protected static Map<String,String> notMap = new HashMap<>();
095    static {
096        notMap.put("false", "true");
097        notMap.put("true",  "false");
098        notMap.put("yes",   "no");
099        notMap.put("no",    "yes");
100    }
101
102    private Environment() throws IOException {
103        name = System.getProperty ("jpos.env");
104        name = name == null ? "default" : name;
105        envDir = System.getProperty("jpos.envdir", DEFAULT_ENVDIR);
106        serviceLoader = ServiceLoader.load(EnvironmentProvider.class);
107        readConfig ();
108    }
109
110    /**
111     * Returns the name of the current environment.
112     * Determined by the {@code jpos.env} system property, defaults to "default".
113     *
114     * @return the environment name
115     */
116    public String getName() {
117        return name;
118    }
119
120    /**
121     * Returns the directory where environment configuration files are located.
122     * Determined by the {@code jpos.envdir} system property, defaults to "cfg".
123     *
124     * @return the environment directory path
125     */
126    public String getEnvDir() {
127        return envDir;
128    }
129
130    /**
131     * Reloads the environment configuration from disk.
132     * Reads the {@code jpos.env} and {@code jpos.envdir} system properties
133     * and reloads the corresponding configuration files.
134     *
135     * @return the newly loaded Environment instance
136     * @throws IOException if an error occurs reading configuration files
137     */
138    public static Environment reload() throws IOException {
139        return (INSTANCE = new Environment());
140    }
141
142    /**
143     * Returns the singleton Environment instance.
144     *
145     * @return the current Environment
146     */
147    public static Environment getEnvironment() {
148        return INSTANCE;
149    }
150
151    /**
152     * Resolves a property expression using the singleton Environment.
153     * If the property cannot be resolved, returns the original expression.
154     *
155     * @param p the property expression to resolve (e.g., "${my.property}")
156     * @return the resolved value, or the original expression if unresolved
157     * @see #getProperty(String)
158     */
159    public static String get (String p) {
160        return getEnvironment().getProperty(p, p);
161    }
162
163    /**
164     * Resolves the given expression using the current {@link Environment}
165     * instance, applying the standard priority resolution rules.
166     *
167     * @param p the expression or literal value to resolve; may contain
168     *          placeholders using the {@code ${...}} syntax
169     * @return the resolved value after applying environment priority rules.
170     *
171     * @see Environment#resolveWithPriority(String)
172     * @see #getEnvironment()
173     */
174    public static String resolve (String p) {
175        return getEnvironment().resolveWithPriority(p);
176    }
177    /**
178     * Resolves a property expression using the singleton Environment.
179     * If the property cannot be resolved, returns the specified default.
180     *
181     * @param p the property expression to resolve
182     * @param def the default value to return if the property is unresolved
183     * @return the resolved value, or {@code def} if unresolved
184     * @see #getProperty(String, String)
185     */
186    public static String get (String p, String def) {
187        return getEnvironment().getProperty(p, def);
188    }
189
190    /**
191     * Resolves a property expression with a default fallback.
192     *
193     * @param p the property expression to resolve
194     * @param def the default value to return if the property resolves to null
195     * @return the resolved value, or {@code def} if null
196     * @see #getProperty(String)
197     */
198    public String getProperty (String p, String def) {
199        String s = getProperty (p);
200        return s != null ? s : def;
201    }
202
203    /**
204     * Returns any error message from the last configuration load attempt.
205     * Typically set when YAML parsing fails.
206     *
207     * @return the error message, or null if no error occurred
208     */
209    public String getErrorString() {
210        return errorString;
211    }
212
213    /**
214     * If property name has the pattern <code>${propname}</code>, this method will
215     *
216     * <ul>
217     *     <li>Attempt to get it from an operating system environment variable called 'propname'</li>
218     *     <li>If not present, it will try to pick it from the Java system.property</li>
219     *     <li>If not present either, it will try the target environment (either <code>.yml</code> or <code>.cfg</code></li>
220     *     <li>Otherwise it returns null</li>
221     * </ul>
222     *
223     * The special pattern <code>$env{propname}</code> would just try to pick it from the OS environment.
224     * <code>$sys{propname}</code> will just try to get it from a System.property and
225     * <code>$verb{propname}</code> will return a verbatim copy of the value.
226     *
227     * @param s property name
228     * @return property value
229     */
230    public String getProperty (String s) {
231        if (s == null)
232            return null;
233
234        // Fast-path: no possible expressions.
235        if (s.indexOf('$') < 0)
236            return s;
237
238        if (s.startsWith("$verb{")) {
239            int closeIdx = s.indexOf('}', 6); // first '}' after "$verb{"
240            if (closeIdx == s.length() - 1 && s.length() > "$verb{}".length()) {
241                return s.substring(6, closeIdx);
242            }
243        }
244        String r = s;
245
246        // Bounded expansion + cycle detection
247        final int MAX_EXPANSION_STEPS = 256;
248        final int MAX_SEEN_STATES = 2048;
249
250        final Set<String> seen = new HashSet<>();
251        seen.add(r);
252
253        for (int step = 0; step < MAX_EXPANSION_STEPS; step++) {
254            String next = expandOnce(r);
255            if (Objects.equals(next, r))
256                break;
257            if (!seen.add(next))
258                break;
259            if (seen.size() > MAX_SEEN_STATES)
260                break;
261            r = next;
262        }
263        r = unescapeVerbatimDollars(r);
264        // If no expansion happened (result equals input), return null so caller's default can be used
265        return r.equals(s) ? null : r;
266    }
267
268    /**
269     * Expands all occurrences of $...{...} in the input string in a single linear pass.
270     * This method is deliberately regex-free to avoid backtracking / stack overflow.
271     */
272    private String expandOnce(String in) {
273        StringBuilder out = new StringBuilder(in.length());
274        int i = 0;
275        boolean changed = false;
276
277        while (i < in.length()) {
278            char ch = in.charAt(i);
279            if (ch != '$') {
280                out.append(ch);
281                i++;
282                continue;
283            }
284
285            // Inline $verb{...}: verbatim payload, no expansion even across passes, Terminate at the first '}'
286            if (in.startsWith("$verb{", i)) {
287                int closeIdx = in.indexOf('}', i + 6);
288                if (closeIdx != -1) {
289                    String payload = in.substring(i + 6, closeIdx);
290                    out.append(escapeVerbatimDollars(payload));
291                    i = closeIdx + 1;
292                    changed = true;
293                    continue;
294                }
295                // If no closing brace found, fall through and treat '$' as literal (via parseToken failure path).
296            }
297            Token t = parseToken(in, i);
298            if (t == null) {
299                // Not a valid token; treat '$' as literal.
300                out.append('$');
301                i++;
302                continue;
303            }
304
305            String replacement = evaluateToken(t, in.substring(t.start(), t.endExclusive()));
306            if (replacement == null) {
307                return in;
308            }
309
310            out.append(replacement);
311            i = t.endExclusive();
312            changed = true;
313        }
314
315        return changed ? out.toString() : in;
316    }
317
318    private boolean isKnownPrefix(String prefix) {
319        return prefix.isEmpty()
320           || CFG_PREFIX.equals(prefix)
321           || SYSTEM_PREFIX.equals(prefix)
322           || ENVIRONMENT_PREFIX.equals(prefix);
323    }
324
325    private String evaluateToken(Token t, String originalTokenText) {
326        if (!isKnownPrefix(t.prefix())) {
327            return null; // expandOnce() will return the original input unchanged.
328        }
329        boolean negated = t.negated();
330        String defValueLiteral = null;
331
332        String resolved = resolveByPrefix(t.prefix(), t.property());
333
334        if (t.op() == '=') {
335            String rhsResolved = t.rhs() == null ? "" : getProperty(t.rhs());
336            resolved = (resolved != null && resolved.equals(rhsResolved)) ? "true" : "false";
337        } else if (t.op() == ':') {
338            // Default case. Keep literal default as-is; outer expansion passes will dereference it.
339            if (resolved == null) {
340                defValueLiteral = t.rhs(); // may be null or empty, both are meaningful
341                resolved = defValueLiteral;
342            }
343        } else {
344            // No op, resolved stays as-is (may be null).
345        }
346
347        if (resolved != null) {
348            resolved = applyProviderTransformations(resolved);
349            resolved = applyNegation(resolved, negated, defValueLiteral == null);
350            return resolved;
351        }
352        // Undefined/unresolved and no default.
353        // If negated and unresolved => "true", otherwise token removal is NOT desired
354        if (negated) {
355            return "true";
356        }
357        // prefixed lookups resolve to empty when missing.
358        return t.prefix().isEmpty() ? originalTokenText : "";
359    }
360
361    private String resolveByPrefix(String prefix, String prop) {
362        return switch (prefix) {
363            case CFG_PREFIX -> propRef.get().getProperty(prop, null);
364            case SYSTEM_PREFIX -> System.getProperty(prop);
365            case ENVIRONMENT_PREFIX -> System.getenv(prop);
366            default -> prefix.isEmpty() ? resolveWithPriority(prop) : null;
367        };
368    }
369
370    /**
371     * Resolves a property using the default priority: ENV > System property > cfg file.
372     */
373    private String resolveWithPriority(String prop) {
374        String r = System.getenv(prop);
375        if (r == null) r = System.getenv(prop.replace('.', '_').toUpperCase());
376        if (r == null) r = System.getProperty(prop);
377        if (r == null) r = propRef.get().getProperty(prop);
378        return r;
379    }
380
381    /**
382     * Applies EnvironmentProvider transformations. Some providers may return a value
383     * that still begins with the same provider prefix (e.g., obf(obf(x))).
384     * In that case, transformations are applied repeatedly until no provider matches
385     * or a safety limit is reached.
386     */
387    private String applyProviderTransformations(String value) {
388        String v = value;
389        if (v == null)
390            return null;
391
392        final int MAX_PROVIDER_STEPS = 32; // safety against misbehaving providers
393
394        for (int step = 0; step < MAX_PROVIDER_STEPS; step++) {
395            boolean changed = false;
396
397            for (EnvironmentProvider p : serviceLoader) {
398                String prefix = p.prefix();
399                int prefixLen = prefix.length();
400                if (v.length() > prefixLen && v.startsWith(prefix)) {
401                    String next = p.get(v.substring(prefixLen));
402                    if (next == null) {
403                        // Be conservative: if provider returns null, stop transforming and return current.
404                        return v;
405                    }
406                    if (!Objects.equals(next, v)) {
407                        v = next;
408                        changed = true;
409                    } else {
410                        // No progress; avoid tight loops.
411                        return v;
412                    }
413                    break; // restart from first provider on the new value
414                }
415            }
416
417            if (!changed)
418                return v;
419        }
420
421        // Safety stop: return the last value we reached.
422        return v;
423    }
424
425    /**
426     * Applies boolean negation if the token was negated and it's not a default literal.
427     */
428    private String applyNegation(String value, boolean negated, boolean canNegate) {
429        if (negated && canNegate) {
430            String normalized = value.trim().toLowerCase();
431            return notMap.getOrDefault(normalized, value);
432        }
433        return value;
434    }
435
436    /**
437     * Token parsed from $...{...}.
438     * @param start position of '$' in the input string
439     * @param endExclusive index just after the closing '}'
440     * @param prefix "", "cfg", "sys", "env", or unknown (unknown handled earlier)
441     * @param negated true if property name started with '!'
442     * @param property the property name (without '!' prefix)
443     * @param op operator: 0 (none), ':', or '='
444     * @param rhs default value or equals RHS (may be null/empty)
445     */
446    private record Token(int start, int endExclusive, String prefix, boolean negated, String property, char op, String rhs) {}
447
448    private Token parseToken(String s, int dollarPos) {
449        final int n = s.length();
450        int i = dollarPos;
451
452        if (i >= n || s.charAt(i) != '$')
453            return null;
454        i++; // skip '$'
455
456        // prefix: [\w]* (may be empty) until '{'
457        int prefixStart = i;
458        while (i < n && isWordChar(s.charAt(i))) {
459            i++;
460        }
461        if (i >= n || s.charAt(i) != '{') {
462            return null;
463        }
464        String prefix = s.substring(prefixStart, i);
465        i++; // skip '{'
466
467        // property name: [-!\w.]+ (but stop on ':' '=' or '}' )
468        if (i >= n)
469            return null;
470
471        boolean negated = false;
472        int propStart = i;
473
474        // read property characters
475        while (i < n) {
476            char c = s.charAt(i);
477            if (c == ':' || c == '=' || c == '}')
478                break;
479            if (!isPropChar(c))
480                return null;
481            i++;
482        }
483
484        if (i == propStart) // empty property name is not valid
485            return null;
486
487        String prop = s.substring(propStart, i);
488        if (prop.startsWith("!")) {
489            negated = true;
490            prop = prop.substring(1);
491            if (prop.isEmpty())
492                return null;
493        }
494
495        // operator?
496        if (i >= n)
497            return null;
498
499        char op = 0;
500        String rhs = null;
501
502        char c = s.charAt(i);
503        if (c == '}' ) {
504            // simple ${prop}
505            return new Token(dollarPos, i + 1, prefix, negated, prop, op, rhs);
506        } else if (c == ':' || c == '=') {
507            op = c;
508            i++; // skip op
509            int rhsStart = i;
510
511            // find matching '}' for this token, supporting nested $...{...} in RHS
512            int end = findTokenEnd(s, dollarPos);
513            if (end < 0)
514                return null;
515
516            // RHS is content between op and final '}' of this token.
517            rhs = s.substring(rhsStart, end);
518
519            return new Token(dollarPos, end + 1, prefix, negated, prop, op, rhs);
520        } else {
521            // unexpected character
522            return null;
523        }
524    }
525
526    /**
527     * Returns the index of the '}' that closes the token beginning at dollarPos, or -1 if not found.
528     * Supports nested tokens inside defaults/RHS by counting nested "$...{" starts.
529     */
530    private int findTokenEnd(String s, int dollarPos) {
531        final int n = s.length();
532        int i = dollarPos;
533
534        // We know s[dollarPos] == '$'. Find the first '{' that starts this token.
535        i++; // after '$'
536        while (i < n && isWordChar(s.charAt(i))) i++;
537        if (i >= n || s.charAt(i) != '{') return -1;
538        i++; // after the '{' of the outer token
539
540        int depth = 0; // nested token depth within RHS
541        while (i < n) {
542            char ch = s.charAt(i);
543
544            if (ch == '$' && looksLikeTokenStart(s, i)) {
545                // consume "$" + prefix + "{", and count as nested
546                depth++;
547                i++; // after '$'
548                while (i < n && isWordChar(s.charAt(i))) i++;
549                if (i < n && s.charAt(i) == '{') {
550                    i++; // after '{'
551                    continue;
552                } else {
553                    // Should not happen because looksLikeTokenStart checked it, but be defensive.
554                    continue;
555                }
556            }
557
558            if (ch == '}') {
559                if (depth == 0) {
560                    return i;
561                }
562                depth--;
563                i++;
564                continue;
565            }
566
567            i++;
568        }
569        return -1;
570    }
571
572    private boolean looksLikeTokenStart(String s, int pos) {
573        final int n = s.length();
574        if (pos < 0 || pos >= n || s.charAt(pos) != '$')
575            return false;
576
577        int i = pos + 1;
578
579        // prefix: [\w]* (may be empty)
580        while (i < n && isWordChar(s.charAt(i))) i++;
581
582        // must have '{'
583        if (i >= n || s.charAt(i) != '{')
584            return false;
585
586        int j = i + 1; // first char inside '{'
587        if (j >= n)
588            return false;
589
590        char first = s.charAt(j);
591
592        // Reject empty property name: "${}"
593        // Reject operator immediately: "${:...}" or "${=...}"
594        // In general, require at least one property-name char.
595        if (first == '}' || first == ':' || first == '=')
596            return false;
597
598        // And it must be a valid property-name char
599        return isPropChar(first);
600    }
601    
602    private static boolean isWordChar(char c) {
603        return (c == '_' ||
604          (c >= '0' && c <= '9') ||
605          (c >= 'A' && c <= 'Z') ||
606          (c >= 'a' && c <= 'z'));
607    }
608
609    private static boolean isPropChar(char c) {
610        return isWordChar(c) || c == '.' || c == '-' || c == '!';
611    }
612
613    @SuppressWarnings("unchecked")
614    private void readConfig () throws IOException {
615        if (name != null) {
616            Properties properties = new Properties();
617            String[] names = ISOUtil.commaDecode(name);
618            for (String n: names) {
619                if (!readYAML(n, properties))
620                    readCfg(n, properties);
621            }
622            extractSystemProperties();
623            propRef.get().put ("jpos.env", name);
624            propRef.get().put ("jpos.envdir", envDir);
625        }
626    }
627
628    private void extractSystemProperties() {
629        Properties properties = propRef.get();
630        properties
631          .stringPropertyNames()
632          .stream()
633          .filter(e -> e.startsWith(SP_PREFIX))
634          .forEach(prop -> System.setProperty(
635            prop.substring(SP_PREFIX_LENGTH), getProperty ((String) properties.get(prop)))
636          );
637    }
638
639    private boolean readYAML (String n, Properties properties) throws IOException {
640        errorString = null;
641        File f = new File(envDir + "/" + n + ".yml");
642        if (f.exists() && f.canRead()) {
643            try (InputStream fis = new FileInputStream(f)) {
644                Yaml yaml = new Yaml();
645                Iterable<Object> document = yaml.loadAll(fis);
646                document.forEach(d -> {
647                    flat(properties, null, (Map<String, Object>) d, false);
648                });
649                propRef.set(properties);
650                return true;
651            } catch (ScannerException e) {
652                errorString = "Environment (" + getName() + ") error " + e.getMessage();
653            }
654        }
655        return false;
656    }
657
658    private boolean readCfg (String n, Properties properties) throws IOException {
659        File f = new File(envDir + "/" + n + ".cfg");
660        if (f.exists() && f.canRead()) {
661            try (InputStream fis = new FileInputStream(f)) {
662                properties.load(new BufferedInputStream(fis));
663                propRef.set(properties);
664                return true;
665            }
666        }
667        return false;
668    }
669
670    /**
671     * Flattens a nested Map structure into a flat Properties object using dot notation.
672     * For example, a nested structure like {@code {server: {port: 8080}}} becomes
673     * the property {@code server.port=8080}.
674     *
675     * <p>List values are comma-encoded using {@link ISOUtil#commaEncode(String[])}.
676     *
677     * @param properties the Properties object to populate
678     * @param prefix the current key prefix (null for root level)
679     * @param c the Map to flatten
680     * @param dereference if true, resolve property expressions in string values
681     */
682    @SuppressWarnings("unchecked")
683    public static void flat (Properties properties, String prefix, Map<String,Object> c, boolean dereference) {
684        for (Map.Entry<String,Object> entry : c.entrySet()) {
685            String p = prefix == null ? entry.getKey() : (prefix + "." + entry.getKey());
686            if (entry.getValue() instanceof Map) {
687                flat(properties, p, (Map<String, Object>) entry.getValue(), dereference);
688            } else if (entry.getValue() instanceof List<?> listParams) {
689                List<String> list = listParams.stream()
690                  .map(Object::toString)
691                  .map(str -> dereference ? Environment.get(str) : str)
692                  .collect(Collectors.toList());
693                properties.put (p, ISOUtil.commaEncode(list.toArray(new String[0])));
694            } else {
695                Object obj = entry.getValue();
696                properties.put (p, (dereference && obj instanceof String ?
697                                    Environment.get((String) obj) :
698                                    entry.getValue().toString()));
699            }
700        }
701    }
702
703    @Override
704    public void dump(final PrintStream p, String indent) {
705        p.printf ("%s<environment name='%s' envdir='%s'>%n", indent, name, envDir);
706        Properties properties = propRef.get();
707        properties.stringPropertyNames()
708          .stream()
709          .sorted().
710          forEachOrdered(prop -> p.printf ("%s  %s=%s%n", indent, prop, properties.getProperty(prop)) );
711        p.printf ("%s</environment>%n", indent);
712    }
713
714    @Override
715    public String toString() {
716        StringBuilder sb = new StringBuilder();
717        if (name != null) {
718            sb.append(String.format("[%s]%n", name));
719            Properties properties = propRef.get();
720            properties.stringPropertyNames().stream().
721              forEachOrdered(prop -> {
722                  String s = properties.getProperty(prop);
723                  String ds = Environment.get(String.format("${%s}", prop)); // de-referenced string
724                  boolean differ = !s.equals(ds);
725                  sb.append(String.format ("  %s=%s%s%n",
726                    prop,
727                    s,
728                    differ ? " (*)" : ""
729                  )
730              );
731            });
732            if (serviceLoader.iterator().hasNext()) {
733                sb.append ("  providers:");
734                sb.append (System.lineSeparator());
735                for (EnvironmentProvider provider : serviceLoader) {
736                    sb.append(String.format("    %s%n", provider.getClass().getCanonicalName()));
737                }
738            }
739        }
740        return sb.toString();
741    }
742
743    private static String escapeVerbatimDollars(String s) {
744        return s == null ? null : s.replace("$", VERB_DOLLAR_SENTINEL);
745    }
746
747    private static String unescapeVerbatimDollars(String s) {
748        return s == null ? null : s.replace(VERB_DOLLAR_SENTINEL, "$");
749    }
750}