xref: /aosp_15_r20/external/pigweed/pw_ide/py/pw_ide/editors.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Framework for configuring code editors for Pigweed projects.
15
16Editors and IDEs vary in the way they're configured and the options they
17provide for configuration. As long as an editor uses files we can parse to
18store its settings, this framework can be used to provide a consistent
19interface to managing those settings in the context of a Pigweed project.
20
21Ideally, we want to provide three levels of editor settings for a project:
22
23- User settings (specific to the user's checkout)
24- Project settings (included in source control, consistent for all users)
25- Default settings (defined by Pigweed)
26
27... where the settings on top can override (or cascade over) settings defined
28below.
29
30Some editors already provide mechanisms for achieving this, but in ways that
31are particular to that editor, and many other editors don't provide this
32mechanism at all. So we provide it in a uniform way here by adding a fourth
33settings level, active settings, which are the actual settings files the editor
34uses. Active settings are *built* (rather than edited or cloned) by looking for
35user, project, and default settings (which are defined by Pigweed and ignored
36by the editor) and combining them in the order described above. In this way,
37Pigweed can provide sensible defaults, projects can define additional settings
38to provide a uniform development experience, and users have the freedom to make
39their own changes.
40"""
41
42# TODO(chadnorvell): Import collections.OrderedDict when we don't need to
43# support Python 3.8 anymore.
44from collections import defaultdict
45from contextlib import contextmanager
46from dataclasses import dataclass
47import enum
48from hashlib import sha1
49import json
50from pathlib import Path
51from typing import (
52    Any,
53    Callable,
54    Generator,
55    Generic,
56    Literal,
57    OrderedDict,
58    Type,
59    TypeVar,
60)
61import yaml
62
63import json5  # type: ignore
64
65from pw_ide.settings import PigweedIdeSettings
66
67
68class _StructuredFileFormat:
69    """Base class for structured settings file formats."""
70
71    @property
72    def ext(self) -> str:
73        """The file extension for this file format."""
74        return 'null'
75
76    @property
77    def unserializable_error(self) -> Type[Exception]:
78        """The error class that will be raised when writing unserializable data.
79
80        This allows us to generically catch serialization errors without needing
81        to know which file format we're using.
82        """
83        return TypeError
84
85    def load(self, *args, **kwargs) -> OrderedDict:
86        raise ValueError(
87            f'Cannot load from file with {self.__class__.__name__}!'
88        )
89
90    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
91        raise ValueError(f'Cannot dump to file with {self.__class__.__name__}!')
92
93
94class JsonFileFormat(_StructuredFileFormat):
95    """JSON file format."""
96
97    @property
98    def ext(self) -> str:
99        return 'json'
100
101    def load(self, *args, **kwargs) -> OrderedDict:
102        """Load JSON into an ordered dict."""
103        # Load into an OrderedDict instead of a plain dict
104        kwargs['object_pairs_hook'] = OrderedDict
105        return json.load(*args, **kwargs)
106
107    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
108        """Dump JSON in a readable format."""
109        # Ensure the output is human-readable
110        kwargs['indent'] = 2
111        json.dump(data, *args, **kwargs)
112
113
114class Json5FileFormat(_StructuredFileFormat):
115    """JSON5 file format.
116
117    Supports parsing files with comments and trailing commas.
118    """
119
120    @property
121    def ext(self) -> str:
122        return 'json'
123
124    def load(self, *args, **kwargs) -> OrderedDict:
125        """Load JSON into an ordered dict."""
126        # Load into an OrderedDict instead of a plain dict
127        kwargs['object_pairs_hook'] = OrderedDict
128        return json5.load(*args, **kwargs)
129
130    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
131        """Dump JSON in a readable format."""
132        # Ensure the output is human-readable
133        kwargs['indent'] = 2
134        # Prevent unquoting keys that don't strictly need to be quoted
135        kwargs['quote_keys'] = True
136        json5.dump(data, *args, **kwargs)
137
138
139class YamlFileFormat(_StructuredFileFormat):
140    """YAML file format."""
141
142    @property
143    def ext(self) -> str:
144        return 'yaml'
145
146    @property
147    def unserializable_error(self) -> Type[Exception]:
148        return yaml.representer.RepresenterError
149
150    def load(self, *args, **kwargs) -> OrderedDict:
151        """Load YAML into an ordered dict."""
152        # This relies on the fact that in Python 3.6+, dicts are stored in
153        # order, as an implementation detail rather than by design contract.
154        data = yaml.safe_load(*args, **kwargs)
155        return dict_swap_type(data, OrderedDict)
156
157    def dump(self, data: OrderedDict, *args, **kwargs) -> None:
158        """Dump YAML in a readable format."""
159        # Ensure the output is human-readable
160        kwargs['indent'] = 2
161        # Always use the "block" style (i.e. the dict-like style)
162        kwargs['default_flow_style'] = False
163        # Don't infere with ordering
164        kwargs['sort_keys'] = False
165        # The yaml module doesn't understand OrderedDicts
166        data_to_dump = dict_swap_type(data, dict)
167        yaml.safe_dump(data_to_dump, *args, **kwargs)
168
169
170# Allows constraining to dicts and dict subclasses, while also constraining to
171# the *same* dict subclass.
172_DictLike = TypeVar('_DictLike', bound=dict)
173
174# Likewise, constrain to a specific dict subclass, but one that can be different
175# from that of _DictLike.
176_AnotherDictLike = TypeVar('_AnotherDictLike', bound=dict)
177
178
179def dict_deep_merge(
180    src: _DictLike,
181    dest: _DictLike,
182    ctor: Callable[[], _DictLike] | None = None,
183) -> _DictLike:
184    """Deep merge dict-like `src` into dict-like `dest`.
185
186    `dest` is mutated in place and also returned.
187
188    `src` and `dest` need to be the same subclass of dict. If they're anything
189    other than basic dicts, you need to also provide a constructor that returns
190    an empty dict of the same subclass.
191
192    This is only intended to support dicts of JSON-serializable values, i.e.,
193    numbers, booleans, strings, lists, and dicts, all of which will be copied.
194    All other object types will be rejected with an exception.
195    """
196    # Ensure that src and dest are the same type of dict.
197    # These kinds of direct class comparisons are un-Pythonic, but the invariant
198    # here really is that they be exactly the same class, rather than "same" in
199    # the polymorphic sense.
200    if dest.__class__ != src.__class__:
201        raise TypeError(
202            'Cannot merge dicts of different subclasses!\n'
203            f'src={src.__class__.__name__}, '
204            f'dest={dest.__class__.__name__}'
205        )
206
207    # If a constructor for this subclass wasn't provided, try using a
208    # zero-arg constructor for the provided dicts.
209    if ctor is None:
210
211        def ctor():
212            return src.__class__()  # pylint: disable=unnecessary-lambda
213
214    # Ensure that we have a way to construct an empty dict of the same type.
215    try:
216        empty_dict = ctor()
217    except TypeError:
218        # The constructor has required arguments.
219        raise TypeError(
220            'When merging a dict subclass, you must provide a '
221            'constructor for the subclass that produces an empty '
222            'dict.\n'
223            f'src/dest={src.__class__.__name__}'
224        )
225
226    if empty_dict.__class__ != src.__class__:
227        # The constructor returns something of the wrong type.
228        raise TypeError(
229            'When merging a dict subclass, you must provide a '
230            'constructor for the subclass that produces an empty '
231            'dict.\n'
232            f'src/dest={src.__class__.__name__}, '
233            f'constructor={ctor().__class__.__name__}'
234        )
235
236    for key, value in src.items():
237        empty_dict = ctor()
238        # The value is a nested dict; recursively merge.
239        if isinstance(value, src.__class__):
240            node = dest.setdefault(key, empty_dict)
241            dict_deep_merge(value, node, ctor)
242        # The value is a list; merge if the corresponding dest value is a list.
243        elif isinstance(value, list) and isinstance(dest.get(key, []), list):
244            # Disallow duplicates arising from the same value appearing in both.
245            try:
246                dest[key] += [x for x in value if x not in dest[key]]
247            except KeyError:
248                dest[key] = list(value)
249        # The value is a string; copy the value.
250        elif isinstance(value, str):
251            dest[key] = f'{value}'
252        # The value is scalar (int, float, bool); copy it over.
253        elif isinstance(value, (int, float, bool)):
254            dest[key] = value
255        # The value is some other object type; it's not supported.
256        else:
257            raise TypeError(f'Cannot merge value of type {type(value)}')
258
259    return dest
260
261
262def dict_swap_type(
263    src: _DictLike,
264    ctor: Callable[[], _AnotherDictLike],
265) -> _AnotherDictLike:
266    """Change the dict subclass of all dicts in a nested dict-like structure.
267
268    This returns new data and does not mutate the original data structure.
269    """
270    dest = ctor()
271
272    for key, value in src.items():
273        # The value is a nested dict; recursively construct.
274        if isinstance(value, src.__class__):
275            dest[key] = dict_swap_type(value, ctor)
276        # The value is something else; copy it over.
277        else:
278            dest[key] = value
279
280    return dest
281
282
283# Editor settings are manipulated via this dict-like data structure. We use
284# OrderedDict to avoid non-deterministic changes to settings files and to make
285# diffs more readable. Note that the values here can't really be "Any". They
286# need to be JSON serializable, and any embedded dicts should also be
287# OrderedDicts.
288EditorSettingsDict = OrderedDict[str, Any]
289
290# A callback that provides default settings in dict form when given ``pw_ide``
291# settings (which may be ignored in many cases).
292DefaultSettingsCallback = Callable[[PigweedIdeSettings], EditorSettingsDict]
293
294
295class EditorSettingsDefinition:
296    """Provides access to a particular group of editor settings.
297
298    A particular editor may have one or more settings *types* (e.g., editor
299    settings vs. automated tasks settings, or separate settings files for
300    each supported language). ``pw_ide`` also supports multiple settings
301    *levels*, where the "active" settings are built from default, project,
302    and user settings. Each combination of settings type and level will have
303    one ``EditorSettingsDefinition``, which may be in memory (e.g., for default
304    settings defined in code) or may be backed by a file (see
305    ``EditorSettingsFile``).
306
307    Settings are accessed using the ``modify`` context manager, which provides
308    you a dict-like data structure to manipulate.
309
310    Initial settings can be provided in the constructor via a callback that
311    takes an instance of ``PigweedIdeSettings`` and returns a settings dict.
312    This allows the initial settings to be dependent on overall IDE features
313    settings.
314    """
315
316    def __init__(
317        self,
318        pw_ide_settings: PigweedIdeSettings | None = None,
319        data: DefaultSettingsCallback | None = None,
320    ):
321        self._data: EditorSettingsDict = OrderedDict()
322
323        if data is not None and pw_ide_settings is not None:
324            self._data = data(pw_ide_settings)
325
326    def __repr__(self) -> str:
327        return f'<{self.__class__.__name__}: (in memory)>'
328
329    def __str__(self) -> str:
330        return json.dumps(self.get(), indent=2)
331
332    def get(self) -> EditorSettingsDict:
333        """Return the settings as an ordered dict."""
334        return self._data
335
336    def hash(self) -> str:
337        return sha1(json.dumps(self.get()).encode('utf-8')).hexdigest()
338
339    @contextmanager
340    def build(self) -> Generator[OrderedDict[str, Any], None, None]:
341        """Expose a settings file builder.
342
343        You get an empty dict when entering the content, then you can build
344        up settings by using ``sync_to`` to merge other settings dicts into this
345        one, as long as everything is JSON-serializable. Example:
346
347        .. code-block:: python
348
349            with settings_definition.modify() as settings:
350                some_other_settings.sync_to(settings)
351
352        This data is not persisted to disk.
353        """
354        new_data: OrderedDict[str, Any] = OrderedDict()
355        yield new_data
356        self._data = new_data
357
358    def sync_to(self, settings: EditorSettingsDict) -> None:
359        """Merge this set of settings on top of the provided settings."""
360        self_settings = self.get()
361        settings = dict_deep_merge(self_settings, settings)
362
363    def is_present(self) -> bool:  # pylint: disable=no-self-use
364        return True
365
366    def delete(self) -> None:
367        pass
368
369    def delete_backups(self) -> None:
370        pass
371
372
373class EditorSettingsFile(EditorSettingsDefinition):
374    """Provides access to an editor settings defintion stored in a file.
375
376    It's assumed that the editor's settings are stored in a file format that
377    can be deserialized to Python dicts. The settings are represented by
378    an ordered dict to make the diff that results from modifying the settings
379    as easy to read as possible (assuming it has a plain text representation).
380
381    This represents the concept of a file; the file may not actually be
382    present on disk yet.
383    """
384
385    def __init__(
386        self, settings_dir: Path, name: str, file_format: _StructuredFileFormat
387    ) -> None:
388        self._name = name
389        self._format = file_format
390        self._path = settings_dir / f'{name}.{self._format.ext}'
391        super().__init__()
392
393    def __repr__(self) -> str:
394        return f'<{self.__class__.__name__}: {str(self._path)}>'
395
396    def get(self) -> EditorSettingsDict:
397        """Read a settings file into an ordered dict.
398
399        This does not keep the file context open, so while the dict is
400        mutable, any changes will not be written to disk.
401        """
402        try:
403            with self._path.open() as file:
404                settings: OrderedDict = self._format.load(file)
405        except ValueError as e:
406            raise ValueError(
407                f"Settings file {self} could not be parsed. "
408                "Check the file for syntax errors."
409            ) from e
410        except FileNotFoundError:
411            settings = OrderedDict()
412
413        return settings
414
415    @contextmanager
416    def build(self) -> Generator[OrderedDict[str, Any], None, None]:
417        """Expose a settings file builder.
418
419        You get an empty dict when entering the content, then you can build
420        up settings by using ``sync_to`` to merge other settings dicts into this
421        one, as long as everything is JSON-serializable. Example:
422
423        .. code-block:: python
424
425            with settings_file.modify() as settings:
426                some_other_settings.sync_to(settings)
427
428        After modifying the settings and leaving this context, the file will
429        be written. If a failure occurs while writing the new file, it will be
430        deleted.
431        """
432        new_data: OrderedDict[str, Any] = OrderedDict()
433        yield new_data
434        file = self._path.open('w')
435
436        try:
437            self._format.dump(new_data, file)
438        except self._format.unserializable_error:
439            # We'll get this error if we try to sneak something in that's
440            # not serializable. Unless we handle this, we may end up
441            # with a partially-written file that can't be parsed. So we
442            # delete that and restore the backup.
443            file.close()
444            self._path.unlink()
445
446            raise
447        finally:
448            if not file.closed:
449                file.close()
450
451    def is_present(self) -> bool:
452        return self._path.exists()
453
454    def delete(self) -> None:
455        try:
456            self._path.unlink()
457        except FileNotFoundError:
458            pass
459
460
461_SettingsLevelName = Literal['default', 'active', 'project', 'user']
462
463
464@dataclass(frozen=True)
465class SettingsLevelData:
466    name: _SettingsLevelName
467    is_user_configurable: bool
468    is_file: bool
469
470
471class SettingsLevel(enum.Enum):
472    """Cascading set of settings.
473
474    This provides a unified mechanism for having active settings (those
475    actually used by an editor) be built from default settings in Pigweed,
476    project settings checked into the project's repository, and user settings
477    particular to one checkout of the project, each of which can override
478    settings higher up in the chain.
479    """
480
481    DEFAULT = SettingsLevelData(
482        'default', is_user_configurable=False, is_file=False
483    )
484    PROJECT = SettingsLevelData(
485        'project', is_user_configurable=True, is_file=True
486    )
487    USER = SettingsLevelData('user', is_user_configurable=True, is_file=True)
488    ACTIVE = SettingsLevelData(
489        'active', is_user_configurable=False, is_file=True
490    )
491
492    @property
493    def is_user_configurable(self) -> bool:
494        return self.value.is_user_configurable
495
496    @property
497    def is_file(self) -> bool:
498        return self.value.is_file
499
500    @classmethod
501    def all_levels(cls) -> Generator['SettingsLevel', None, None]:
502        return (level for level in cls)
503
504    @classmethod
505    def all_not_default(cls) -> Generator['SettingsLevel', None, None]:
506        return (level for level in cls if level is not cls.DEFAULT)
507
508    @classmethod
509    def all_user_configurable(cls) -> Generator['SettingsLevel', None, None]:
510        return (level for level in cls if level.is_user_configurable)
511
512    @classmethod
513    def all_files(cls) -> Generator['SettingsLevel', None, None]:
514        return (level for level in cls if level.is_file)
515
516
517# A map of configurable settings levels and the string that will be prepended
518# to their files to indicate their settings level.
519SettingsFilePrefixes = dict[SettingsLevel, str]
520
521# Each editor will have one or more settings types that typically reflect each
522# of the files used to define their settings. So each editor should have an
523# enum type that defines each of those settings types, and this type var
524# represents that generically. The value of each enum case should be the file
525# name of that settings file, without the extension.
526# TODO(chadnorvell): Would be great to constrain this to enums, but bound=
527# doesn't do what we want with Enum or EnumMeta.
528_SettingsTypeT = TypeVar('_SettingsTypeT')
529
530# Maps each settings type with the callback that generates the default settings
531# for that settings type.
532EditorSettingsTypesWithDefaults = dict[_SettingsTypeT, DefaultSettingsCallback]
533
534
535def undefined_default_settings_dir(
536    _pw_ide_settings: PigweedIdeSettings,
537) -> Path:
538    """Raise an error if a subclass doesn't define default_settings_dir."""
539
540    raise NotImplementedError()
541
542
543class EditorSettingsManager(Generic[_SettingsTypeT]):
544    """Manages all settings for a particular editor.
545
546    This is where you interact with an editor's settings (actually in a
547    subclass of this class, not here). Initializing this class sets up access
548    to one or more settings files for an editor (determined by
549    ``_SettingsTypeT``, fulfilled by an enum that defines each of an editor's
550    settings files), along with the cascading settings levels.
551    """
552
553    # Prefixes should only be defined for settings that will be stored on disk
554    # and are not the active settings file, which will use the name without a
555    # prefix. This may be overridden in child classes, but typically should
556    # not be.
557    prefixes: SettingsFilePrefixes = {
558        SettingsLevel.PROJECT: 'pw_project_',
559        SettingsLevel.USER: 'pw_user_',
560    }
561
562    # These must be overridden in child classes.
563    file_format: _StructuredFileFormat = _StructuredFileFormat()
564    types_with_defaults: EditorSettingsTypesWithDefaults[_SettingsTypeT] = {}
565
566    # The settings directory can be defined as a static path, or as a lambda
567    # that takes an instance of `PigweedIdeSettings` as an argument.
568    default_settings_dir: Path | Callable[
569        [PigweedIdeSettings], Path
570    ] = undefined_default_settings_dir
571
572    def __init__(
573        self,
574        pw_ide_settings: PigweedIdeSettings,
575        settings_dir: Path | None = None,
576        file_format: _StructuredFileFormat | None = None,
577        types_with_defaults: (
578            EditorSettingsTypesWithDefaults[_SettingsTypeT] | None
579        ) = None,
580    ):
581        if SettingsLevel.ACTIVE in self.__class__.prefixes:
582            raise ValueError(
583                'You cannot assign a file name prefix to '
584                'an active settings file.'
585            )
586
587        # This lets us use ``self._prefixes`` transparently for any file,
588        # including active settings files, since it will provide an empty
589        # string prefix for those files. In other words, while the class
590        # attribute `prefixes` can only be defined for configurable settings,
591        # `self._prefixes` extends it to work for any settings file.
592        self._prefixes = defaultdict(str, self.__class__.prefixes)
593
594        # The default settings directory is defined by the subclass attribute
595        # `default_settings_dir`, and that value is used the vast majority of
596        # the time. But you can inject an alternative directory in the
597        # constructor if needed (e.g. for tests).
598        if settings_dir is not None:
599            self._settings_dir = settings_dir
600        else:
601            if isinstance(self.__class__.default_settings_dir, Path):
602                self._settings_dir = self.__class__.default_settings_dir
603            else:
604                self._settings_dir = self.__class__.default_settings_dir(
605                    pw_ide_settings
606                )
607
608        if not self._settings_dir.exists():
609            self._settings_dir.mkdir()
610
611        # The backing file format should normally be defined by the class
612        # attribute ``file_format``, but can be overridden in the constructor.
613        self._file_format: _StructuredFileFormat = (
614            file_format
615            if file_format is not None
616            else self.__class__.file_format
617        )
618
619        # The settings types with their defaults should normally be defined by
620        # the class attribute ``types_with_defaults``, but can be overridden
621        # in the constructor.
622        self._types_with_defaults = (
623            types_with_defaults
624            if types_with_defaults is not None
625            else self.__class__.types_with_defaults
626        )
627
628        # For each of the settings levels, there is a settings definition for
629        # each settings type. Those settings definitions may be stored in files
630        # or not.
631        self._settings_definitions: dict[
632            SettingsLevel, dict[_SettingsTypeT, EditorSettingsDefinition]
633        ] = {}
634
635        self._settings_types = tuple(self._types_with_defaults.keys())
636
637        # Initialize the default settings level for each settings type, which
638        # defined in code, not files.
639        self._settings_definitions[SettingsLevel.DEFAULT] = {}
640
641        for (
642            settings_type
643        ) in (
644            self._types_with_defaults
645        ):  # pylint: disable=consider-using-dict-items
646            self._settings_definitions[SettingsLevel.DEFAULT][
647                settings_type
648            ] = EditorSettingsDefinition(
649                pw_ide_settings, self._types_with_defaults[settings_type]
650            )
651
652        # Initialize the settings definitions for each settings type for each
653        # settings level that's stored on disk.
654        for level in SettingsLevel.all_files():
655            self._settings_definitions[level] = {}
656
657            for settings_type in self._types_with_defaults:
658                name = f'{self._prefixes[level]}{settings_type.value}'
659                self._settings_definitions[level][
660                    settings_type
661                ] = EditorSettingsFile(
662                    self._settings_dir, name, self._file_format
663                )
664
665    def default(self, settings_type: _SettingsTypeT):
666        """Default settings for the provided settings type."""
667        return self._settings_definitions[SettingsLevel.DEFAULT][settings_type]
668
669    def project(self, settings_type: _SettingsTypeT):
670        """Project settings for the provided settings type."""
671        return self._settings_definitions[SettingsLevel.PROJECT][settings_type]
672
673    def user(self, settings_type: _SettingsTypeT):
674        """User settings for the provided settings type."""
675        return self._settings_definitions[SettingsLevel.USER][settings_type]
676
677    def active(self, settings_type: _SettingsTypeT):
678        """Active settings for the provided settings type."""
679        return self._settings_definitions[SettingsLevel.ACTIVE][settings_type]
680
681    def delete_all_active_settings(self) -> None:
682        """Delete all active settings files."""
683        for settings_type in self._settings_types:
684            self.active(settings_type).delete()
685
686    def delete_all_backups(self) -> None:
687        """Delete all backup files."""
688        for settings_type in self._settings_types:
689            self.project(settings_type).delete_backups()
690            self.user(settings_type).delete_backups()
691            self.active(settings_type).delete_backups()
692