Repository: mwouts/jupytext
Pull request #429 (2020-02-11 00:34:19)

Add support for C# and F#
This provides support in Jupytext for C# and F# notebooks, cf. #427 

codecov[bot]: # [Codecov](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=h1) Report
> Merging [#429](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=desc) into [master](https://codecov.io/gh/mwouts/jupytext/commit/0dec43d03be1144f042d28cb8f91ebe2378669d8?src=pr&el=desc) will **decrease** coverage by `0.02%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/mwouts/jupytext/pull/429/graphs/tree.svg?width=650&token=a1pNjsiILi&height=150&src=pr)](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #429      +/-   ##
==========================================
- Coverage   98.94%   98.91%   -0.03%     
==========================================
  Files          75       75              
  Lines        7645     7484     -161     
==========================================
- Hits         7564     7403     -161     
  Misses         81       81
```


| [Impacted Files](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [jupytext/version.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-anVweXRleHQvdmVyc2lvbi5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [jupytext/languages.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-anVweXRleHQvbGFuZ3VhZ2VzLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [tests/test\_pep8.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9wZXA4LnB5) | `98.36% <0%> (-0.03%)` | :arrow_down: |
| [tests/test\_cli.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9jbGkucHk=) | `100% <0%> (ø)` | :arrow_up: |
| [tests/test\_black.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9ibGFjay5weQ==) | `100% <0%> (ø)` | :arrow_up: |
| [tests/test\_active\_cells.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9hY3RpdmVfY2VsbHMucHk=) | `100% <0%> (ø)` | :arrow_up: |
| [tests/test\_knitr\_spin.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9rbml0cl9zcGluLnB5) | `100% <0%> (ø)` | :arrow_up: |
| [tests/test\_compare.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9jb21wYXJlLnB5) | `100% <0%> (ø)` | :arrow_up: |
| [tests/test\_ipynb\_to\_rmd.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9pcHluYl90b19ybWQucHk=) | `100% <0%> (ø)` | :arrow_up: |
| [tests/conftest.py](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree#diff-dGVzdHMvY29uZnRlc3QucHk=) | `100% <0%> (ø)` | :arrow_up: |
| ... and [19 more](https://codecov.io/gh/mwouts/jupytext/pull/429/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=footer). Last update [0dec43d...fbf0ee9](https://codecov.io/gh/mwouts/jupytext/pull/429?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af4397df..9bcf294d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+1.3.4 (2020-02-??)
+------------------
+
+**Fixed**
+- `jupytext --to script *.ipynb` now computes the script extension for each notebook (#428) 
+- Fix shebang handling for languages with non-# comments, by Jonas Bushart (#434)
+
+
 1.3.3 (2020-01-27)
 ------------------
 
diff --git a/README.md b/README.md
index ccc8ec7f..0fca2fe3 100755
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ It can also convert these documents **into** Jupyter
 Notebooks, allowing you to synchronize content in both
 directions.
 
-The languages that are currently supported by Jupytext are: Julia, Python, R, Bash, Scheme, Clojure, Matlab, Octave, C++, q/kdb+, IDL, TypeScript, Javascript, Scala, Rust/Evxcr, PowerShell and Robot Framework. Extending Jupytext to more languages should be easy - read more at [CONTRIBUTING.md](https://github.com/mwouts/jupytext/blob/master/CONTRIBUTING.md). In addition, jupytext users can choose between two formats for notebooks as scripts:
+The languages that are currently supported by Jupytext are: Julia, Python, R, Bash, Scheme, Clojure, Matlab, Octave, C++, q/kdb+, IDL, TypeScript, Javascript, Scala, Rust/Evxcr, PowerShell, C#, F#, and Robot Framework. Extending Jupytext to more languages should be easy - read more at [CONTRIBUTING.md](https://github.com/mwouts/jupytext/blob/master/CONTRIBUTING.md). In addition, jupytext users can choose between two formats for notebooks as scripts:
 - The `percent` format, compatible with several IDEs, including Spyder, Hydrogen, VScode and PyCharm. In that format, cells are delimited with a commented `%%`.
 - The `light` format, designed for this project. Use that format to open standard scripts as notebooks, or to save notebooks as scripts with few cell markers - none when possible.
 
diff --git a/docs/introduction.md b/docs/introduction.md
index 1401dfac..a694e03d 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -10,7 +10,7 @@ Jupytext can save Jupyter notebooks as
 - Markdown and R Markdown documents,
 - Scripts in many languages.
 
-The languages that are currently supported by Jupytext are: Julia, Python, R, Bash, Scheme, Clojure, Matlab, Octave, C++, q/kdb+, IDL, TypeScript, Javascript, Scala, Rust/Evxcr, PowerShell and Robot Framework. Extending Jupytext to more languages should be easy - read more at [CONTRIBUTING.md](https://github.com/mwouts/jupytext/blob/master/CONTRIBUTING.md#). In addition, jupytext users can choose between two formats for notebooks as scripts:
+The languages that are currently supported by Jupytext are: Julia, Python, R, Bash, Scheme, Clojure, Matlab, Octave, C++, q/kdb+, IDL, TypeScript, Javascript, Scala, Rust/Evxcr, PowerShell, C#, F#,P and Robot Framework. Extending Jupytext to more languages should be easy - read more at [CONTRIBUTING.md](https://github.com/mwouts/jupytext/blob/master/CONTRIBUTING.md#). In addition, jupytext users can choose between two formats for notebooks as scripts:
 - The `percent` format, compatible with several IDEs, including Spyder, Hydrogen, VScode and PyCharm. In that format, cells are delimited with a commented `%%`.
 - The `light` format, designed for this project. Use that format to open standard scripts as notebooks, or to save notebooks as scripts with few cell markers - none when possible.
 
diff --git a/jupytext/cell_metadata.py b/jupytext/cell_metadata.py
index 1bbd3e79..82688a00 100644
--- a/jupytext/cell_metadata.py
+++ b/jupytext/cell_metadata.py
@@ -49,7 +49,7 @@ _IGNORE_CELL_METADATA = ','.join('-{}'.format(name) for name in [
     'autoscroll', 'collapsed', 'scrolled', 'trusted', 'ExecuteTime'] +
                                  _JUPYTEXT_CELL_METADATA)
 
-_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_\\.]+[a-zA-Z0-9_\\.]*$')
+_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_\.]+[a-zA-Z0-9_\.]*$')
 
 
 def _r_logical_values(pybool):
@@ -266,13 +266,6 @@ def rmd_options_to_metadata(options, use_runtools=False):
     return metadata.get('language') or language, metadata
 
 
-def metadata_to_md_options(metadata):
-    """Encode {'class':None, 'key':'value'} into 'class key="value"' """
-
-    return ' '.join(["{}={}".format(key, dumps(metadata[key]))
-                     if metadata[key] is not None else key for key in metadata])
-
-
 def try_eval_metadata(metadata, name):
     """Evaluate the metadata to a python object, if possible"""
     value = metadata[name]
@@ -345,7 +338,7 @@ def parse_key_equal_value(text):
     last_space_pos = text.rfind(' ')
 
     # Just an identifier?
-    if isidentifier(text[last_space_pos + 1:]):
+    if not text.startswith('--') and isidentifier(text[last_space_pos + 1:]):
         key = text[last_space_pos + 1:]
         value = None
         result = {key: value}
diff --git a/jupytext/cell_reader.py b/jupytext/cell_reader.py
index 2afb99cc..7bb10ef0 100644
--- a/jupytext/cell_reader.py
+++ b/jupytext/cell_reader.py
@@ -263,7 +263,7 @@ class BaseCellReader(object):
 class MarkdownCellReader(BaseCellReader):
     """Read notebook cells from Markdown documents"""
     comment = ''
-    start_code_re = re.compile(r"^```({})($|\s(.*)$)".format(
+    start_code_re = re.compile(r"^```(\s*)({})($|\s.*$)".format(
         '|'.join(_JUPYTER_LANGUAGES_LOWER_AND_UPPER).replace('+', '\\+')))
     non_jupyter_code_re = re.compile(r"^```")
     end_code_re = re.compile(r"^```\s*$")
diff --git a/jupytext/cell_to_text.py b/jupytext/cell_to_text.py
index 9dfb6c21..71a23354 100644
--- a/jupytext/cell_to_text.py
+++ b/jupytext/cell_to_text.py
@@ -3,7 +3,7 @@
 import re
 import warnings
 from copy import copy
-from .languages import cell_language, comment_lines
+from .languages import cell_language, comment_lines, same_language
 from .cell_metadata import is_active, _IGNORE_CELL_METADATA
 from .cell_metadata import metadata_to_text, metadata_to_rmd_options, metadata_to_double_percent_options
 from .metadata_filter import filter_metadata
@@ -37,16 +37,18 @@ class BaseCellExporter(object):
         self.metadata = filter_metadata(cell.metadata,
                                         self.fmt.get('cell_metadata_filter'),
                                         _IGNORE_CELL_METADATA)
-        self.language, magic_args = cell_language(self.source) if self.parse_cell_language else (None, None)
+        if self.parse_cell_language:
+            self.language, magic_args = cell_language(self.source, default_language)
 
-        if self.language:
             if magic_args:
                 self.metadata['magic_args'] = magic_args
+        else:
+            self.language = None
 
-            if not self.ext.endswith('.Rmd'):
-                self.metadata['language'] = self.language
+        if self.language and not self.ext.endswith('.Rmd'):
+            self.metadata['language'] = self.language
 
-        self.language = self.language or default_language
+        self.language = self.language or cell.metadata.get('language', default_language)
         self.default_language = default_language
         self.comment = _SCRIPT_EXTENSIONS.get(self.ext, {}).get('comment', '#')
         self.comment_magics = self.fmt.get('comment_magics', self.default_comment_magics)
@@ -252,7 +254,7 @@ class LightScriptCellExporter(BaseCellExporter):
 
     def code_to_text(self):
         """Return the text representation of a code cell"""
-        active = is_active(self.ext, self.metadata, self.language == self.default_language)
+        active = is_active(self.ext, self.metadata, same_language(self.language, self.default_language))
         source = copy(self.source)
         escape_code_start(source, self.ext, self.language)
         comment_questions = self.metadata.pop('comment_questions', True)
@@ -378,7 +380,7 @@ class DoublePercentCellExporter(BaseCellExporter):  # pylint: disable=W0223
         if self.cell_type != 'code':
             self.metadata['cell_type'] = self.cell_type
 
-        active = is_active(self.ext, self.metadata, self.language == self.default_language)
+        active = is_active(self.ext, self.metadata, same_language(self.language, self.default_language))
         if self.cell_type == 'raw' and 'active' in self.metadata and self.metadata['active'] == '':
             del self.metadata['active']
 
diff --git a/jupytext/cli.py b/jupytext/cli.py
index 2b250fe7..dff62c17 100644
--- a/jupytext/cli.py
+++ b/jupytext/cli.py
@@ -315,7 +315,7 @@ def jupytext_single_file(nb_file, args, log):
     # Compute actual extension when using script/auto, and update nb_dest if necessary
     dest_fmt = args.to
     if dest_fmt and dest_fmt['extension'] == '.auto':
-        check_auto_ext(dest_fmt, notebook.metadata, '--to')
+        dest_fmt = check_auto_ext(dest_fmt, notebook.metadata, '--to')
         if not args.output and nb_file != '-':
             nb_dest = full_path(base_path(nb_file, args.input_format), dest_fmt)
 
@@ -625,7 +625,7 @@ def pipe_notebook(notebook, command, fmt='py:percent', update=True, prefix=None)
         command = command + ' {}'
 
     fmt = long_form_one_format(fmt, notebook.metadata, auto_ext_requires_language_info=False)
-    check_auto_ext(fmt, notebook.metadata, '--pipe-fmt')
+    fmt = check_auto_ext(fmt, notebook.metadata, '--pipe-fmt')
     text = writes(notebook, fmt)
 
     command = command.split(' ')
diff --git a/jupytext/formats.py b/jupytext/formats.py
index dcd5e4de..521b829a 100644
--- a/jupytext/formats.py
+++ b/jupytext/formats.py
@@ -583,18 +583,22 @@ def auto_ext_from_metadata(metadata):
     if auto_ext == '.r':
         return '.R'
 
+    if auto_ext == '.fs':
+        return '.fsx'
+
     return auto_ext
 
 
 def check_auto_ext(fmt, metadata, option):
-    """Raise a ValueError when the .auto extension cannot be determined"""
+    """Replace the auto extension with the actual file extension, and raise a ValueError if it cannot be determined"""
     if fmt['extension'] != '.auto':
-        return
+        return fmt
 
     auto_ext = auto_ext_from_metadata(metadata)
     if auto_ext:
+        fmt = fmt.copy()
         fmt['extension'] = auto_ext
-        return
+        return fmt
 
     raise ValueError("The notebook does not have a 'language_info' metadata. "
                      "Please replace 'auto' with the actual language extension in the {} option (currently {})."
diff --git a/jupytext/header.py b/jupytext/header.py
index c23ff725..99e89bb7 100644
--- a/jupytext/header.py
+++ b/jupytext/header.py
@@ -47,7 +47,7 @@ def encoding_and_executable(notebook, metadata, ext):
     jupytext_metadata = metadata.get('jupytext', {})
 
     if comment is not None and 'executable' in jupytext_metadata:
-        lines.append(comment + '!' + jupytext_metadata.pop('executable'))
+        lines.append('#!' + jupytext_metadata.pop('executable'))
 
     if 'encoding' in jupytext_metadata:
         lines.append(jupytext_metadata.pop('encoding'))
@@ -144,7 +144,7 @@ def header_to_metadata_and_cell(lines, header_prefix, ext=None):
     encoding_re = re.compile(r'^[ \t\f]*{}.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)'.format(comment))
 
     for i, line in enumerate(lines):
-        if i == 0 and line.startswith(comment + '!'):
+        if i == 0 and line.startswith('#!'):
             metadata.setdefault('jupytext', {})['executable'] = line[2:]
             start = i + 1
             continue
diff --git a/jupytext/jupytext.py b/jupytext/jupytext.py
index 7cbb74e0..17baab9a 100644
--- a/jupytext/jupytext.py
+++ b/jupytext/jupytext.py
@@ -71,21 +71,22 @@ class TextNotebookConverter(NotebookReader, NotebookWriter):
         if self.implementation.format_name and self.implementation.format_name.startswith('sphinx'):
             cells.append(new_code_cell(source='%matplotlib inline'))
 
-        cell_metadata = set()
         cell_metadata_json = False
 
         while lines:
             reader = self.implementation.cell_reader_class(self.fmt, default_language)
             cell, pos = reader.read(lines)
             cells.append(cell)
-            cell_metadata.update(cell.metadata.keys())
             cell_metadata_json = cell_metadata_json or reader.cell_metadata_json
             if pos <= 0:
                 raise Exception('Blocked at lines ' + '\n'.join(lines[:6]))  # pragma: no cover
             lines = lines[pos:]
 
-        update_metadata_filters(metadata, jupyter_md, cell_metadata)
         set_main_and_cell_language(metadata, cells, self.implementation.extension)
+        cell_metadata = set()
+        for cell in cells:
+            cell_metadata.update(cell.metadata.keys())
+        update_metadata_filters(metadata, jupyter_md, cell_metadata)
 
         if cell_metadata_json:
             metadata.setdefault('jupytext', {}).setdefault('cell_metadata_json', True)
@@ -130,7 +131,9 @@ class TextNotebookConverter(NotebookReader, NotebookWriter):
             cells=nb.cells)
 
         metadata = nb.metadata
-        default_language = default_language_from_metadata_and_ext(metadata, self.implementation.extension) or 'python'
+        default_language = default_language_from_metadata_and_ext(metadata,
+                                                                  self.implementation.extension,
+                                                                  True) or 'python'
         self.update_fmt_with_notebook_options(nb.metadata)
         if 'use_runtools' not in self.fmt:
             for cell in nb.cells:
@@ -138,9 +141,6 @@ class TextNotebookConverter(NotebookReader, NotebookWriter):
                     self.fmt['use_runtools'] = True
                     break
 
-        if 'main_language' in metadata.get('jupytext', {}):
-            del metadata['jupytext']['main_language']
-
         header = encoding_and_executable(nb, metadata, self.ext)
         header_content, header_lines_to_next_cell = metadata_and_cell_to_header(nb, metadata,
                                                                                 self.implementation, self.ext)
diff --git a/jupytext/languages.py b/jupytext/languages.py
index cfeeea46..33aa25a0 100644
--- a/jupytext/languages.py
+++ b/jupytext/languages.py
@@ -24,23 +24,29 @@ _SCRIPT_EXTENSIONS = {'.py': {'language': 'python', 'comment': '#'},
                       '.ts': {'language': 'typescript', 'comment': '//'},
                       '.scala': {'language': 'scala', 'comment': '//'},
                       '.rs': {'language': 'rust', 'comment': '//'},
-                      '.robot': {'language': 'robotframework', 'comment': '#'}}
+                      '.robot': {'language': 'robotframework', 'comment': '#'},
+                      '.cs': {'language': 'csharp', 'comment': '//'},
+                      '.fsx': {'language': 'fsharp', 'comment': '//'},
+                      '.fs': {'language': 'fsharp', 'comment': '//'}}
 
 _COMMENT_CHARS = [_SCRIPT_EXTENSIONS[ext]['comment'] for ext in _SCRIPT_EXTENSIONS if
                   _SCRIPT_EXTENSIONS[ext]['comment'] != '#']
 
 _COMMENT = {_SCRIPT_EXTENSIONS[ext]['language']: _SCRIPT_EXTENSIONS[ext]['comment'] for ext in _SCRIPT_EXTENSIONS}
-_JUPYTER_LANGUAGES = set(_JUPYTER_LANGUAGES).union(_COMMENT.keys())
+_JUPYTER_LANGUAGES = set(_JUPYTER_LANGUAGES).union(_COMMENT.keys()).union(['c#', 'f#', 'cs', 'fs'])
 _JUPYTER_LANGUAGES_LOWER_AND_UPPER = _JUPYTER_LANGUAGES.union({str.upper(lang) for lang in _JUPYTER_LANGUAGES})
 
 
-def default_language_from_metadata_and_ext(metadata, ext):
+def default_language_from_metadata_and_ext(metadata, ext, pop_main_language=False):
     """Return the default language given the notebook metadata, and a file extension"""
     default_from_ext = _SCRIPT_EXTENSIONS.get(ext, {}).get('language')
 
-    language = (metadata.get('jupytext', {}).get('main_language')
-                or metadata.get('kernelspec', {}).get('language')
-                or default_from_ext)
+    main_language = metadata.get('jupytext', {}).get('main_language')
+    default_language = metadata.get('kernelspec', {}).get('language') or default_from_ext
+    language = main_language or default_language
+
+    if main_language is not None and main_language == default_language and pop_main_language:
+        metadata['jupytext'].pop('main_language')
 
     if language is None or language == 'R':
         return language
@@ -48,20 +54,28 @@ def default_language_from_metadata_and_ext(metadata, ext):
     if language.startswith('C++'):
         return 'c++'
 
-    return language.lower()
+    return language.lower().replace('#', 'sharp')
+
+
+def usual_language_name(language):
+    """Return the usual language name (one that may be found in _SCRIPT_EXTENSIONS above)"""
+    language = language.lower()
+    if language == 'r':
+        return 'R'
+    if language.startswith('c++'):
+        return 'c++'
+    if language == 'octave':
+        return 'matlab'
+    if language in ['cs', 'c#']:
+        return 'csharp'
+    if language in ['fs', 'f#']:
+        return 'fsharp'
+    return language
 
 
 def same_language(kernel_language, language):
     """Are those the same language?"""
-    if kernel_language == language:
-        return True
-    if kernel_language.lower() == language:
-        return True
-    if kernel_language.startswith('C++') and language == 'c++':
-        return True
-    if kernel_language == 'octave' and language == 'matlab':
-        return True
-    return False
+    return usual_language_name(kernel_language) == usual_language_name(language)
 
 
 def set_main_and_cell_language(metadata, cells, ext):
@@ -73,7 +87,7 @@ def set_main_and_cell_language(metadata, cells, ext):
         languages = {'python': 0.5}
         for cell in cells:
             if 'language' in cell['metadata']:
-                language = cell['metadata']['language']
+                language = usual_language_name(cell['metadata']['language'])
                 languages[language] = languages.get(language, 0.0) + 1
 
         main_language = max(languages, key=languages.get)
@@ -85,20 +99,35 @@ def set_main_and_cell_language(metadata, cells, ext):
     # Remove 'language' meta data and add a magic if not main language
     for cell in cells:
         if 'language' in cell['metadata']:
-            language = cell['metadata'].pop('language')
-            if language != main_language and language in _JUPYTER_LANGUAGES:
+            language = cell['metadata']['language']
+            if language == main_language:
+                cell['metadata'].pop('language')
+                continue
+
+            if usual_language_name(language) == main_language:
+                continue
+
+            if language in _JUPYTER_LANGUAGES:
+                cell['metadata'].pop('language')
+                magic = '%%' if main_language != 'csharp' else '#!'
                 if 'magic_args' in cell['metadata']:
                     magic_args = cell['metadata'].pop('magic_args')
-                    cell['source'] = u'%%{} {}\n'.format(language, magic_args) + cell['source']
+                    cell['source'] = u'{}{} {}\n'.format(magic, language, magic_args) + cell['source']
                 else:
-                    cell['source'] = u'%%{}\n'.format(language) + cell['source']
+                    cell['source'] = u'{}{}\n'.format(magic, language) + cell['source']
 
 
-def cell_language(source):
+def cell_language(source, default_language):
     """Return cell language and language options, if any"""
     if source:
         line = source[0]
-        if line.startswith('%%'):
+        if default_language == 'csharp':
+            if line.startswith('#!'):
+                lang = line[2:].strip()
+                if lang in _JUPYTER_LANGUAGES:
+                    source.pop(0)
+                    return lang, ''
+        elif line.startswith('%%'):
             magic = line[2:]
             if ' ' in magic:
                 lang, magic_args = magic.split(' ', 1)
diff --git a/jupytext/magics.py b/jupytext/magics.py
index 0b53ba61..4fc35277 100644
--- a/jupytext/magics.py
+++ b/jupytext/magics.py
@@ -2,7 +2,7 @@
 
 import re
 from .stringparser import StringParser
-from .languages import _SCRIPT_EXTENSIONS, _COMMENT
+from .languages import _SCRIPT_EXTENSIONS, _COMMENT, usual_language_name
 
 # A magic expression is a line or cell or metakernel magic (#94, #61) escaped zero, or multiple times
 _MAGIC_RE = {_SCRIPT_EXTENSIONS[ext]['language']: re.compile(
@@ -20,6 +20,11 @@ _MAGIC_RE['rust'] = re.compile(r"^(// |//)*:[a-zA-Z]")
 _MAGIC_FORCE_ESC_RE['rust'] = re.compile(r"^(// |//)*:[a-zA-Z](.*)//\s*escape")
 _MAGIC_FORCE_ESC_RE['rust'] = re.compile(r"^(// |//)*:[a-zA-Z](.*)//\s*noescape")
 
+# C# magics start with '#!'
+_MAGIC_RE['csharp'] = re.compile(r"^(// |//)*#![a-zA-Z]")
+_MAGIC_FORCE_ESC_RE['csharp'] = re.compile(r"^(// |//)*#![a-zA-Z](.*)//\s*escape")
+_MAGIC_FORCE_ESC_RE['csharp'] = re.compile(r"^(// |//)*#![a-zA-Z](.*)//\s*noescape")
+
 # Commands starting with a question or exclamation mark have to be escaped
 _PYTHON_HELP_OR_BASH_CMD = re.compile(r"^(# |#)*(\?|!)\s*[A-Za-z]")
 
@@ -37,6 +42,7 @@ _SCRIPT_LANGUAGES = [_SCRIPT_EXTENSIONS[ext]['language'] for ext in _SCRIPT_EXTE
 
 def is_magic(line, language, global_escape_flag=True, explicitly_code=False):
     """Is the current line a (possibly escaped) Jupyter magic, and should it be commented?"""
+    language = usual_language_name(language)
     if language in ['octave', 'matlab'] or language not in _SCRIPT_LANGUAGES:
         return False
     if _MAGIC_FORCE_ESC_RE[language].match(line):
diff --git a/jupytext/metadata_filter.py b/jupytext/metadata_filter.py
index 5c083ab9..d16fff08 100644
--- a/jupytext/metadata_filter.py
+++ b/jupytext/metadata_filter.py
@@ -75,8 +75,6 @@ def metadata_filter_as_string(metadata_filter):
 def update_metadata_filters(metadata, jupyter_md, cell_metadata):
     """Update or set the notebook and cell metadata filters"""
 
-    cell_metadata = [m for m in cell_metadata if m not in ['language', 'magic_args']]
-
     if 'cell_metadata_filter' in metadata.get('jupytext', {}):
         metadata_filter = metadata_filter_as_dict(metadata.get('jupytext', {})['cell_metadata_filter'])
         if isinstance(metadata_filter.get('excluded'), list):
diff --git a/jupytext/version.py b/jupytext/version.py
index 6b2562e3..28574526 100644
--- a/jupytext/version.py
+++ b/jupytext/version.py
@@ -1,3 +1,3 @@
 """Jupytext's version number"""
 
-__version__ = '1.3.3'
+__version__ = '1.3.3+dev'


Test patch:
diff --git a/tests/notebooks/ipynb_cs/csharp.ipynb b/tests/notebooks/ipynb_cs/csharp.ipynb
new file mode 100644
index 00000000..89704418
--- /dev/null
+++ b/tests/notebooks/ipynb_cs/csharp.ipynb
@@ -0,0 +1,152 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We start with..."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Hello World!\r\n"
+     ]
+    }
+   ],
+   "source": [
+    "Console.WriteLine(\"Hello World!\");"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Then we do a plot with Plotly, following the [Plotting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Plotting%20with%20Xplot.ipynb) example from `dotnet/interactive`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div id=\"9a70b50f-ec94-4c20-8fa8-e14290eb0440\" style=\"width: 900px; height: 500px;\"></div>\r\n",
+       "<script type=\"text/javascript\">\r\n",
+       "\r\n",
+       "var renderPlotly = function() {\r\n",
+       "    var xplotRequire = requirejs.config({context:'xplot-3.0.1',paths:{plotly:'https://cdn.plot.ly/plotly-1.49.2.min'}});\r\n",
+       "    xplotRequire(['plotly'], function(Plotly) {\r\n",
+       "\n",
+       "            var data = [{\"type\":\"bar\",\"x\":[\"A\",\"B\",\"C\"],\"y\":[1,3,2],\"name\":\"Bar\"}];\n",
+       "            var layout = {\"title\":\"A bar plot\"};\n",
+       "            Plotly.newPlot('9a70b50f-ec94-4c20-8fa8-e14290eb0440', data, layout);\n",
+       "        \r\n",
+       "});\r\n",
+       "};\r\n",
+       "if ((typeof(requirejs) !==  typeof(Function)) || (typeof(requirejs.config) !== typeof(Function))) { \r\n",
+       "    var script = document.createElement(\"script\"); \r\n",
+       "    script.setAttribute(\"src\", \"https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js\"); \r\n",
+       "    script.onload = function(){\r\n",
+       "        renderPlotly();\r\n",
+       "    };\r\n",
+       "    document.getElementsByTagName(\"head\")[0].appendChild(script); \r\n",
+       "}\r\n",
+       "else {\r\n",
+       "    renderPlotly();\r\n",
+       "}\r\n",
+       "</script>\r\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "using XPlot.Plotly;\n",
+    "\n",
+    "var bar = new Graph.Bar\n",
+    "{\n",
+    "    name = \"Bar\",\n",
+    "    x = new[] {'A', 'B', 'C'},\n",
+    "    y = new[] {1, 3, 2}\n",
+    "};\n",
+    "\n",
+    "var chart = Chart.Plot(new[] {bar});\n",
+    "chart.WithTitle(\"A bar plot\");\n",
+    "display(chart);"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We also test the math outputs as in the [Math and Latex](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Math%20and%20LaTeX.ipynb) example:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/latex": [
+       "\\begin{align} e^{i \\pi} = -1\\end{align}"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "(LaTeXString)@\"\\begin{align} e^{i \\pi} = -1\\end{align}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/latex": [
+       "$$e^{i \\pi} = -1$$"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "(MathString)@\"e^{i \\pi} = -1\""
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": ".NET (C#)",
+   "language": "C#",
+   "name": ".net-csharp"
+  },
+  "language_info": {
+   "file_extension": ".cs",
+   "mimetype": "text/x-csharp",
+   "name": "C#",
+   "pygments_lexer": "csharp",
+   "version": "8.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 1
+}
diff --git a/tests/notebooks/ipynb_fs/fsharp.ipynb b/tests/notebooks/ipynb_fs/fsharp.ipynb
new file mode 100644
index 00000000..a1048ca0
--- /dev/null
+++ b/tests/notebooks/ipynb_fs/fsharp.ipynb
@@ -0,0 +1,88 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "This notebook was inspired by [Plottting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/fsharp/Docs/Plotting%20with%20Xplot.ipynb)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "open XPlot.Plotly"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div id=\"401733f6-77ba-44c5-bb00-14b2beb80964\" style=\"width: 900px; height: 500px;\"></div>\r\n",
+       "<script type=\"text/javascript\">\r\n",
+       "\r\n",
+       "var renderPlotly = function() {\r\n",
+       "    var xplotRequire = requirejs.config({context:'xplot-3.0.1',paths:{plotly:'https://cdn.plot.ly/plotly-1.49.2.min'}});\r\n",
+       "    xplotRequire(['plotly'], function(Plotly) {\r\n",
+       "\n",
+       "            var data = [{\"type\":\"bar\",\"x\":[\"A\",\"B\",\"C\"],\"y\":[1,3,2],\"name\":\"Bar 1\"}];\n",
+       "            var layout = {\"title\":\"A sample bar plot\"};\n",
+       "            Plotly.newPlot('401733f6-77ba-44c5-bb00-14b2beb80964', data, layout);\n",
+       "        \r\n",
+       "});\r\n",
+       "};\r\n",
+       "if ((typeof(requirejs) !==  typeof(Function)) || (typeof(requirejs.config) !== typeof(Function))) { \r\n",
+       "    var script = document.createElement(\"script\"); \r\n",
+       "    script.setAttribute(\"src\", \"https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js\"); \r\n",
+       "    script.onload = function(){\r\n",
+       "        renderPlotly();\r\n",
+       "    };\r\n",
+       "    document.getElementsByTagName(\"head\")[0].appendChild(script); \r\n",
+       "}\r\n",
+       "else {\r\n",
+       "    renderPlotly();\r\n",
+       "}\r\n",
+       "</script>\r\n"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "let bar =\n",
+    "    Bar(\n",
+    "        name = \"Bar 1\",\n",
+    "        x = [\"A\"; \"B\"; \"C\"],\n",
+    "        y = [1; 3; 2])\n",
+    "\n",
+    "[bar]\n",
+    "|> Chart.Plot\n",
+    "|> Chart.WithTitle \"A sample bar plot\""
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": ".NET (F#)",
+   "language": "F#",
+   "name": ".net-fsharp"
+  },
+  "language_info": {
+   "file_extension": ".fs",
+   "mimetype": "text/x-fsharp",
+   "name": "C#",
+   "pygments_lexer": "fsharp",
+   "version": "4.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/notebooks/mirror/ipynb_to_Rmd/csharp.Rmd b/tests/notebooks/mirror/ipynb_to_Rmd/csharp.Rmd
new file mode 100644
index 00000000..04b7529a
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_Rmd/csharp.Rmd
@@ -0,0 +1,40 @@
+---
+jupyter:
+  kernelspec:
+    display_name: .NET (C#)
+    language: C#
+    name: .net-csharp
+---
+
+We start with...
+
+```{csharp}
+Console.WriteLine("Hello World!");
+```
+
+Then we do a plot with Plotly, following the [Plotting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Plotting%20with%20Xplot.ipynb) example from `dotnet/interactive`:
+
+```{csharp}
+using XPlot.Plotly;
+
+var bar = new Graph.Bar
+{
+    name = "Bar",
+    x = new[] {'A', 'B', 'C'},
+    y = new[] {1, 3, 2}
+};
+
+var chart = Chart.Plot(new[] {bar});
+chart.WithTitle("A bar plot");
+display(chart);
+```
+
+We also test the math outputs as in the [Math and Latex](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Math%20and%20LaTeX.ipynb) example:
+
+```{csharp}
+(LaTeXString)@"\begin{align} e^{i \pi} = -1\end{align}"
+```
+
+```{csharp}
+(MathString)@"e^{i \pi} = -1"
+```
diff --git a/tests/notebooks/mirror/ipynb_to_Rmd/fsharp.Rmd b/tests/notebooks/mirror/ipynb_to_Rmd/fsharp.Rmd
new file mode 100644
index 00000000..b3d886b8
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_Rmd/fsharp.Rmd
@@ -0,0 +1,25 @@
+---
+jupyter:
+  kernelspec:
+    display_name: .NET (F#)
+    language: F#
+    name: .net-fsharp
+---
+
+This notebook was inspired by [Plottting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/fsharp/Docs/Plotting%20with%20Xplot.ipynb).
+
+```{fsharp}
+open XPlot.Plotly
+```
+
+```{fsharp}
+let bar =
+    Bar(
+        name = "Bar 1",
+        x = ["A"; "B"; "C"],
+        y = [1; 3; 2])
+
+[bar]
+|> Chart.Plot
+|> Chart.WithTitle "A sample bar plot"
+```
diff --git a/tests/notebooks/mirror/ipynb_to_hydrogen/csharp.cs b/tests/notebooks/mirror/ipynb_to_hydrogen/csharp.cs
new file mode 100644
index 00000000..c513c83d
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_hydrogen/csharp.cs
@@ -0,0 +1,39 @@
+// ---
+// jupyter:
+//   kernelspec:
+//     display_name: .NET (C#)
+//     language: C#
+//     name: .net-csharp
+// ---
+
+// %% [markdown]
+// We start with...
+
+// %%
+Console.WriteLine("Hello World!");
+
+// %% [markdown]
+// Then we do a plot with Plotly, following the [Plotting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Plotting%20with%20Xplot.ipynb) example from `dotnet/interactive`:
+
+// %%
+using XPlot.Plotly;
+
+var bar = new Graph.Bar
+{
+    name = "Bar",
+    x = new[] {'A', 'B', 'C'},
+    y = new[] {1, 3, 2}
+};
+
+var chart = Chart.Plot(new[] {bar});
+chart.WithTitle("A bar plot");
+display(chart);
+
+// %% [markdown]
+// We also test the math outputs as in the [Math and Latex](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Math%20and%20LaTeX.ipynb) example:
+
+// %%
+(LaTeXString)@"\begin{align} e^{i \pi} = -1\end{align}"
+
+// %%
+(MathString)@"e^{i \pi} = -1"
diff --git a/tests/notebooks/mirror/ipynb_to_hydrogen/fsharp.fsx b/tests/notebooks/mirror/ipynb_to_hydrogen/fsharp.fsx
new file mode 100644
index 00000000..94307eb6
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_hydrogen/fsharp.fsx
@@ -0,0 +1,24 @@
+// ---
+// jupyter:
+//   kernelspec:
+//     display_name: .NET (F#)
+//     language: F#
+//     name: .net-fsharp
+// ---
+
+// %% [markdown]
+// This notebook was inspired by [Plottting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/fsharp/Docs/Plotting%20with%20Xplot.ipynb).
+
+// %%
+open XPlot.Plotly
+
+// %%
+let bar =
+    Bar(
+        name = "Bar 1",
+        x = ["A"; "B"; "C"],
+        y = [1; 3; 2])
+
+[bar]
+|> Chart.Plot
+|> Chart.WithTitle "A sample bar plot"
diff --git a/tests/notebooks/mirror/ipynb_to_md/csharp.md b/tests/notebooks/mirror/ipynb_to_md/csharp.md
new file mode 100644
index 00000000..ce3cc59d
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_md/csharp.md
@@ -0,0 +1,40 @@
+---
+jupyter:
+  kernelspec:
+    display_name: .NET (C#)
+    language: C#
+    name: .net-csharp
+---
+
+We start with...
+
+```csharp
+Console.WriteLine("Hello World!");
+```
+
+Then we do a plot with Plotly, following the [Plotting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Plotting%20with%20Xplot.ipynb) example from `dotnet/interactive`:
+
+```csharp
+using XPlot.Plotly;
+
+var bar = new Graph.Bar
+{
+    name = "Bar",
+    x = new[] {'A', 'B', 'C'},
+    y = new[] {1, 3, 2}
+};
+
+var chart = Chart.Plot(new[] {bar});
+chart.WithTitle("A bar plot");
+display(chart);
+```
+
+We also test the math outputs as in the [Math and Latex](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Math%20and%20LaTeX.ipynb) example:
+
+```csharp
+(LaTeXString)@"\begin{align} e^{i \pi} = -1\end{align}"
+```
+
+```csharp
+(MathString)@"e^{i \pi} = -1"
+```
diff --git a/tests/notebooks/mirror/ipynb_to_md/fsharp.md b/tests/notebooks/mirror/ipynb_to_md/fsharp.md
new file mode 100644
index 00000000..686325e4
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_md/fsharp.md
@@ -0,0 +1,25 @@
+---
+jupyter:
+  kernelspec:
+    display_name: .NET (F#)
+    language: F#
+    name: .net-fsharp
+---
+
+This notebook was inspired by [Plottting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/fsharp/Docs/Plotting%20with%20Xplot.ipynb).
+
+```fsharp
+open XPlot.Plotly
+```
+
+```fsharp
+let bar =
+    Bar(
+        name = "Bar 1",
+        x = ["A"; "B"; "C"],
+        y = [1; 3; 2])
+
+[bar]
+|> Chart.Plot
+|> Chart.WithTitle "A sample bar plot"
+```
diff --git a/tests/notebooks/mirror/ipynb_to_percent/csharp.cs b/tests/notebooks/mirror/ipynb_to_percent/csharp.cs
new file mode 100644
index 00000000..c513c83d
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_percent/csharp.cs
@@ -0,0 +1,39 @@
+// ---
+// jupyter:
+//   kernelspec:
+//     display_name: .NET (C#)
+//     language: C#
+//     name: .net-csharp
+// ---
+
+// %% [markdown]
+// We start with...
+
+// %%
+Console.WriteLine("Hello World!");
+
+// %% [markdown]
+// Then we do a plot with Plotly, following the [Plotting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Plotting%20with%20Xplot.ipynb) example from `dotnet/interactive`:
+
+// %%
+using XPlot.Plotly;
+
+var bar = new Graph.Bar
+{
+    name = "Bar",
+    x = new[] {'A', 'B', 'C'},
+    y = new[] {1, 3, 2}
+};
+
+var chart = Chart.Plot(new[] {bar});
+chart.WithTitle("A bar plot");
+display(chart);
+
+// %% [markdown]
+// We also test the math outputs as in the [Math and Latex](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Math%20and%20LaTeX.ipynb) example:
+
+// %%
+(LaTeXString)@"\begin{align} e^{i \pi} = -1\end{align}"
+
+// %%
+(MathString)@"e^{i \pi} = -1"
diff --git a/tests/notebooks/mirror/ipynb_to_percent/fsharp.fsx b/tests/notebooks/mirror/ipynb_to_percent/fsharp.fsx
new file mode 100644
index 00000000..94307eb6
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_percent/fsharp.fsx
@@ -0,0 +1,24 @@
+// ---
+// jupyter:
+//   kernelspec:
+//     display_name: .NET (F#)
+//     language: F#
+//     name: .net-fsharp
+// ---
+
+// %% [markdown]
+// This notebook was inspired by [Plottting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/fsharp/Docs/Plotting%20with%20Xplot.ipynb).
+
+// %%
+open XPlot.Plotly
+
+// %%
+let bar =
+    Bar(
+        name = "Bar 1",
+        x = ["A"; "B"; "C"],
+        y = [1; 3; 2])
+
+[bar]
+|> Chart.Plot
+|> Chart.WithTitle "A sample bar plot"
diff --git a/tests/notebooks/mirror/ipynb_to_script/csharp.cs b/tests/notebooks/mirror/ipynb_to_script/csharp.cs
new file mode 100644
index 00000000..8856abe3
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_script/csharp.cs
@@ -0,0 +1,34 @@
+// ---
+// jupyter:
+//   kernelspec:
+//     display_name: .NET (C#)
+//     language: C#
+//     name: .net-csharp
+// ---
+
+// We start with...
+
+Console.WriteLine("Hello World!");
+
+// Then we do a plot with Plotly, following the [Plotting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Plotting%20with%20Xplot.ipynb) example from `dotnet/interactive`:
+
+// +
+using XPlot.Plotly;
+
+var bar = new Graph.Bar
+{
+    name = "Bar",
+    x = new[] {'A', 'B', 'C'},
+    y = new[] {1, 3, 2}
+};
+
+var chart = Chart.Plot(new[] {bar});
+chart.WithTitle("A bar plot");
+display(chart);
+// -
+
+// We also test the math outputs as in the [Math and Latex](https://github.com/dotnet/interactive/blob/master/NotebookExamples/csharp/Docs/Math%20and%20LaTeX.ipynb) example:
+
+(LaTeXString)@"\begin{align} e^{i \pi} = -1\end{align}"
+
+(MathString)@"e^{i \pi} = -1"
diff --git a/tests/notebooks/mirror/ipynb_to_script/fsharp.fsx b/tests/notebooks/mirror/ipynb_to_script/fsharp.fsx
new file mode 100644
index 00000000..45980feb
--- /dev/null
+++ b/tests/notebooks/mirror/ipynb_to_script/fsharp.fsx
@@ -0,0 +1,22 @@
+// ---
+// jupyter:
+//   kernelspec:
+//     display_name: .NET (F#)
+//     language: F#
+//     name: .net-fsharp
+// ---
+
+// This notebook was inspired by [Plottting with XPlot](https://github.com/dotnet/interactive/blob/master/NotebookExamples/fsharp/Docs/Plotting%20with%20Xplot.ipynb).
+
+open XPlot.Plotly
+
+// +
+let bar =
+    Bar(
+        name = "Bar 1",
+        x = ["A"; "B"; "C"],
+        y = [1; 3; 2])
+
+[bar]
+|> Chart.Plot
+|> Chart.WithTitle "A sample bar plot"
diff --git a/tests/test_active_cells.py b/tests/test_active_cells.py
index 5f24fd0d..712c9c3d 100644
--- a/tests/test_active_cells.py
+++ b/tests/test_active_cells.py
@@ -53,12 +53,17 @@ ACTIVE_ALL = {'.py': """# + active="ipynb,py,R,Rmd"
                          'outputs': []}}
 
 
+def check_active_cell(ext, active_dict):
+    text = ('' if ext == '.py' else HEADER[ext]) + active_dict[ext]
+    nb = jupytext.reads(text, ext)
+    assert len(nb.cells) == 1
+    compare(jupytext.writes(nb, ext), text)
+    compare(nb.cells[0], active_dict['.ipynb'])
+
+
 @pytest.mark.parametrize('ext', ['.Rmd', '.md', '.py', '.R'])
 def test_active_all(ext, no_jupytext_version_number):
-    nb = jupytext.reads(HEADER[ext] + ACTIVE_ALL[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_ALL[ext])
-    compare(nb.cells[0], ACTIVE_ALL['.ipynb'])
+    check_active_cell(ext, ACTIVE_ALL)
 
 
 ACTIVE_IPYNB = {'.py': """# + active="ipynb"
@@ -90,10 +95,7 @@ ACTIVE_IPYNB = {'.py': """# + active="ipynb"
 @skip_if_dict_is_not_ordered
 @pytest.mark.parametrize('ext', ['.Rmd', '.md', '.py', '.R'])
 def test_active_ipynb(ext, no_jupytext_version_number):
-    nb = jupytext.reads(HEADER[ext] + ACTIVE_IPYNB[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_IPYNB[ext])
-    compare(nb.cells[0], ACTIVE_IPYNB['.ipynb'])
+    check_active_cell(ext, ACTIVE_IPYNB)
 
 
 ACTIVE_IPYNB_RMD_USING_TAG = {'.py': """# + tags=["active-ipynb-Rmd"]
@@ -125,10 +127,7 @@ ACTIVE_IPYNB_RMD_USING_TAG = {'.py': """# + tags=["active-ipynb-Rmd"]
 @skip_if_dict_is_not_ordered
 @pytest.mark.parametrize('ext', ['.Rmd', '.md', '.py', '.R'])
 def test_active_ipynb_rmd_using_tags(ext, no_jupytext_version_number):
-    nb = jupytext.reads(HEADER[ext] + ACTIVE_IPYNB_RMD_USING_TAG[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_IPYNB_RMD_USING_TAG[ext])
-    compare(nb.cells[0], ACTIVE_IPYNB_RMD_USING_TAG['.ipynb'])
+    check_active_cell(ext, ACTIVE_IPYNB_RMD_USING_TAG)
 
 
 ACTIVE_IPYNB_RSPIN = {'.R': """#+ active="ipynb", eval=FALSE
@@ -176,10 +175,7 @@ ACTIVE_PY_IPYNB = {'.py': """# + active="ipynb,py"
 @skip_if_dict_is_not_ordered
 @pytest.mark.parametrize('ext', ['.Rmd', '.md', '.py', '.R'])
 def test_active_py_ipynb(ext, no_jupytext_version_number):
-    nb = jupytext.reads(HEADER[ext] + ACTIVE_PY_IPYNB[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_PY_IPYNB[ext])
-    compare(nb.cells[0], ACTIVE_PY_IPYNB['.ipynb'])
+    check_active_cell(ext, ACTIVE_PY_IPYNB)
 
 
 ACTIVE_PY_R_IPYNB = {'.py': """# + active="ipynb,py,R"
@@ -203,10 +199,7 @@ ACTIVE_PY_R_IPYNB = {'.py': """# + active="ipynb,py,R"
 @skip_if_dict_is_not_ordered
 @pytest.mark.parametrize('ext', ['.Rmd', '.py', '.R'])
 def test_active_py_r_ipynb(ext, no_jupytext_version_number):
-    nb = jupytext.reads(HEADER[ext] + ACTIVE_PY_R_IPYNB[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_PY_R_IPYNB[ext])
-    compare(nb.cells[0], ACTIVE_PY_R_IPYNB['.ipynb'])
+    check_active_cell(ext, ACTIVE_PY_R_IPYNB)
 
 
 ACTIVE_RMD = {'.py': """# + active="Rmd"
@@ -227,10 +220,7 @@ ACTIVE_RMD = {'.py': """# + active="Rmd"
 @skip_if_dict_is_not_ordered
 @pytest.mark.parametrize('ext', ['.Rmd', '.py', '.R'])
 def test_active_rmd(ext, no_jupytext_version_number):
-    nb = jupytext.reads(HEADER[ext] + ACTIVE_RMD[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_RMD[ext])
-    compare(nb.cells[0], ACTIVE_RMD['.ipynb'])
+    check_active_cell(ext, ACTIVE_RMD)
 
 
 ACTIVE_NOT_INCLUDE_RMD = {'.py': """# + tags=["remove_cell"] active="Rmd"
@@ -252,7 +242,4 @@ ACTIVE_NOT_INCLUDE_RMD = {'.py': """# + tags=["remove_cell"] active="Rmd"
 @skip_if_dict_is_not_ordered
 @pytest.mark.parametrize('ext', ['.Rmd', '.py', '.R'])
 def test_active_not_include_rmd(ext, no_jupytext_version_number):
-    nb = jupytext.reads(ACTIVE_NOT_INCLUDE_RMD[ext], ext)
-    assert len(nb.cells) == 1
-    compare(jupytext.writes(nb, ext), ACTIVE_NOT_INCLUDE_RMD[ext])
-    compare(nb.cells[0], ACTIVE_NOT_INCLUDE_RMD['.ipynb'])
+    check_active_cell(ext, ACTIVE_NOT_INCLUDE_RMD)
diff --git a/tests/test_auto_ext.py b/tests/test_auto_ext.py
index 0813e8bd..feaacdbc 100644
--- a/tests/test_auto_ext.py
+++ b/tests/test_auto_ext.py
@@ -32,6 +32,8 @@ def test_auto_from_kernelspecs_works(nb_file):
         pytest.skip('No file_extension in language_info')
     if expected_ext == '.r':
         expected_ext = '.R'
+    elif expected_ext == '.fs':
+        expected_ext = '.fsx'
     auto_ext = auto_ext_from_metadata(nb.metadata)
     assert auto_ext == expected_ext
 
diff --git a/tests/test_cell_metadata.py b/tests/test_cell_metadata.py
index 0a86ce26..8a79d1cb 100644
--- a/tests/test_cell_metadata.py
+++ b/tests/test_cell_metadata.py
@@ -164,3 +164,8 @@ def test_values_with_equal_signs_inside(text='python string="value=5"',
 def test_incorrectly_encoded(text="this is an incorrect expression d={{4 b=3"):
     value = text_to_metadata(text, allow_title=True)
     assert metadata_to_text(*value) == text
+
+
+def test_incorrectly_encoded_json(text='this is an incorrect expression {"d": "}'):
+    value = text_to_metadata(text, allow_title=True)
+    assert metadata_to_text(*value) == text
diff --git a/tests/test_mirror.py b/tests/test_mirror.py
index 8143409b..ddacf0c9 100644
--- a/tests/test_mirror.py
+++ b/tests/test_mirror.py
@@ -35,7 +35,7 @@ def assert_conversion_same_as_mirror(nb_file, fmt, mirror_name, compare_notebook
     file_name, org_ext = os.path.splitext(basename)
     fmt = long_form_one_format(fmt)
     notebook = jupytext.read(nb_file, fmt=fmt)
-    check_auto_ext(fmt, notebook.metadata, '')
+    fmt = check_auto_ext(fmt, notebook.metadata, '')
     ext = fmt['extension']
     mirror_file = os.path.join(dirname, '..', 'mirror', mirror_name, full_path(file_name, fmt))
 
diff --git a/tests/test_read_dotnet_try_markdown.py b/tests/test_read_dotnet_try_markdown.py
new file mode 100644
index 00000000..25296a39
--- /dev/null
+++ b/tests/test_read_dotnet_try_markdown.py
@@ -0,0 +1,39 @@
+import jupytext
+from jupytext.cell_metadata import parse_key_equal_value
+from jupytext.compare import compare
+
+
+def test_parse_metadata():
+    assert parse_key_equal_value('--key value --key-2 .\\a\\b.cs') == \
+           {'incorrectly_encoded_metadata': '--key value --key-2 .\\a\\b.cs'}
+
+
+def test_parse_double_hyphen_metadata():
+    assert parse_key_equal_value('--key1 value1 --key2 value2') == \
+           {'incorrectly_encoded_metadata': '--key1 value1 --key2 value2'}
+
+
+def test_read_dotnet_try_markdown(md='''This is a dotnet/try Markdown file, inspired
+from this [post](https://devblogs.microsoft.com/dotnet/creating-interactive-net-documentation/)
+
+``` cs --region methods --source-file .\\myapp\\Program.cs --project .\\myapp\\myapp.csproj
+var name ="Rain";
+Console.WriteLine($"Hello {name.ToUpper()}!");
+```
+'''):
+    # Read the notebook
+    nb = jupytext.reads(md, fmt='.md')
+    assert nb.metadata['jupytext']['main_language'] == 'csharp'
+    assert len(nb.cells) == 2
+    assert nb.cells[0].cell_type == 'markdown'
+    assert nb.cells[1].cell_type == 'code'
+    assert nb.cells[1].source == """var name ="Rain";
+Console.WriteLine($"Hello {name.ToUpper()}!");"""
+    compare(nb.cells[1].metadata, {
+        'language': 'cs',
+        'incorrectly_encoded_metadata':
+            '--region methods --source-file .\\myapp\\Program.cs --project .\\myapp\\myapp.csproj'})
+
+    # Round trip to Markdown
+    md2 = jupytext.writes(nb, 'md')
+    compare(md2, md.replace('``` cs', '```cs'))
diff --git a/tests/test_read_simple_csharp.py b/tests/test_read_simple_csharp.py
new file mode 100644
index 00000000..10fdfdc6
--- /dev/null
+++ b/tests/test_read_simple_csharp.py
@@ -0,0 +1,78 @@
+import pytest
+import jupytext
+from jupytext.compare import compare
+
+
+@pytest.mark.parametrize('lang', ['cs', 'c#', 'csharp'])
+def test_simple_cs(lang):
+    source = """// A Hello World! program in C#.
+Console.WriteLine("Hello World!");
+"""
+    md = """```{lang}
+{source}
+```
+""".format(lang=lang, source=source)
+    nb = jupytext.reads(md, 'md')
+    assert nb.metadata['jupytext']['main_language'] == 'csharp'
+    assert len(nb.cells) == 1
+    assert nb.cells[0].cell_type == 'code'
+
+    cs = jupytext.writes(nb, 'cs')
+    assert source in cs
+    if lang != 'csharp':
+        assert cs.startswith('// + language="{lang}"'.format(lang=lang))
+
+    md2 = jupytext.writes(nb, 'md')
+    compare(md2, md)
+
+
+@pytest.mark.parametrize('lang', ['cs', 'c#', 'csharp'])
+def test_csharp_magics(no_jupytext_version_number, lang):
+    md = """```{lang}
+#!html
+<b>Hello!</b>
+```
+""".format(lang=lang)
+    nb = jupytext.reads(md, 'md')
+    nb.metadata['jupytext'].pop('notebook_metadata_filter')
+    nb.metadata['jupytext'].pop('cell_metadata_filter')
+    assert nb.metadata['jupytext']['main_language'] == 'csharp'
+    assert len(nb.cells) == 1
+    assert nb.cells[0].cell_type == 'code'
+
+    cs = jupytext.writes(nb, 'cs')
+    assert all(line.startswith('//') for line in cs.splitlines()), cs
+
+    md2 = jupytext.writes(nb, 'md')
+    md_expected = """---
+jupyter:
+  jupytext:
+    main_language: csharp
+---
+
+```html
+<b>Hello!</b>
+```
+"""
+    compare(md2, md_expected)
+
+
+def test_read_html_cell_from_md(no_jupytext_version_number):
+    md = """---
+jupyter:
+  jupytext:
+    main_language: csharp
+---
+
+```html
+<b>Hello!</b>
+```
+"""
+
+    nb = jupytext.reads(md, 'md')
+    assert len(nb.cells) == 1
+    assert nb.cells[0].cell_type == 'code'
+    compare(nb.cells[0].source, "#!html\n<b>Hello!</b>")
+
+    md2 = jupytext.writes(nb, 'md')
+    compare(md2, md)
diff --git a/tests/test_read_simple_markdown.py b/tests/test_read_simple_markdown.py
index d358d794..c821b380 100644
--- a/tests/test_read_simple_markdown.py
+++ b/tests/test_read_simple_markdown.py
@@ -490,7 +490,7 @@ a = 1
     assert nb.cells[1].source == 'a = 1'
 
     text2 = jupytext.writes(nb, 'md')
-    compare(text2, text.replace('```IDL', '```idl'))
+    compare(text2, text)
 
 
 def test_inactive_cell(text='''```python active="md"
diff --git a/tests/test_read_simple_python.py b/tests/test_read_simple_python.py
index 1271a8f7..63417efc 100644
--- a/tests/test_read_simple_python.py
+++ b/tests/test_read_simple_python.py
@@ -534,7 +534,7 @@ print('Hello world')
     compare(pynb2, pynb)
 
 
-def test_read_write_script_with_metadata_241(pynb="""#!/usr/bin/env python3
+def test_read_write_script_with_metadata_241(no_jupytext_version_number, pynb="""#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 # ---
 # jupyter:
@@ -542,8 +542,6 @@ def test_read_write_script_with_metadata_241(pynb="""#!/usr/bin/env python3
 #     text_representation:
 #       extension: .py
 #       format_name: light
-#       format_version: '1.4'
-#       jupytext_version: 1.1.2
 #   kernelspec:
 #     display_name: Python 3
 #     language: python
@@ -559,11 +557,7 @@ a + 1
     assert 'encoding' in nb.metadata['jupytext']
     pynb2 = jupytext.writes(nb, 'py')
 
-    # remove version information
-    def remove_version_info(text):
-        return '\n'.join([line for line in text.splitlines() if 'version' not in line])
-
-    compare(remove_version_info(pynb2), remove_version_info(pynb))
+    compare(pynb2, pynb)
 
 
 def test_notebook_blank_lines(script="""# +
diff --git a/tests/test_read_simple_rust.py b/tests/test_read_simple_rust.py
index b8c1b1f6..72dad373 100644
--- a/tests/test_read_simple_rust.py
+++ b/tests/test_read_simple_rust.py
@@ -43,3 +43,26 @@ pub fn fib(x: i32) -> i32 {
 ::std::mem::drop''')
     ]))
     compare(jupytext.writes(nb, 'rs'), text)
+
+
+def test_read_write_script_with_metadata_241(no_jupytext_version_number, rsnb="""#!/usr/bin/env scriptisto
+// ---
+// jupyter:
+//   jupytext:
+//     text_representation:
+//       extension: .rs
+//       format_name: light
+//   kernelspec:
+//     display_name: Rust
+//     language: rust
+//     name: rust
+// ---
+
+let mut a: i32 = 2;
+a += 1;
+"""):
+    nb = jupytext.reads(rsnb, 'rs')
+    assert 'executable' in nb.metadata['jupytext']
+    rsnb2 = jupytext.writes(nb, 'rs')
+
+    compare(rsnb, rsnb2)