main
John Lauer Keep Codex turns running across editor reloads; fix hidden-tab startup and composer fade in the optional package 4307d94 1mo ago
"""Edit one top-level JSONC setting while preserving comments and other bytes."""
import json
import re


def masked(text):
    # Keep offsets unchanged while removing comments and trailing commas.
    pattern = r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*[\s\S]*?\*/'
    clean = re.sub(pattern, lambda m: m[0] if m[0].startswith('"') else
                   ''.join('\n' if c == '\n' else ' ' for c in m[0]), text)
    return re.sub(r'"(?:\\.|[^"\\])*"|,(?=\s*[}\]])',
                  lambda m: ' ' if m[0] == ',' else m[0], clean)


def entries(text):
    clean = masked(text)
    parsed = json.loads(clean)
    if not isinstance(parsed, dict):
        raise ValueError('VS Code settings must be an object')
    decoder = json.JSONDecoder()
    pos = clean.index('{') + 1
    found = []
    while True:
        while clean[pos].isspace() or clean[pos] == ',': pos += 1
        if clean[pos] == '}': break
        start = pos
        key, pos = decoder.raw_decode(clean, pos)
        while clean[pos].isspace(): pos += 1
        if clean[pos] != ':': raise ValueError('Expected setting colon')
        pos += 1
        while clean[pos].isspace(): pos += 1
        value_start = pos
        value, pos = decoder.raw_decode(clean, pos)
        found.append((key, value, start, value_start, pos))
    if len({e[0] for e in found}) != len(found):
        raise ValueError('Duplicate settings keys; preserving file for review')
    return found, pos


def change(text, key, value=None, remove=False):
    items, closing = entries(text)
    match = next((e for e in items if e[0] == key), None)
    if match:
        _, old, start, begin, end = match
        if not remove:
            return text[:begin] + json.dumps(value) + text[end:]
        # Remove the property and its following comma if present; otherwise
        # remove the preceding separator. Preserve comments between properties.
        clean = re.sub(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*[\s\S]*?\*/',
                       lambda m: m[0] if m[0].startswith('"') else ' ' * len(m[0]), text)
        after = end
        while clean[after].isspace(): after += 1
        if clean[after] == ',':
            return text[:start] + text[end:after] + text[after + 1:]
        before = start - 1
        while clean[before].isspace(): before -= 1
        if clean[before] == ',':
            return text[:before] + text[before + 1:start] + text[end:]
        return text[:start] + text[end:]
    if remove: return text
    # Insert immediately after the last value, keeping trailing comments and
    # an existing trailing comma attached to the resulting last property.
    at = items[-1][4] if items else text.index('{') + 1
    addition = (',' if items else '') + '\n  ' + json.dumps(key) + ': ' + json.dumps(value)
    return text[:at] + addition + text[at:]