diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22b2cb3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/tests/ export-ignore +/Readme.md export-ignore diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..372679b --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,24 @@ +name: tests + +on: [push, pull_request] + +jobs: + run-tests: + strategy: + fail-fast: false + matrix: + st-version: [4] + os: ["ubuntu-latest", "macOS-latest", "windows-latest"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: SublimeText/UnitTesting/actions/setup@v1 + with: + sublime-text-version: ${{ matrix.st-version }} + - uses: SublimeText/UnitTesting/actions/run-tests@v1 + with: + coverage: true + # We do not have a codecov token; let’s not do this. + # I’m still keeping it around as we might want it later; + # after must have been in the source file this action is from for a reason, I guess… + #- uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index a556819..261a990 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,5 @@ -*.hgignore -*.hgtags -*.pyc -*.cache -*.sublime-project - -_*.txt -sample-grammar.js -Manifest -MANIFEST - -dist/ -build/ \ No newline at end of file +*.pyc +*.cache +*.sublime-project + +/pyrightconfig.json diff --git a/.hgignore b/.hgignore deleted file mode 100644 index 8896bf0..0000000 --- a/.hgignore +++ /dev/null @@ -1,9 +0,0 @@ -syntax: glob - -*.pyc -_*.txt - -MANIFEST - -build/ -dist/ \ No newline at end of file diff --git a/.hgtags b/.hgtags deleted file mode 100644 index 5b56993..0000000 --- a/.hgtags +++ /dev/null @@ -1,2 +0,0 @@ -e4ef87463c48f5fc15b9dbe4ea2807b48ce82542 1.0 -f7da5e3a151589d7d11ee184d235f18eb77cefca 1.1 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..cc1923a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 diff --git a/Default.sublime-commands b/Default.sublime-commands new file mode 100644 index 0000000..fe5a0ba --- /dev/null +++ b/Default.sublime-commands @@ -0,0 +1,14 @@ +[ + { + "caption": "Preferences: Sublime Modelines Settings", + "command": "edit_settings", + "args": { + "base_file": "${packages}/Modelines/Sublime Modelines.sublime-settings", + "default": "/* See the left pane for the list of settings and valid values. */\n{\n\t$0\n}\n", + } + }, + { + "caption": "Sublime Modelines: Apply", + "command": "sublime_modelines_apply", + }, +] diff --git a/LICENSE.TXT b/License.txt similarity index 96% rename from LICENSE.TXT rename to License.txt index 021ec65..b02fa06 100644 --- a/LICENSE.TXT +++ b/License.txt @@ -1,19 +1,20 @@ -Copyright (c) 2010 Guillermo López-Anglada - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. \ No newline at end of file +Copyright (c) 2010 Guillermo López-Anglada + (c) 2026 Frizlab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index fa6606a..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include sublime_modelines.py -include LICENSE.TXT -include README.rst -prune setup.py \ No newline at end of file diff --git a/Main.sublime-menu b/Main.sublime-menu new file mode 100644 index 0000000..b20dbdf --- /dev/null +++ b/Main.sublime-menu @@ -0,0 +1,21 @@ +[{ + "id": "preferences", + "children": [ + { + "caption": "Package Settings", + "mnemonic": "P", + "id": "package-settings", + "children": [ + { + "caption": "Sublime Modelines", + "id": "sublime-modelines-settings", + "command": "edit_settings", + "args": { + "base_file": "${packages}/Modelines/Sublime Modelines.sublime-settings", + "default": "/* See the left pane for the list of settings and valid values. */\n{\n\t$0\n}\n", + } + } + ] + } + ] +}] diff --git a/README.rst b/README.rst deleted file mode 100644 index 0d788ad..0000000 --- a/README.rst +++ /dev/null @@ -1,72 +0,0 @@ -Sublime Modelines -================= - -Set settings local to a single buffer. A more granular approach to settings -than the per file type ``.sublime-settings`` files. - -Inspired in Vim's modelines feature. - -Getting Started -*************** - -Download and install `SublimeModelines`_. - -See the `installation instructions`_ for ``.sublime-package``\ s. - -.. _installation instructions: http://sublimetext.info/docs/en/extensibility/packages.html#installation-of-packages -.. _SublimeModelines: https://bitbucket.org/guillermooo/sublimemodelines/downloads/SublimeModelines.sublime-package - -Side Effects -************ - -Buffers will be scanned ``.on_load()`` for modelines and settings will be set -accordingly. Settings will apply **only** to the buffer declaring them. - -.. **Note**: Application- and Window-level options declared in modelines are -.. obviously global. - -Usage -***** - -How to Declare Modelines ------------------------- - -Modelines must be declared at the top or the bottom of source code files with -one of the following syntaxes:: - - # sublime: option_name value - # sublime: option_name value; another_option value; third_option value - -**Note**: ``#`` is the default comment character. Use the corresponding -single-line comment character for your language. When there isn't a concept of -comment, the default comment character must be used. - -How to Define Comment Characters in Sublime Text ------------------------------------------------- - -SublimeModelines finds the appropriate single-line comment character by inspecting -the ``shellVariables`` preference, which must be defined in a ``.tmPreferences`` -file. To see an example of how this is done, open ``Packages/Python/Miscellaneous.tmPreferences``. - -Many packages giving support for programming languages already include this, but -you might need to create a ``.tmPreferences`` file for the language you're working -with if you want SublimeModelines to be available. - - -Caveats -******* - -If the option's value contains a semicolon (``;``), make sure it isn't followed -by a blank space. Otherwise it will be interpreted as a multioption separator. - - -Non-Standard Options -******************** - -For some common cases, no directly settable option exists (for example, a -setting to specify a syntax). For such cases, Sublime Modelines provides -non-standard accessors as a stop-gap solution. - -**x_syntax** *Packages/Foo/Foo.tmLanguage* - -Sets the syntax to the specified *.tmLanguage* file. diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..268c3a6 --- /dev/null +++ b/Readme.md @@ -0,0 +1,88 @@ +# Sublime Modelines + +Set settings local to a single buffer. +A more granular approach to settings than the per file type `.sublime-settings` files. + +Inspired by Vim’s modelines feature. + + +## Getting Started + +### Recommended Installation + +Use Package Control and install `SublimeModelines` (compatibility starts at Sublime Text 4). + +### Manual Installation + +Download and install [SublimeModelines](). + +See the [installation instructions]() for `.sublime-package`s. + + +## Side Effects + +Buffers will be scanned `.on_load()` and `.on_post_save()` (by default, customizable) for modelines and settings will be set accordingly. +Settings will apply **only** to the buffer declaring them. + +There is also a command to manually apply modelines. + +**Note**: Application- and window-level options declared in modelines are obviously global. + + +## Usage + +### How to Declare Modelines + +Modelines must be declared at the top or the bottom of source code files with the following default syntax: + +```text +# ~*~ sublime: key=val; key2=val2; key3 ~*~ +``` + +VIM and Emacs-style syntax are also supported. +See the settings file for (a lot) more info. + + +## Example + +This is a simple example, that disable tabs auto-translation to spaces, set the tab size to 3 and set the file syntax to Python. + +```text +# ~*~ sublime: syntax=Python; tab_size=3; translate_tabs_to_spaces=false ~*~ +``` + +## Developer Note + +To get proper completion and errors in the editor when working on this repo, + one can create a `pyrightconfig.json` file at the root of the repo, + containing something like this (on macOS; adjust paths accordingly depending on your environment): +```json +{ + "venvPath": ".", + "venv": "sublime-modelines", + "extraPaths": [ + "/Applications/Sublime Text.app/Contents/MacOS/Lib/python38", + "/Users/YOUR_USER_NAME/Library/Application Support/Sublime Text/Lib/python38", + ] +} +``` + + +# Contributors + +[François Lamboley (Frizlab)](): +- Full rewrite featuring: + - Sublime Text 4 compatibility; + - A whole new modeline syntax; + - Better VIM syntax support; + - Emacs syntax support; + - Legacy syntax support (original modeline syntax from this repo, before the rewrite). + +[Guillermo López-Anglada](): +- Implemented the first version of this package (for Sublime Text 2). + +Kay-Uwe (Kiwi) Lorenz (): +- Added VIM compatibility; +- Smart syntax matching; +- Modelines also parsed on save; +- Settings are erased from view, if removed from modeline. diff --git a/Sublime Modelines.sublime-settings b/Sublime Modelines.sublime-settings new file mode 100644 index 0000000..ab96047 --- /dev/null +++ b/Sublime Modelines.sublime-settings @@ -0,0 +1,199 @@ +{ + /* Apply modelines on file open. */ + "apply_on_load": true, + /* Apply modelines on file save. */ + "apply_on_save": true, + + /* These two settings determine how far the search for a modeline should be done. */ + "number_of_lines_to_check_from_beginning": 5, + "number_of_lines_to_check_from_end": 5, + + /* Which types of modelines format are allowed. + * When multiple formats are specified, the parsing is attempted using the formats in the order they are given. */ + "formats": [ + /* Default format. + * Examples: + * `// ~*~ sublime: key=val; key2=val2; key3 ~*~` + * `// ~*~ sublime: key = val; key2+=val2; ~*~` + * `// ~*~ sublime : key=["hello": "world"] ~*~` + * (Also works with /*-styled comments, but JSON does not supported nested comments, so I’m skipping this example…) + * + * This format is inspired by VIM (`sublime:` prefix, key=val) as well as Emacs (`~*~` delimiters; Emacs uses `-*-`). + * + * Any value that starts with either a double-quote (`"`), a brace (`{`) or a bracket (`[`) is parsed as a JSON string. + * If the JSON fails to parse, the original string is kept. + * The literal strings `true` and `false` are converted to their boolean values. + * Literal numbers (`42`, `3.14`, `-007`, `+12.345`, `23e32`) are converted to ints or floats. + * The exact rule is: if `int(value)` does not throw, the int value is used, then if `float(value)` does not throw, the float value is used. + * The literal string `null` is converted to None. + * You can use double-quotes for these cases if you need an explicit string instead. + * + * All values are trimmed of their spaces (before being parsed if the value is a JSON string). + * If a value should contain a semicolon (`;`), it should be doubled (included inside a JSON string) + * to avoid being interpreted as the delimiter for the end of the value. + * + * To avoid ambiguities, if there are multiple `~*~` tokens on the line, only the first and last are considered. */ + "default", + + /* VIM-like modelines. + * Examples (straight from ): + * - `// vim: noai:ts=4:sw=4` + * - `/* vim: noai:ts=4:sw=4` (w/ closing comment token on next line) */ + // - `/* vim: set noai ts=4 sw=4: */` + // - `/* vim: set fdm=expr fde=getline(v\:lnum)=~'{'?'>1'\:'1': */` + /* + * For this format we map the VIM commands to Sublime Text commands. + * Additional mapping can be added in this config file. + * + * It is also possible to prefix commands with `st-`, `sublime-`, `sublime-text-` or `sublimetext-` + * to directly execute Sublime Text commands without needing any mapping. + * + * See the Readme for more information. */ + //"vim", + + /* Emacs-like modelines. + * Examples: + * `-*- key: value; key2: value2 -*-` + * `-*- syntax_name -*-` + * + * Just like for the VIM format, we map the Emacs commands to Sublime Text commands. */ + //"emacs", + + /* Classic (legacy) format. + * Example: `# sublime: key val(; key2 val2)*` + * + * Usually works well unless putting the modeline in a `/*`-style comment. + * + * Can also not work when the syntax of the file is not known, + * because we check the line to begin with the comment char before parsing it + * (`#` is used when the character is unknown). + * + * The parsing algorithm is exactly the same as the old ST2 version of the plugin. */ + //"classic", + + /* Classic (legacy) format with VIM support. + * + * Same as previous, with original VIM support implementation. */ + //"classic+vim", + ], + + /* Default VIM commands mapping. + * Use can use `vim_mapping_user` to define your own mapping while keeping this one. + * From . */ + "vim_mapping": { + /* Enable/disable automatic indentation. */ + "autoindent": {"aliases": ["ai"], "key": "auto_indent", "value": true}, + "noautoindent": {"aliases": ["noai"], "key": "auto_indent", "value": false}, + /* Set line endings (DOS, Legacy MacOS, UNIX). */ + "fileformat": {"aliases": ["ff"], "key": "set_line_endings()", "value-mapping": {"dos": "windows", "mac": "CR", "unix": "unix"}}, + /* Set the syntax of the file. */ + "filetype": {"aliases": ["ft"], "key": "syntax"}, + /* # of columns for each tab character. */ + "tabstop": {"aliases": ["ts"], "key": "tab_size"}, + /* # of columns for indent operation. */ + "shiftwidth": {"aliases": ["sw"], "key": null /* Not supported by Sublime. */}, + /* # of columns for tab key (space & tab). */ + "softtab": {"aliases": ["st"], "key": null /* Not supported by Sublime. */}, + /* Tabs → Spaces enable/disable. */ + "expandtab": {"aliases": ["et"], "key": "translate_tabs_to_spaces", "value": true}, + "noexpandtab": {"aliases": ["noet"], "key": "translate_tabs_to_spaces", "value": false}, + /* Show/hide line number. */ + "number": {"aliases": ["nu"], "key": "line_numbers", "value": true}, + "nonumber": {"aliases": ["nonu"], "key": "line_numbers", "value": false}, + /* Enable/disable word wrap. */ + "wrap": {"key": "word_wrap", "value": true}, + "nowrap": {"key": "word_wrap", "value": false}, + /* Set file encoding. */ + "fileencoding": {"aliases": ["fenc"], "key": "set_encoding()", "value-transforms": [ + {"type": "lowercase"}, + {"type": "map", "parameters": {"table": { + /* null values are explicitly unsupported and will set the status line for the plugin to notify of the failure. + * If you use an encoding not in the list, it is implicitly unsupported and will also set the status line error. */ + "latin1": "Western (Windows 1252)", + "koi8-r": "Cyrillic (KOI8-R)", + "koi8-u": "Cyrillic (KOI8-U)", + "macroman": "Western (Mac Roman)", + "iso-8859-1": "Western (ISO 8859-1)", + "iso-8859-2": "Central European (ISO 8859-2)", + "iso-8859-3": "Western (ISO 8859-3)", + "iso-8859-4": "Baltic (ISO 8859-4)", + "iso-8859-5": "Cyrillic (ISO 8859-5)", + "iso-8859-6": "Arabic (ISO 8859-6)", + "iso-8859-7": "Greek (ISO 8859-7)", + "iso-8859-8": "Hebrew (ISO 8859-8)", + "iso-8859-9": "Turkish (ISO 8859-9)", + "iso-8859-10": "Nordic (ISO 8859-10)", + "iso-8859-13": "Estonian (ISO 8859-13)", + "iso-8859-14": "Celtic (ISO 8859-14)", + "iso-8859-15": "Western (ISO 8859-15)", + "iso-8859-16": "Romanian (ISO 8859-16)", + "cp437": "DOS (CP 437)", + "cp737": null, + "cp775": null, + "cp850": null, + "cp852": null, + "cp855": null, + "cp857": null, + "cp860": null, + "cp861": null, + "cp862": null, + "cp863": null, + "cp865": null, + "cp866": "Cyrillic (Windows 866)", + "cp869": null, + "cp874": null, + "cp1250": "Central European (Windows 1250)", + "cp1251": "Cyrillic (Windows 1251)", + "cp1252": "Western (Windows 1252)", + "cp1253": "Greek (Windows 1253)", + "cp1254": "Turkish (Windows 1254)", + "cp1255": "Hebrew (Windows 1255)", + "cp1256": "Arabic (Windows 1256)", + "cp1257": "Baltic (Windows 1257)", + "cp1258": "Vietnamese (Windows 1258)", + "cp932": null, + "euc-jp": null, + "sjis ": null, + "cp949": null, + "euc-kr": null, + "cp936": null, + "euc-cn": null, + "cp950": null, + "big5": null, + "euc-tw": null, + "utf-8": "utf-8", + "ucs-2le": "utf-16 le", + "utf-16": "utf-16 be", + "utf-16le": "utf-16 le", + "ucs-4": null, + "ucs-4le": null + }}}, + ]}, + }, + /* User mapping for VIM modelines (deep-merged for dictionaries; everything else is replaced; set a key to null to remove it from the default mapping). */ + "vim_mapping_user": {}, + + /* Default Emacs commands mapping. + * Use can use `emacs_mapping_user` to define your own mapping while keeping this one. + * From . */ + "emacs_mapping": { + /* Set line endings (DOS, Legacy MacOS, UNIX). */ + "coding": {"key": "set_line_endings()", "value-mapping": {"dos": "windows", "mac": "CR", "unix": "unix"}}, + /* Tabs → Spaces enable/disable. */ + "indent-tabs-mode": {"key": "translate_tabs_to_spaces", "value-mapping": {"nil": true, "0": true}, "value-mapping-default": false}, + /* Set the syntax of the file. */ + "mode": {"key": "syntax"}, + /* # of columns for each tab character. */ + "tab-width": {"key": "tab_size"}, + }, + /* User mapping for Emacs modelines (deep-merged for dictionaries; everything else is replaced; set a key to null to remove it from the default mapping). */ + "emacs_mapping_user": {}, + + /* Whether debug logs should be enabled. */ + "verbose": false, + /* Whether to log to `/tmp/sublime_modelines_debug.log` in addition to stderr. + * This dates back to a time where I did not know how to show the console in Sublime (ctrl-`). + * I used to log to a temporary file that I tailed. + * Now this should probably always be `false`. */ + "log_to_tmp": false, +} diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/logger.py b/app/logger.py new file mode 100644 index 0000000..97d9a2c --- /dev/null +++ b/app/logger.py @@ -0,0 +1,42 @@ +import sys + + + +# Note: I tried logging with colors, it does not work (in the Sublime console). +class Logger: + """A simple logger.""" + + enable_debug_log = False + log_to_tmp = False + + @staticmethod + def debug(s: str, *args) -> None: + if not Logger.enable_debug_log: + return + Logger._log(Logger._format("", s, *args)) + + @staticmethod + def info(s: str, *args) -> None: + Logger._log(Logger._format("", s, *args)) + + @staticmethod + def warning(s: str, *args) -> None: + Logger._log(Logger._format("*** ", s, *args)) + + @staticmethod + def error(s: str, *args) -> None: + Logger._log(Logger._format("***** ERROR: ", s, *args)) + + @staticmethod + def _format(prefix: str, s: str, *args) -> str: + return "[Sublime Modelines] " + prefix + (s % args) + "\n" + + @staticmethod + def _log(str: str) -> None: + if Logger.log_to_tmp: + with open("/tmp/sublime_modelines_debug.log", "a") as myfile: + myfile.write(str) + sys.stderr.write(str) + + def __new__(cls, *args, **kwargs): + raise RuntimeError("Logger is static and thus cannot be instantiated.") diff --git a/app/logger_settings.py b/app/logger_settings.py new file mode 100644 index 0000000..e26919f --- /dev/null +++ b/app/logger_settings.py @@ -0,0 +1,9 @@ +from .logger import Logger +from .settings import Settings + + + +# This cannot be defined in Logger because we need to import Settings to implement the function, and Settings uses Logger… +def updateLoggerSettings(settings: Settings) -> None: + Logger.enable_debug_log = settings.verbose() + Logger.log_to_tmp = settings.log_to_tmp() diff --git a/app/modeline.py b/app/modeline.py new file mode 100644 index 0000000..40dbe19 --- /dev/null +++ b/app/modeline.py @@ -0,0 +1,29 @@ +# This can be removed when using Python >= 3.10. +from typing import List + +from .modeline_instruction import ModelineInstruction + + + +class Modeline: + + instructions: List[ModelineInstruction] + + def __init__(self, instructions: List[ModelineInstruction] = []): + super().__init__() + # We copy the list because otherwise the _default argument_ can get modified… + self.instructions = instructions.copy() + + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Modeline): + return False + return (self.instructions == other.instructions) + + def __str__(self) -> str: + # There is probably a more Pythonic way of doing this (map + join?), but this works. + res = "Modeline:\n" + for i in self.instructions: + res += " - " + i.__str__() + res += "\n" + return res diff --git a/app/modeline_instruction.py b/app/modeline_instruction.py new file mode 100644 index 0000000..953b7a5 --- /dev/null +++ b/app/modeline_instruction.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod + +from enum import Enum +from sublime import View as SublimeView +from sublime_types import Value as SublimeValue + + + +class ModelineInstruction(ABC): + + class ValueModifier(str, Enum): + NONE = "" + ADD = "+" + REMOVE = "-" + + @abstractmethod + def __init__(self, key: str, value: SublimeValue, modifier: ValueModifier = ValueModifier.NONE) -> None: + pass + + @abstractmethod + def apply(self, view: SublimeView) -> None: + pass + + + def __str__(self) -> str: + return f"{type(self)}" diff --git a/app/modeline_instructions/call_view_function.py b/app/modeline_instructions/call_view_function.py new file mode 100644 index 0000000..19bcd6d --- /dev/null +++ b/app/modeline_instructions/call_view_function.py @@ -0,0 +1,37 @@ +from typing import final + +from sublime import View as SublimeView +from sublime_types import Value as SublimeValue + +from ..modeline_instruction import ModelineInstruction + + + +@final +class ModelineInstruction_CallViewFunction(ModelineInstruction): + + function_name: str + function_arg: SublimeValue + + def __init__(self, key: str, value: SublimeValue, modifier: ModelineInstruction.ValueModifier = ModelineInstruction.ValueModifier.NONE) -> None: + super().__init__(key, value, modifier) + + if modifier != ModelineInstruction.ValueModifier.NONE: + raise ValueError(f"Unsupported value modifier “{modifier}” for a call view function modeline instruction.") + + self.function_name = key + self.function_arg = value + + def apply(self, view: SublimeView) -> None: + f = getattr(view, self.function_name) + f(self.function_arg) + + + def __eq__(self, other: object): + if not isinstance(other, ModelineInstruction_CallViewFunction): + return False + return (self.function_name == other.function_name and + self.function_arg == other.function_arg) + + def __str__(self) -> str: + return f"ModelineInstruction: CallViewFunction: {self.function_name}()={self.function_arg}" diff --git a/app/modeline_instructions/set_view_setting.py b/app/modeline_instructions/set_view_setting.py new file mode 100644 index 0000000..db547cb --- /dev/null +++ b/app/modeline_instructions/set_view_setting.py @@ -0,0 +1,95 @@ +from typing import final + +from os import path +from sublime import View as SublimeView +from sublime_types import Value as SublimeValue +import sublime + +from ..logger import Logger +from ..modeline_instruction import ModelineInstruction + + + +@final +class ModelineInstruction_SetViewSetting(ModelineInstruction): + + setting_name: str + setting_value: SublimeValue + setting_modifier: ModelineInstruction.ValueModifier + + def __init__(self, key: str, value: SublimeValue, modifier: ModelineInstruction.ValueModifier = ModelineInstruction.ValueModifier.NONE) -> None: + super().__init__(key, value, modifier) + self.setting_name = key + self.setting_value = value + self.setting_modifier = modifier + + def apply(self, view: SublimeView) -> None: + settings = view.settings() + + # Process setting value for special `syntax` case. + # Note might be a better algorithm. + # Among other things, it allows users to have a custom mapping of syntaxes, which we don’t. + if (self.setting_name == "syntax" and + isinstance(self.setting_value, str) and + not self.setting_value.endswith("tmLanguage") and + not self.setting_value.endswith("sublime-syntax") and + not "/" in self.setting_value and + hasattr(sublime, "find_resources") + ): + # We modify the value to find the proper file (this avoids specifying `Swift.tmLanguage`; instead we can use `Swift`). + candidates = sublime.find_resources(f"{self.setting_value}.sublime-syntax") + sublime.find_resources(f"{self.setting_value}.tmLanguage") + if len(candidates) > 0: + # Note: We only use the basename of the found resource. + # For some (unknown) reason, using the full path and the basename does not yield the same results, + # even when there is only one possible alternative! + self.setting_value = path.basename(path.normpath(candidates[0])) + + new_setting_value: SublimeValue + # The “match” instruction has been added to Python 3.10. + # We use `if elif else` instead. + if self.setting_modifier == ModelineInstruction.ValueModifier.NONE: + new_setting_value = self.setting_value + + elif self.setting_modifier == ModelineInstruction.ValueModifier.ADD: + # We’re told to add the given value(s) to the current value. + # We can do this only if the current value is a list. + # (Technically we could probably imagine rules for strings, dictionaries, etc., but they would be a stretch; let’s stay simple.) + current_value = settings.get(self.setting_name, []) + if isinstance(current_value, list): + if isinstance(self.setting_value, list): new_setting_value = current_value + self.setting_value + else: new_setting_value = current_value + [self.setting_value] + else: + # If the current value is not a known type, we fail. + # Note current_value should never be None as we ask for an empty list for the default value. + raise ValueError("Cannot add value to a non list setting.") + + elif self.setting_modifier == ModelineInstruction.ValueModifier.REMOVE: + # We’re told to remove the given value(s) to the current value. + # We can do this only if the current value is a list. + # (Technically we could probably imagine rules for strings, dictionaries, etc., but they would be a stretch; let’s stay simple.) + current_value = settings.get(self.setting_name) + if current_value is None: + new_setting_value = None + elif isinstance(current_value, list): + if isinstance(self.setting_value, list): new_setting_value = [v for v in current_value if not v in self.setting_value] + else: new_setting_value = [v for v in current_value if not v == self.setting_value] + else: + # If the current value is not a known type, we fail. + raise ValueError("Cannot remove value to a non list setting.") + + else: + Logger.error(f"Unknown setting modifier “{self.setting_modifier}” when applying a `SetViewSetting` modeline instruction.") + raise Exception("Unknown setting modifier.") + + settings.set(self.setting_name, new_setting_value) + + + def __eq__(self, other: object): + if not isinstance(other, ModelineInstruction_SetViewSetting): + return False + return (self.setting_name == other.setting_name and + self.setting_value == other.setting_value and + self.setting_modifier == other.setting_modifier) + + def __str__(self) -> str: + return f"ModelineInstruction: SetViewSetting: {self.setting_name}{self.setting_modifier}={self.setting_value}" diff --git a/app/modeline_instructions_mapping.py b/app/modeline_instructions_mapping.py new file mode 100644 index 0000000..88c06ad --- /dev/null +++ b/app/modeline_instructions_mapping.py @@ -0,0 +1,175 @@ +# This can be removed when using Python >= 3.10 (for List at least; the rest idk). +from typing import Dict, List, Optional, Tuple + +from abc import ABC, abstractmethod + +from .logger import Logger +from .utils import Utils + + + +class ModelineInstructionsMapping: + + class MappingValue: + + class ValueTransform(ABC): + + @abstractmethod + def __init__(self, parameters: Dict[str, object]) -> None: + pass + + @abstractmethod + def apply(self, value: object) -> object: + pass + + + class ValueTransformLowercase(ValueTransform): + + def __init__(self, parameters: Dict[str, object]) -> None: + super().__init__(parameters) + + def apply(self, value: object) -> object: + if not isinstance(value, str): + Logger.warning(f"Skipping lowercase transform for value “{value}” because it is not a string.") + return None + return value.lower() + + + class ValueTransformMapping(ValueTransform): + + mapping: Dict[str, object] + # If there is no mapping for the given value, the default value is returned. + default_on_no_mapping: object + + def __init__(self, parameters: Dict[str, object]) -> None: + super().__init__(parameters) + + if not "table" in parameters: + raise ValueError("Mandatory parameter “table” not present for a “map” transform.") + self.mapping = Utils.checked_cast_to_dict_with_string_keys( + parameters["table"], + ValueError("Invalid “table” value: not a dictionary with string keys.") + ) + self.default_on_no_mapping = parameters.get("default") + + def apply(self, value: object) -> object: + if not isinstance(value, str): + Logger.warning(f"Skipping lowercase transform for value “{value}” because it is not a string.") + return None + return self.mapping.get(value, self.default_on_no_mapping) + + + # This is `None` if the mapped instruction is unsupported (e.g. vim’s “softtab” which is unsupported in Sublime). + # If this is `None`, all the other parameters should be ignored. + key: Optional[str] + # If this is set, the value for the mapped instruction should be unset, and will be overridden by this value. + value: object + # These transforms will be applied to the value. + value_transforms: List[ValueTransform] + + def __init__(self, raw_mapping_value: Dict[str, object]) -> None: + super().__init__() + + key = raw_mapping_value["key"] + if key is None: + self.key = None + self.value = None + self.value_transforms = [] + return + + self.key = Utils.checked_cast_to_string(key, ValueError("Invalid “key” value: not a string.")) + # Note: We do not differentiate a None value and the absence of a value. + self.value = raw_mapping_value.get("value") + + # Parse transforms shortcut (`value-mapping`). + raw_value_transforms: List[Dict[str, object]] + if "value-mapping" in raw_mapping_value: + if "value-transforms" in raw_mapping_value: + raise ValueError("“value-transforms” must not be in mapping if “value-mapping” exists.") + + raw_value_transforms = [{ + "type": "map", + "parameters": { + "table": Utils.checked_cast_to_dict_with_string_keys( + raw_mapping_value["value-mapping"], + ValueError("Invalid “value-mapping” value: not a dictionary with string keys.") + ), + "default": raw_mapping_value.get("value-mapping-default") + } + }] + + else: + raw_value_transforms = Utils.checked_cast_to_list_of_dict_with_string_keys( + raw_mapping_value.get("value-transforms", []), + ValueError("") + ) + + # Parse transforms from `raw_value_transforms`. + self.value_transforms = [] + for raw_value_transform in raw_value_transforms: + params: Dict[str, object] = Utils.checked_cast_to_dict_with_string_keys( + raw_value_transform.get("parameters", {}), + ValueError("Invalid “parameters” for a value transform: not a dictionary with string keys.") + ) + # The “match” instruction has been added to Python 3.10. + # We use `if elif else` instead. + type = Utils.checked_cast_to_optional_string(raw_value_transform.get("type")) + if type == "lowercase": self.value_transforms.append(self.ValueTransformLowercase(params)) + elif type == "map": self.value_transforms.append(self.ValueTransformMapping(params)) + else: raise ValueError("Invalid/unknown type for a value transform.") + + def __str__(self) -> str: + return f"\tkey: {self.key}\n\tvalue: {self.value}\n\ttransforms_count: {len(self.value_transforms)}" + + + mapping: Dict[str, MappingValue] + + def __init__(self, raw_mapping_object: Dict[str, Dict[str, Optional[object]]] = {}) -> None: + super().__init__() + + self.mapping = {} + for key, val in raw_mapping_object.items(): + # We must silently skip None values as these are valid overrides for user mappings, to remove a specific mapping. + if val is None: continue + + try: + aliases = Utils.checked_cast_to_list_of_strings( + val.get("aliases", []), + ValueError("Invalid “aliases” value: not a list of strings.") + ) + + val = ModelineInstructionsMapping.MappingValue(val) + for key in [key] + aliases: + self.mapping[key] = val + + except ValueError as e: + Logger.warning(f"Skipping invalid mapping value for key “{key}”: “{e}”.") + + def __str__(self) -> str: + # There is probably a more Pythonic way of doing this (map + join?), but this works. + res = "" + for k, v in self.mapping.items(): + res += k + ":\n" + v.__str__() + res += "\n" + return res + + # Returns `None` if the mapping tells the key is unsupported. + def apply(self, key: str, value: object) -> Optional[Tuple[str, object]]: + mapping_value = self.mapping.get(key) + # If the mapping value is None, we return the unmodified source. + # If there is a None key in the mapping value, the key is unsupported: we return None. + if mapping_value is None: return (key, value) + if mapping_value.key is None: return None + + key = mapping_value.key + + # Replace the value if the mapping has a forced value. + if not mapping_value.value is None: + if not value is None: + Logger.warning(f"Replacing value “{value}” for key “{key}” with “{mapping_value.value}”: the key is mapped with a forced value.") + value = mapping_value.value + + for transform in mapping_value.value_transforms: + value = transform.apply(value) + + return (key, value) diff --git a/app/modeline_parser.py b/app/modeline_parser.py new file mode 100644 index 0000000..a54e6ac --- /dev/null +++ b/app/modeline_parser.py @@ -0,0 +1,105 @@ +# This can be removed when using Python >= 3.10 (for List at least; the rest idk). +from typing import final, List, Optional, Tuple + +from abc import ABC, abstractmethod +from sublime import View as SublimeView +import json + +from .logger import Logger +from .modeline import Modeline +from .modeline_instruction import ModelineInstruction +from .modeline_instructions.set_view_setting import ModelineInstruction_SetViewSetting +from .modeline_instructions.call_view_function import ModelineInstruction_CallViewFunction +from .modeline_instructions_mapping import ModelineInstructionsMapping +from .utils import Utils + + + +class ModelineParser(ABC): + + # Concrete sub-classes should set the value of this variable if they have a custom mapping (e.g. for the vim format, “filetype” -> “syntax”). + mapping: ModelineInstructionsMapping + + def __init__(self): + super().__init__() + self.mapping = ModelineInstructionsMapping() + + @final + def parse_line(self, line: str, parser_data: object) -> Optional[Modeline]: + instructions_raw: Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]] + try: + instructions_raw = self.parse_line_raw(line, parser_data) + if instructions_raw is None: + return None + except Exception as e: + Logger.warning(f"Got an exception while parsing raw modeline instructions from a line. This is an error in the concrete subclass: it should return None instead. -- line=“{line}”, error=“{e}”") + return None + + res = Modeline() + for key, raw_value, modifier in instructions_raw: + try: + # Let’s parse the value. + # It should already be trimmed (`parse_line_raw` should do it). + # See the Sublime settings file for the rules (and update it if they change). + if not raw_value is None: + if j := self.__parse_jsonesque_str(raw_value): value = j + elif raw_value == "true": value = True + elif raw_value == "false": value = False + elif i := Utils.as_int_or_none (raw_value): value = i + elif f := Utils.as_float_or_none(raw_value): value = f + elif raw_value == "null": value = None + else: value = raw_value + else: + value = None # aka. raw_value + + # Apply the mapping to the key and value. + key_value_pair = self.mapping.apply(key, value) + if key_value_pair is None: continue # Unsupported key + (key, value) = key_value_pair + + # Apply the post-mapping transform on the key. + key = self.transform_key_postmapping(key, parser_data) + sublime_value = Utils.checked_cast_to_sublime_value( + value, + ValueError("Post-mapped value is invalid (not a SublimeValue).") + ) + + if key.endswith("()"): res.instructions.append(ModelineInstruction_CallViewFunction(key[:-2], sublime_value, modifier)) + else: res.instructions.append(ModelineInstruction_SetViewSetting (key, sublime_value, modifier)) + except Exception as e: + Logger.warning(f"Failed converting modeline raw instruction to structured instruction. -- key=“{key}”, raw_value=“{raw_value}”, modifier=“{modifier}”, error=“{e}”") + return res + + @abstractmethod + def parse_line_raw(self, line: str, parser_data: object) -> Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]]: + """ + Abstract method whose concrete implementation should parse the given line as a dictionary of key/values if the line is a modeline. + No parsing of any sort should be done on the key or value, including mappings; this will be handled by the `parse` function (which calls that function). + If applicable, trimming should be done by this function though. + """ + pass + + def transform_key_postmapping(self, key: str, parser_data: object) -> str: + """ + Gives an opportunity to concrete sub-classes to post-process the key after the mapping has been applied. + This is used for instance by the VIM modeline parser class to implement Sublime commands with a prefix, bypassing the mapping. + In practice this is very much useless and only there for full backward compatibility. + """ + return key + + def parser_data_for_view(self, view: SublimeView) -> object: + """ + Gives the opportunity to concrete sub-classes to return some view-bound data for parsing lines. + The object returned by this method will be passed verbatim to the `parse_line_raw` and `transform_key_postmapping` methods. + """ + return None + + + # Parse strings that starts with either a double-quote (`"`), a brace (`{`) or a bracket (`[`) as a JSON string. + @final + def __parse_jsonesque_str(self, str: str) -> object: + if not str.startswith('"') and not str.startswith('{') and not str.startswith('['): + return None + + try: return json.loads(str) + except json.decoder.JSONDecodeError: return None diff --git a/app/modeline_parsers/emacs.py b/app/modeline_parsers/emacs.py new file mode 100644 index 0000000..309ed8f --- /dev/null +++ b/app/modeline_parsers/emacs.py @@ -0,0 +1,51 @@ +from typing import final, List, Optional, Tuple + +import re + +from ..modeline_instruction import ModelineInstruction +from ..modeline_instructions_mapping import ModelineInstructionsMapping +from ..modeline_parser import ModelineParser + + + +@final +class ModelineParser_Emacs(ModelineParser): + + def __init__(self, mapping: ModelineInstructionsMapping): + super().__init__() + self.mapping = mapping + + + def parse_line_raw(self, line: str, parser_data: object) -> Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]]: + # From . + # We probably should rewrite this properly though… + m = re.match(self.__modeline_re, line) + if not m: return None + + res: List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]] = [] + + modeline = m.group(1) # Original implementation had a lowercase here. It does not make sense though. + for opt in modeline.split(";"): + opt = opt.strip() + if len(opt) == 0: continue + + opts = re.match(r"\s*(st-|sublime-text-|sublime-|sublimetext-)?(.+):\s*(.+)\s*", opt) + if opts: + key, value = (self.__sublime_prefix if opts.group(1) else "") + opts.group(2), opts.group(3) + res.append((key, value, ModelineInstruction.ValueModifier.NONE)) + else: + # Not a `key: value`-pair: we assume it’s a syntax-name. + res.append(("syntax", opt.strip(), ModelineInstruction.ValueModifier.NONE)) + + return res + + + def transform_key_postmapping(self, key: str, parser_data: object) -> str: + transformed = super().transform_key_postmapping(key, parser_data) + if transformed.startswith(self.__sublime_prefix): + transformed = transformed[len(self.__sublime_prefix):] + return transformed + + + __modeline_re = r".*-\*-\s*(.+?)\s*-\*-.*" + __sublime_prefix = "sublimetext--" diff --git a/app/modeline_parsers/legacy.py b/app/modeline_parsers/legacy.py new file mode 100644 index 0000000..41dcd55 --- /dev/null +++ b/app/modeline_parsers/legacy.py @@ -0,0 +1,71 @@ +from typing import cast, final, Any, Generator, List, Optional, Tuple + +from sublime import View as SublimeView +import re + +from ..modeline_instruction import ModelineInstruction +from ..modeline_instructions_mapping import ModelineInstructionsMapping +from ..modeline_parser import ModelineParser +from ..utils import Utils + + + +@final +class ModelineParser_Legacy(ModelineParser): + + def __init__(self): + super().__init__() + self.mapping.mapping["x_syntax"] = ModelineInstructionsMapping.MappingValue({"key": "syntax"}) + + + def parse_line_raw(self, line: str, parser_data: object) -> Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]]: + modeline_prefix_re = Utils.checked_cast_to_string(parser_data, ValueError("Parser called with invalid parser data.")) + if not re.match(modeline_prefix_re, line): + return None + + res: List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]] = [] + for opt in self.__gen_raw_options(line): + name, _, value = opt.partition(" ") + res.append((name.rstrip(":").strip(), value.rstrip(";").strip(), ModelineInstruction.ValueModifier.NONE)) + return res + + + def parser_data_for_view(self, view: SublimeView) -> object: + line_comment = self.__get_line_comment_char_re(view).lstrip() or re.escape(self.__DEFAULT_LINE_COMMENT) + return (self.__MODELINE_PREFIX_TPL % line_comment) + + + __MODELINE_PREFIX_TPL = "%s\\s*(st|sublime): " + __DEFAULT_LINE_COMMENT = "#" + __MULTIOPT_SEP = "; " + + + def __is_modeline(self, prefix, line): + return bool(re.match(prefix, line)) + + def __gen_raw_options(self, raw_modeline: str) -> Generator[str, None, None]: + opt = raw_modeline.partition(":")[2].strip() + if self.__MULTIOPT_SEP in opt: + for subopt in (s for s in opt.split(self.__MULTIOPT_SEP)): + yield subopt + else: + yield opt + + def __get_line_comment_char_re(self, view: SublimeView): + commentChar = "" + commentChar2 = "" + try: + for pair in cast(Any, view.meta_info("shellVariables", 0)): + if pair["name"] == "TM_COMMENT_START": + commentChar = pair["value"] + if pair["name"] == "TM_COMMENT_START_2": + commentChar2 = pair["value"] + if commentChar and commentChar2: + break + except TypeError: + pass + + if not commentChar2: + return re.escape(commentChar.strip()) + else: + return "(" + re.escape(commentChar.strip()) + "|" + re.escape(commentChar2.strip()) + ")" diff --git a/app/modeline_parsers/legacy_vim.py b/app/modeline_parsers/legacy_vim.py new file mode 100644 index 0000000..007fa70 --- /dev/null +++ b/app/modeline_parsers/legacy_vim.py @@ -0,0 +1,134 @@ +from typing import cast, final, Any, Generator, List, Optional, Tuple, Union + +from sublime import View as SublimeView +import re + +from ..modeline_instruction import ModelineInstruction +from ..modeline_instructions_mapping import ModelineInstructionsMapping +from ..modeline_parser import ModelineParser +from ..utils import Utils + + + +@final +class ModelineParser_LegacyVIM(ModelineParser): + + def __init__(self, mapping: ModelineInstructionsMapping): + super().__init__() + self.mapping = mapping + self.mapping.mapping["x_syntax"] = ModelineInstructionsMapping.MappingValue({"key": "syntax"}) + + + def parse_line_raw(self, line: str, parser_data: object) -> Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]]: + modeline_prefix_re = Utils.checked_cast_to_string(parser_data, ValueError("Parser called with invalid parser data.")) + if not re.match(modeline_prefix_re, line): + return None + + res: List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]] = [] + for opt in self.__gen_raw_options(line): + if not isinstance(opt, tuple): + name, _, value = opt.partition(" ") + res.append((name.rstrip(":"), value.rstrip(";"), ModelineInstruction.ValueModifier.NONE)) + + else: + name, op, value = opt + modifier = ModelineInstruction.ValueModifier.NONE + if op == "+=": modifier = ModelineInstruction.ValueModifier.ADD + + res.append((name, value, modifier)) + + return res + + + def parser_data_for_view(self, view: SublimeView) -> object: + line_comment = self.__get_line_comment_char_re(view).lstrip() or self.__DEFAULT_LINE_COMMENT + return (self.__MODELINE_PREFIX_TPL % line_comment) + + + __DEFAULT_LINE_COMMENT = "#" + + __MODELINE_PREFIX_TPL = "%s\\s*(st|sublime|vim):" + __MODELINE_TYPE_1 = re.compile(r"[\x20\t](st|sublime|vim):\x20?set\x20(.*):.*$") + __MODELINE_TYPE_2 = re.compile(r"[\x20\t](st|sublime|vim):(.*):.*$") + + __KEY_VALUE = re.compile(r""" + (?x) \s* + (?P\w+) \s* (?P\+?=) \s* (?P + (?: "(?:\\.|[^"\\])*" + | [\[\{].* + | [^\s:]+ + ) + )""" + ) + __KEY_ONLY = re.compile(r"""(?x)\s*(?P\w+)""") + + __MULTIOPT_SEP = "; " + + + def __is_modeline(self, prefix, line): + return bool(re.match(prefix, line)) + + def __gen_raw_options(self, raw_modeline: str) -> Generator[Union[str, Tuple[str, str, str]], None, None]: + match = self.__MODELINE_TYPE_1.search(raw_modeline) + if not match: + match = self.__MODELINE_TYPE_2.search(raw_modeline) + + if match: + type, s = match.groups() + + while True: + if s.startswith(":"): s = s[1:] + + m = self.__KEY_VALUE.match(s) + if m: + key, op, value = m.groups() + yield key, op, value + s = s[m.end():] + continue + + m = self.__KEY_ONLY.match(s) + if m: + k, = m.groups() + value = "true" + + _k = k + # Original implementation dropped the prefix `no` and set the value to false. + # We do that in the mapping now, which IMHO is better because some `no` prefix don’t make sense (`nots`? what would that mean?). + #if k.startswith("no") and (type == "vim" or len(k) <= 4): + # value = "false" + # _k = k[2:] + + yield _k, "=", value + + s = s[m.end():] + continue + + break + + else: + # Original sublime modelines style. + opt = raw_modeline.partition(":")[2].strip() + if self.__MULTIOPT_SEP in opt: + for subopt in (s for s in opt.split(self.__MULTIOPT_SEP)): + yield subopt + else: + yield opt + + def __get_line_comment_char_re(self, view: SublimeView): + commentChar = "" + commentChar2 = "" + try: + for pair in cast(Any, view.meta_info("shellVariables", 0)): + if pair["name"] == "TM_COMMENT_START": + commentChar = pair["value"] + if pair["name"] == "TM_COMMENT_START_2": + commentChar2 = pair["value"] + if commentChar and commentChar2: + break + except TypeError: + pass + + if not commentChar2: + return re.escape(commentChar.strip()) + else: + return "(" + re.escape(commentChar.strip()) + "|" + re.escape(commentChar2.strip()) + ")" diff --git a/app/modeline_parsers/sublime.py b/app/modeline_parsers/sublime.py new file mode 100644 index 0000000..6f06a1d --- /dev/null +++ b/app/modeline_parsers/sublime.py @@ -0,0 +1,78 @@ +from typing import final, List, Optional, Tuple + +import re + +from ..modeline_instruction import ModelineInstruction +from ..modeline_parser import ModelineParser + + + +@final +class ModelineParser_Sublime(ModelineParser): + + def parse_line_raw(self, line: str, parser_data: object) -> Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]]: + # Find the first and last `~*~` tokens in the line, if any. + start = line.find(self.__token) + if start == -1: return None + end = line.rfind(self.__token) + if end == start: return None + line = line[start+len(self.__token):end].strip() + + # Verify the string between the two tokens starts with `sublime`. + if not line.startswith(self.__prefix): return None + line = line[len(self.__prefix):].strip() + + if not line.startswith(":"): return None + line = line[1:] + + def find_next_tuple() -> Optional[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]: + nonlocal line + + line = line.strip() + if len(line) == 0: + return None + + # Read line until the next `+=`, `-=` or `=`. + match = self.__re__plus_minus_equal.search(line) + if match is None: + key = line; line = "" + return (key, None, ModelineInstruction.ValueModifier.NONE) + + operator = line[match.start():match.end()] + modifer: ModelineInstruction.ValueModifier + if operator == "=": modifer = ModelineInstruction.ValueModifier.NONE + elif operator == "+=": modifer = ModelineInstruction.ValueModifier.ADD + elif operator == "-=": modifer = ModelineInstruction.ValueModifier.REMOVE + else: raise Exception("Internal error: Unknown operator.") + + key = line[:match.start()] + line = line[match.end():] + + value: str = "" + while idx := line.find(";") + 1: # +1: If not found, idx will be 0, and thus we will exit the loop. + idx -= 1 + value += line[:idx] + line = line[idx+1:] + if len(line) > 0 and line[0] == ";": + value += ";" + line = line[1:] + else: + break + else: + value += line + line = "" + + return (key.strip(), value.strip(), modifer) + + try: + res: List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]] = [] + while tuple := find_next_tuple(): + res.append(tuple) + return res + except ValueError: + return None + + __token = "~*~" + __prefix = "sublime" + + __re__plus_minus_equal = re.compile(r"=|\+=|-=") diff --git a/app/modeline_parsers/vim.py b/app/modeline_parsers/vim.py new file mode 100644 index 0000000..50d1221 --- /dev/null +++ b/app/modeline_parsers/vim.py @@ -0,0 +1,47 @@ +from typing import cast, final, List, Optional, Tuple + +import re + +from ..modeline_instruction import ModelineInstruction +from ..modeline_instructions_mapping import ModelineInstructionsMapping +from ..modeline_parser import ModelineParser + + + +@final +class ModelineParser_VIM(ModelineParser): + + def __init__(self, mapping: ModelineInstructionsMapping): + super().__init__() + self.mapping = mapping + + + def parse_line_raw(self, line: str, parser_data: object) -> Optional[List[Tuple[str, Optional[str], ModelineInstruction.ValueModifier]]]: + match = self.__modeline_re.search(line) + + if match: + modeline = "".join(m for m in match.groups() if m) + matches = [self.__attr_kvp_re.match(attr) for attr in filter(bool, self.__attr_sep_re.split(modeline))] + raw_attrs = [cast(Tuple[str, str], match.groups()) for match in filter(None, matches)] + return [( + raw_attr[0], + raw_attr[1] or None, # If raw_attr.1 is empty we return None. + ModelineInstruction.ValueModifier.NONE + ) for raw_attr in raw_attrs] + + return None + + + __modeline_re = re.compile(r""" + (?:^vim? # begin line with either vi or vim + | \s(?:vim? | ex)) # … or white-space then vi, vim, or ex + (?:\d*): # optional version digits, closed with : + \s* # optional white-space after ~vim700: + (?: # alternation of type 1 & 2 modelines + (?:set?[ ])([^ ].*):.*$ # type 2: optional set or se, spc, opts, : + | (?!set?[ ])([^ ].*)$ # type 1: everything following + ) + """, re.VERBOSE) + + __attr_sep_re = re.compile(r"[:\s]") + __attr_kvp_re = re.compile(r"([^=]+)=?([^=]*)") diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..33cc0c0 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,144 @@ +# This can be removed when using Python >= 3.10. +from typing import List, NewType, Tuple + +from enum import Enum +import sublime + +from .logger import Logger +from .modeline_instructions_mapping import ModelineInstructionsMapping +from .modeline_parser import ModelineParser +from .modeline_parsers.emacs import ModelineParser_Emacs +from .modeline_parsers.legacy import ModelineParser_Legacy +from .modeline_parsers.legacy_vim import ModelineParser_LegacyVIM +from .modeline_parsers.sublime import ModelineParser_Sublime +from .modeline_parsers.vim import ModelineParser_VIM +from .utils import Utils + + + +class ModelineFormat(str, Enum): + DEFAULT = "default" + VIM = "vim" + EMACS = "emacs" + LEGACY = "classic" + LEGACY_VIM = "classic+vim" + + # Forward declare Settings because we use it in ModelineFormat (and reciprocally). + Settings = NewType("Settings", None) + + def get_parser_with_data(self, settings: Settings, view: sublime.View) -> Tuple[ModelineParser, object]: + def add_data(parser: ModelineParser) -> Tuple[ModelineParser, object]: + return (parser, parser.parser_data_for_view(view)) + # The “match” instruction has been added to Python 3.10. + # We use `if elif else` instead. + if self == ModelineFormat.DEFAULT: return add_data(ModelineParser_Sublime()) + elif self == ModelineFormat.VIM: return add_data(ModelineParser_VIM(settings.vimMapping())) + elif self == ModelineFormat.EMACS: return add_data(ModelineParser_Emacs(settings.emacsMapping())) + elif self == ModelineFormat.LEGACY: return add_data(ModelineParser_Legacy()) + elif self == ModelineFormat.LEGACY_VIM: return add_data(ModelineParser_LegacyVIM(settings.vimMapping())) + else: raise Exception("Internal error: Unknown parser ID.") + + +class Settings: + """ + A class that gives convenient access to the settings for our plugin. + + Creating an instance of this class will load the settings. + """ + + def __init__(self): + super().__init__() + self.settings = sublime.load_settings("Sublime Modelines.sublime-settings") + + def modelines_formats(self) -> List[ModelineFormat]: + default_for_syntax_error = [ModelineFormat.DEFAULT] + + raw_formats = self.settings.get("formats") + if not isinstance(raw_formats, list): + Logger.warning("Did not get an array in the settings for the “formats” key.") + return default_for_syntax_error + + formats = [] + for raw_format in raw_formats: + if not isinstance(raw_format, str): + Logger.warning("Found an invalid value (not a string) in the “formats” key. Returning the default modeline formats.") + return default_for_syntax_error + + try: + formats.append(ModelineFormat(raw_format)) + except ValueError: + Logger.warning("Found an invalid value (unknown format) in the “formats” key. Skipping this value.") + + return formats + + def apply_on_load(self) -> bool: + raw_value = self.settings.get("apply_on_load") + if not isinstance(raw_value, bool): + Logger.warning("Did not get a bool in the settings for the apply_on_load key.") + return False + return raw_value + + def apply_on_save(self) -> bool: + raw_value = self.settings.get("apply_on_save") + if not isinstance(raw_value, bool): + Logger.warning("Did not get a bool in the settings for the apply_on_save key.") + return False + return raw_value + + def number_of_lines_to_check_from_beginning(self) -> int: + raw_value = self.settings.get("number_of_lines_to_check_from_beginning") + if not isinstance(raw_value, int): + Logger.warning("Did not get an int in the settings for the number_of_lines_to_check_from_beginning key.") + return 5 + return raw_value + + def number_of_lines_to_check_from_end(self) -> int: + raw_value = self.settings.get("number_of_lines_to_check_from_end") + if not isinstance(raw_value, int): + Logger.warning("Did not get an int in the settings for the number_of_lines_to_check_from_end key.") + return 5 + return raw_value + + def vimMapping(self) -> ModelineInstructionsMapping: + raw_value = Utils.checked_cast_to_dict_with_string_keys( + self.settings.get("vim_mapping"), + ValueError("Invalid “vim_mapping” setting value: not a dict with string keys.") + ) + raw_value_user = Utils.checked_cast_to_dict_with_string_keys( + self.settings.get("vim_mapping_user"), + ValueError("Invalid “vim_mapping_user” setting value: not a dict with string keys.") + ) + raw_value = Utils.checked_cast_to_dict_of_dict_with_string_keys( + Utils.merge(raw_value, raw_value_user), + ValueError("Invalid “vim_mapping” or “vim_mapping_user”: the resulting merged dictionary is not a dictionary with string keys of dictionary with string keys.") + ) + return ModelineInstructionsMapping(raw_value) + + def emacsMapping(self) -> ModelineInstructionsMapping: + raw_value = Utils.checked_cast_to_dict_with_string_keys( + self.settings.get("emacs_mapping"), + ValueError("Invalid “emacs_mapping” setting value: not a dict with string keys.") + ) + raw_value_user = Utils.checked_cast_to_dict_with_string_keys( + self.settings.get("emacs_mapping_user"), + ValueError("Invalid “emacs_mapping_user” setting value: not a dict with string keys.") + ) + raw_value = Utils.checked_cast_to_dict_of_dict_with_string_keys( + Utils.merge(raw_value, raw_value_user), + ValueError("Invalid “emacs_mapping” or “emacs_mapping_user”: the resulting merged dictionary is not a dictionary with string keys of dictionary with string keys.") + ) + return ModelineInstructionsMapping(raw_value) + + def verbose(self) -> bool: + raw_value = self.settings.get("verbose") + if not isinstance(raw_value, bool): + Logger.warning("Did not get a bool in the settings for the verbose key.") + return False + return raw_value + + def log_to_tmp(self) -> bool: + raw_value = self.settings.get("log_to_tmp") + if not isinstance(raw_value, bool): + Logger.warning("Did not get a bool in the settings for the log_to_tmp key.") + return False + return raw_value diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..b80d893 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,100 @@ +# This can be removed when using Python >= 3.10 (for List at least; the rest idk). +from typing import cast, Dict, List, Optional, TypeVar + +from sublime_types import Value as SublimeValue + + + +class Utils: + + @staticmethod + def is_dict_with_string_keys(variable: object) -> bool: + """Casts the given object to a dictionary with string keys; raises the given exception if the given object is not that.""" + return isinstance(variable, dict) and all(isinstance(elem, str) for elem in variable.keys()) + + @staticmethod + def checked_cast_to_string(variable: object, exception: Exception = ValueError("Given object is not a string.")) -> str: + """Casts the given object to a string; raises the given exception if the given object is not that.""" + if not isinstance(variable, str): + raise exception + return cast(str, variable) + + @staticmethod + def checked_cast_to_optional_string(variable: object, exception: Exception = ValueError("Given object is not an optional string.")) -> Optional[str]: + """Casts the given object to an optional string; raises the given exception if the given object is not that.""" + if object is None: + return None + return Utils.checked_cast_to_string(variable, exception) + + @staticmethod + def checked_cast_to_list_of_strings(variable: object, exception: Exception = ValueError("Given object is not a list of strings.")) -> List[str]: + """Casts the given object to a list of strings; raises the given exception if the given object is not that.""" + if not isinstance(variable, list) or not all(isinstance(elem, str) for elem in variable): + raise exception + return cast(List[str], variable) + + @staticmethod + def checked_cast_to_dict_with_string_keys(variable: object, exception: Exception = ValueError("Given object is not a dictionary with string keys.")) -> Dict[str, object]: + """Casts the given object to a dictionary with string keys; raises the given exception if the given object is not that.""" + if not Utils.is_dict_with_string_keys(variable): + raise exception + return cast(Dict[str, object], variable) + + @staticmethod + def checked_cast_to_list_of_dict_with_string_keys(variable: object, exception: Exception = ValueError("Given object is not a list of dictionaries with string keys.")) -> List[Dict[str, object]]: + """Casts the given object to a list of dictionaries with string keys; raises the given exception if the given object is not a that.""" + if not isinstance(variable, list) or not all(Utils.is_dict_with_string_keys(elem) for elem in variable): + raise exception + return cast(List[Dict[str, object]], variable) + + @staticmethod + def checked_cast_to_dict_of_dict_with_string_keys(variable: object, exception: Exception = ValueError("Given object is not a dictionary with string keys of dictionaries with string keys.")) -> Dict[str, Dict[str, object]]: + """Casts the given object to a dictionary with string key of dictionaries with string keys; raises the given exception if the given object is not a that.""" + dict = Utils.checked_cast_to_dict_with_string_keys(variable, exception) + if not all(Utils.is_dict_with_string_keys(elem) for elem in dict.values()): + raise exception + return cast(Dict[str, Dict[str, object]], variable) + + @staticmethod + def checked_cast_to_sublime_value(variable: object, exception: Exception = ValueError("Given object is not a Sublime Value.")) -> SublimeValue: + """Casts the given object to a Sublime Value; raises the given exception if the given object is not that.""" + if variable is None: + return cast(SublimeValue, variable) + # I don’t think there is a way to automatically check all the elements of the Value union, so we do them manually. + # We’ll have to manually update the checks when the Value type is updated in Sublime. + # Note: We do None separately because NoneType causes issues w/ Python 3.8 apparently. + for t in [bool, str, int, float, list, dict]: + if isinstance(variable, t): + return cast(SublimeValue, variable) + raise exception + + @staticmethod + def as_int_or_none(variable: str) -> Optional[int]: + try: return int(variable) + except ValueError: return None + + @staticmethod + def as_float_or_none(variable: str) -> Optional[float]: + try: return float(variable) + except ValueError: return None + + K = TypeVar("K"); V = TypeVar("V") + @staticmethod + def merge(a: Dict[K, V], b: Dict[K, V], path=[]) -> Dict[K, V]: + """ + Merges b in a, in place, and returns a. + From , modified (and not extensively tested…). + """ + for key in b: + if key in a: + if isinstance(a[key], dict) and isinstance(b[key], dict): + Utils.merge(cast(Dict[object, object], a[key]), cast(Dict[object, object], b[key]), path + [str(key)]) + else: + # Original SO source checked whether the values were the same; we do not care and just trump. + a[key] = b[key] + else: + a[key] = b[key] + return a + + def __new__(cls, *args, **kwargs): + raise RuntimeError("Utils is static and thus cannot be instantiated.") diff --git a/bin/CleanUp.ps1 b/bin/CleanUp.ps1 deleted file mode 100644 index 8fd3ed7..0000000 --- a/bin/CleanUp.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -$script:here = split-path $MyInvocation.MyCommand.Definition -parent - -push-location "$script:here/.." - remove-item "*.pyc" -recurse -erroraction silentlycontinue - remove-item "build" -recurse -erroraction silentlycontinue - remove-item "dist" -recurse -erroraction silentlycontinue -pop-location diff --git a/bin/MakeRelease.ps1 b/bin/MakeRelease.ps1 deleted file mode 100644 index 8d63820..0000000 --- a/bin/MakeRelease.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -$script:here = split-path $MyInvocation.MyCommand.Definition -parent -push-location "$script:here/.." - -& "$script:here/CleanUp.ps1" - -$zipExe = "$env:ProgramFiles/7-zip/7z.exe" - -& "hg" "update" "release" -& "hg" "merge" "default" -& "hg" "commit" "-m" "Merged with default." 2>&1 - -if ($rv.exception -like "*unresolved*") { - write-host "hg pull --update failed. Take a look." -foreground yellow - break -} - -$targetDir = "./dist/SublimeModelines.sublime-package" - -& "python.exe" ".\setup.py" "spa" "--no-defaults" - -(resolve-path (join-path ` - (get-location).providerpath ` - $targetDir)).path | clip.exe - -start-process chrome -arg "https://bitbucket.org/guillermooo/sublimemodelines/downloads" - -& "hg" "update" "default" -pop-location - -Write-Host "Don't forget to tag release." -foreground yellow -Write-Host "Don't forget to push to bitbucket." -foreground yellow \ No newline at end of file diff --git a/bin/RunTests.ps1 b/bin/RunTests.ps1 deleted file mode 100644 index c91523a..0000000 --- a/bin/RunTests.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -# py.test.exe should discover tests autoamically without our help, but I don't -# seem to be able to get it working. -$script:here = split-path $MyInvocation.MyCommand.Definition -parent -push-location "$script:here/../tests" - -& "py.test.exe" -pop-location \ No newline at end of file diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000..3d2f54b --- /dev/null +++ b/dependencies.json @@ -0,0 +1,7 @@ +{ + "*": { + "*": [ + "typing-extensions" + ] + } +} diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..ec18bed --- /dev/null +++ b/plugin.py @@ -0,0 +1,130 @@ +from typing import Final, List, Optional, Tuple + +import sublime, sublime_plugin + +from .app.logger import Logger +from .app.logger_settings import updateLoggerSettings +from .app.modeline import Modeline +from .app.settings import Settings + + +# The plugin structure is heavily inspired by . +# We have mostly added typing, and fixed a potential issue if on_load or on_post_save is called in a view which is not the front-most one in a window. + + +PLUGIN_NAME: Final[str] = "SublimeModelines" + +# Before everything else, update the settings of the logger. +settings = Settings() +updateLoggerSettings(settings) + + +def plugin_loaded(): + Logger.debug("Plugin loaded.") + + # Call on_load() for existing views, since files may load before the plugin. + # First we verify the plugin is properly instantiated (it should be). + plugin = SublimeModelinesPlugin.instance + if plugin is None: + Logger.warning("Plugin instance is not set.") + return + + for w in sublime.windows(): + for g in range(w.num_groups()): + view = w.active_view_in_group(g) + if view is None: continue + plugin.on_load(view) + + +def plugin_unloaded(): + Logger.debug("Plugin unloaded.") + + +class SublimeModelinesPlugin(sublime_plugin.EventListener): + """Event listener to invoke the command on load & save.""" + + #instance: Optional[SublimeModelinesPlugin] = None + instance = None + + def __init__(self): + super().__init__() + Logger.debug("EventListener init.") + SublimeModelinesPlugin.instance = self + + def on_load(self, view: sublime.View) -> None: + Logger.debug("on_load called.") + if settings.apply_on_load(): + do_modelines(view) + + def on_post_save(self, view: sublime.View) -> None: + Logger.debug("on_post_save called.") + if settings.apply_on_save(): + do_modelines(view) + + +# The command name will be `sublime_modelines_apply`. +# See [the rules to get command names]() for more info. +class SublimeModelinesApplyCommand(sublime_plugin.WindowCommand): + """Apply modelines in the given view.""" + + def run(self): + view = self.window.active_view() + if view is None: return + + do_modelines(view) + + +def do_modelines(view: sublime.View) -> None: + Logger.debug("Searching for and applying modelines.") + + view.erase_status(PLUGIN_NAME) + + nstart = settings.number_of_lines_to_check_from_beginning() + nend = settings.number_of_lines_to_check_from_end() + lines: List[sublime.Region] = [] + if nstart > 0: + # Grab lines from beginning of view. + regionEnd = view.text_point(nstart, 0) + region = sublime.Region(0, regionEnd) + lines = view.lines(region) + last_first_lines = lines[-1] if len(lines) > 0 else None + if nend > 0: + # Get the last line in the file. + line = view.line(view.size()) + # Add the last N lines of the file to the lines list. + for i in range(0, nend): + # Add the line to the list of lines + lines.append(line) + if line.a == 0: + # We are at the first line; let’s stop there. + break + # Move the line to the previous line + line = view.line(line.a - 1) + if not last_first_lines is None and line.a < last_first_lines.b: + # No overlapping lines. + break + + parsers = [parser_id.get_parser_with_data(settings, view) for parser_id in settings.modelines_formats()] + + for line in lines: + line = view.substr(line) + for (parser, parser_info) in parsers: + modeline: Optional[Modeline] + try: + modeline = parser.parse_line(line, parser_info) + except Exception as e: + Logger.warning(f"Got exception while parsing line with parser “{type(parser)}”. Ignoring. (Note: This should not have happened!) exception=“{e}”, line=“{line}”") + continue + + if not modeline is None: + Logger.debug(f"Found instructions in a line using parser “{type(parser)}”.") + for instruction in modeline.instructions: + try: + Logger.debug(f"-> Applying modeline instruction: {instruction}.") + instruction.apply(view) + except Exception as e: + Logger.warning(f"Got exception while applying modeline instruction. Ignoring. exception=“{e}”, line=“{line}”") + continue + + # We do not continue to the next parser. + break diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh new file mode 100755 index 0000000..6d0d7fd --- /dev/null +++ b/scripts/cleanup.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + + +# Note: Though not strictly equivalent, this could also be `git clean -xffd`… +find . \( -name "*.pyc" -o -name "__pycache__" -o -name "build" -o -name "dist" \) -exec rm -frv {} + diff --git a/setup.py b/setup.py deleted file mode 100644 index d204cc0..0000000 --- a/setup.py +++ /dev/null @@ -1,583 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Commands to build and manage .sublime-package archives with distutils.""" - -import os - -from distutils.core import Command -from distutils.filelist import FileList -from distutils.text_file import TextFile -from distutils import dir_util, dep_util, file_util, archive_util -from distutils import log -from distutils.core import setup -from distutils.errors import * - - -import os, string -import sys -from types import * -from glob import glob -from distutils.core import Command -from distutils import dir_util, dep_util, file_util, archive_util -from distutils.text_file import TextFile -from distutils.errors import * -from distutils.filelist import FileList -from distutils import log - -import os -from distutils.errors import DistutilsExecError -from distutils.spawn import spawn -from distutils.dir_util import mkpath -from distutils import log - -def make_zipfile (base_name, base_dir, verbose=0, dry_run=0): - """Create a zip file from all the files under 'base_dir'. The output - zip file will be named 'base_dir' + ".zip". Uses either the "zipfile" - Python module (if available) or the InfoZIP "zip" utility (if installed - and found on the default search path). If neither tool is available, - raises DistutilsExecError. Returns the name of the output zip file. - """ - try: - import zipfile - except ImportError: - zipfile = None - - zip_filename = base_name + ".sublime-package" - mkpath(os.path.dirname(zip_filename), dry_run=dry_run) - - # If zipfile module is not available, try spawning an external - # 'zip' command. - if zipfile is None: - if verbose: - zipoptions = "-r" - else: - zipoptions = "-rq" - - try: - spawn(["zip", zipoptions, zip_filename, base_dir], - dry_run=dry_run) - except DistutilsExecError: - # XXX really should distinguish between "couldn't find - # external 'zip' command" and "zip failed". - raise DistutilsExecError, \ - ("unable to create zip file '%s': " - "could neither import the 'zipfile' module nor " - "find a standalone zip utility") % zip_filename - - else: - log.info("creating '%s' and adding '%s' to it", - zip_filename, base_dir) - - if not dry_run: - z = zipfile.ZipFile(zip_filename, "w", - compression=zipfile.ZIP_DEFLATED) - - for dirpath, dirnames, filenames in os.walk(base_dir): - for name in filenames: - path = os.path.normpath(os.path.join(dirpath, name)) - if dirpath == base_dir: - arcname = name - else: - arcname = path - if os.path.isfile(path): - z.write(path, arcname) - log.info("adding '%s'" % path) - z.close() - - return zip_filename - - -def show_formats (): - """Print all possible values for the 'formats' option (used by - the "--help-formats" command-line option). - """ - from distutils.fancy_getopt import FancyGetopt - from distutils.archive_util import ARCHIVE_FORMATS - formats=[] - for format in ARCHIVE_FORMATS.keys(): - formats.append(("formats=" + format, None, - ARCHIVE_FORMATS[format][2])) - formats.sort() - pretty_printer = FancyGetopt(formats) - pretty_printer.print_help( - "List of available source distribution formats:") - -class spa (Command): - - description = "create a source distribution (tarball, zip file, etc.)" - - user_options = [ - ('template=', 't', - "name of manifest template file [default: MANIFEST.in]"), - ('manifest=', 'm', - "name of manifest file [default: MANIFEST]"), - ('use-defaults', None, - "include the default file set in the manifest " - "[default; disable with --no-defaults]"), - ('no-defaults', None, - "don't include the default file set"), - ('prune', None, - "specifically exclude files/directories that should not be " - "distributed (build tree, RCS/CVS dirs, etc.) " - "[default; disable with --no-prune]"), - ('no-prune', None, - "don't automatically exclude anything"), - ('manifest-only', 'o', - "just regenerate the manifest and then stop " - "(implies --force-manifest)"), - ('force-manifest', 'f', - "forcibly regenerate the manifest and carry on as usual"), - ('formats=', None, - "formats for source distribution (comma-separated list)"), - ('keep-temp', 'k', - "keep the distribution tree around after creating " + - "archive file(s)"), - ('dist-dir=', 'd', - "directory to put the source distribution archive(s) in " - "[default: dist]"), - ] - - boolean_options = ['use-defaults', 'prune', - 'manifest-only', 'force-manifest', - 'keep-temp'] - - help_options = [ - ('help-formats', None, - "list available distribution formats", show_formats), - ] - - negative_opt = {'no-defaults': 'use-defaults', - 'no-prune': 'prune' } - - default_format = { 'posix': 'gztar', - 'nt': 'zip' } - - def initialize_options (self): - # 'template' and 'manifest' are, respectively, the names of - # the manifest template and manifest file. - self.template = None - self.manifest = None - - # 'use_defaults': if true, we will include the default file set - # in the manifest - self.use_defaults = 1 - self.prune = 1 - - self.manifest_only = 0 - self.force_manifest = 0 - - self.formats = None - self.keep_temp = 0 - self.dist_dir = None - - self.archive_files = None - - - def finalize_options (self): - if self.manifest is None: - self.manifest = "MANIFEST" - if self.template is None: - self.template = "MANIFEST.in" - - self.ensure_string_list('formats') - if self.formats is None: - try: - self.formats = [self.default_format[os.name]] - except KeyError: - raise DistutilsPlatformError, \ - "don't know how to create source distributions " + \ - "on platform %s" % os.name - - bad_format = archive_util.check_archive_formats(self.formats) - if bad_format: - raise DistutilsOptionError, \ - "unknown archive format '%s'" % bad_format - - if self.dist_dir is None: - self.dist_dir = "dist" - - - def run (self): - - # 'filelist' contains the list of files that will make up the - # manifest - self.filelist = FileList() - - # Ensure that all required meta-data is given; warn if not (but - # don't die, it's not *that* serious!) - self.check_metadata() - - # Do whatever it takes to get the list of files to process - # (process the manifest template, read an existing manifest, - # whatever). File list is accumulated in 'self.filelist'. - self.get_file_list() - - # If user just wanted us to regenerate the manifest, stop now. - if self.manifest_only: - return - - # Otherwise, go ahead and create the source distribution tarball, - # or zipfile, or whatever. - self.make_distribution() - - - def check_metadata (self): - """Ensure that all required elements of meta-data (name, version, - URL, (author and author_email) or (maintainer and - maintainer_email)) are supplied by the Distribution object; warn if - any are missing. - """ - metadata = self.distribution.metadata - - missing = [] - for attr in ('name', 'version', 'url'): - if not (hasattr(metadata, attr) and getattr(metadata, attr)): - missing.append(attr) - - if missing: - self.warn("missing required meta-data: " + - string.join(missing, ", ")) - - if metadata.author: - if not metadata.author_email: - self.warn("missing meta-data: if 'author' supplied, " + - "'author_email' must be supplied too") - elif metadata.maintainer: - if not metadata.maintainer_email: - self.warn("missing meta-data: if 'maintainer' supplied, " + - "'maintainer_email' must be supplied too") - else: - self.warn("missing meta-data: either (author and author_email) " + - "or (maintainer and maintainer_email) " + - "must be supplied") - - # check_metadata () - - - def get_file_list (self): - """Figure out the list of files to include in the source - distribution, and put it in 'self.filelist'. This might involve - reading the manifest template (and writing the manifest), or just - reading the manifest, or just using the default file set -- it all - depends on the user's options and the state of the filesystem. - """ - - # If we have a manifest template, see if it's newer than the - # manifest; if so, we'll regenerate the manifest. - template_exists = os.path.isfile(self.template) - if template_exists: - template_newer = dep_util.newer(self.template, self.manifest) - - # The contents of the manifest file almost certainly depend on the - # setup script as well as the manifest template -- so if the setup - # script is newer than the manifest, we'll regenerate the manifest - # from the template. (Well, not quite: if we already have a - # manifest, but there's no template -- which will happen if the - # developer elects to generate a manifest some other way -- then we - # can't regenerate the manifest, so we don't.) - self.debug_print("checking if %s newer than %s" % - (self.distribution.script_name, self.manifest)) - setup_newer = dep_util.newer(self.distribution.script_name, - self.manifest) - - # cases: - # 1) no manifest, template exists: generate manifest - # (covered by 2a: no manifest == template newer) - # 2) manifest & template exist: - # 2a) template or setup script newer than manifest: - # regenerate manifest - # 2b) manifest newer than both: - # do nothing (unless --force or --manifest-only) - # 3) manifest exists, no template: - # do nothing (unless --force or --manifest-only) - # 4) no manifest, no template: generate w/ warning ("defaults only") - - manifest_outofdate = (template_exists and - (template_newer or setup_newer)) - force_regen = self.force_manifest or self.manifest_only - manifest_exists = os.path.isfile(self.manifest) - neither_exists = (not template_exists and not manifest_exists) - - # Regenerate the manifest if necessary (or if explicitly told to) - if manifest_outofdate or neither_exists or force_regen: - if not template_exists: - self.warn(("manifest template '%s' does not exist " + - "(using default file list)") % - self.template) - self.filelist.findall() - - if self.use_defaults: - self.add_defaults() - if template_exists: - self.read_template() - if self.prune: - self.prune_file_list() - - self.filelist.sort() - self.filelist.remove_duplicates() - self.write_manifest() - - # Don't regenerate the manifest, just read it in. - else: - self.read_manifest() - - # get_file_list () - - - def add_defaults (self): - """Add all the default files to self.filelist: - - README or README.txt - - setup.py - - test/test*.py - - all pure Python modules mentioned in setup script - - all C sources listed as part of extensions or C libraries - in the setup script (doesn't catch C headers!) - Warns if (README or README.txt) or setup.py are missing; everything - else is optional. - """ - - standards = [('README', 'README.txt'), self.distribution.script_name] - for fn in standards: - # XXX - if fn == 'setup.py': continue # We don't want setup.py - if type(fn) is TupleType: - alts = fn - got_it = 0 - for fn in alts: - if os.path.exists(fn): - got_it = 1 - self.filelist.append(fn) - break - - if not got_it: - self.warn("standard file not found: should have one of " + - string.join(alts, ', ')) - else: - if os.path.exists(fn): - self.filelist.append(fn) - else: - self.warn("standard file '%s' not found" % fn) - - optional = ['test/test*.py', 'setup.cfg'] - for pattern in optional: - files = filter(os.path.isfile, glob(pattern)) - if files: - self.filelist.extend(files) - - if self.distribution.has_pure_modules(): - build_py = self.get_finalized_command('build_py') - self.filelist.extend(build_py.get_source_files()) - - if self.distribution.has_ext_modules(): - build_ext = self.get_finalized_command('build_ext') - self.filelist.extend(build_ext.get_source_files()) - - if self.distribution.has_c_libraries(): - build_clib = self.get_finalized_command('build_clib') - self.filelist.extend(build_clib.get_source_files()) - - if self.distribution.has_scripts(): - build_scripts = self.get_finalized_command('build_scripts') - self.filelist.extend(build_scripts.get_source_files()) - - # add_defaults () - - - def read_template (self): - """Read and parse manifest template file named by self.template. - - (usually "MANIFEST.in") The parsing and processing is done by - 'self.filelist', which updates itself accordingly. - """ - log.info("reading manifest template '%s'", self.template) - template = TextFile(self.template, - strip_comments=1, - skip_blanks=1, - join_lines=1, - lstrip_ws=1, - rstrip_ws=1, - collapse_join=1) - - while 1: - line = template.readline() - if line is None: # end of file - break - - try: - self.filelist.process_template_line(line) - except DistutilsTemplateError, msg: - self.warn("%s, line %d: %s" % (template.filename, - template.current_line, - msg)) - - # read_template () - - - def prune_file_list (self): - """Prune off branches that might slip into the file list as created - by 'read_template()', but really don't belong there: - * the build tree (typically "build") - * the release tree itself (only an issue if we ran "spa" - previously with --keep-temp, or it aborted) - * any RCS, CVS, .svn, .hg, .git, .bzr, _darcs directories - """ - build = self.get_finalized_command('build') - base_dir = self.distribution.get_fullname() - - self.filelist.exclude_pattern(None, prefix=build.build_base) - self.filelist.exclude_pattern(None, prefix=base_dir) - - # pruning out vcs directories - # both separators are used under win32 - if sys.platform == 'win32': - seps = r'/|\\' - else: - seps = '/' - - vcs_dirs = ['RCS', 'CVS', r'\.svn', r'\.hg', r'\.git', r'\.bzr', - '_darcs'] - vcs_ptrn = r'(^|%s)(%s)(%s).*' % (seps, '|'.join(vcs_dirs), seps) - self.filelist.exclude_pattern(vcs_ptrn, is_regex=1) - - def write_manifest (self): - """Write the file list in 'self.filelist' (presumably as filled in - by 'add_defaults()' and 'read_template()') to the manifest file - named by 'self.manifest'. - """ - self.execute(file_util.write_file, - (self.manifest, self.filelist.files), - "writing manifest file '%s'" % self.manifest) - - # write_manifest () - - - def read_manifest (self): - """Read the manifest file (named by 'self.manifest') and use it to - fill in 'self.filelist', the list of files to include in the source - distribution. - """ - log.info("reading manifest file '%s'", self.manifest) - manifest = open(self.manifest) - while 1: - line = manifest.readline() - if line == '': # end of file - break - if line[-1] == '\n': - line = line[0:-1] - self.filelist.append(line) - manifest.close() - - # read_manifest () - - - def make_release_tree (self, base_dir, files): - """Create the directory tree that will become the source - distribution archive. All directories implied by the filenames in - 'files' are created under 'base_dir', and then we hard link or copy - (if hard linking is unavailable) those files into place. - Essentially, this duplicates the developer's source tree, but in a - directory named after the distribution, containing only the files - to be distributed. - """ - # Create all the directories under 'base_dir' necessary to - # put 'files' there; the 'mkpath()' is just so we don't die - # if the manifest happens to be empty. - self.mkpath(base_dir) - dir_util.create_tree(base_dir, files, dry_run=self.dry_run) - - # And walk over the list of files, either making a hard link (if - # os.link exists) to each one that doesn't already exist in its - # corresponding location under 'base_dir', or copying each file - # that's out-of-date in 'base_dir'. (Usually, all files will be - # out-of-date, because by default we blow away 'base_dir' when - # we're done making the distribution archives.) - - if hasattr(os, 'link'): # can make hard links on this system - link = 'hard' - msg = "making hard links in %s..." % base_dir - else: # nope, have to copy - link = None - msg = "copying files to %s..." % base_dir - - if not files: - log.warn("no files to distribute -- empty manifest?") - else: - log.info(msg) - for file in files: - if not os.path.isfile(file): - log.warn("'%s' not a regular file -- skipping" % file) - else: - dest = os.path.join(base_dir, file) - self.copy_file(file, dest, link=link) - - self.distribution.metadata.write_pkg_info(base_dir) - - # make_release_tree () - - def make_distribution (self): - """Create the source distribution(s). First, we create the release - tree with 'make_release_tree()'; then, we create all required - archive files (according to 'self.formats') from the release tree. - Finally, we clean up by blowing away the release tree (unless - 'self.keep_temp' is true). The list of archive files created is - stored so it can be retrieved later by 'get_archive_files()'. - """ - # Don't warn about missing meta-data here -- should be (and is!) - # done elsewhere. - # base_dir = self.distribution.get_fullname() - base_dir = self.distribution.get_name() - # XXX - base_dir = base_dir - base_name = os.path.join(self.dist_dir, base_dir) - - - self.make_release_tree(base_dir, self.filelist.files) - archive_files = [] # remember names of files we create - # tar archive must be created last to avoid overwrite and remove - if 'tar' in self.formats: - self.formats.append(self.formats.pop(self.formats.index('tar'))) - - for fmt in self.formats: - # file = self.make_archive(base_name, fmt, base_dir=base_dir) - file = make_zipfile(base_name, base_dir=base_dir) - archive_files.append(file) - self.distribution.dist_files.append(('spa', '', file)) - - self.archive_files = archive_files - - if not self.keep_temp: - dir_util.remove_tree(base_dir, dry_run=self.dry_run) - - def get_archive_files (self): - """Return the list of archive files created when the command - was run, or None if the command hasn't run yet. - """ - return self.archive_files - -# class spa - - -class install(Command): - """Does it make sense?""" - - user_options = [('aa', 'a', 'aa')] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - print NotImplementedError("Command not implemented yet.") - - -setup(cmdclass={'spa': spa, 'install': install}, - name='SublimeModelines', - version='1.1', - description='Vim-like modelines for Sublime Text.', - author='Guillermo López-Anglada', - author_email='guillermo@sublimetext.info', - url='http://sublimetext.info', - py_modules=['sublime_modelines.py'] - ) \ No newline at end of file diff --git a/snippets/EMacs Modeline.sublime-snippet b/snippets/EMacs Modeline.sublime-snippet new file mode 100644 index 0000000..8c4297b --- /dev/null +++ b/snippets/EMacs Modeline.sublime-snippet @@ -0,0 +1,11 @@ + + + + + + -*- + EMacs Modeline + + + diff --git a/snippets/Sublime Modeline.sublime-snippet b/snippets/Sublime Modeline.sublime-snippet new file mode 100644 index 0000000..f3245aa --- /dev/null +++ b/snippets/Sublime Modeline.sublime-snippet @@ -0,0 +1,11 @@ + + + + + + ~*~ + Sublime Modeline + + + diff --git a/snippets/VIM Modeline.sublime-snippet b/snippets/VIM Modeline.sublime-snippet new file mode 100644 index 0000000..b85d94e --- /dev/null +++ b/snippets/VIM Modeline.sublime-snippet @@ -0,0 +1,11 @@ + + + + + + vim: + VIM Modeline + + + diff --git a/sublime_modelines.py b/sublime_modelines.py deleted file mode 100644 index 7dc84e4..0000000 --- a/sublime_modelines.py +++ /dev/null @@ -1,119 +0,0 @@ -import sublime, sublime_plugin - -import re - - -MODELINE_PREFIX_TPL = "%s\\s*(st|sublime): " -DEFAULT_LINE_COMMENT = '#' -MULTIOPT_SEP = '; ' -MAX_LINES_TO_CHECK = 50 -LINE_LENGTH = 80 -MODELINES_REG_SIZE = MAX_LINES_TO_CHECK * LINE_LENGTH - - -def is_modeline(prefix, line): - return bool(re.match(prefix, line)) - - -def gen_modelines(view): - topRegEnd = min(MODELINES_REG_SIZE, view.size()) - candidates = view.lines(sublime.Region(0, view.full_line(topRegEnd).end())) - - # Consider modelines at the end of the buffer too. - # There might be overlap with the top region, but it doesn't matter because - # it means the buffer is tiny. - bottomRegStart = filter(lambda x: x > -1, - ((view.size() - MODELINES_REG_SIZE), 0))[0] - candidates += view.lines(sublime.Region(bottomRegStart, view.size())) - - prefix = build_modeline_prefix(view) - modelines = (view.substr(c) for c in candidates if is_modeline(prefix, view.substr(c))) - - for modeline in modelines: - yield modeline - - -def gen_raw_options(modelines): - for m in modelines: - opt = m.partition(':')[2].strip() - if MULTIOPT_SEP in opt: - for subopt in (s for s in opt.split(MULTIOPT_SEP)): - yield subopt - else: - yield opt - - -def gen_modeline_options(view): - modelines = gen_modelines(view) - for opt in gen_raw_options(modelines): - name, sep, value = opt.partition(' ') - yield view.settings().set, name.rstrip(':'), value.rstrip(';') - - -def get_line_comment_char(view): - commentChar = "" - commentChar2 = "" - try: - for pair in view.meta_info("shellVariables", 0): - if pair["name"] == "TM_COMMENT_START": - commentChar = pair["value"] - if pair["name"] == "TM_COMMENT_START_2": - commentChar2 = pair["value"] - if commentChar and commentChar2: - break - except TypeError: - pass - - if not commentChar2: - return re.escape(commentChar.strip()) - else: - return "(" + re.escape(commentChar.strip()) + "|" + re.escape(commentChar2.strip()) + ")" - -def build_modeline_prefix(view): - lineComment = get_line_comment_char(view).lstrip() or DEFAULT_LINE_COMMENT - return (MODELINE_PREFIX_TPL % lineComment) - - -def to_json_type(v): - """"Convert string value to proper JSON type. - """ - if v.lower() in ('true', 'false'): - v = v[0].upper() + v[1:].lower() - - try: - return eval(v, {}, {}) - except: - raise ValueError("Could not convert to JSON type.") - - -class ExecuteSublimeTextModeLinesCommand(sublime_plugin.EventListener): - """This plugin provides a feature similar to vim modelines. - Modelines set options local to the view by declaring them in the - source code file itself. - - Example: - mysourcecodefile.py - # sublime: gutter false - # sublime: translate_tab_to_spaces true - - The top as well as the bottom of the buffer is scanned for modelines. - MAX_LINES_TO_CHECK * LINE_LENGTH defines the size of the regions to be - scanned. - """ - def do_modelines(self, view): - for setter, name, value in gen_modeline_options(view): - if name == 'x_syntax': - view.set_syntax_file(value) - else: - try: - setter(name, to_json_type(value)) - except ValueError, e: - sublime.status_message("[SublimeModelines] Bad modeline detected.") - print "[SublimeModelines] Bad option detected: %s, %s" % (name, value) - print "[SublimeModelines] Tip: Keys cannot be empty strings." - - def on_load(self, view): - self.do_modelines(view) - - def on_post_save(self, view): - self.do_modelines(view) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sublime.py b/tests/sublime.py deleted file mode 100644 index 53f04ec..0000000 --- a/tests/sublime.py +++ /dev/null @@ -1,19 +0,0 @@ -# -#class View(object): -# pass -# -# -#class RegionSet(object): -# pass -# -# -#class Region(object): -# pass -# -# -#class Window(object): -# pass -# -# -#class Options(object): -# pass \ No newline at end of file diff --git a/tests/sublime_plugin.py b/tests/sublime_plugin.py deleted file mode 100644 index 4e09c54..0000000 --- a/tests/sublime_plugin.py +++ /dev/null @@ -1,18 +0,0 @@ -class Plugin(object): - pass - - -class ApplicationCommand(Plugin): - pass - - -class WindowCommand(Plugin): - pass - - -class TextCommand(Plugin): - pass - - -class EventListener(Plugin): - pass \ No newline at end of file diff --git a/tests/test_emacs_parser.py b/tests/test_emacs_parser.py new file mode 100644 index 0000000..939a546 --- /dev/null +++ b/tests/test_emacs_parser.py @@ -0,0 +1,46 @@ +from unittest import TestCase + +from ..app.modeline import Modeline +from ..app.modeline_instructions.set_view_setting import ModelineInstruction_SetViewSetting +from ..app.modeline_instructions_mapping import ModelineInstructionsMapping +from ..app.modeline_parsers.emacs import ModelineParser_Emacs + + + +class VIMModelineParsingTest(TestCase): + + def test_simple_case(self): + self.__test_parsing( + "# -*- setting1:key1 -*-", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ]) + ) + + def test_two_settings(self): + self.__test_parsing( + "/* -*- setting1:key1; setting2 -*- */", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("syntax", "setting2"), + ]) + ) + + def test_weird_chars(self): + self.__test_parsing( + 'dnl -*-setting1:key1; setting2:key2 ; setting3:key3;;;  setting4:" key4"-*-', + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ModelineInstruction_SetViewSetting("setting3", "key3"), + ModelineInstruction_SetViewSetting("setting4", " key4"), + ]) + ) + + + def __test_parsing(self, line: str, expected: Modeline): + parser = ModelineParser_Emacs(ModelineInstructionsMapping()) + print(parser.parse_line(line, None)) + self.assertEqual(parser.parse_line(line, None), expected) + +# Note: We don’t do another integration test as we have done it in the Sublime parser test (and the legacy+vim one). diff --git a/tests/test_legacy_parser.py b/tests/test_legacy_parser.py new file mode 100644 index 0000000..bb61190 --- /dev/null +++ b/tests/test_legacy_parser.py @@ -0,0 +1,75 @@ +from unittest import TestCase +from unittest.mock import Mock +import re + +from ..app.modeline import Modeline +from ..app.modeline_instructions.set_view_setting import ModelineInstruction_SetViewSetting +from ..app.modeline_parsers.legacy import ModelineParser_Legacy + + + +class LegacyModelineParsingTest(TestCase): + + def test_parsing_data_retrieval(self): + """Checks whether we retrieve the correct comment char.""" + parser = ModelineParser_Legacy() + + # Note for the tests in this method: retrieving the comment char is a private method in the parser, + # so we check the final parser data, which are the full modeline prefix regex. + + view = Mock() + view.meta_info = Mock(return_value=[{"name": "TM_COMMENT_START", "value": "#"}]) + self.assertEqual(parser.parser_data_for_view(view), "%s\\s*(st|sublime): " % re.escape("#")) + + view.meta_info = Mock(return_value=[{"name": "TM_COMMENT_START", "value": "//"}]) + self.assertEqual(parser.parser_data_for_view(view), "%s\\s*(st|sublime): " % re.escape("//")) + + view.meta_info = Mock(return_value=[{"name": "TM_COMMENT_START", "value": "/* "}]) + self.assertEqual(parser.parser_data_for_view(view), "%s\\s*(st|sublime): " % re.escape("/*")) + + view.meta_info = Mock(return_value=[{"name": "NOT_TM_COMMENT_START", "value": "//"}]) + self.assertEqual(parser.parser_data_for_view(view), "%s\\s*(st|sublime): " % re.escape("#")) # `#` is the default comment start (set in the parser). + + view.meta_info = Mock(return_value=None) + self.assertEqual(parser.parser_data_for_view(view), "%s\\s*(st|sublime): " % re.escape("#")) # `#` is the default comment start (set in the parser). + + def test_simple_case(self): + self.__test_parsing( + "#", + "# sublime: setting1 key1", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ]) + ) + + def test_two_settings(self): + self.__test_parsing( + ";", + "; sublime: setting1 key1; setting2 key2", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ]) + ) + + def test_weird_chars(self): + self.__test_parsing( + "dnl", + 'dnl st: setting1 key1; setting2 key2 ; setting3 key;3;  setting4 " key4"', + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ModelineInstruction_SetViewSetting("setting3", "key;3"), + ModelineInstruction_SetViewSetting("setting4", " key4"), + ]) + ) + + + def __test_parsing(self, comment_char: str, line: str, expected: Modeline): + view = Mock() + parser = ModelineParser_Legacy() + view.meta_info = Mock(return_value=[{"name": "TM_COMMENT_START", "value": comment_char}]) + #print(parser.parse_line(line, parser.parser_data_for_view(view))) + self.assertEqual(parser.parse_line(line, parser.parser_data_for_view(view)), expected) + +# Note: We don’t do another integration test as we have done it in the Sublime parser test (and the legacy+vim one). diff --git a/tests/test_legacy_vim_parser.py b/tests/test_legacy_vim_parser.py new file mode 100644 index 0000000..2efe617 --- /dev/null +++ b/tests/test_legacy_vim_parser.py @@ -0,0 +1,173 @@ +from typing import cast, Any, Optional + +from unittest import TestCase +from unittest.mock import Mock + +from sublime import View as SublimeView +from sublime import Window as SublimeWindow +from unittesting import DeferrableTestCase +import sublime + +from ..app.modeline import Modeline +from ..app.modeline_instruction import ModelineInstruction +from ..app.modeline_instructions.set_view_setting import ModelineInstruction_SetViewSetting +from ..app.modeline_instructions_mapping import ModelineInstructionsMapping +from ..app.modeline_parsers.legacy_vim import ModelineParser_LegacyVIM +from ..plugin import do_modelines + + + +class LegacyVIMModelineParsingTest(TestCase): + + def test_parsing_vim_compatibility_1(self): + self.__test_parsing( + "#", + "# vim: set ai noet ts=4:", + Modeline([ + ModelineInstruction_SetViewSetting("ai", True), + ModelineInstruction_SetViewSetting("noet", True), + ModelineInstruction_SetViewSetting("ts", 4), + ]) + ) + + def test_parsing_vim_compatibility_2(self): + self.__test_parsing( + "#", + "# vim:ai:et:ts=4:", + Modeline([ + ModelineInstruction_SetViewSetting("ai", True), + ModelineInstruction_SetViewSetting("et", True), + ModelineInstruction_SetViewSetting("ts", 4), + ]) + ) + + def test_parsing_vim_compatibility_3(self): + self.__test_parsing( + "#", + '# sublime:ai:et:ts=4:ignored_packages+="Makefile Improved":', + Modeline([ + ModelineInstruction_SetViewSetting("ai", True), + ModelineInstruction_SetViewSetting("et", True), + ModelineInstruction_SetViewSetting("ts", 4), + ModelineInstruction_SetViewSetting("ignored_packages", "Makefile Improved", ModelineInstruction.ValueModifier.ADD), + ]) + ) + + def test_parsing_vim_compatibility_4(self): + self.__test_parsing( + "#", + '# sublime:ai:et:ts=4:ignored_packages+=["Makefile Improved", "Vintage"]:', + Modeline([ + ModelineInstruction_SetViewSetting("ai", True), + ModelineInstruction_SetViewSetting("et", True), + ModelineInstruction_SetViewSetting("ts", 4), + ModelineInstruction_SetViewSetting("ignored_packages", ["Makefile Improved", "Vintage"], ModelineInstruction.ValueModifier.ADD), + ]) + ) + + def test_parsing_vim_compatibility_5(self): + self.__test_parsing( + "#", + '# sublime: set color_scheme="Packages/Color Scheme - Default/Monokai.tmTheme":', + Modeline([ModelineInstruction_SetViewSetting("color_scheme", "Packages/Color Scheme - Default/Monokai.tmTheme")]) + ) + + def test_parsing_legacy_compatibility(self): + # Note: The original test was more interesting. + # It parsed multiple lines at once and verified the resulting instructions contained all of the instructions from all of the lines. + # We have strayed too far from the original implementation for the test to make sense, so we do this middle ground instead. + # We could also remove the test completely, I guess… + for l, r in [ + ("# sublime: foo bar", Modeline([ModelineInstruction_SetViewSetting("foo", "bar")])), + ("# sublime: bar foo; foo bar", Modeline([ModelineInstruction_SetViewSetting("bar", "foo"), ModelineInstruction_SetViewSetting("foo", "bar")])), + ("# st: baz foob", Modeline([ModelineInstruction_SetViewSetting("baz", "foob")])), + ("# st: fibz zap; zup blah", Modeline([ModelineInstruction_SetViewSetting("fibz", "zap"), ModelineInstruction_SetViewSetting("zup", "blah")])), + ]: + self.__test_parsing("#", l, r) + + + def __test_parsing(self, comment_char: str, line: str, expected: Modeline): + parser = ModelineParser_LegacyVIM(ModelineInstructionsMapping()) + #print(parser.parse_line(line, comment_char)) + self.assertEqual(parser.parse_line(line, comment_char), expected) + + +class LegacyVIMModelineIntegrationTest(DeferrableTestCase): + + view: SublimeView + window: SublimeWindow + + def setUp(self): + # Make sure we have a window to work with. + s = sublime.load_settings("Preferences.sublime-settings") + s.set("close_windows_when_empty", False) + + # Set some plugin settings we require for the tests. + s = sublime.load_settings("Sublime Modelines.sublime-settings") + s.set("formats", ["classic+vim"]) + s.set("number_of_lines_to_check_from_beginning", 3) + s.set("number_of_lines_to_check_from_end", 3) + s.set("verbose", True) + + self.window = sublime.active_window() + self.view = self.window.new_file() + while self.view.is_loading(): + yield + + def tearDown(self): + if self.view: + self.view.set_scratch(True) + self.window.focus_view(self.view) + self.window.run_command("close_file") + + def test_modelines_1(self): + self.view.run_command("insert", {"characters": "# sublime:noet:ai:ts=3:\n"}) + self.window.run_command("sublime_modelines_apply") + self.assertEqual(self.view.settings().get("tab_size"), 3) + self.assertEqual(self.view.settings().get("auto_indent"), True) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), False) + + self.view.run_command("insert", {"characters": "# vim: ts=7:noai:et:\n"}) + self.window.run_command("sublime_modelines_apply") + self.assertEqual(self.view.settings().get("tab_size"), 7) + self.assertEqual(self.view.settings().get("auto_indent"), False) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), True) + + def test_modelines_2(self): + self.view.run_command("insert", {"characters": "# sublime:noet:ai:ts=3:\n"}) + self.window.run_command("sublime_modelines_apply") + self.assertEqual(self.view.settings().get("tab_size"), 3) + self.assertEqual(self.view.settings().get("auto_indent"), True) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), False) + + self.view.run_command("insert", {"characters": "// vim: ts=7:noai:et:\n"}) + self.window.run_command("sublime_modelines_apply") + self.assertEqual(self.view.settings().get("tab_size"), 3) + self.assertEqual(self.view.settings().get("auto_indent"), True) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), False) + + self.view.meta_info = Mock(return_value=[{"name": "TM_COMMENT_START", "value": "//"}]) + self.assertEqual(self.__find_comment_start(), "//") + # Call `do_modelines` directly instead of running the `sublime_modelines_apply` command. + # `do_modelines` is the underlying function that is called when running the command, + # however we need to pass our mocked view in order for the comment change to work. + # I tried changing the comment start another way, but that does not seem possible. + # Here’s a guy asking for something related . + do_modelines(self.view) + self.assertEqual(self.view.settings().get("tab_size"), 7) + self.assertEqual(self.view.settings().get("auto_indent"), False) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), True) + + + def __find_comment_start(self) -> Optional[str]: + commentChar = "" + try: + for pair in cast(Any, self.view.meta_info("shellVariables", 0)): + if pair["name"] == "TM_COMMENT_START": + commentChar = pair["value"] + if commentChar: + break + except TypeError: + pass + + return commentChar diff --git a/tests/test_sublime_modelines.py b/tests/test_sublime_modelines.py deleted file mode 100644 index 536f1fd..0000000 --- a/tests/test_sublime_modelines.py +++ /dev/null @@ -1,157 +0,0 @@ -import unittest -import sys -import os - -import mock - -import sublime - - -sys.path.extend([".."]) - -sublime.packagesPath = mock.Mock() -sublime.packagesPath.return_value = "XXX" - - -import sublime_plugin -import sublime_modelines - - -def pytest_funcarg__view(request): - view = mock.Mock() - return view - - -def test_get_line_comment_char_Does_meta_info_GetCorrectArgs(view): - sublime_modelines.get_line_comment_char(view) - - actual = view.meta_info.call_args - expected = (("shellVariables", 0), {}) - - assert actual == expected - - -def test_get_line_comment_char_DoWeGetLineCommentCharIfExists(view): - view.meta_info.return_value = [{ "name": "TM_COMMENT_START", "value": "#"}] - - expected = "#" - actual = sublime_modelines.get_line_comment_char(view) - - assert expected == actual - - -def test_get_line_comment_char_DoWeGetEmptyLineIfLineCommentCharDoesntExist(view): - view.meta_info.return_value = [{ "name": "NOT_TM_COMMENT_START", "value": "#"}] - - expected = "" - actual = sublime_modelines.get_line_comment_char(view) - - assert expected == actual - - -def test_get_line_comment_char_ShouldReturnEmptyStringIfNoExtraVariablesExist(view): - view.meta_info.return_value = None - - expected = "" - actual = sublime_modelines.get_line_comment_char(view) - - assert expected == actual - - -def test_build_modeline_prefix_AreDefaultsCorrect(): - actual = sublime_modelines.MODELINE_PREFIX_TPL % "TEST", sublime_modelines.DEFAULT_LINE_COMMENT - expected = "%s\\s*(st|sublime): " % "TEST", "#" - assert actual == expected - - -def test_BuildPrefixWithDynamicLineCommentChar(view): - view.meta_info.return_value = [{ "name": "TM_COMMENT_START", "value": "//"}] - expected = "%s\\s*(st|sublime): " % "//" - actual = sublime_modelines.build_modeline_prefix(view) - assert actual == expected - - -def test_BuildPrefixWithDefaultLineCommentChar(view): - view.meta_info.return_value = None - - expected = "%s\\s*(st|sublime): " % "#" - actual = sublime_modelines.build_modeline_prefix(view) - - assert expected == actual - - -def test_gen_modelines(view): - sublime.Region = mock.Mock() - view.substr.side_effect = lambda x: x - view.size.return_value = 0 - view.lines.return_value = [ - "# sublime: hello world", - "# sublime: hi there; it's me", - "#sublime: some modeline", - "random stuff" - ] - modelines = [ - "# sublime: hello world", - "# sublime: hi there; it's me", - "#sublime: some modeline" - ] * 2 # the buffer is so small that there's overlap top/bottom modelines. - - assert modelines == [l for l in sublime_modelines.gen_modelines(view)] - - -def test_gen_raw_options(): - mdls = [ - "# sublime: foo bar", - "# sublime: bar foo; foo bar", - "# st: baz foob", - "# st: fibz zap; zup blah" - ] - - actual = [ - "foo bar", - "bar foo", - "foo bar", - "baz foob", - "fibz zap", - "zup blah", - ] - - assert actual == [x for x in sublime_modelines.gen_raw_options(mdls)] - - -def test_gen_modeline_options(view): - set = view.settings().set - - gen_modelines = mock.Mock() - gen_modelines.return_value = ["# sublime: foo bar", - "# sublime: baz zoom"] - - gen_raw_options = mock.Mock() - gen_raw_options.return_value = ["foo bar", - "baz zoom"] - - sublime_modelines.gen_modelines = gen_modelines - sublime_modelines.gen_raw_options = gen_raw_options - - actual = [x for x in sublime_modelines.gen_modeline_options(view)] - assert [(set, "foo", "bar"), (set, "baz", "zoom")] == actual - - -def test_is_modeline(view): - sublime_modelines.build_modeline_prefix = mock.Mock(return_value="# sublime: ") - view.substr.return_value = "# sublime: " - assert sublime_modelines.is_modeline(view, 0) - - -def test_to_json_type(): - a = "1" - b = "1.0" - c = "false" - d = "true" - e = list() - - assert sublime_modelines.to_json_type(a) == 1 - assert sublime_modelines.to_json_type(b) == 1.0 - assert sublime_modelines.to_json_type(c) == False - assert sublime_modelines.to_json_type(d) == True - assert sublime_modelines.to_json_type(e) == e \ No newline at end of file diff --git a/tests/test_sublime_parser.py b/tests/test_sublime_parser.py new file mode 100644 index 0000000..51a2711 --- /dev/null +++ b/tests/test_sublime_parser.py @@ -0,0 +1,101 @@ +from unittest import TestCase + +from sublime import View as SublimeView +from sublime import Window as SublimeWindow +from unittesting import DeferrableTestCase +import sublime + +from ..app.modeline import Modeline +from ..app.modeline_instruction import ModelineInstruction +from ..app.modeline_instructions.call_view_function import ModelineInstruction_CallViewFunction +from ..app.modeline_instructions.set_view_setting import ModelineInstruction_SetViewSetting +from ..app.modeline_parsers.sublime import ModelineParser_Sublime +from ..plugin import do_modelines + + + +class SublimeModelineParsingTest(TestCase): + + def test_simple_case(self): + self.__test_parsing( + "# ~*~ sublime: setting1=key1 ~*~", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ]) + ) + + def test_two_settings(self): + self.__test_parsing( + "# ~*~ sublime: setting1=key1; setting2=key2 ~*~", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ]) + ) + + def test_weird_chars(self): + self.__test_parsing( + '# ~*~ sublime: setting1=key1;setting2=key2 ; setting3 =key3;;;  setting4 = " key;;4" ~*~', + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ModelineInstruction_SetViewSetting("setting3", "key3;"), + ModelineInstruction_SetViewSetting("setting4", " key;4"), + ]) + ) + + def test_settings_and_functions(self): + self.__test_parsing( + "# ~*~ sublime: setting1=key1; func() =42; setting2=key2 ~*~", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_CallViewFunction("func", 42), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ]) + ) + + + def __test_parsing(self, line: str, expected: Modeline): + parser = ModelineParser_Sublime() + #print(parser.parse_line(line, None)) + self.assertEqual(parser.parse_line(line, None), expected) + + +class SublimeModelineIntegrationTest(DeferrableTestCase): + + view: SublimeView + window: SublimeWindow + + def setUp(self): + # Make sure we have a window to work with. + s = sublime.load_settings("Preferences.sublime-settings") + s.set("close_windows_when_empty", False) + + # Set some plugin settings we require for the tests. + s = sublime.load_settings("Sublime Modelines.sublime-settings") + s.set("formats", ["default"]) + s.set("number_of_lines_to_check_from_beginning", 3) + s.set("number_of_lines_to_check_from_end", 3) + s.set("verbose", True) + + self.window = sublime.active_window() + self.view = self.window.new_file() + while self.view.is_loading(): + yield + + def tearDown(self): + if self.view: + self.view.set_scratch(True) + self.window.focus_view(self.view) + self.window.run_command("close_file") + + def test_modelines_1(self): + self.view.run_command("insert", {"characters": "/* ~*~ sublime: tab_size=7; translate_tabs_to_spaces=true ~*~ */\n"}) + self.window.run_command("sublime_modelines_apply") + self.assertEqual(self.view.settings().get("tab_size"), 7) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), True) + + self.view.run_command("insert", {"characters": "/* ~*~ sublime: tab_size=3; translate_tabs_to_spaces=false ~*~ */\n"}) + self.window.run_command("sublime_modelines_apply") + self.assertEqual(self.view.settings().get("tab_size"), 3) + self.assertEqual(self.view.settings().get("translate_tabs_to_spaces"), False) diff --git a/tests/test_vim_parser.py b/tests/test_vim_parser.py new file mode 100644 index 0000000..0daff6f --- /dev/null +++ b/tests/test_vim_parser.py @@ -0,0 +1,46 @@ +from unittest import TestCase + +from ..app.modeline import Modeline +from ..app.modeline_instructions.set_view_setting import ModelineInstruction_SetViewSetting +from ..app.modeline_instructions_mapping import ModelineInstructionsMapping +from ..app.modeline_parsers.vim import ModelineParser_VIM + + + +class VIMModelineParsingTest(TestCase): + + def test_simple_case(self): + self.__test_parsing( + "# vim: setting1=key1", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ]) + ) + + def test_two_settings(self): + self.__test_parsing( + "// vim: setting1=key1 setting2", + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", None), + ]) + ) + + def test_weird_chars(self): + self.__test_parsing( + 'dnl vim: setting1=key1 setting2=key2 setting3=key3;;;  setting4="key;;4"', + Modeline([ + ModelineInstruction_SetViewSetting("setting1", "key1"), + ModelineInstruction_SetViewSetting("setting2", "key2"), + ModelineInstruction_SetViewSetting("setting3", "key3;;;"), + ModelineInstruction_SetViewSetting("setting4", "key;;4"), + ]) + ) + + + def __test_parsing(self, line: str, expected: Modeline): + parser = ModelineParser_VIM(ModelineInstructionsMapping()) + #print(parser.parse_line(line, None)) + self.assertEqual(parser.parse_line(line, None), expected) + +# Note: We don’t do another integration test as we have done it in the Sublime parser test (and the legacy+vim one).