├── .rustfmt.toml ├── tests ├── empty │ ├── source │ │ └── empty.tex │ └── target │ │ └── empty.tex ├── tabsize │ ├── tex-fmt.toml │ ├── cli.txt │ ├── source │ │ └── tabsize.tex │ └── target │ │ └── tabsize.tex ├── lists │ ├── tex-fmt.toml │ ├── source │ │ └── lists.tex │ └── target │ │ └── lists.tex ├── verbatim │ ├── tex-fmt.toml │ ├── source │ │ └── verbatim.tex │ └── target │ │ └── verbatim.tex ├── wrap_chars │ ├── tex-fmt.toml │ ├── source │ │ ├── wrap_chars_chinese.tex │ │ └── wrap_chars.tex │ └── target │ │ ├── wrap_chars_chinese.tex │ │ └── wrap_chars.tex ├── linear_map_chinese │ └── tex-fmt.toml ├── no_indent_envs │ ├── tex-fmt.toml │ ├── source │ │ └── no_indent_envs.tex │ └── target │ │ └── no_indent_envs.tex ├── environments │ ├── source │ │ ├── document.tex │ │ └── environment_lines.tex │ └── target │ │ ├── document.tex │ │ └── environment_lines.tex ├── readme │ ├── source │ │ └── readme.tex │ └── target │ │ └── readme.tex ├── unicode │ ├── source │ │ └── unicode.tex │ └── target │ │ └── unicode.tex ├── sections │ ├── source │ │ └── sections.tex │ └── target │ │ └── sections.tex ├── ignore │ ├── target │ │ └── ignore.tex │ └── source │ │ └── ignore.tex ├── comments │ ├── source │ │ └── comments.tex │ └── target │ │ └── comments.tex ├── verb │ ├── source │ │ ├── verbplus.tex │ │ └── verb.tex │ └── target │ │ ├── verbplus.tex │ │ └── verb.tex ├── wrap │ ├── source │ │ ├── heavy_wrap.tex │ │ └── wrap.tex │ └── target │ │ ├── heavy_wrap.tex │ │ └── wrap.tex ├── brackets │ ├── source │ │ └── brackets.tex │ └── target │ │ └── brackets.tex ├── short_document │ ├── source │ │ └── short_document.tex │ └── target │ │ └── short_document.tex ├── higher_categories_thesis │ ├── target │ │ └── quiver.sty │ └── source │ │ └── quiver.sty ├── cv │ ├── source │ │ ├── wgu-cv.cls │ │ └── cv.tex │ └── target │ │ ├── wgu-cv.cls │ │ └── cv.tex ├── phd_dissertation │ ├── source │ │ └── puthesis.cls │ └── target │ │ └── puthesis.cls └── masters_dissertation │ ├── source │ └── ociamthesis.cls │ └── target │ └── ociamthesis.cls ├── overlay.nix ├── .gitattributes ├── tex-fmt.toml ├── .pre-commit-hooks.yaml ├── .gitignore ├── default.nix ├── man ├── README.md └── tex-fmt.1 ├── extra ├── prof.sh ├── latex.sh ├── logo.py ├── card.py ├── perf.sh ├── binary.sh └── logo.svg ├── ctan ├── README.md └── latex-formatter.pkg ├── flake.nix ├── shell.nix ├── src ├── comments.rs ├── lib.rs ├── bin.rs ├── search.rs ├── write.rs ├── wasm.rs ├── read.rs ├── verbatim.rs ├── regexes.rs ├── ignore.rs ├── cli.rs ├── subs.rs ├── command.rs ├── wrap.rs ├── config.rs ├── logging.rs ├── tests.rs └── indent.rs ├── LICENSE ├── completion ├── README.md ├── tex-fmt.fish ├── _tex-fmt ├── tex-fmt.bash ├── tex-fmt.elv └── _tex-fmt.ps1 ├── Cargo.toml ├── flake.lock ├── web ├── index.js └── index.html ├── justfile ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── notes.org └── NEWS.md /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | -------------------------------------------------------------------------------- /tests/empty/source/empty.tex: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/empty/target/empty.tex: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/tabsize/tex-fmt.toml: -------------------------------------------------------------------------------- 1 | tabsize = 4 2 | -------------------------------------------------------------------------------- /tests/lists/tex-fmt.toml: -------------------------------------------------------------------------------- 1 | lists = ["myitemize"] 2 | -------------------------------------------------------------------------------- /tests/tabsize/cli.txt: -------------------------------------------------------------------------------- 1 | --tabsize 4 2 | --wraplen 80 3 | -------------------------------------------------------------------------------- /tests/verbatim/tex-fmt.toml: -------------------------------------------------------------------------------- 1 | verbatims = ["myverbatim"] 2 | -------------------------------------------------------------------------------- /tests/wrap_chars/tex-fmt.toml: -------------------------------------------------------------------------------- 1 | wrap-chars = [",", ",", "。", ":", ";", "?"] 2 | -------------------------------------------------------------------------------- /overlay.nix: -------------------------------------------------------------------------------- 1 | _: prev: { 2 | tex-fmt = prev.callPackage ./default.nix {}; 3 | } 4 | -------------------------------------------------------------------------------- /tests/linear_map_chinese/tex-fmt.toml: -------------------------------------------------------------------------------- 1 | wrap-chars = [",", "。", ":", ";", "?"] 2 | -------------------------------------------------------------------------------- /tests/no_indent_envs/tex-fmt.toml: -------------------------------------------------------------------------------- 1 | no-indent-envs = ["mydocument", "myproof"] 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/** linguist-vendored 2 | completion/** linguist-vendored 3 | man/** linguist-vendored 4 | -------------------------------------------------------------------------------- /tests/environments/source/document.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Documents should not be globally indented. 6 | 7 | \end{document} 8 | -------------------------------------------------------------------------------- /tests/environments/target/document.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Documents should not be globally indented. 6 | 7 | \end{document} 8 | -------------------------------------------------------------------------------- /tex-fmt.toml: -------------------------------------------------------------------------------- 1 | # tex-fmt.toml 2 | check = false 3 | print = false 4 | wrap = true 5 | wraplen = 80 6 | tabsize = 2 7 | tabchar = "space" 8 | stdin = false 9 | verbosity = "warn" 10 | lists = [] 11 | no-indent-envs = [] 12 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: tex-fmt 2 | name: Format LaTeX files 3 | language: rust 4 | entry: tex-fmt 5 | args: [--fail-on-change] 6 | files: \.(tex|bib|cls|sty)$ 7 | stages: [pre-commit, pre-merge-commit, pre-push, manual] -------------------------------------------------------------------------------- /tests/wrap_chars/source/wrap_chars_chinese.tex: -------------------------------------------------------------------------------- 1 | 的不变子空间包括可交换的算子的值域与零空间,其自身及其多项式的值域与子空间自然也包含在内。特别的,自身零空间的子空间与包含值域的子空间也是不变子空间。子空间的交与和也不变。 2 | 3 | 从线性变换的角度看,这是一个从\(V\) 到\(\F\) 的线性映射,也就是一个\textbf{线性泛函}。从某种意义上,线性泛函就是一个行向量。这样理解不仅可以省去不断把线性泛函看作函数而非``对象'', 4 | -------------------------------------------------------------------------------- /tests/wrap_chars/target/wrap_chars_chinese.tex: -------------------------------------------------------------------------------- 1 | 的不变子空间包括可交换的算子的值域与零空间,其自身及其多项式的值域与子空间自然也包含在内。特别的, 2 | 自身零空间的子空间与包含值域的子空间也是不变子空间。子空间的交与和也不变。 3 | 4 | 从线性变换的角度看,这是一个从\(V\) 到\(\F\) 的线性映射,也就是一个\textbf{线性泛函}。从某种意义上, 5 | 线性泛函就是一个行向量。这样理解不仅可以省去不断把线性泛函看作函数而非``对象'', 6 | -------------------------------------------------------------------------------- /tests/readme/source/readme.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \begin{itemize} 6 | \item Lists with items 7 | over multiple lines 8 | \end{itemize} 9 | 10 | \begin{equation} 11 | E = m c^2 12 | \end{equation} 13 | 14 | \end{document} 15 | -------------------------------------------------------------------------------- /tests/tabsize/source/tabsize.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \begin{itemize} 6 | \item Lists with items 7 | over multiple lines 8 | \end{itemize} 9 | 10 | \begin{equation} 11 | E = m c^2 12 | \end{equation} 13 | 14 | \end{document} 15 | -------------------------------------------------------------------------------- /tests/readme/target/readme.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \begin{itemize} 6 | \item Lists with items 7 | over multiple lines 8 | \end{itemize} 9 | 10 | \begin{equation} 11 | E = m c^2 12 | \end{equation} 13 | 14 | \end{document} 15 | -------------------------------------------------------------------------------- /tests/tabsize/target/tabsize.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \begin{itemize} 6 | \item Lists with items 7 | over multiple lines 8 | \end{itemize} 9 | 10 | \begin{equation} 11 | E = m c^2 12 | \end{equation} 13 | 14 | \end{document} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /debug/ 2 | /target/ 3 | **/*.rs.bk 4 | *.pdb 5 | /result 6 | *.log 7 | flamegraph.svg 8 | perf.data* 9 | *.csv 10 | *.pdf 11 | *.png 12 | cachegrind.out 13 | /web/pkg 14 | /ctan/latex-formatter/ 15 | *.tar.gz 16 | *.aux 17 | *.fdb_latexmk 18 | *.fls 19 | *.synctex.gz 20 | *.dvi 21 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: let 2 | manifest = (pkgs.lib.importTOML ./Cargo.toml).package; 3 | in 4 | pkgs.rustPlatform.buildRustPackage rec { 5 | pname = manifest.name; 6 | version = manifest.version; 7 | cargoLock.lockFile = ./Cargo.lock; 8 | src = pkgs.lib.cleanSource ./.; 9 | } 10 | -------------------------------------------------------------------------------- /tests/no_indent_envs/source/no_indent_envs.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Documents are not indented. 6 | 7 | \begin{mydocument} 8 | 9 | Neither is this environment. 10 | 11 | \begin{proof} 12 | 13 | This environment is indented. 14 | 15 | \begin{myproof} 16 | 17 | But not this custom environment. 18 | 19 | \end{myproof} 20 | 21 | \end{proof} 22 | 23 | \end{mydocument} 24 | 25 | \end{document} 26 | -------------------------------------------------------------------------------- /tests/no_indent_envs/target/no_indent_envs.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Documents are not indented. 6 | 7 | \begin{mydocument} 8 | 9 | Neither is this environment. 10 | 11 | \begin{proof} 12 | 13 | This environment is indented. 14 | 15 | \begin{myproof} 16 | 17 | But not this custom environment. 18 | 19 | \end{myproof} 20 | 21 | \end{proof} 22 | 23 | \end{mydocument} 24 | 25 | \end{document} 26 | -------------------------------------------------------------------------------- /man/README.md: -------------------------------------------------------------------------------- 1 | # Man page generation for tex-fmt 2 | 3 | A man page can be generated at run-time using the 4 | `--man` flag, as follows. 5 | 6 | ```shell 7 | mkdir -p man/man1 8 | tex-fmt --man > man/man1/tex-fmt.1 9 | MANPATH="$PWD/man" man tex-fmt 10 | ``` 11 | 12 | It is also available for download in 13 | [this directory]( 14 | https://github.com/WGUNDERWOOD/tex-fmt/tree/main/man/), 15 | but may not be up-to-date with your tex-fmt installation. 16 | -------------------------------------------------------------------------------- /tests/unicode/source/unicode.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | This is a long line with a unicode arrow in the middle of it ↓ which should be split correctly 6 | 7 | Here an indent begins ( 8 | and should not be closed with this arrow and comment ↓% 9 | until the next parenthesis 10 | ) 11 | 12 | This line contains some French accent characters éééééééééééééééééééééééééééééé 13 | which include zero-width chars, so look narrower than they are. 14 | 15 | \end{document} 16 | -------------------------------------------------------------------------------- /tests/unicode/target/unicode.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | This is a long line with a unicode arrow in the middle of it ↓ which 6 | should be split correctly 7 | 8 | Here an indent begins ( 9 | and should not be closed with this arrow and comment ↓% 10 | until the next parenthesis 11 | ) 12 | 13 | This line contains some French accent characters éééééééééééééééééééééééééééééé 14 | which include zero-width chars, so look narrower than they are. 15 | 16 | \end{document} 17 | -------------------------------------------------------------------------------- /tests/sections/source/sections.tex: -------------------------------------------------------------------------------- 1 | \documentclass{book} 2 | 3 | \begin{document} 4 | 5 | \section{Section test} 6 | 7 | Sectioning commands should be moved to their own lines.\subsection{Result} Even if there is more than one.\subsection{Result 2} 8 | 9 | Also \section*{A} unnumbered sectioning commands \subsection*{B} should be split onto their own lines, even if there \subsubsection*{C} is more than one. 10 | 11 | All of this \part{D} should also hold \part*{E} for parts \chapter{F} and chapters \chapter*{G}. 12 | 13 | \end{document} 14 | -------------------------------------------------------------------------------- /tests/sections/target/sections.tex: -------------------------------------------------------------------------------- 1 | \documentclass{book} 2 | 3 | \begin{document} 4 | 5 | \section{Section test} 6 | 7 | Sectioning commands should be moved to their own lines. 8 | \subsection{Result} Even if there is more than one. 9 | \subsection{Result 2} 10 | 11 | Also 12 | \section*{A} unnumbered sectioning commands 13 | \subsection*{B} should be split onto their own lines, even if there 14 | \subsubsection*{C} is more than one. 15 | 16 | All of this 17 | \part{D} should also hold 18 | \part*{E} for parts 19 | \chapter{F} and chapters 20 | \chapter*{G}. 21 | 22 | \end{document} 23 | -------------------------------------------------------------------------------- /tests/ignore/target/ignore.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Lines which end with the ignore keyword are not indented or wrapped even if they are long % tex-fmt: skip 6 | 7 | % tex-fmt: off 8 | It is also possible to ignore blocks 9 | of lines together and not indent them 10 | even like this 11 | % tex-fmt: on 12 | 13 | Not ignored 14 | 15 | % tex-fmt: on 16 | 17 | Not ignored 18 | 19 | % tex-fmt: off 20 | 21 | Ignored 22 | 23 | % tex-fmt: off 24 | 25 | Ignored 26 | 27 | % tex-fmt: on 28 | 29 | Not ignored 30 | 31 | % tex-fmt: off 32 | 33 | Ignored 34 | 35 | \end{document} 36 | -------------------------------------------------------------------------------- /extra/prof.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Making flamegraph profile" 3 | DIR="$(mktemp -d)" 4 | cp -r ../tests/* "$DIR" 5 | CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release 6 | BIN="../target/release/tex-fmt" 7 | 8 | mv "$DIR"/*/source/* "$DIR" 9 | rm "$DIR"/*/target/* 10 | find "$DIR" -name "*.toml" -delete 11 | find "$DIR" -name "*.txt" -delete 12 | find "$DIR"/* -empty -type d -delete 13 | find "$DIR" -empty -type d -delete 14 | 15 | echo -n "Test files: $(find "$DIR" | wc -l) files, " 16 | echo -n "$(wc -l --total=only "$DIR"/*) lines, " 17 | du -hs "$DIR" | cut -f 1 18 | echo 19 | 20 | flamegraph -F 10000 -- "$BIN" "$DIR"/* 21 | -------------------------------------------------------------------------------- /tests/comments/source/comments.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | % Comments should be indented along with other text 6 | (these parentheses 7 | make the middle line here 8 | % and this comment aligns with the text 9 | indented as usual) 10 | 11 | % Comments do not directly affect indenting, 12 | % so they can contain arbitrary brackets (((( 13 | % which may not match. 14 | 15 | % Similarly they might contain \begin{align} 16 | unmatched % environment tags. 17 | 18 | This is a percent sign \% and not a comment 19 | 20 | Some lines might have both \% percents % and comments \end{align} 21 | 22 | \end{document} 23 | -------------------------------------------------------------------------------- /tests/ignore/source/ignore.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Lines which end with the ignore keyword are not indented or wrapped even if they are long % tex-fmt: skip 6 | 7 | % tex-fmt: off 8 | It is also possible to ignore blocks 9 | of lines together and not indent them 10 | even like this 11 | % tex-fmt: on 12 | 13 | Not ignored 14 | 15 | % tex-fmt: on 16 | 17 | Not ignored 18 | 19 | % tex-fmt: off 20 | 21 | Ignored 22 | 23 | % tex-fmt: off 24 | 25 | Ignored 26 | 27 | % tex-fmt: on 28 | 29 | Not ignored 30 | 31 | % tex-fmt: off 32 | 33 | Ignored 34 | 35 | \end{document} 36 | -------------------------------------------------------------------------------- /tests/comments/target/comments.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | % Comments should be indented along with other text 6 | (these parentheses 7 | make the middle line here 8 | % and this comment aligns with the text 9 | indented as usual) 10 | 11 | % Comments do not directly affect indenting, 12 | % so they can contain arbitrary brackets (((( 13 | % which may not match. 14 | 15 | % Similarly they might contain \begin{align} 16 | unmatched % environment tags. 17 | 18 | This is a percent sign \% and not a comment 19 | 20 | Some lines might have both \% percents % and comments \end{align} 21 | 22 | \end{document} 23 | -------------------------------------------------------------------------------- /ctan/README.md: -------------------------------------------------------------------------------- 1 | # latex-formatter 2 | 3 | An extremely fast LaTeX formatter written in Rust. 4 | 5 | ## Description 6 | 7 | This package provides the `tex-fmt` command line tool 8 | for formatting LaTeX source files. It implements indentation 9 | and line wrapping rules which are aware of TeX and LaTeX syntax, 10 | including comments and various environments. 11 | It aims to offer some simple configuration options while maintaining 12 | very good run-time performance. 13 | The tool is written in Rust, and binaries are provided for Linux, MacOS and Windows. 14 | 15 | - Author: William George Underwood, wg.underwood13@gmail.com 16 | - License: MIT 17 | -------------------------------------------------------------------------------- /tests/lists/source/lists.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \begin{itemize} 6 | 7 | \item Lists with items on one line 8 | 9 | \item Lists with items 10 | on multiple lines 11 | 12 | % comments before a list item 13 | \item Another item 14 | 15 | \item Another item 16 | % comments inside a list item 17 | Or even just % trailing comments 18 | 19 | \item Every \item should start \item a new line 20 | 21 | \end{itemize} 22 | 23 | \begin{myitemize} 24 | 25 | \item Custom list environments can be specified in the configuration file. 26 | 27 | \end{myitemize} 28 | 29 | Commands such as itemsep should not be affected. 30 | \setlength{\itemsep}{0pt} 31 | 32 | \end{document} 33 | -------------------------------------------------------------------------------- /tests/verb/source/verbplus.tex: -------------------------------------------------------------------------------- 1 | In case of double column layout, tables which do not fit in single column width should be set to full text width. For this, you need to use \verb+\begin{table*}+ \verb+...+ \verb+\end{table*}+ instead of \verb+\begin{table}+ \verb+...+ \verb+\end{table}+ environment. Lengthy tables which do not fit in textwidth should be set as rotated table. For this, you need to use \verb+\begin{sidewaystable}+ \verb+...+ \verb+\end{sidewaystable}+ instead of \verb+\begin{table*}+ \verb+...+ \verb+\end{table*}+ environment. This environment puts tables rotated to single column width. For tables rotated to double column width, use \verb+\begin{sidewaystable*}+ \verb+...+ \verb+\end{sidewaystable*}+. 2 | -------------------------------------------------------------------------------- /tests/lists/target/lists.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \begin{itemize} 6 | 7 | \item Lists with items on one line 8 | 9 | \item Lists with items 10 | on multiple lines 11 | 12 | % comments before a list item 13 | \item Another item 14 | 15 | \item Another item 16 | % comments inside a list item 17 | Or even just % trailing comments 18 | 19 | \item Every 20 | \item should start 21 | \item a new line 22 | 23 | \end{itemize} 24 | 25 | \begin{myitemize} 26 | 27 | \item Custom list environments can be specified in the configuration file. 28 | 29 | \end{myitemize} 30 | 31 | Commands such as itemsep should not be affected. 32 | \setlength{\itemsep}{0pt} 33 | 34 | \end{document} 35 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "LaTeX formatter written in Rust"; 3 | inputs = { 4 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | }; 7 | outputs = { 8 | self, 9 | nixpkgs, 10 | flake-utils, 11 | }: 12 | flake-utils.lib.eachDefaultSystem ( 13 | system: let 14 | pkgs = import nixpkgs {inherit system;}; 15 | in { 16 | packages = { 17 | default = pkgs.callPackage ./default.nix {inherit pkgs;}; 18 | }; 19 | devShells = { 20 | default = pkgs.callPackage ./shell.nix {inherit pkgs;}; 21 | }; 22 | } 23 | ) 24 | // { 25 | overlays.default = import ./overlay.nix; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /tests/verb/target/verbplus.tex: -------------------------------------------------------------------------------- 1 | In case of double column layout, tables which do not fit in single 2 | column width should be set to full text width. For this, you need to 3 | use \verb+\begin{table*}+ \verb+...+ \verb+\end{table*}+ instead of 4 | \verb+\begin{table}+ \verb+...+ \verb+\end{table}+ environment. 5 | Lengthy tables which do not fit in textwidth should be set as rotated 6 | table. For this, you need to use \verb+\begin{sidewaystable}+ 7 | \verb+...+ \verb+\end{sidewaystable}+ instead of 8 | \verb+\begin{table*}+ \verb+...+ \verb+\end{table*}+ environment. 9 | This environment puts tables rotated to single column width. For 10 | tables rotated to double column width, use 11 | \verb+\begin{sidewaystable*}+ \verb+...+ \verb+\end{sidewaystable*}+. 12 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: 2 | pkgs.mkShell { 3 | inputsFrom = [(pkgs.callPackage ./default.nix {})]; 4 | buildInputs = let 5 | python = pkgs.python3.withPackages (ps: 6 | with ps; [ 7 | grip 8 | matplotlib 9 | pillow 10 | ]); 11 | in [ 12 | pkgs.alejandra 13 | pkgs.bacon 14 | pkgs.binaryen 15 | pkgs.cacert 16 | pkgs.cargo-edit 17 | pkgs.cargo-flamegraph 18 | pkgs.cargo-shear 19 | pkgs.clippy 20 | pkgs.diff-so-fancy 21 | pkgs.gh 22 | pkgs.hyperfine 23 | pkgs.lld 24 | pkgs.poppler_utils 25 | pkgs.ripgrep 26 | pkgs.rustfmt 27 | pkgs.shellcheck 28 | pkgs.texlive.combined.scheme-full 29 | pkgs.wasm-bindgen-cli 30 | python 31 | ]; 32 | } 33 | -------------------------------------------------------------------------------- /tests/wrap/source/heavy_wrap.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \usepackage{amsmath} 4 | \usepackage{amsthm} 5 | 6 | \newtheorem{definition}{Definition} 7 | 8 | \begin{document} 9 | 10 | \begin{definition} 11 | \begin{definition} 12 | \begin{definition} 13 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 14 | \end{definition} 15 | \end{definition} 16 | \end{definition} 17 | 18 | \end{document} 19 | -------------------------------------------------------------------------------- /ctan/latex-formatter.pkg: -------------------------------------------------------------------------------- 1 | \pkg{latex-formatter} 2 | \version{0.5.6} 3 | \author{William George Underwood} 4 | \email{wg.underwood13@gmail.com} 5 | \uploader{William George Underwood} 6 | \ctanPath{/support/latex-formatter} 7 | \license{mit} 8 | \repository{https://github.com/WGUNDERWOOD/tex-fmt} 9 | \bugtracker{https://github.com/WGUNDERWOOD/tex-fmt/issues} 10 | \update{false} 11 | \topic{code-layout} 12 | \summary{LaTeX formatter written in Rust} 13 | \description{This package provides the `tex-fmt` command line tool for formatting LaTeX source files. Binaries are included for Linux, MacOS and Windows.} 14 | \announcement{This package provides the `tex-fmt` command line tool for formatting LaTeX source files. Binaries are included for Linux, MacOS and Windows.} 15 | \file{latex-formatter.tar.gz} 16 | -------------------------------------------------------------------------------- /src/comments.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for finding, extracting and removing LaTeX comments 2 | 3 | use crate::format::Pattern; 4 | 5 | /// Find the location where a comment begins in a line 6 | #[must_use] 7 | pub fn find_comment_index(line: &str, pattern: &Pattern) -> Option { 8 | // Often there is no '%' so check this first 9 | if pattern.contains_comment { 10 | let mut prev_c = ' '; 11 | for (i, c) in line.char_indices() { 12 | if c == '%' && prev_c != '\\' { 13 | return Some(i); 14 | } 15 | prev_c = c; 16 | } 17 | } 18 | None 19 | } 20 | 21 | /// Remove a comment from the end of a line 22 | #[must_use] 23 | pub fn remove_comment(line: &str, comment: Option) -> &str { 24 | comment.map_or_else(|| line, |c| &line[0..c]) 25 | } 26 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Main library 2 | 3 | #![doc( 4 | html_logo_url = "https://raw.githubusercontent.com/WGUNDERWOOD/tex-fmt/main/extra/logo.svg" 5 | )] 6 | #![warn(clippy::pedantic)] 7 | 8 | pub mod args; 9 | pub mod cli; 10 | pub mod comments; 11 | pub mod config; 12 | pub mod format; 13 | pub mod ignore; 14 | pub mod indent; 15 | pub mod logging; 16 | pub mod read; 17 | pub mod regexes; 18 | pub mod search; 19 | pub mod subs; 20 | pub mod verbatim; 21 | pub mod wrap; 22 | pub mod write; 23 | 24 | #[cfg(feature = "wasm")] 25 | pub mod wasm; 26 | 27 | #[cfg(test)] 28 | pub mod tests; 29 | 30 | #[cfg(any(target_family = "unix", target_family = "wasm"))] 31 | /// Line ending for unix 32 | const LINE_END: &str = "\n"; 33 | 34 | #[cfg(target_family = "windows")] 35 | /// Line ending for Windows 36 | const LINE_END: &str = "\r\n"; 37 | -------------------------------------------------------------------------------- /src/bin.rs: -------------------------------------------------------------------------------- 1 | //! tex-fmt 2 | //! An extremely fast LaTeX formatter written in Rust 3 | 4 | #![warn(missing_docs)] 5 | #![warn(clippy::nursery)] 6 | #![warn(clippy::cargo)] 7 | #![warn(clippy::missing_docs_in_private_items)] 8 | #![warn(clippy::pedantic)] 9 | #![allow(clippy::multiple_crate_versions)] 10 | 11 | use std::process::ExitCode; 12 | use tex_fmt::args::get_args; 13 | use tex_fmt::format::run; 14 | use tex_fmt::logging::{init_logger, print_logs, Log}; 15 | 16 | fn main() -> ExitCode { 17 | let mut args = get_args(); 18 | init_logger(args.verbosity); 19 | 20 | let mut logs = Vec::::new(); 21 | let mut exit_code = args.resolve(&mut logs); 22 | 23 | if exit_code == 0 { 24 | exit_code = run(&args, &mut logs); 25 | } 26 | 27 | print_logs(&mut logs); 28 | ExitCode::from(exit_code) 29 | } 30 | -------------------------------------------------------------------------------- /tests/verbatim/source/verbatim.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | \usepackage{listings} 3 | 4 | \begin{document} 5 | 6 | \begin{verbatim} 7 | 8 | code code code code code code code code code code code code code code code code code code 9 | 10 | code code code code code code code code code code code code code code code code code code 11 | 12 | \item \item \item 13 | 14 | \begin{align} E = mc^2 \end{align} 15 | 16 | \end{verbatim} 17 | 18 | \begin{lstlisting}[caption={A very long and complicated caption that does not fit into one line}] 19 | Code 20 | \end{lstlisting} 21 | 22 | % Here is a special user-defined verbatim environment 23 | 24 | \begin{myverbatim} 25 | code code code code code code code code code code code code code code code code code code 26 | \end{myverbatim} 27 | 28 | \end{document} 29 | -------------------------------------------------------------------------------- /tests/verbatim/target/verbatim.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | \usepackage{listings} 3 | 4 | \begin{document} 5 | 6 | \begin{verbatim} 7 | 8 | code code code code code code code code code code code code code code code code code code 9 | 10 | code code code code code code code code code code code code code code code code code code 11 | 12 | \item \item \item 13 | 14 | \begin{align} E = mc^2 \end{align} 15 | 16 | \end{verbatim} 17 | 18 | \begin{lstlisting}[caption={A very long and complicated caption that does not fit into one line}] 19 | Code 20 | \end{lstlisting} 21 | 22 | % Here is a special user-defined verbatim environment 23 | 24 | \begin{myverbatim} 25 | code code code code code code code code code code code code code code code code code code 26 | \end{myverbatim} 27 | 28 | \end{document} 29 | -------------------------------------------------------------------------------- /tests/wrap/target/heavy_wrap.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \usepackage{amsmath} 4 | \usepackage{amsthm} 5 | 6 | \newtheorem{definition}{Definition} 7 | 8 | \begin{document} 9 | 10 | \begin{definition} 11 | \begin{definition} 12 | \begin{definition} 13 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do 14 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut 15 | enim ad minim veniam, quis nostrud exercitation ullamco laboris 16 | nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor 17 | in reprehenderit in voluptate velit esse cillum dolore eu 18 | fugiat nulla pariatur. Excepteur sint occaecat cupidatat non 19 | proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | \end{definition} 21 | \end{definition} 22 | \end{definition} 23 | 24 | \end{document} 25 | -------------------------------------------------------------------------------- /tests/environments/source/environment_lines.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \newenvironment{env1}{}{} 6 | \newenvironment{env2}{}{} 7 | \newenvironment{env3}{}{} 8 | \newenvironment{env4}{}{} 9 | 10 | % environments on separate lines 11 | \begin{env1} 12 | \begin{env2} 13 | \end{env2} 14 | \end{env1} 15 | 16 | % environments on shared lines 17 | \begin{env1}\begin{env2} 18 | \end{env2}\end{env1} 19 | 20 | % environments on shared lines with spaces 21 | \begin{env1} \begin{env2} 22 | \end{env2} \end{env1} 23 | 24 | % environments all on same line 25 | \begin{env1}\begin{env2}\end{env2}\end{env1} % with a comment \begin{env1} 26 | 27 | % environments with extra brackets 28 | \begin{env1}(a)(b \begin{env2}[c{d}e] \end{env2}[f]g)\end{env1} 29 | 30 | % environments and a long line 31 | \begin{env1}\begin{env2}\begin{env3}\begin{env4}\end{env4}\end{env3}\end{env2}\end{env1} 32 | 33 | \end{document} 34 | -------------------------------------------------------------------------------- /extra/latex.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Checking latex PDFs agree" 3 | DIR="$(mktemp -d)" 4 | cp -r ../tests/* "$DIR" 5 | echo "$DIR" 6 | cd "$DIR" || exit 7 | 8 | # empty file cannot be compiled 9 | rm -r ./empty/ 10 | 11 | echo 12 | 13 | for TESTDIR in "$DIR"/*; do 14 | for file in "$TESTDIR/source"/*.tex; do 15 | f=$(basename "$file" .tex) 16 | echo "Running latex for $f.tex" 17 | (cd "$TESTDIR/source" && latexmk -pdflua "$f.tex") 18 | (cd "$TESTDIR/target" && latexmk -pdflua "$f.tex") 19 | (cd "$TESTDIR/source" && pdftotext -q "$f.pdf") 20 | (cd "$TESTDIR/target" && pdftotext -q "$f.pdf") 21 | done 22 | done 23 | 24 | echo 25 | 26 | for TESTDIR in "$DIR"/*; do 27 | for file in "$TESTDIR/source"/*.tex; do 28 | f=$(basename "$file" .tex) 29 | echo "Checking PDF for $f.tex" 30 | diff -u "$TESTDIR/source/$f.txt" "$TESTDIR/target/$f.txt" | diff-so-fancy 31 | done 32 | done 33 | 34 | echo "$DIR" 35 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | //! Finding files in the file system 2 | 3 | use crate::regexes::EXTENSIONS; 4 | use ignore::Walk; 5 | use std::path::PathBuf; 6 | 7 | /// Find files recursively and append 8 | /// 9 | /// # Panics 10 | /// 11 | /// This function panics when a file extension cannot be converted to a string. 12 | pub fn find_files(dir: &PathBuf, files: &mut Vec) { 13 | // Recursive walk of passed directory (ignore errors, symlinks and non-dirs) 14 | for entry in Walk::new(dir).filter_map(std::result::Result::ok) { 15 | // If entry is file and has accepted extension, push to files 16 | if entry.file_type().unwrap().is_file() { 17 | let file = entry.path(); 18 | if let Some(ext_osstr) = file.extension() { 19 | let ext = ext_osstr.to_str().unwrap(); 20 | if EXTENSIONS.contains(&ext) { 21 | files.push(file.to_path_buf()); 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/brackets/source/brackets.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Matching brackets on a line do nothing (like this). 6 | 7 | Matching brackets on two lines also do nothing (like this 8 | longer example). 9 | 10 | Matching brackets on three lines get an indent (like this 11 | much much longer example 12 | right here on these lines). 13 | 14 | Matching brackets on more lines also get an indent (like this 15 | much much 16 | much much 17 | much longer example 18 | here). 19 | 20 | The brackets could start at the beginning of the line 21 | (so maybe 22 | they look 23 | like this). 24 | 25 | [They could 26 | be any shape 27 | of bracket] 28 | 29 | {Even braces get 30 | the same 31 | indents too} 32 | 33 | What about equations? They are the same: 34 | $(1 + 2 + 3)$ 35 | 36 | $(1 + 2 37 | + 3 + 4 38 | + 5 + 7 39 | + 8 + 9)$ 40 | 41 | And the dollars can go anywhere as expected: 42 | 43 | $ 44 | (1 + 2 45 | + 3 + 4 46 | + 5 + 7 47 | + 8 + 9) 48 | $ 49 | 50 | Note that dollars themselves are not indented 51 | 52 | \end{document} 53 | -------------------------------------------------------------------------------- /tests/environments/target/environment_lines.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | \newenvironment{env1}{}{} 6 | \newenvironment{env2}{}{} 7 | \newenvironment{env3}{}{} 8 | \newenvironment{env4}{}{} 9 | 10 | % environments on separate lines 11 | \begin{env1} 12 | \begin{env2} 13 | \end{env2} 14 | \end{env1} 15 | 16 | % environments on shared lines 17 | \begin{env1} 18 | \begin{env2} 19 | \end{env2} 20 | \end{env1} 21 | 22 | % environments on shared lines with spaces 23 | \begin{env1} 24 | \begin{env2} 25 | \end{env2} 26 | \end{env1} 27 | 28 | % environments all on same line 29 | \begin{env1} 30 | \begin{env2} 31 | \end{env2} 32 | \end{env1} % with a comment \begin{env1} 33 | 34 | % environments with extra brackets 35 | \begin{env1}(a)(b 36 | \begin{env2}[c{d}e] 37 | \end{env2}[f]g) 38 | \end{env1} 39 | 40 | % environments and a long line 41 | \begin{env1} 42 | \begin{env2} 43 | \begin{env3} 44 | \begin{env4} 45 | \end{env4} 46 | \end{env3} 47 | \end{env2} 48 | \end{env1} 49 | 50 | \end{document} 51 | -------------------------------------------------------------------------------- /tests/brackets/target/brackets.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | Matching brackets on a line do nothing (like this). 6 | 7 | Matching brackets on two lines also do nothing (like this 8 | longer example). 9 | 10 | Matching brackets on three lines get an indent (like this 11 | much much longer example 12 | right here on these lines). 13 | 14 | Matching brackets on more lines also get an indent (like this 15 | much much 16 | much much 17 | much longer example 18 | here). 19 | 20 | The brackets could start at the beginning of the line 21 | (so maybe 22 | they look 23 | like this). 24 | 25 | [They could 26 | be any shape 27 | of bracket] 28 | 29 | {Even braces get 30 | the same 31 | indents too} 32 | 33 | What about equations? They are the same: 34 | $(1 + 2 + 3)$ 35 | 36 | $(1 + 2 37 | + 3 + 4 38 | + 5 + 7 39 | + 8 + 9)$ 40 | 41 | And the dollars can go anywhere as expected: 42 | 43 | $ 44 | (1 + 2 45 | + 3 + 4 46 | + 5 + 7 47 | + 8 + 9) 48 | $ 49 | 50 | Note that dollars themselves are not indented 51 | 52 | \end{document} 53 | -------------------------------------------------------------------------------- /src/write.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for writing formatted files 2 | 3 | use crate::args::Args; 4 | use crate::logging::{record_file_log, Log}; 5 | use log::Level::{Error, Info}; 6 | use std::fs; 7 | use std::path::Path; 8 | 9 | /// Write a formatted file to disk 10 | fn write_file(file: &Path, text: &str) { 11 | let filepath = file.canonicalize().unwrap(); 12 | fs::write(filepath, text).expect("Could not write the file"); 13 | } 14 | 15 | /// Handle the newly formatted file 16 | pub fn process_output( 17 | args: &Args, 18 | file: &Path, 19 | text: &str, 20 | new_text: &str, 21 | logs: &mut Vec, 22 | ) -> u8 { 23 | if args.print { 24 | print!("{}", &new_text); 25 | } else if args.check && text != new_text { 26 | record_file_log(logs, Error, file, "Incorrect formatting."); 27 | return 1; 28 | } else if text != new_text { 29 | write_file(file, new_text); 30 | if args.fail_on_change { 31 | record_file_log(logs, Info, file, "Fixed incorrect formatting."); 32 | return 1; 33 | } 34 | } 35 | 0 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 William George Underwood 4 | 5 | Permission is hereby granted, free of charge, to any 6 | person obtaining a copy of this software and associated 7 | documentation files (the "Software"), to deal in the 8 | Software without restriction, including without 9 | limitation the rights to use, copy, modify, merge, 10 | publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software 12 | is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions 17 | of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 20 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 21 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 22 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 23 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 24 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 25 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 26 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 27 | DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /tests/verb/source/verb.tex: -------------------------------------------------------------------------------- 1 | \begin{document} 2 | 3 | % Do not break a line inside inline verb environment 4 | 5 | \verb|code code code code code code code code code code code code code code code code| 6 | 7 | % Ok to break a line before or after inline verb environment 8 | 9 | Some words in a sentence \verb|code code code code code code code code code code code| 10 | 11 | \verb|code code code code code code code code code code code| some words in a sentence 12 | 13 | % Indenting should be as usual 14 | 15 | \begin{itemize} 16 | \item 17 | \verb|code code code code code code code code code code code code code code code| 18 | \end{itemize} 19 | 20 | % Do not split \begin{environment} onto a new line if inside \verb|...| 21 | 22 | \verb|\end{description}| 23 | 24 | \verb|\begin{description}| 25 | 26 | % This should not affect line breaking 27 | 28 | Some words in a long sentence which could be broken before the verb part of this line \verb|\begin{description}| 29 | 30 | % Also detect \mintinline 31 | 32 | \mintinline|code code code code code code code code code code code code code code code code| 33 | 34 | Some words in a sentence \mintinline|code code code code code code code code code code code| 35 | 36 | \mintinline|code code code code code code code code code code code| some words in a sentence 37 | 38 | \end{document} 39 | -------------------------------------------------------------------------------- /completion/README.md: -------------------------------------------------------------------------------- 1 | # Shell completion for tex-fmt 2 | 3 | Shell completion scripts can be generated at run-time using the 4 | `--completion ` flag, as detailed below. Completion scripts 5 | generated at compile-time are also available for download in 6 | [this directory]( 7 | https://github.com/WGUNDERWOOD/tex-fmt/tree/main/completion/), 8 | but they may not be up-to-date with your tex-fmt installation. 9 | 10 | For **bash**: 11 | 12 | ```shell 13 | dir="$XDG_CONFIG_HOME/bash_completion" 14 | mkdir -p "$dir" 15 | tex-fmt --completion bash > "$dir/tex-fmt.bash" 16 | ``` 17 | 18 | For **fish**: 19 | 20 | ```shell 21 | dir="$XDG_CONFIG_HOME/fish/completions" 22 | mkdir -p "$dir" 23 | tex-fmt --completion fish > "$dir/tex-fmt.fish" 24 | ``` 25 | 26 | For **zsh**: 27 | 28 | ```shell 29 | dir="$HOME/.zsh-complete" 30 | mkdir -p "$dir" 31 | tex-fmt --completion zsh > "$dir/_tex-fmt" 32 | ``` 33 | 34 | For **elvish**: 35 | 36 | ```shell 37 | dir="$HOME/.elvish/lib" 38 | mkdir -p "$dir" 39 | tex-fmt --completion elvish > "$dir/tex-fmt.elv" 40 | use tex-fmt 41 | ``` 42 | 43 | For **PowerShell**, create the completions: 44 | 45 | ```shell 46 | tex-fmt --completion powershell > _tex-fmt.ps1 47 | ``` 48 | 49 | Then add `. _tex-fmt.ps1` to your PowerShell profile. 50 | If the `_tex-fmt.ps1` file is not on your `PATH`, do 51 | `. /path/to/_tex-fmt.ps1` instead. 52 | -------------------------------------------------------------------------------- /tests/verb/target/verb.tex: -------------------------------------------------------------------------------- 1 | \begin{document} 2 | 3 | % Do not break a line inside inline verb environment 4 | 5 | \verb|code code code code code code code code code code code code code code code code| 6 | 7 | % Ok to break a line before or after inline verb environment 8 | 9 | Some words in a sentence 10 | \verb|code code code code code code code code code code code| 11 | 12 | \verb|code code code code code code code code code code code| some 13 | words in a sentence 14 | 15 | % Indenting should be as usual 16 | 17 | \begin{itemize} 18 | \item 19 | \verb|code code code code code code code code code code code code code code code| 20 | \end{itemize} 21 | 22 | % Do not split \begin{environment} onto a new line if inside \verb|...| 23 | 24 | \verb|\end{description}| 25 | 26 | \verb|\begin{description}| 27 | 28 | % This should not affect line breaking 29 | 30 | Some words in a long sentence which could be broken before the verb 31 | part of this line \verb|\begin{description}| 32 | 33 | % Also detect \mintinline 34 | 35 | \mintinline|code code code code code code code code code code code code code code code code| 36 | 37 | Some words in a sentence 38 | \mintinline|code code code code code code code code code code code| 39 | 40 | \mintinline|code code code code code code code code code code code| 41 | some words in a sentence 42 | 43 | \end{document} 44 | -------------------------------------------------------------------------------- /src/wasm.rs: -------------------------------------------------------------------------------- 1 | //! Web assembly implementation 2 | 3 | use js_sys::{Object, Reflect}; 4 | use merge::Merge; 5 | use std::path::PathBuf; 6 | use wasm_bindgen::prelude::*; 7 | 8 | use crate::args::{Args, OptionArgs}; 9 | use crate::config::get_config_args; 10 | use crate::format::format_file; 11 | use crate::logging::{format_logs, Log}; 12 | 13 | /// Main function for WASM interface with JS 14 | /// 15 | /// # Panics 16 | /// 17 | /// This function panics if the config cannot be parsed 18 | #[wasm_bindgen] 19 | #[must_use] 20 | pub fn main(text: &str, config: &str) -> JsValue { 21 | // Get args 22 | let config = Some((PathBuf::new(), String::new(), config.to_string())); 23 | let mut args: OptionArgs = get_config_args(config).unwrap(); 24 | args.merge(OptionArgs::default()); 25 | let mut args = Args::from(args); 26 | args.stdin = true; 27 | 28 | // Run tex-fmt 29 | let mut logs = Vec::::new(); 30 | args.resolve(&mut logs); 31 | let file = PathBuf::from("input"); 32 | let new_text = format_file(text, &file, &args, &mut logs); 33 | let logs = format_logs(&mut logs, &args); 34 | 35 | // Wrap into JS object 36 | let js_object = Object::new(); 37 | Reflect::set(&js_object, &"output".into(), &new_text.into()).unwrap(); 38 | Reflect::set(&js_object, &"logs".into(), &logs.into()).unwrap(); 39 | js_object.into() 40 | } 41 | -------------------------------------------------------------------------------- /tests/short_document/source/short_document.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \usepackage{amsmath} 4 | \usepackage{amsthm} 5 | 6 | \newtheorem{theorem}{Theorem} 7 | 8 | \title{Testing \texttt{tex-fmt}} 9 | \author{William G.\ Underwood} 10 | \begin{document} 11 | \maketitle 12 | 13 | \begin{align} 14 | E = m c^2 \\ 15 | 1 + 2 16 | + (3 + 4) 17 | + (5 + 6 18 | + 7 + 8) 19 | + (9 + 10 20 | + 11 + 12 21 | + 13 + 14) 22 | \end{align} 23 | 24 | \begin{itemize} 25 | \item Item one % trailing comment with ]) brackets 26 | \item Item two on 27 | multiple lines 28 | \item Item three 29 | \begin{itemize} 30 | \item Subitem one of item two % this line has trailing spaces 31 | \item Subitem two of item two 32 | \end{itemize} 33 | \item Item four % trailing comment % with [( brackets 34 | \item 35 | \end{itemize} 36 | 37 | \begin{theorem}[Pythagoras]% 38 | \label{thm:pythagoras} 39 | 40 | For a right triangle with hypotenuse $c$ and other sides $a$ and $b$, 41 | we have 42 | % 43 | \begin{align*} 44 | a^2 + b^2 = c^2 45 | \end{align*} 46 | % 47 | % some comments 48 | 49 | \end{theorem} 50 | 51 | This line contains \emph{emphasized} text. 52 | \emph{This line contains only emphasized text, 53 | and is broken over two lines}. 54 | \emph{This line contains only 55 | emphasized text, 56 | and is broken over three lines}. 57 | 58 | \end{document} 59 | 60 | % This file ends with trailing newlines 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /tests/short_document/target/short_document.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \usepackage{amsmath} 4 | \usepackage{amsthm} 5 | 6 | \newtheorem{theorem}{Theorem} 7 | 8 | \title{Testing \texttt{tex-fmt}} 9 | \author{William G.\ Underwood} 10 | \begin{document} 11 | \maketitle 12 | 13 | \begin{align} 14 | E = m c^2 \\ 15 | 1 + 2 16 | + (3 + 4) 17 | + (5 + 6 18 | + 7 + 8) 19 | + (9 + 10 20 | + 11 + 12 21 | + 13 + 14) 22 | \end{align} 23 | 24 | \begin{itemize} 25 | \item Item one % trailing comment with ]) brackets 26 | \item Item two on 27 | multiple lines 28 | \item Item three 29 | \begin{itemize} 30 | \item Subitem one of item two % this line has trailing spaces 31 | \item Subitem two of item two 32 | \end{itemize} 33 | \item Item four % trailing comment % with [( brackets 34 | \item 35 | \end{itemize} 36 | 37 | \begin{theorem}[Pythagoras]% 38 | \label{thm:pythagoras} 39 | 40 | For a right triangle with hypotenuse $c$ and other sides $a$ and $b$, 41 | we have 42 | % 43 | \begin{align*} 44 | a^2 + b^2 = c^2 45 | \end{align*} 46 | % 47 | % some comments 48 | 49 | \end{theorem} 50 | 51 | This line contains \emph{emphasized} text. 52 | \emph{This line contains only emphasized text, 53 | and is broken over two lines}. 54 | \emph{This line contains only 55 | emphasized text, 56 | and is broken over three lines}. 57 | 58 | \end{document} 59 | 60 | % This file ends with trailing newlines 61 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tex-fmt" 3 | version = "0.5.6" 4 | authors = ["William George Underwood"] 5 | license = "MIT" 6 | repository = "https://github.com/WGUNDERWOOD/tex-fmt" 7 | edition = "2021" 8 | description = "LaTeX formatter written in Rust" 9 | keywords = ["latex", "formatter"] 10 | categories = ["command-line-utilities", "development-tools"] 11 | exclude = [ 12 | "tests/*", 13 | "extra/*", 14 | "*.nix", 15 | "flake.lock", 16 | "justfile", 17 | ".github/*", 18 | "completion/*", 19 | "man/*", 20 | "notes.org", 21 | ] 22 | 23 | [features] 24 | shellinstall = [] 25 | wasm = ["dep:js-sys", "dep:wasm-bindgen"] 26 | 27 | [dependencies] 28 | clap = { version = "4.5.48", features = ["cargo"] } 29 | clap_complete = "4.5.58" 30 | clap_mangen = "0.2.29" 31 | colored = "2.2.0" 32 | dirs = "5.0.1" 33 | env_logger = "0.11.8" 34 | ignore = "0.4.23" 35 | js-sys = { version = "0.3.77", optional = true } 36 | log = "0.4.28" 37 | merge = "0.2.0" 38 | regex = "1.11.3" 39 | similar = "2.7.0" 40 | toml = "0.8.23" 41 | wasm-bindgen = { version = "0.2.100", optional = true } 42 | web-time = "1.1.0" 43 | 44 | [build-dependencies] 45 | clap = { version = "4.5.48", features = ["cargo"] } 46 | clap_complete = "4.5.58" 47 | clap_mangen = "0.2.29" 48 | 49 | [profile.release] 50 | codegen-units = 1 51 | 52 | [lib] 53 | name = "tex_fmt" 54 | path = "src/lib.rs" 55 | crate-type = ["cdylib", "rlib"] 56 | 57 | [[bin]] 58 | name = "tex-fmt" 59 | path = "src/bin.rs" 60 | -------------------------------------------------------------------------------- /completion/tex-fmt.fish: -------------------------------------------------------------------------------- 1 | complete -c tex-fmt -s l -l wraplen -d 'Line length for wrapping [default: 80]' -r 2 | complete -c tex-fmt -s t -l tabsize -d 'Number of characters to use as tab size [default: 2]' -r 3 | complete -c tex-fmt -l config -d 'Path to config file' -r -F 4 | complete -c tex-fmt -l completion -d 'Generate shell completion script' -r -f -a "bash\t'' 5 | elvish\t'' 6 | fish\t'' 7 | powershell\t'' 8 | zsh\t''" 9 | complete -c tex-fmt -s c -l check -d 'Check formatting, do not modify files' 10 | complete -c tex-fmt -s p -l print -d 'Print to stdout, do not modify files' 11 | complete -c tex-fmt -s f -l fail-on-change -d 'Format files and return non-zero exit code if files are modified' 12 | complete -c tex-fmt -s n -l nowrap -d 'Do not wrap long lines' 13 | complete -c tex-fmt -l usetabs -d 'Use tabs instead of spaces for indentation' 14 | complete -c tex-fmt -s s -l stdin -d 'Process stdin as a single file, output to stdout' 15 | complete -c tex-fmt -l noconfig -d 'Do not read any config file' 16 | complete -c tex-fmt -s v -l verbose -d 'Show info messages' 17 | complete -c tex-fmt -s q -l quiet -d 'Hide warning messages' 18 | complete -c tex-fmt -l trace -d 'Show trace messages' 19 | complete -c tex-fmt -l man -d 'Generate man page' 20 | complete -c tex-fmt -l args -d 'Print arguments passed to tex-fmt and exit' 21 | complete -c tex-fmt -s r -l recursive -d 'Recursively search for files to format' 22 | complete -c tex-fmt -s h -l help -d 'Print help' 23 | complete -c tex-fmt -s V -l version -d 'Print version' 24 | -------------------------------------------------------------------------------- /extra/logo.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import matplotlib.font_manager as fm 3 | 4 | # start plot 5 | (fig, ax) = plt.subplots(figsize=(5, 5)) 6 | plt.xticks([]) 7 | plt.yticks([]) 8 | for side in ["bottom", "top", "left", "right"]: 9 | ax.spines[side].set_color("#FFFFFF00") 10 | 11 | # colors 12 | col_dark = "#191924" 13 | col_orange = "#e5652e" 14 | col_yellow = "#eed858" 15 | col_light = "#faf7e5" 16 | 17 | outer_col = col_orange 18 | inner_col = col_dark 19 | text_col = col_light 20 | line_col = col_yellow 21 | 22 | # outer box 23 | lw = 24 24 | xs_outer = [0.5, 1, 1, 0, 0, 0.5] 25 | ys_outer = [0, 0, 1, 1, 0, 0] 26 | plt.plot(xs_outer, ys_outer, c=outer_col, lw=lw, zorder=0) 27 | plt.fill(xs_outer, ys_outer, c=outer_col, lw=0) 28 | 29 | # inner box 30 | eps = 0.05 31 | xs_inner = [0.5, 1-eps, 1-eps, eps, eps, 0.5] 32 | ys_inner = [eps, eps, 1-eps, 1-eps, eps, eps] 33 | plt.plot(xs_inner, ys_inner, c=inner_col, lw=0.6*lw, zorder=2) 34 | plt.fill(xs_inner, ys_inner, c=inner_col, lw=0) 35 | 36 | # line 37 | eps = 0.125 38 | plt.plot([0.5, eps, 1-eps, 0.5], [0.485] * 4, 39 | lw=5, c=col_yellow) 40 | 41 | # text 42 | fontfamily = "Bungee" 43 | fonts = fm.findSystemFonts(fontpaths=None, fontext='ttf') 44 | [fm.fontManager.addfont(f) for f in fonts if fontfamily.split()[0] in f] 45 | 46 | fontsize = 100 47 | plt.text(0.5, 0.72, "TEX", fontsize=fontsize, ha="center", va="center", fontweight="light", c=text_col, fontfamily=fontfamily, fontstyle="normal") 48 | 49 | fontsize = 96 50 | plt.text(0.496, 0.25, "FMT", fontsize=fontsize, ha="center", va="center", fontweight="light", c=text_col, fontfamily=fontfamily, fontstyle="normal") 51 | 52 | # save 53 | plt.savefig("logo.svg", dpi=1000, transparent=True) 54 | plt.close("all") 55 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1759386674, 24 | "narHash": "sha256-wg1Lz/1FC5Q13R+mM5a2oTV9TA9L/CHHTm3/PiLayfA=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "625ad6366178f03acd79f9e3822606dd7985b657", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /src/read.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for reading files 2 | 3 | use crate::logging::{record_file_log, Log}; 4 | use crate::regexes::EXTENSIONS; 5 | use log::Level::{Error, Trace}; 6 | use std::fs; 7 | use std::io::Read; 8 | use std::path::PathBuf; 9 | 10 | /// Add a missing extension and read the file 11 | /// 12 | /// # Panics 13 | /// 14 | /// This function panics when a file extension cannot be converted to a string. 15 | pub fn read(file: &PathBuf, logs: &mut Vec) -> Option { 16 | // Check if file has an accepted extension 17 | let ext = file.extension().unwrap().to_str().unwrap(); 18 | let has_ext = EXTENSIONS.contains(&ext); 19 | 20 | if let Ok(text) = fs::read_to_string(file) { 21 | return Some(text); 22 | } 23 | if has_ext { 24 | record_file_log(logs, Error, file, "Could not open file."); 25 | } else { 26 | record_file_log(logs, Error, file, "File type invalid."); 27 | } 28 | None 29 | } 30 | 31 | /// Attempt to read from stdin, return filename `` and text 32 | pub fn read_stdin(logs: &mut Vec) -> Option { 33 | let mut text = String::new(); 34 | let stdin_path = PathBuf::from(""); 35 | match std::io::stdin().read_to_string(&mut text) { 36 | Ok(bytes) => { 37 | record_file_log( 38 | logs, 39 | Trace, 40 | &stdin_path, 41 | &format!("Read {bytes} bytes."), 42 | ); 43 | Some(text) 44 | } 45 | Err(e) => { 46 | record_file_log( 47 | logs, 48 | Error, 49 | &stdin_path, 50 | &format!("Could not read from stdin: {e}"), 51 | ); 52 | None 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /web/index.js: -------------------------------------------------------------------------------- 1 | // Import wasm 2 | import init, { main } from './pkg/tex_fmt.js'; 3 | 4 | // Initialize wasm 5 | (async () => { 6 | try { 7 | await init(); 8 | console.log('WASM initialized successfully.'); 9 | } catch (error) { 10 | console.error('Error initializing WASM :', error); 11 | alert('Failed to initialize WASM. Check console for details.'); 12 | } 13 | })(); 14 | 15 | // Submit button logic 16 | document.getElementById('formatButton').addEventListener( 17 | 'click', async () => { 18 | const copyMessage = document.getElementById('copyMessage'); 19 | copyMessage.innerText = "" 20 | const inputText = document.getElementById('inputText').value; 21 | const outputText = document.getElementById('outputText'); 22 | const logText = document.getElementById('logText'); 23 | try { 24 | const configText = document.getElementById('configText').value; 25 | const result = await main(inputText, configText); 26 | outputText.value = result.output; 27 | logText.value = result.logs; 28 | } catch (error) { 29 | console.error('Error calling WebAssembly function:', error); 30 | alert('An error occurred. Check the console for details.'); 31 | } 32 | } 33 | ); 34 | 35 | // Copy output text to clipboard 36 | document.getElementById('copyButton').addEventListener( 37 | 'click', () => { 38 | const outputText = document.getElementById('outputText'); 39 | outputText.select(); 40 | outputText.setSelectionRange(0, 99999); 41 | try { 42 | document.execCommand('copy'); 43 | const copyMessage = document.getElementById('copyMessage'); 44 | copyMessage.innerText = "Copied!" 45 | outputText.blur(); 46 | } catch (err) { 47 | console.error('Failed to copy text: ', err); 48 | } 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /tests/higher_categories_thesis/target/quiver.sty: -------------------------------------------------------------------------------- 1 | % *** quiver *** 2 | % A package for drawing commutative diagrams exported from https://q.uiver.app. 3 | % 4 | % This package is currently a wrapper around the `tikz-cd` package, 5 | % importing necessary TikZ 6 | % libraries, and defining a new TikZ style for curves of a fixed height. 7 | % 8 | % Version: 1.4.2 9 | % Authors: 10 | % - varkor (https://github.com/varkor) 11 | % - AndréC (https://tex.stackexchange.com/users/138900/andr%C3%A9c) 12 | 13 | \NeedsTeXFormat{LaTeX2e} 14 | \ProvidesPackage{quiver}[2021/01/11 quiver] 15 | 16 | % `tikz-cd` is necessary to draw commutative diagrams. 17 | \RequirePackage{tikz-cd} 18 | % `amssymb` is necessary for `\lrcorner` and `\ulcorner`. 19 | \RequirePackage{amssymb} 20 | % `calc` is necessary to draw curved arrows. 21 | \usetikzlibrary{calc} 22 | % `pathmorphing` is necessary to draw squiggly arrows. 23 | \usetikzlibrary{decorations.pathmorphing} 24 | 25 | % A TikZ style for curved arrows of a fixed height, due to AndréC. 26 | \tikzset{curve/.style={settings={#1},to path={(\tikztostart) 27 | .. controls ($(\tikztostart)!\pv{pos}!(\tikztotarget)!\pv{height}!270:(\tikztotarget)$) % tex-fmt: skip 28 | and ($(\tikztostart)!1-\pv{pos}!(\tikztotarget)!\pv{height}!270:(\tikztotarget)$) % tex-fmt: skip 29 | .. (\tikztotarget)\tikztonodes}}, 30 | settings/.code={\tikzset{quiver/.cd,#1} 31 | \def\pv##1{\pgfkeysvalueof{/tikz/quiver/##1}}}, 32 | quiver/.cd,pos/.initial=0.35,height/.initial=0} 33 | 34 | % TikZ arrowhead/tail styles. 35 | \tikzset{tail reversed/.code={\pgfsetarrowsstart{tikzcd to}}} 36 | \tikzset{2tail/.code={\pgfsetarrowsstart{Implies[reversed]}}} 37 | \tikzset{2tail reversed/.code={\pgfsetarrowsstart{Implies}}} 38 | % TikZ arrow styles. 39 | \tikzset{no body/.style={/tikz/dash pattern=on 0 off 1mm}} 40 | 41 | \endinput 42 | -------------------------------------------------------------------------------- /extra/card.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | import matplotlib.pyplot as plt 3 | import matplotlib.font_manager as fm 4 | 5 | # start plot 6 | (fig, ax) = plt.subplots(figsize=(10, 5)) 7 | plt.xticks([]) 8 | plt.yticks([]) 9 | for side in ["bottom", "top", "left", "right"]: 10 | ax.spines[side].set_color("#FFFFFF00") 11 | 12 | # colors 13 | col_dark = "#191924" 14 | col_yellow = "#eed858" 15 | col_light = "#faf7e5" 16 | 17 | outer_col = col_yellow 18 | inner_col = col_light 19 | text_col = col_dark 20 | 21 | # outer box 22 | w = 200 23 | h = 100 24 | xs_outer = [w/2, w, w, 0, 0, w/2] 25 | ys_outer = [0, 0, h, h, 0, 0] 26 | plt.fill(xs_outer, ys_outer, c=outer_col, lw=1, zorder=1) 27 | 28 | # inner box 29 | dw = 23 30 | dh = 20 31 | xs_inner = [w/2, w-dw, w-dw, dw, dw, w/2] 32 | ys_inner = [dh, dh, h-dh, h-dh, dh, dh] 33 | plt.plot(xs_inner, ys_inner, c=inner_col, lw=30, zorder=2) 34 | plt.fill(xs_inner, ys_inner, c=inner_col, lw=0) 35 | 36 | # logo 37 | img = Image.open("logo.png").resize((900, 900)) 38 | fig.figimage(img, 2210, 540) 39 | 40 | # text 41 | fontfamily = "Roboto Slab" 42 | fonts = fm.findSystemFonts(fontpaths=None, fontext='ttf') 43 | [fm.fontManager.addfont(f) for f in fonts if fontfamily.split()[0] in f] 44 | 45 | fontsize = 16 46 | plt.text(31, 50, "An extremely fast La\nformatter written in Rust.", fontsize=fontsize, ha="left", va="center", fontweight="light", c=text_col, fontfamily=fontfamily, fontstyle="normal") 47 | 48 | plt.text(92.6, 53.53, "T", fontsize=fontsize, ha="left", va="center", fontweight="light", c=text_col, fontfamily=fontfamily, fontstyle="normal") 49 | 50 | plt.text(96.55, 53.53, "eX", fontsize=fontsize, ha="left", va="center", fontweight="light", c=text_col, fontfamily=fontfamily, fontstyle="normal") 51 | 52 | # save 53 | plt.savefig("card.svg", dpi=400, transparent=True) 54 | plt.close("all") 55 | -------------------------------------------------------------------------------- /tests/higher_categories_thesis/source/quiver.sty: -------------------------------------------------------------------------------- 1 | % *** quiver *** 2 | % A package for drawing commutative diagrams exported from https://q.uiver.app. 3 | % 4 | % This package is currently a wrapper around the `tikz-cd` package, importing necessary TikZ 5 | % libraries, and defining a new TikZ style for curves of a fixed height. 6 | % 7 | % Version: 1.4.2 8 | % Authors: 9 | % - varkor (https://github.com/varkor) 10 | % - AndréC (https://tex.stackexchange.com/users/138900/andr%C3%A9c) 11 | 12 | \NeedsTeXFormat{LaTeX2e} 13 | \ProvidesPackage{quiver}[2021/01/11 quiver] 14 | 15 | % `tikz-cd` is necessary to draw commutative diagrams. 16 | \RequirePackage{tikz-cd} 17 | % `amssymb` is necessary for `\lrcorner` and `\ulcorner`. 18 | \RequirePackage{amssymb} 19 | % `calc` is necessary to draw curved arrows. 20 | \usetikzlibrary{calc} 21 | % `pathmorphing` is necessary to draw squiggly arrows. 22 | \usetikzlibrary{decorations.pathmorphing} 23 | 24 | % A TikZ style for curved arrows of a fixed height, due to AndréC. 25 | \tikzset{curve/.style={settings={#1},to path={(\tikztostart) 26 | .. controls ($(\tikztostart)!\pv{pos}!(\tikztotarget)!\pv{height}!270:(\tikztotarget)$) % tex-fmt: skip 27 | and ($(\tikztostart)!1-\pv{pos}!(\tikztotarget)!\pv{height}!270:(\tikztotarget)$) % tex-fmt: skip 28 | .. (\tikztotarget)\tikztonodes}}, 29 | settings/.code={\tikzset{quiver/.cd,#1} 30 | \def\pv##1{\pgfkeysvalueof{/tikz/quiver/##1}}}, 31 | quiver/.cd,pos/.initial=0.35,height/.initial=0} 32 | 33 | % TikZ arrowhead/tail styles. 34 | \tikzset{tail reversed/.code={\pgfsetarrowsstart{tikzcd to}}} 35 | \tikzset{2tail/.code={\pgfsetarrowsstart{Implies[reversed]}}} 36 | \tikzset{2tail reversed/.code={\pgfsetarrowsstart{Implies}}} 37 | % TikZ arrow styles. 38 | \tikzset{no body/.style={/tikz/dash pattern=on 0 off 1mm}} 39 | 40 | \endinput 41 | -------------------------------------------------------------------------------- /extra/perf.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Getting performance metrics" 3 | DIR="$(mktemp -d)" 4 | cp -r ../tests/* "$DIR" 5 | RUSTFLAGS="-C target-cpu=native" cargo build --release 6 | 7 | calc(){ awk "BEGIN { print ""$*"" }"; } 8 | 9 | echo 10 | echo -n "Test files: $(find "$DIR"/*/source/* "$DIR"/*/target/* | wc -l) files," 11 | echo -n " $(wc -l --total=only "$DIR"/*/source/* "$DIR"/*/target/*) lines, " 12 | du -hs "$DIR" | cut -f 1 13 | echo 14 | 15 | # tex-fmt 16 | TEXFMTFILE="hyperfine-tex-fmt.csv" 17 | hyperfine --warmup 10 \ 18 | --min-runs 20 \ 19 | --export-csv $TEXFMTFILE \ 20 | --command-name "tex-fmt" \ 21 | --prepare "cp -r ../tests/* $DIR" \ 22 | "../target/release/tex-fmt $DIR/*/source/* $DIR/*/target/*" 23 | 24 | # latexindent 25 | LATEXINDENTFILE="hyperfine-latexindent.csv" 26 | hyperfine --warmup 0 \ 27 | --export-csv $LATEXINDENTFILE \ 28 | --runs 1 \ 29 | --command-name "latexindent" \ 30 | --prepare "cp -r ../tests/* $DIR" \ 31 | "latexindent $DIR/*/source/* $DIR/*/target/*" 32 | 33 | # latexindent -m 34 | LATEXINDENTMFILE="hyperfine-latexindent-m.csv" 35 | hyperfine --warmup 0 \ 36 | --export-csv $LATEXINDENTMFILE \ 37 | --runs 1 \ 38 | --command-name "latexindent -m" \ 39 | --prepare "cp -r ../tests/* $DIR" \ 40 | "latexindent -m $DIR/*/source/* $DIR/*/target/*" 41 | 42 | # print results 43 | TEXFMT=$(cat $TEXFMTFILE | tail -n 1 | cut -d "," -f 2) 44 | echo "tex-fmt: ${TEXFMT}s" 45 | 46 | LATEXINDENT=$(cat $LATEXINDENTFILE | tail -n 1 | cut -d "," -f 2) 47 | LATEXINDENTTIMES=$(calc "$LATEXINDENT"/"$TEXFMT") 48 | echo "latexindent: ${LATEXINDENT}s, x$LATEXINDENTTIMES" 49 | 50 | LATEXINDENTM=$(cat $LATEXINDENTMFILE | tail -n 1 | cut -d "," -f 2) 51 | LATEXINDENTMTIMES=$(calc "$LATEXINDENTM"/"$TEXFMT") 52 | echo "latexindent -m: ${LATEXINDENTM}s, x$LATEXINDENTMTIMES" 53 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: test doc clippy format shellcheck shellinstall wasm 2 | 3 | all: default prof binary logo ctan perf latex 4 | 5 | alias b := build 6 | alias d := doc 7 | alias t := test 8 | alias l := latex 9 | alias c := clippy 10 | alias f := format 11 | 12 | build: 13 | @cargo build -r 14 | 15 | test: 16 | @cargo test --no-fail-fast 17 | 18 | doc: 19 | @cargo doc 20 | 21 | shellinstall: 22 | @cargo build -r --features shellinstall 23 | 24 | testignored: 25 | @cargo test -- --ignored 26 | 27 | clippy: 28 | @cargo clippy -r && cargo shear 29 | 30 | format: 31 | @cargo fmt 32 | @alejandra -q . 33 | 34 | latex: 35 | @cd extra && bash latex.sh 36 | 37 | wasm: 38 | @mkdir -p web/pkg 39 | @cargo build -r -F wasm --lib --target wasm32-unknown-unknown 40 | @wasm-bindgen --target web --out-dir web/pkg \ 41 | target/wasm32-unknown-unknown/release/tex_fmt.wasm 42 | @cd web/pkg && wasm-opt -Oz -o tex_fmt_bg.wasm tex_fmt_bg.wasm 43 | 44 | perf: 45 | @cd extra && bash perf.sh 46 | 47 | prof: 48 | @cd extra && bash prof.sh 49 | 50 | binary: 51 | @cd extra && bash binary.sh 52 | 53 | upgrade: 54 | @cargo upgrade && cargo update 55 | 56 | shellcheck: 57 | @shellcheck extra/*.sh 58 | 59 | ctan: 60 | @rm -f ctan/latex-formatter.pdf ctan/latex-formatter.tar.gz 61 | @cd ctan && latexmk -pdf latex-formatter.tex 62 | @rm -rf ctan/latex-formatter 63 | @rm -f ctan/*.aux ctan/*.fdb_latexmk ctan/*.fls ctan/*.log 64 | @rm -f ctan/*.synctex.gz ctan/*.dvi 65 | @mkdir -p ctan/latex-formatter 66 | @cp ctan/latex-formatter.pdf ctan/latex-formatter.tex ctan/latex-formatter 67 | @cp ctan/README.md LICENSE NEWS.md Cargo.toml ctan/latex-formatter 68 | @cp -r src/ ctan/latex-formatter 69 | 70 | nix: 71 | @nix flake update 72 | 73 | todo: 74 | @rg -g '!justfile' todo 75 | 76 | logo: 77 | @cd extra && python logo.py 78 | @cd extra && magick -background none logo.svg -resize 5000x5000 logo.png 79 | @cd extra && python card.py 80 | @cd extra && magick -background none card.svg -resize 1280x640\! card.png 81 | @cd extra && inkscape -w 2560 -h 1280 card.svg -o card.png 82 | @cd extra && rm -f logo.png card.svg 83 | -------------------------------------------------------------------------------- /extra/binary.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Testing binary" 3 | 4 | # tempdir for unmodified test files 5 | DIR_ORIG="$(mktemp -d)" 6 | cp -r ../tests/* "$DIR_ORIG" 7 | 8 | # tempdir for formatted test files 9 | DIR_TEST="$(mktemp -d)" 10 | cp -r ../tests/* "$DIR_TEST" 11 | 12 | # build binary 13 | cargo build --release 14 | BIN=$(realpath "../target/release/tex-fmt") 15 | 16 | # run tex-fmt in DIR_TEST 17 | for TESTDIR in "$DIR_TEST"/*; do 18 | FLAGS="-q" 19 | if [ -f "$TESTDIR/tex-fmt.toml" ]; then 20 | FLAGS="$FLAGS --config $TESTDIR/tex-fmt.toml" 21 | else 22 | FLAGS="$FLAGS --noconfig" 23 | fi 24 | if [ -f "$TESTDIR/cli.txt" ]; then 25 | FLAGS+=" $(paste -sd' ' "$TESTDIR/cli.txt")" 26 | fi 27 | (cd "$TESTDIR" && eval "$BIN $FLAGS" "$TESTDIR/source"/*) 28 | (cd "$TESTDIR" && eval "$BIN $FLAGS" "$TESTDIR/target"/*) 29 | done 30 | 31 | # check tex-fmt agrees with target files 32 | for TESTDIR in "$DIR_TEST"/*; do 33 | DIRNAME=$(basename "$TESTDIR") 34 | for file in "$TESTDIR/source"/*; do 35 | f=$(basename "$file") 36 | diff "$DIR_ORIG/$DIRNAME/target/$f" "$TESTDIR/target/$f" | diff-so-fancy 37 | diff "$DIR_ORIG/$DIRNAME/target/$f" "$TESTDIR/source/$f" | diff-so-fancy 38 | done 39 | done 40 | 41 | # if both config and cli exist, run tex-fmt again in DIR_TEST 42 | for TESTDIR in "$DIR_TEST"/*; do 43 | DIRNAME=$(basename "$TESTDIR") 44 | if [ -f "$TESTDIR/tex-fmt.toml" ] && [ -f "$TESTDIR/cli.txt" ]; then 45 | FLAGS="-q --noconfig" 46 | FLAGS+=" $(paste -sd' ' "$TESTDIR/cli.txt")" 47 | cp -r "$DIR_ORIG/$DIRNAME"/* "$TESTDIR" 48 | (cd "$TESTDIR" && eval "$BIN $FLAGS" "$TESTDIR/source"/*) 49 | (cd "$TESTDIR" && eval "$BIN $FLAGS" "$TESTDIR/target"/*) 50 | fi 51 | done 52 | 53 | # check tex-fmt agrees with target files 54 | for TESTDIR in "$DIR_TEST"/*; do 55 | DIRNAME=$(basename "$TESTDIR") 56 | for file in "$TESTDIR/source"/*; do 57 | f=$(basename "$file") 58 | diff "$DIR_ORIG/$DIRNAME/target/$f" "$TESTDIR/target/$f" | diff-so-fancy 59 | diff "$DIR_ORIG/$DIRNAME/target/$f" "$TESTDIR/source/$f" | diff-so-fancy 60 | done 61 | done 62 | -------------------------------------------------------------------------------- /src/verbatim.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for ignoring verbatim environments 2 | 3 | use crate::format::{Pattern, State}; 4 | use crate::logging::{record_line_log, Log}; 5 | use log::Level::Warn; 6 | use std::path::Path; 7 | 8 | /// Information on the verbatim state of a line 9 | #[derive(Clone, Debug)] 10 | pub struct Verbatim { 11 | /// The verbatim depth of a line 12 | pub actual: i8, 13 | /// Whether the line is in a verbatim environment 14 | pub visual: bool, 15 | } 16 | 17 | impl Verbatim { 18 | /// Construct a new verbatim state 19 | #[must_use] 20 | pub const fn new() -> Self { 21 | Self { 22 | actual: 0, 23 | visual: false, 24 | } 25 | } 26 | } 27 | 28 | impl Default for Verbatim { 29 | fn default() -> Self { 30 | Self::new() 31 | } 32 | } 33 | 34 | /// Determine whether a line is in a verbatim environment 35 | #[allow(clippy::too_many_arguments)] 36 | pub fn get_verbatim( 37 | line: &str, 38 | state: &State, 39 | logs: &mut Vec, 40 | file: &Path, 41 | warn: bool, 42 | pattern: &Pattern, 43 | verbatims_begin: &[String], 44 | verbatims_end: &[String], 45 | ) -> Verbatim { 46 | let diff = get_verbatim_diff(line, pattern, verbatims_begin, verbatims_end); 47 | let actual = state.verbatim.actual + diff; 48 | let visual = actual > 0 || state.verbatim.actual > 0; 49 | 50 | if warn && (actual < 0) { 51 | record_line_log( 52 | logs, 53 | Warn, 54 | file, 55 | state.linum_new, 56 | state.linum_old, 57 | line, 58 | "Verbatim count is negative.", 59 | ); 60 | } 61 | 62 | Verbatim { actual, visual } 63 | } 64 | 65 | /// Calculate total verbatim depth change 66 | fn get_verbatim_diff( 67 | line: &str, 68 | pattern: &Pattern, 69 | verbatims_begin: &[String], 70 | verbatims_end: &[String], 71 | ) -> i8 { 72 | if pattern.contains_env_begin 73 | && verbatims_begin.iter().any(|r| line.contains(r)) 74 | { 75 | 1 76 | } else if pattern.contains_env_end 77 | && verbatims_end.iter().any(|r| line.contains(r)) 78 | { 79 | -1 80 | } else { 81 | 0 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/wrap/source/wrap.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | % no comment 6 | This line is too long because it has more than eighty characters inside it. Therefore it should be split. 7 | 8 | % break before comment 9 | This line is too long because it has more than eighty characters inside it. Therefore it % should be split. 10 | 11 | % break after spaced comment 12 | This line is too long because it has more than % eighty characters inside it. Therefore it should be split. 13 | 14 | % break after non-spaced comment 15 | This line is too long because it has more than% eighty characters inside it. Therefore it should be split. 16 | 17 | % unbreakable line 18 | Thislineistoolongbecauseithasmorethan%eightycharactersinsideit.Buttherearenospacessoitcannotbesplit. 19 | 20 | % line can be broken after 80 chars 21 | Thislineistoolongbecauseithasmorethaneightycharactersinsideitandtherearenospacesuntillater where there are some spaces so we can split this line here 22 | 23 | % long line only after indenting 24 | ( 25 | 1234567890 1234567890 1234567890 1234567890 1234567890 1234567890 1234567890 123 26 | ) 27 | 28 | % double break after comment 29 | This line has a long comment. % This comment is very long so needs to be split over three lines which is another edge case which should be checked here with all these extra words 30 | 31 | % double break after only comment 32 | % This line is all a long comment. This comment is very long so needs to be split over three lines which is another edge case which should be checked here with all these extra words 33 | 34 | % lines containing \ 35 | This line would usually be split at the special character part with a slash\ but it's best to break the line earlier. 36 | 37 | % long lines with brackets 38 | (This line is too long because it has more than eighty characters inside it. Therefore it should be split. It also needs splitting onto multiple lines, and the middle lines should be indented due to these brackets.) 39 | 40 | % long lines with double brackets 41 | ((This line is too long because it has more than eighty characters inside it. Therefore it should be split. It also needs splitting onto multiple lines, and the middle lines should be doubly indented due to these brackets.)) 42 | 43 | \end{document} 44 | -------------------------------------------------------------------------------- /tests/wrap/target/wrap.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | % no comment 6 | This line is too long because it has more than eighty characters 7 | inside it. Therefore it should be split. 8 | 9 | % break before comment 10 | This line is too long because it has more than eighty characters 11 | inside it. Therefore it % should be split. 12 | 13 | % break after spaced comment 14 | This line is too long because it has more than % eighty characters 15 | % inside it. Therefore it should be split. 16 | 17 | % break after non-spaced comment 18 | This line is too long because it has more than% eighty characters 19 | % inside it. Therefore it should be split. 20 | 21 | % unbreakable line 22 | Thislineistoolongbecauseithasmorethan%eightycharactersinsideit.Buttherearenospacessoitcannotbesplit. 23 | 24 | % line can be broken after 80 chars 25 | Thislineistoolongbecauseithasmorethaneightycharactersinsideitandtherearenospacesuntillater 26 | where there are some spaces so we can split this line here 27 | 28 | % long line only after indenting 29 | ( 30 | 1234567890 1234567890 1234567890 1234567890 1234567890 1234567890 31 | 1234567890 123 32 | ) 33 | 34 | % double break after comment 35 | This line has a long comment. % This comment is very long so needs to 36 | % be split over three lines which is another edge case which should 37 | % be checked here with all these extra words 38 | 39 | % double break after only comment 40 | % This line is all a long comment. This comment is very long so needs 41 | % to be split over three lines which is another edge case which 42 | % should be checked here with all these extra words 43 | 44 | % lines containing \ 45 | This line would usually be split at the special character part with a 46 | slash\ but it's best to break the line earlier. 47 | 48 | % long lines with brackets 49 | (This line is too long because it has more than eighty characters 50 | inside it. Therefore it should be split. It also needs splitting onto 51 | multiple lines, and the middle lines should be indented due to these brackets.) 52 | 53 | % long lines with double brackets 54 | ((This line is too long because it has more than eighty characters 55 | inside it. Therefore it should be split. It also needs splitting onto 56 | multiple lines, and the middle lines should be doubly indented due to 57 | these brackets.)) 58 | 59 | \end{document} 60 | -------------------------------------------------------------------------------- /tests/cv/source/wgu-cv.cls: -------------------------------------------------------------------------------- 1 | %! TeX root = WGUnderwood.tex 2 | 3 | % class 4 | \NeedsTeXFormat{LaTeX2e} 5 | \ProvidesClass{wgu-cv} 6 | 7 | % packages 8 | \LoadClass[10pt]{article} 9 | \RequirePackage[margin=1in,top=0.9in]{geometry} 10 | \RequirePackage{hyperref} 11 | %\RequirePackage{fontspec} 12 | \RequirePackage{microtype} 13 | \RequirePackage{fancyhdr} 14 | \RequirePackage{enumitem} 15 | \RequirePackage{ifthen} 16 | 17 | % variables 18 | \def\yourname#1{\def\@yourname{#1}} 19 | \def\youraddress#1{\def\@youraddress{#1}} 20 | \def\youremail#1{\def\@youremail{#1}} 21 | \def\yourwebsite#1{\def\@yourwebsite{#1}} 22 | 23 | % settings 24 | %\setmainfont{Libre Baskerville}[Scale=0.9] 25 | %\setmonofont{Source Code Pro}[Scale=0.97] 26 | \geometry{a4paper} 27 | \setlength\parindent{0pt} 28 | \bibliographystyle{abbrvnat} 29 | \pagestyle{fancy} 30 | \renewcommand{\headrulewidth}{0pt} 31 | \cfoot{\thepage} 32 | \rfoot{\today} 33 | \setlist{ 34 | leftmargin=0.5cm, 35 | topsep=0cm, 36 | partopsep=0cm, 37 | parsep=-0.04cm, % item spacing 38 | before=\vspace{0.12cm}, 39 | after=\vspace{0.08cm}, 40 | } 41 | 42 | % arxiv 43 | \newcommand{\arxiv}[1]{% 44 | \href{https://arxiv.org/abs/#1}{% 45 | \texttt{arXiv{:}{\allowbreak}#1}}% 46 | } 47 | 48 | % github 49 | \newcommand{\github}[1]{% 50 | GitHub: \href{https://github.com/#1}{% 51 | \texttt{#1}}% 52 | } 53 | 54 | % title 55 | \renewcommand{\maketitle}{% 56 | \vspace*{-1.2cm}% 57 | \begin{center}% 58 | \begin{huge}% 59 | \@yourname \\ 60 | \end{huge}% 61 | \vspace{0.5cm}% 62 | \@youraddress \\ 63 | \vspace{0.16cm}% 64 | \begin{minipage}{0.45\textwidth}% 65 | \centering% 66 | \href{mailto:\@youremail}{\nolinkurl{\@youremail}}% 67 | \end{minipage}% 68 | \begin{minipage}{0.45\textwidth}% 69 | \centering% 70 | \href{https://\@yourwebsite}{\nolinkurl{\@yourwebsite}}% 71 | \end{minipage} 72 | \end{center}% 73 | } 74 | 75 | % section 76 | \renewcommand{\section}[1]{% 77 | \vspace{0.3cm}% 78 | \par\hbox{\large\textbf{#1}\strut}% 79 | \vspace{-0.25cm}% 80 | \rule{\textwidth}{0.8pt}% 81 | \vspace{-0.15cm}% 82 | } 83 | 84 | % subsection 85 | \renewcommand{\subsection}[2]{% 86 | \vspace{0.30cm}% 87 | \textbf{#1}% 88 | \hfill{#2}% 89 | \vspace{0.03cm}% 90 | } 91 | 92 | % subsubsection 93 | \renewcommand{\subsubsection}[1]{% 94 | \linebreak 95 | \textit{#1}% 96 | \vspace{0.05cm}% 97 | } 98 | -------------------------------------------------------------------------------- /tests/cv/target/wgu-cv.cls: -------------------------------------------------------------------------------- 1 | %! TeX root = WGUnderwood.tex 2 | 3 | % class 4 | \NeedsTeXFormat{LaTeX2e} 5 | \ProvidesClass{wgu-cv} 6 | 7 | % packages 8 | \LoadClass[10pt]{article} 9 | \RequirePackage[margin=1in,top=0.9in]{geometry} 10 | \RequirePackage{hyperref} 11 | %\RequirePackage{fontspec} 12 | \RequirePackage{microtype} 13 | \RequirePackage{fancyhdr} 14 | \RequirePackage{enumitem} 15 | \RequirePackage{ifthen} 16 | 17 | % variables 18 | \def\yourname#1{\def\@yourname{#1}} 19 | \def\youraddress#1{\def\@youraddress{#1}} 20 | \def\youremail#1{\def\@youremail{#1}} 21 | \def\yourwebsite#1{\def\@yourwebsite{#1}} 22 | 23 | % settings 24 | %\setmainfont{Libre Baskerville}[Scale=0.9] 25 | %\setmonofont{Source Code Pro}[Scale=0.97] 26 | \geometry{a4paper} 27 | \setlength\parindent{0pt} 28 | \bibliographystyle{abbrvnat} 29 | \pagestyle{fancy} 30 | \renewcommand{\headrulewidth}{0pt} 31 | \cfoot{\thepage} 32 | \rfoot{\today} 33 | \setlist{ 34 | leftmargin=0.5cm, 35 | topsep=0cm, 36 | partopsep=0cm, 37 | parsep=-0.04cm, % item spacing 38 | before=\vspace{0.12cm}, 39 | after=\vspace{0.08cm}, 40 | } 41 | 42 | % arxiv 43 | \newcommand{\arxiv}[1]{% 44 | \href{https://arxiv.org/abs/#1}{% 45 | \texttt{arXiv{:}{\allowbreak}#1}}% 46 | } 47 | 48 | % github 49 | \newcommand{\github}[1]{% 50 | GitHub: \href{https://github.com/#1}{% 51 | \texttt{#1}}% 52 | } 53 | 54 | % title 55 | \renewcommand{\maketitle}{% 56 | \vspace*{-1.2cm}% 57 | \begin{center}% 58 | \begin{huge}% 59 | \@yourname \\ 60 | \end{huge}% 61 | \vspace{0.5cm}% 62 | \@youraddress \\ 63 | \vspace{0.16cm}% 64 | \begin{minipage}{0.45\textwidth}% 65 | \centering% 66 | \href{mailto:\@youremail}{\nolinkurl{\@youremail}}% 67 | \end{minipage}% 68 | \begin{minipage}{0.45\textwidth}% 69 | \centering% 70 | \href{https://\@yourwebsite}{\nolinkurl{\@yourwebsite}}% 71 | \end{minipage} 72 | \end{center}% 73 | } 74 | 75 | % section 76 | \renewcommand{\section}[1]{% 77 | \vspace{0.3cm}% 78 | \par\hbox{\large\textbf{#1}\strut}% 79 | \vspace{-0.25cm}% 80 | \rule{\textwidth}{0.8pt}% 81 | \vspace{-0.15cm}% 82 | } 83 | 84 | % subsection 85 | \renewcommand{\subsection}[2]{% 86 | \vspace{0.30cm}% 87 | \textbf{#1}% 88 | \hfill{#2}% 89 | \vspace{0.03cm}% 90 | } 91 | 92 | % subsubsection 93 | \renewcommand{\subsubsection}[1]{% 94 | \linebreak 95 | \textit{#1}% 96 | \vspace{0.05cm}% 97 | } 98 | -------------------------------------------------------------------------------- /completion/_tex-fmt: -------------------------------------------------------------------------------- 1 | #compdef tex-fmt 2 | 3 | autoload -U is-at-least 4 | 5 | _tex-fmt() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" : \ 18 | '-l+[Line length for wrapping \[default\: 80\]]:N:_default' \ 19 | '--wraplen=[Line length for wrapping \[default\: 80\]]:N:_default' \ 20 | '-t+[Number of characters to use as tab size \[default\: 2\]]:N:_default' \ 21 | '--tabsize=[Number of characters to use as tab size \[default\: 2\]]:N:_default' \ 22 | '--config=[Path to config file]:PATH:_files' \ 23 | '--completion=[Generate shell completion script]:SHELL:(bash elvish fish powershell zsh)' \ 24 | '-c[Check formatting, do not modify files]' \ 25 | '--check[Check formatting, do not modify files]' \ 26 | '-p[Print to stdout, do not modify files]' \ 27 | '--print[Print to stdout, do not modify files]' \ 28 | '-f[Format files and return non-zero exit code if files are modified]' \ 29 | '--fail-on-change[Format files and return non-zero exit code if files are modified]' \ 30 | '-n[Do not wrap long lines]' \ 31 | '--nowrap[Do not wrap long lines]' \ 32 | '--usetabs[Use tabs instead of spaces for indentation]' \ 33 | '-s[Process stdin as a single file, output to stdout]' \ 34 | '--stdin[Process stdin as a single file, output to stdout]' \ 35 | '--noconfig[Do not read any config file]' \ 36 | '-v[Show info messages]' \ 37 | '--verbose[Show info messages]' \ 38 | '-q[Hide warning messages]' \ 39 | '--quiet[Hide warning messages]' \ 40 | '--trace[Show trace messages]' \ 41 | '--man[Generate man page]' \ 42 | '--args[Print arguments passed to tex-fmt and exit]' \ 43 | '-r[Recursively search for files to format]' \ 44 | '--recursive[Recursively search for files to format]' \ 45 | '-h[Print help]' \ 46 | '--help[Print help]' \ 47 | '-V[Print version]' \ 48 | '--version[Print version]' \ 49 | '::files -- List of files to be formatted:_default' \ 50 | && ret=0 51 | } 52 | 53 | (( $+functions[_tex-fmt_commands] )) || 54 | _tex-fmt_commands() { 55 | local commands; commands=() 56 | _describe -t commands 'tex-fmt commands' commands "$@" 57 | } 58 | 59 | if [ "$funcstack[1]" = "_tex-fmt" ]; then 60 | _tex-fmt "$@" 61 | else 62 | compdef _tex-fmt tex-fmt 63 | fi 64 | -------------------------------------------------------------------------------- /man/tex-fmt.1: -------------------------------------------------------------------------------- 1 | .ie \n(.g .ds Aq \(aq 2 | .el .ds Aq ' 3 | .TH tex-fmt 1 "tex-fmt 0.5.6" 4 | .SH NAME 5 | tex\-fmt \- LaTeX formatter written in Rust 6 | .SH SYNOPSIS 7 | \fBtex\-fmt\fR [\fB\-c\fR|\fB\-\-check\fR] [\fB\-p\fR|\fB\-\-print\fR] [\fB\-f\fR|\fB\-\-fail\-on\-change\fR] [\fB\-n\fR|\fB\-\-nowrap\fR] [\fB\-l\fR|\fB\-\-wraplen\fR] [\fB\-t\fR|\fB\-\-tabsize\fR] [\fB\-\-usetabs\fR] [\fB\-s\fR|\fB\-\-stdin\fR] [\fB\-\-config\fR] [\fB\-\-noconfig\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-\-trace\fR] [\fB\-\-completion\fR] [\fB\-\-man\fR] [\fB\-\-args\fR] [\fB\-r\fR|\fB\-\-recursive\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIfiles\fR] 8 | .SH DESCRIPTION 9 | LaTeX formatter written in Rust 10 | .SH OPTIONS 11 | .TP 12 | \fB\-c\fR, \fB\-\-check\fR 13 | Check formatting, do not modify files 14 | .TP 15 | \fB\-p\fR, \fB\-\-print\fR 16 | Print to stdout, do not modify files 17 | .TP 18 | \fB\-f\fR, \fB\-\-fail\-on\-change\fR 19 | Format files and return non\-zero exit code if files are modified 20 | .TP 21 | \fB\-n\fR, \fB\-\-nowrap\fR 22 | Do not wrap long lines 23 | .TP 24 | \fB\-l\fR, \fB\-\-wraplen\fR \fI\fR 25 | Line length for wrapping [default: 80] 26 | .TP 27 | \fB\-t\fR, \fB\-\-tabsize\fR \fI\fR 28 | Number of characters to use as tab size [default: 2] 29 | .TP 30 | \fB\-\-usetabs\fR 31 | Use tabs instead of spaces for indentation 32 | .TP 33 | \fB\-s\fR, \fB\-\-stdin\fR 34 | Process stdin as a single file, output to stdout 35 | .TP 36 | \fB\-\-config\fR \fI\fR 37 | Path to config file 38 | .TP 39 | \fB\-\-noconfig\fR 40 | Do not read any config file 41 | .TP 42 | \fB\-v\fR, \fB\-\-verbose\fR 43 | Show info messages 44 | .TP 45 | \fB\-q\fR, \fB\-\-quiet\fR 46 | Hide warning messages 47 | .TP 48 | \fB\-\-trace\fR 49 | Show trace messages 50 | .TP 51 | \fB\-\-completion\fR \fI\fR 52 | Generate shell completion script 53 | .br 54 | 55 | .br 56 | [\fIpossible values: \fRbash, elvish, fish, powershell, zsh] 57 | .TP 58 | \fB\-\-man\fR 59 | Generate man page 60 | .TP 61 | \fB\-\-args\fR 62 | Print arguments passed to tex\-fmt and exit 63 | .TP 64 | \fB\-r\fR, \fB\-\-recursive\fR 65 | Recursively search for files to format 66 | .TP 67 | \fB\-h\fR, \fB\-\-help\fR 68 | Print help 69 | .TP 70 | \fB\-V\fR, \fB\-\-version\fR 71 | Print version 72 | .TP 73 | [\fIfiles\fR] 74 | List of files to be formatted 75 | .SH VERSION 76 | v0.5.6 77 | .SH AUTHORS 78 | William George Underwood, wg.underwood13@gmail.com 79 | -------------------------------------------------------------------------------- /completion/tex-fmt.bash: -------------------------------------------------------------------------------- 1 | _tex-fmt() { 2 | local i cur prev opts cmd 3 | COMPREPLY=() 4 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 5 | cur="$2" 6 | else 7 | cur="${COMP_WORDS[COMP_CWORD]}" 8 | fi 9 | prev="$3" 10 | cmd="" 11 | opts="" 12 | 13 | for i in "${COMP_WORDS[@]:0:COMP_CWORD}" 14 | do 15 | case "${cmd},${i}" in 16 | ",$1") 17 | cmd="tex__fmt" 18 | ;; 19 | *) 20 | ;; 21 | esac 22 | done 23 | 24 | case "${cmd}" in 25 | tex__fmt) 26 | opts="-c -p -f -n -l -t -s -v -q -r -h -V --check --print --fail-on-change --nowrap --wraplen --tabsize --usetabs --stdin --config --noconfig --verbose --quiet --trace --completion --man --args --recursive --help --version [files]..." 27 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 28 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 29 | return 0 30 | fi 31 | case "${prev}" in 32 | --wraplen) 33 | COMPREPLY=($(compgen -f "${cur}")) 34 | return 0 35 | ;; 36 | -l) 37 | COMPREPLY=($(compgen -f "${cur}")) 38 | return 0 39 | ;; 40 | --tabsize) 41 | COMPREPLY=($(compgen -f "${cur}")) 42 | return 0 43 | ;; 44 | -t) 45 | COMPREPLY=($(compgen -f "${cur}")) 46 | return 0 47 | ;; 48 | --config) 49 | COMPREPLY=($(compgen -f "${cur}")) 50 | return 0 51 | ;; 52 | --completion) 53 | COMPREPLY=($(compgen -W "bash elvish fish powershell zsh" -- "${cur}")) 54 | return 0 55 | ;; 56 | *) 57 | COMPREPLY=() 58 | ;; 59 | esac 60 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 61 | return 0 62 | ;; 63 | esac 64 | } 65 | 66 | if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then 67 | complete -F _tex-fmt -o nosort -o bashdefault -o default tex-fmt 68 | else 69 | complete -F _tex-fmt -o bashdefault -o default tex-fmt 70 | fi 71 | -------------------------------------------------------------------------------- /src/regexes.rs: -------------------------------------------------------------------------------- 1 | //! Regexes and matching utilities 2 | 3 | use crate::LINE_END; 4 | use regex::{Regex, RegexSet}; 5 | use std::sync::LazyLock; 6 | 7 | /// Match a LaTeX \item 8 | pub const ITEM: &str = "\\item"; 9 | 10 | /// Match a LaTeX \begin{...} 11 | pub const ENV_BEGIN: &str = "\\begin{"; 12 | 13 | /// Match a LaTeX \end{...} 14 | pub const ENV_END: &str = "\\end{"; 15 | 16 | /// Acceptable LaTeX file extensions 17 | pub const EXTENSIONS: [&str; 4] = ["tex", "bib", "sty", "cls"]; 18 | /// Match a LaTeX \verb|...| 19 | pub const VERBS: [&str; 3] = ["\\verb|", "\\verb+", "\\mintinline"]; 20 | 21 | /// Regex matches for sectioning commands 22 | const SPLITTING: [&str; 6] = [ 23 | r"\\begin\{", 24 | r"\\end\{", 25 | r"\\item(?:$|[^a-zA-Z])", 26 | r"\\(?:sub){0,2}section\*?\{", 27 | r"\\chapter\*?\{", 28 | r"\\part\*?\{", 29 | ]; 30 | 31 | // A static `String` which is a regex to match any of [`SPLITTING_COMMANDS`]. 32 | static SPLITTING_STRING: LazyLock = 33 | LazyLock::new(|| ["(", SPLITTING.join("|").as_str(), ")"].concat()); 34 | 35 | // Regex to match newlines 36 | pub static RE_NEWLINES: LazyLock = LazyLock::new(|| { 37 | Regex::new(&format!(r"{LINE_END}{LINE_END}({LINE_END})+")).unwrap() 38 | }); 39 | 40 | // Regex to match trailing new ines 41 | pub static RE_TRAIL: LazyLock = 42 | LazyLock::new(|| Regex::new(&format!(r" +{LINE_END}")).unwrap()); 43 | 44 | // Regex that matches splitting commands 45 | pub static RE_SPLITTING: LazyLock = 46 | LazyLock::new(|| RegexSet::new(SPLITTING).unwrap()); 47 | 48 | // Matches splitting commands with non-whitespace characters before it. 49 | pub static RE_SPLITTING_SHARED_LINE: LazyLock = LazyLock::new(|| { 50 | Regex::new( 51 | [r"(:?\S.*?)", "(:?", SPLITTING_STRING.as_str(), ".*)"] 52 | .concat() 53 | .as_str(), 54 | ) 55 | .unwrap() 56 | }); 57 | 58 | // Matches any splitting command with non-whitespace 59 | // characters before it, catches the previous text in a group called 60 | // "prev" and captures the command itself and the remaining text 61 | // in a group called "env". 62 | pub static RE_SPLITTING_SHARED_LINE_CAPTURE: LazyLock = 63 | LazyLock::new(|| { 64 | Regex::new( 65 | [ 66 | r"(?P\S.*?)", 67 | "(?P", 68 | SPLITTING_STRING.as_str(), 69 | ".*)", 70 | ] 71 | .concat() 72 | .as_str(), 73 | ) 74 | .unwrap() 75 | }); 76 | -------------------------------------------------------------------------------- /tests/wrap_chars/source/wrap_chars.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | This,file,is,similar,to,the,regular,wrapping,test,,but,uses,commas, 6 | instead,of,spaces,,and,attempts,to,split,at,commas. 7 | 8 | % no comment 9 | This,line,is,too,long,because,it,has,more,than,eighty,characters,inside,it.,Therefore,it,should,be,split. 10 | 11 | % break before comment 12 | This,line,is,too,long,because,it,has,more,than,eighty,characters,inside,it.,Therefore,it,%,should,be,split. 13 | 14 | % break after spaced comment 15 | This,line,is,too,long,because,it,has,more,than,%,eighty,characters,inside,it.,Therefore,it,should,be,split. 16 | 17 | % break after non-spaced comment 18 | This,line,is,too,long,because,it,has,more,than%,eighty,characters,inside,it.,Therefore,it,should,be,split. 19 | 20 | % unbreakable line 21 | Thislineistoolongbecauseithasmorethan%eightycharactersinsideit.Buttherearenospacessoitcannotbesplit. 22 | 23 | % line can be broken after 80 chars 24 | Thislineistoolongbecauseithasmorethaneightycharactersinsideitandtherearenospacesuntillater,where,there,are,some,spaces,so,we,can,split,this,line,here 25 | 26 | % long line only after indenting 27 | ( 28 | 1234567890,1234567890,1234567890,1234567890,1234567890,1234567890,1234567890,123 29 | ) 30 | 31 | % double break after comment 32 | This,line,has,a,long,comment.,%,This,comment,is,very,long,so,needs,to,be,split,over,three,lines,which,is,another,edge,case,which,should,be,checked,here,with,all,these,extra,words 33 | 34 | % double break after only comment 35 | % This,line,is,all,a,long,comment.,This,comment,is,very,long,so,needs,to,be,split,over,three,lines,which,is,another,edge,case,which,should,be,checked,here,with,all,these,extra,words 36 | 37 | % lines containing \ 38 | This,line,would,usually,be,split,at,the,special,character,part,with,a,slash\,but,it's,best,to,break,the,line,earlier. 39 | 40 | % long lines with brackets 41 | (This,line,is,too,long,because,it,has,more,than,eighty,characters,inside,it.,Therefore,it,should,be,split.,It,also,needs,splitting,onto,multiple,lines,,and,the,middle,lines,should,be,indented,due,to,these,brackets.) 42 | 43 | % long lines with double brackets 44 | ((This,line,is,too,long,because,it,has,more,than,eighty,characters,inside,it.,Therefore,it,should,be,split.,It,also,needs,splitting,onto,multiple,lines,,and,the,middle,lines,should,be,doubly,indented,due,to,these,brackets.)) 45 | 46 | % long line ending with non-ascii char 47 | thisisthefilethisisthefilethisisthefilethisisthefilethisisthefilethisisthefilethisisthefile。 48 | 49 | \end{document} 50 | -------------------------------------------------------------------------------- /completion/tex-fmt.elv: -------------------------------------------------------------------------------- 1 | 2 | use builtin; 3 | use str; 4 | 5 | set edit:completion:arg-completer[tex-fmt] = {|@words| 6 | fn spaces {|n| 7 | builtin:repeat $n ' ' | str:join '' 8 | } 9 | fn cand {|text desc| 10 | edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc 11 | } 12 | var command = 'tex-fmt' 13 | for word $words[1..-1] { 14 | if (str:has-prefix $word '-') { 15 | break 16 | } 17 | set command = $command';'$word 18 | } 19 | var completions = [ 20 | &'tex-fmt'= { 21 | cand -l 'Line length for wrapping [default: 80]' 22 | cand --wraplen 'Line length for wrapping [default: 80]' 23 | cand -t 'Number of characters to use as tab size [default: 2]' 24 | cand --tabsize 'Number of characters to use as tab size [default: 2]' 25 | cand --config 'Path to config file' 26 | cand --completion 'Generate shell completion script' 27 | cand -c 'Check formatting, do not modify files' 28 | cand --check 'Check formatting, do not modify files' 29 | cand -p 'Print to stdout, do not modify files' 30 | cand --print 'Print to stdout, do not modify files' 31 | cand -f 'Format files and return non-zero exit code if files are modified' 32 | cand --fail-on-change 'Format files and return non-zero exit code if files are modified' 33 | cand -n 'Do not wrap long lines' 34 | cand --nowrap 'Do not wrap long lines' 35 | cand --usetabs 'Use tabs instead of spaces for indentation' 36 | cand -s 'Process stdin as a single file, output to stdout' 37 | cand --stdin 'Process stdin as a single file, output to stdout' 38 | cand --noconfig 'Do not read any config file' 39 | cand -v 'Show info messages' 40 | cand --verbose 'Show info messages' 41 | cand -q 'Hide warning messages' 42 | cand --quiet 'Hide warning messages' 43 | cand --trace 'Show trace messages' 44 | cand --man 'Generate man page' 45 | cand --args 'Print arguments passed to tex-fmt and exit' 46 | cand -r 'Recursively search for files to format' 47 | cand --recursive 'Recursively search for files to format' 48 | cand -h 'Print help' 49 | cand --help 'Print help' 50 | cand -V 'Print version' 51 | cand --version 'Print version' 52 | } 53 | ] 54 | $completions[$command] 55 | } 56 | -------------------------------------------------------------------------------- /tests/wrap_chars/target/wrap_chars.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \begin{document} 4 | 5 | This,file,is,similar,to,the,regular,wrapping,test,,but,uses,commas, 6 | instead,of,spaces,,and,attempts,to,split,at,commas. 7 | 8 | % no comment 9 | This,line,is,too,long,because,it,has,more,than,eighty,characters, 10 | inside,it.,Therefore,it,should,be,split. 11 | 12 | % break before comment 13 | This,line,is,too,long,because,it,has,more,than,eighty,characters, 14 | inside,it.,Therefore,it,%,should,be,split. 15 | 16 | % break after spaced comment 17 | This,line,is,too,long,because,it,has,more,than,%,eighty,characters, 18 | % inside,it.,Therefore,it,should,be,split. 19 | 20 | % break after non-spaced comment 21 | This,line,is,too,long,because,it,has,more,than%,eighty,characters, 22 | % inside,it.,Therefore,it,should,be,split. 23 | 24 | % unbreakable line 25 | Thislineistoolongbecauseithasmorethan%eightycharactersinsideit.Buttherearenospacessoitcannotbesplit. 26 | 27 | % line can be broken after 80 chars 28 | Thislineistoolongbecauseithasmorethaneightycharactersinsideitandtherearenospacesuntillater, 29 | where,there,are,some,spaces,so,we,can,split,this,line,here 30 | 31 | % long line only after indenting 32 | ( 33 | 1234567890,1234567890,1234567890,1234567890,1234567890,1234567890, 34 | 1234567890,123 35 | ) 36 | 37 | % double break after comment 38 | This,line,has,a,long,comment.,%,This,comment,is,very,long,so,needs,to, 39 | % be,split,over,three,lines,which,is,another,edge,case,which,should, 40 | % be,checked,here,with,all,these,extra,words 41 | 42 | % double break after only comment 43 | % This,line,is,all,a,long,comment.,This,comment,is,very,long,so,needs, 44 | % to,be,split,over,three,lines,which,is,another,edge,case,which, 45 | % should,be,checked,here,with,all,these,extra,words 46 | 47 | % lines containing \ 48 | This,line,would,usually,be,split,at,the,special,character,part,with,a, 49 | slash\,but,it's,best,to,break,the,line,earlier. 50 | 51 | % long lines with brackets 52 | (This,line,is,too,long,because,it,has,more,than,eighty,characters, 53 | inside,it.,Therefore,it,should,be,split.,It,also,needs,splitting,onto, 54 | multiple,lines,,and,the,middle,lines,should,be,indented,due,to,these,brackets.) 55 | 56 | % long lines with double brackets 57 | ((This,line,is,too,long,because,it,has,more,than,eighty,characters, 58 | inside,it.,Therefore,it,should,be,split.,It,also,needs,splitting,onto, 59 | multiple,lines,,and,the,middle,lines,should,be,doubly,indented,due,to, 60 | these,brackets.)) 61 | 62 | % long line ending with non-ascii char 63 | thisisthefilethisisthefilethisisthefilethisisthefilethisisthefilethisisthefilethisisthefile。 64 | 65 | \end{document} 66 | -------------------------------------------------------------------------------- /src/ignore.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for ignoring/skipping source lines 2 | 3 | use crate::format::State; 4 | use crate::logging::{record_line_log, Log}; 5 | use log::Level::Warn; 6 | use std::path::Path; 7 | 8 | /// Information on the ignored state of a line 9 | #[derive(Clone, Debug)] 10 | pub struct Ignore { 11 | /// Whether the line is in an ignore block 12 | pub actual: bool, 13 | /// Whether the line should be ignored/skipped 14 | pub visual: bool, 15 | } 16 | 17 | impl Ignore { 18 | /// Construct a new ignore state 19 | #[must_use] 20 | pub const fn new() -> Self { 21 | Self { 22 | actual: false, 23 | visual: false, 24 | } 25 | } 26 | } 27 | 28 | impl Default for Ignore { 29 | fn default() -> Self { 30 | Self::new() 31 | } 32 | } 33 | 34 | /// Determine whether a line should be ignored 35 | pub fn get_ignore( 36 | line: &str, 37 | state: &State, 38 | logs: &mut Vec, 39 | file: &Path, 40 | warn: bool, 41 | ) -> Ignore { 42 | let skip = contains_ignore_skip(line); 43 | let begin = contains_ignore_begin(line); 44 | let end = contains_ignore_end(line); 45 | let actual: bool; 46 | let visual: bool; 47 | 48 | if skip { 49 | actual = state.ignore.actual; 50 | visual = true; 51 | } else if begin { 52 | actual = true; 53 | visual = true; 54 | if warn && state.ignore.actual { 55 | record_line_log( 56 | logs, 57 | Warn, 58 | file, 59 | state.linum_new, 60 | state.linum_old, 61 | line, 62 | "Cannot begin ignore block:", 63 | ); 64 | } 65 | } else if end { 66 | actual = false; 67 | visual = true; 68 | if warn && !state.ignore.actual { 69 | record_line_log( 70 | logs, 71 | Warn, 72 | file, 73 | state.linum_new, 74 | state.linum_old, 75 | line, 76 | "No ignore block to end.", 77 | ); 78 | } 79 | } else { 80 | actual = state.ignore.actual; 81 | visual = state.ignore.actual; 82 | } 83 | 84 | Ignore { actual, visual } 85 | } 86 | 87 | /// Check if a line contains a skip directive 88 | fn contains_ignore_skip(line: &str) -> bool { 89 | line.ends_with("% tex-fmt: skip") 90 | } 91 | 92 | /// Check if a line contains the start of an ignore block 93 | fn contains_ignore_begin(line: &str) -> bool { 94 | line.ends_with("% tex-fmt: off") 95 | } 96 | 97 | /// Check if a line contains the end of an ignore block 98 | fn contains_ignore_end(line: &str) -> bool { 99 | line.ends_with("% tex-fmt: on") 100 | } 101 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | //! Functionality to parse CLI arguments 2 | 3 | use crate::args::{OptionArgs, TabChar}; 4 | use clap::ArgMatches; 5 | use clap_complete::{generate, Shell}; 6 | use clap_mangen::Man; 7 | use log::LevelFilter; 8 | use std::io; 9 | 10 | // Get the clap CLI command from a separate file 11 | include!("command.rs"); 12 | 13 | /// Read `ArgMatches` flag into `Option` 14 | fn get_flag(arg_matches: &ArgMatches, flag: &str) -> Option { 15 | if arg_matches.get_flag(flag) { 16 | Some(true) 17 | } else { 18 | None 19 | } 20 | } 21 | 22 | /// Parse CLI arguments into `OptionArgs` struct 23 | /// 24 | /// # Panics 25 | /// 26 | /// This function panics if the man page cannot be written. 27 | pub fn get_cli_args(matches: Option) -> OptionArgs { 28 | let mut command = get_cli_command(); 29 | let arg_matches = match matches { 30 | Some(m) => m, 31 | None => command.clone().get_matches(), 32 | }; 33 | 34 | // Generate completions and exit 35 | if let Some(shell) = arg_matches.get_one::("completion") { 36 | generate(*shell, &mut command, "tex-fmt", &mut io::stdout()); 37 | std::process::exit(0); 38 | } 39 | 40 | // Generate man page and exit 41 | if arg_matches.get_flag("man") { 42 | let man = Man::new(command); 43 | man.render(&mut io::stdout()).unwrap(); 44 | std::process::exit(0); 45 | } 46 | 47 | let wrap: Option = if arg_matches.get_flag("nowrap") { 48 | Some(false) 49 | } else { 50 | None 51 | }; 52 | let tabchar = if arg_matches.get_flag("usetabs") { 53 | Some(TabChar::Tab) 54 | } else { 55 | None 56 | }; 57 | let verbosity = if arg_matches.get_flag("trace") { 58 | Some(LevelFilter::Trace) 59 | } else if arg_matches.get_flag("verbose") { 60 | Some(LevelFilter::Info) 61 | } else if arg_matches.get_flag("quiet") { 62 | Some(LevelFilter::Error) 63 | } else { 64 | None 65 | }; 66 | let args = OptionArgs { 67 | check: get_flag(&arg_matches, "check"), 68 | print: get_flag(&arg_matches, "print"), 69 | fail_on_change: get_flag(&arg_matches, "fail-on-change"), 70 | wrap, 71 | wraplen: arg_matches.get_one::("wraplen").copied(), 72 | wrapmin: None, 73 | tabsize: arg_matches.get_one::("tabsize").copied(), 74 | tabchar, 75 | stdin: get_flag(&arg_matches, "stdin"), 76 | config: arg_matches.get_one::("config").cloned(), 77 | noconfig: get_flag(&arg_matches, "noconfig"), 78 | lists: vec![], 79 | verbatims: vec![], 80 | no_indent_envs: vec![], 81 | wrap_chars: vec![], 82 | verbosity, 83 | arguments: get_flag(&arg_matches, "args"), 84 | files: arg_matches 85 | .get_many::("files") 86 | .unwrap_or_default() 87 | .map(PathBuf::from) 88 | .collect::>(), 89 | recursive: get_flag(&arg_matches, "recursive"), 90 | }; 91 | args 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "Publish" 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | build: 8 | name: Build (${{ matrix.archive }}) 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | include: 13 | - os: windows-latest 14 | target: x86_64-pc-windows-msvc 15 | program: cargo 16 | archive: tex-fmt-x86_64-windows.zip 17 | - os: windows-latest 18 | target: i686-pc-windows-msvc 19 | program: cargo 20 | archive: tex-fmt-i686-windows.zip 21 | - os: windows-latest 22 | target: aarch64-pc-windows-msvc 23 | program: cargo 24 | archive: tex-fmt-aarch64-windows.zip 25 | - os: macos-latest 26 | target: x86_64-apple-darwin 27 | program: cargo 28 | archive: tex-fmt-x86_64-macos.tar.gz 29 | - os: macos-latest 30 | target: aarch64-apple-darwin 31 | program: cargo 32 | archive: tex-fmt-aarch64-macos.tar.gz 33 | - os: ubuntu-latest 34 | target: x86_64-unknown-linux-gnu 35 | program: cargo 36 | archive: tex-fmt-x86_64-linux.tar.gz 37 | - os: ubuntu-latest 38 | target: aarch64-unknown-linux-gnu 39 | program: cross 40 | archive: tex-fmt-aarch64-linux.tar.gz 41 | - os: ubuntu-latest 42 | target: armv7-unknown-linux-gnueabihf 43 | program: cross 44 | archive: tex-fmt-armv7hf-linux.tar.gz 45 | - os: ubuntu-latest 46 | target: x86_64-unknown-linux-musl 47 | program: cargo 48 | archive: tex-fmt-x86_64-alpine.tar.gz 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: dtolnay/rust-toolchain@stable 52 | with: 53 | targets: ${{ matrix.target }} 54 | - name: Install cross 55 | if: ${{ matrix.program == 'cross' }} 56 | run: cargo install cross 57 | - name: Build 58 | run: | 59 | ${{ matrix.program }} build --target ${{ matrix.target }} --all-features --release --locked 60 | - name: Compress (windows) 61 | if: ${{ contains(matrix.os, 'windows') }} 62 | run: | 63 | ${{ format('Compress-Archive target/{0}/release/tex-fmt.exe {1}', 64 | matrix.target, matrix.archive) }} 65 | - name: Compress (macos) 66 | if: ${{ contains(matrix.os, 'macos') }} 67 | run: | 68 | ${{ format('gtar -czvf {1} -C target/{0}/release tex-fmt', 69 | matrix.target, matrix.archive) }} 70 | - name: Compress (linux) 71 | if: ${{ contains(matrix.os, 'ubuntu') }} 72 | run: | 73 | ${{ format('tar -czvf {1} -C target/{0}/release tex-fmt', 74 | matrix.target, matrix.archive) }} 75 | - name: Upload binary archive 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: ${{ matrix.target }} 79 | path: ${{ matrix.archive }} 80 | github: 81 | name: GitHub archive upload 82 | needs: [build] 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | - uses: actions/download-artifact@v4 87 | - name: Publish binaries 88 | run: | 89 | gh release upload ${{ github.ref_name }} $(find . -iname tex-fmt*.zip) 90 | gh release upload ${{ github.ref_name }} $(find . -iname tex-fmt*.tar.gz) 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - develop 7 | push: 8 | branches: 9 | - main 10 | - develop 11 | workflow_dispatch: 12 | jobs: 13 | test: 14 | name: Cargo test (${{ matrix.os }}) 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [windows-latest, macos-latest, ubuntu-latest] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: dtolnay/rust-toolchain@stable 23 | - uses: Swatinem/rust-cache@v2 24 | - name: Test 25 | run: cargo test 26 | format: 27 | name: Cargo format (${{ matrix.os }}) 28 | runs-on: ${{ matrix.os }} 29 | strategy: 30 | fail-fast: true 31 | matrix: 32 | os: [windows-latest, macos-latest, ubuntu-latest] 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: dtolnay/rust-toolchain@stable 36 | with: 37 | components: rustfmt 38 | - uses: Swatinem/rust-cache@v2 39 | - name: Format 40 | run: cargo fmt --check 41 | cross: 42 | name: Cargo cross build (${{ matrix.target }}) 43 | runs-on: ubuntu-latest 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | target: 48 | - aarch64-unknown-linux-gnu 49 | - x86_64-unknown-linux-musl 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: dtolnay/rust-toolchain@stable 53 | - uses: Swatinem/rust-cache@v2 54 | - run: cargo install cross 55 | - name: Build 56 | run: cross build --target ${{ matrix.target }} 57 | wasm: 58 | name: Cargo wasm build 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: dtolnay/rust-toolchain@stable 63 | with: 64 | targets: wasm32-unknown-unknown 65 | - uses: Swatinem/rust-cache@v2 66 | - uses: jetli/wasm-bindgen-action@v0.2.0 67 | with: 68 | version: '0.2.100' 69 | - name: Build wasm 70 | run: cargo build -r -F wasm --lib --target wasm32-unknown-unknown 71 | - name: Bind wasm 72 | run: | 73 | wasm-bindgen --target web --out-dir web/pkg \ 74 | target/wasm32-unknown-unknown/release/tex_fmt.wasm 75 | - name: Optimize wasm 76 | uses: NiklasEi/wasm-opt-action@v2 77 | with: 78 | options: -Oz 79 | file: web/pkg/tex_fmt_bg.wasm 80 | output: web/pkg/tex_fmt_bg.wasm 81 | - name: Upload wasm 82 | if: github.ref == 'refs/heads/main' 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: pkg 86 | path: web/pkg/ 87 | pages: 88 | if: github.ref == 'refs/heads/main' 89 | name: Deploy to GitHub Pages 90 | runs-on: ubuntu-latest 91 | needs: wasm 92 | steps: 93 | - uses: actions/checkout@v3 94 | - name: Download WASM and JS artifacts 95 | uses: actions/download-artifact@v4 96 | with: 97 | name: pkg 98 | path: web/pkg 99 | - uses: peaceiris/actions-gh-pages@v3 100 | with: 101 | github_token: ${{ secrets.GITHUB_TOKEN }} 102 | publish_dir: web 103 | nix: 104 | name: Nix build 105 | runs-on: ubuntu-latest 106 | steps: 107 | - uses: actions/checkout@v4 108 | - uses: cachix/install-nix-action@v25 109 | with: 110 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 111 | nix_path: nixpkgs=channel:nixos-unstable 112 | - uses: DeterminateSystems/magic-nix-cache-action@main 113 | - run: nix build 114 | - run: nix flake check --all-systems 115 | -------------------------------------------------------------------------------- /notes.org: -------------------------------------------------------------------------------- 1 | #+title: tex-fmt 2 | * Tasks 3 | * Release process 4 | ** Checkout main branch 5 | ** Update release notes in NEWS.md 6 | *** git log --oneline --no-merges vX.X.X..main 7 | ** Update version number in Cargo.toml to vY.Y.Y 8 | ** Update version number for pre-commit in README 9 | ** Update year in LICENSE 10 | ** Update Nix flake and lock 11 | *** just nix 12 | *** nix develop 13 | ** Update Rust version 14 | *** just upgrade 15 | ** Run tests 16 | *** just 17 | *** just perf 18 | *** Update performance results in README.md 19 | ** Push to GitHub and check action tests pass 20 | ** Create a git tag 21 | *** git tag vY.Y.Y 22 | *** git push --tags 23 | *** Can delete remote tags with git push --delete origin vY.Y.Y 24 | ** Publish to crates.io 25 | *** cargo package --list 26 | *** cargo publish 27 | *** Pass --allow-dirty if notes.org has changed 28 | ** Publish GitHub release with notes from NEWS.md 29 | *** No need to add a title 30 | *** GitHub binaries published automatically with actions 31 | ** Submit to CTAN 32 | *** Update version, update, announcement in ctan/latex-formatter.pkg 33 | *** Update ctan/README.md 34 | *** Update LaTeX docs at ctan/latex-formatter.tex 35 | *** cd ctan 36 | *** just ctan 37 | *** gh release download vY.Y.Y -D latex-formatter/bin 38 | *** tar -czf latex-formatter.tar.gz latex-formatter 39 | *** Validate with ctan-o-mat latex-formatter.pkg 40 | *** Submit with ctan-o-mat --submit latex-formatter.pkg 41 | ** Publish in nixpkgs 42 | *** Go to nixpkgs fork directory 43 | *** git checkout master 44 | *** git fetch upstream 45 | *** git rebase upstream/master 46 | *** git fetch 47 | *** git push --force-with-lease origin master 48 | *** git branch -d update-tex-fmt 49 | *** git switch --create update-tex-fmt upstream/master 50 | *** nvim pkgs/by-name/te/tex-fmt/package.nix 51 | *** Update version and invalidate src.hash and cargoHash 52 | *** nix-build -A tex-fmt 53 | *** Fix both hashes, get a successful build 54 | *** git add pkgs/by-name/te/tex-fmt/package.nix 55 | *** git commit -m "tex-fmt: X.X.X -> Y.Y.Y" 56 | *** git push --set-upstream origin HEAD 57 | *** Go to GitHub and create a pull request 58 | *** Submit pull request and check relevant boxes 59 | ** Tidy repository 60 | *** Commit any new changes to NEWS.md or notes.org 61 | * CLI and config structure 62 | *** args.rs 63 | **** Core argument definitions 64 | **** Args struct defines arguments used internally by tex-fmt 65 | **** OptionArgs struct defines an intermediate target 66 | ***** CLI arguments are read into OptionArgs in cli.rs 67 | ***** Config file arguments are read into OptionArgs in config.rs 68 | ***** Default values for OptionArgs are defined here 69 | **** These OptionArgs are merged together 70 | **** Then converted into Args 71 | **** Conflicting arguments are resolved 72 | **** The Display trait is implemented for args 73 | *** command.rs 74 | **** Contains the clap command definition 75 | **** Sets options exposed to the user on the CLI 76 | *** cli.rs 77 | **** Logic for reading from CLI 78 | **** Clap uses command.rs to read from CLI 79 | **** This file then parses from clap into OptionArgs 80 | *** config.rs 81 | **** Logic for reading from config file 82 | **** Determines the config file path by looking in several places 83 | **** Reads from this path and parses to a toml Table 84 | **** Values are then assigned to an OptionArgs struct 85 | * Process for adding new arguments 86 | ** General 87 | *** args.rs 88 | **** Update Args struct if core argument 89 | **** Update OptionArgs struct 90 | **** Update Args resolve() if extra logic necessary 91 | **** Update Args fmt::Display if core argument 92 | ** CLI arguments 93 | *** command.rs 94 | **** Update clap command definition 95 | *** cli.rs 96 | **** Update get_cli_args() and add extra logic if needed 97 | ** Config arguments 98 | *** config.rs 99 | **** Update get_config_args() 100 | ** Fix compiler warnings 101 | ** Implement behaviour 102 | ** Add tests 103 | ** Update README 104 | *** CLI options 105 | *** Config options 106 | *** Usage section if commonly used option 107 | -------------------------------------------------------------------------------- /tests/phd_dissertation/source/puthesis.cls: -------------------------------------------------------------------------------- 1 | \NeedsTeXFormat{LaTeX2e} 2 | \ProvidesClass{puthesis} 3 | \RequirePackage{setspace} 4 | \RequirePackage{xcolor} 5 | \def\current@color{ Black} 6 | \newcounter{subyear} 7 | \setcounter{subyear}{\number\year} 8 | \def\submitted#1{\gdef\@submitted{#1}} 9 | \def\@submittedyear{\ifnum\month>10 \stepcounter{subyear}\thesubyear 10 | \else\thesubyear\fi} 11 | \def\@submittedmonth{\ifnum\month>10 January\else\ifnum\month>8 November 12 | \else\ifnum\month>6 September\else May\fi\fi\fi} 13 | \def\adviser#1{\gdef\@adviser{#1}} 14 | \long\def\@abstract{\@latex@error{No \noexpand\abstract given}\@ehc} 15 | \newcommand*{\frontmatter}{ 16 | %\pagenumbering{roman} 17 | } 18 | \newcommand*{\mainmatter}{ 19 | %\pagenumbering{arabic} 20 | } 21 | \newcommand*{\makelot}{} 22 | \newcommand*{\makelof}{} 23 | \newcommand*{\makelos}{} 24 | \newcommand*{\begincmd}{ 25 | \doublespacing 26 | \frontmatter\maketitlepage\makecopyrightpage\makeabstract 27 | \makeacknowledgments\makededication\tableofcontents\clearpage 28 | \makelot\clearpage\makelof\clearpage\makelos 29 | \clearpage\mainmatter} 30 | \def\@submitted{\@submittedmonth~\@submittedyear} 31 | \def\@dept{Operations Research and Financial Engineering} 32 | \def\@deptpref{Department of} 33 | \def\departmentprefix#1{\gdef\@deptpref{#1}} 34 | \def\department#1{\gdef\@dept{#1}} 35 | \long\def\acknowledgments#1{\gdef\@acknowledgments{#1}} 36 | \def\dedication#1{\gdef\@dedication{#1}} 37 | \newcommand{\maketitlepage}{{ 38 | \thispagestyle{empty} 39 | \sc 40 | \vspace*{0in} 41 | \begin{center} 42 | \LARGE \@title 43 | \end{center} 44 | \vspace{.6in} 45 | \begin{center} 46 | \@author 47 | \end{center} 48 | \vspace{.6in} 49 | \begin{center} 50 | A Dissertation \\ 51 | Presented to the Faculty \\ 52 | of Princeton University \\ 53 | in Candidacy for the Degree \\ 54 | of Doctor of Philosophy 55 | \end{center} 56 | \vspace{.3in} 57 | \begin{center} 58 | Recommended for Acceptance \\ 59 | by the \@deptpref \\ 60 | \@dept \\ 61 | Adviser: \@adviser 62 | \end{center} 63 | \vspace{.3in} 64 | \begin{center} 65 | \@submitted 66 | \end{center} 67 | \clearpage 68 | }} 69 | \newcommand*{\makecopyrightpage}{ 70 | \thispagestyle{empty} 71 | \vspace*{0in} 72 | \begin{center} 73 | \copyright\ Copyright by \@author, \number\year. \\ 74 | All rights reserved. 75 | \end{center} 76 | \clearpage} 77 | \newcommand*{\makeabstract}{ 78 | \newpage 79 | \addcontentsline{toc}{section}{Abstract} 80 | \begin{center} 81 | \Large \textbf{Abstract} 82 | \end{center} 83 | \@abstract 84 | \clearpage 85 | } 86 | \def\makeacknowledgments{ 87 | \ifx\@acknowledgments\undefined 88 | \else 89 | \addcontentsline{toc}{section}{Acknowledgments} 90 | \begin{center} 91 | \Large \textbf{Acknowledgments} 92 | \end{center} 93 | \@acknowledgments 94 | \clearpage 95 | \fi 96 | } 97 | \def\makededication{ 98 | \ifx\@dedication\undefined 99 | \else 100 | \vspace*{1.5in} 101 | \begin{flushright} 102 | \@dedication 103 | \end{flushright} 104 | \clearpage 105 | \fi 106 | } 107 | \DeclareOption{myorder}{ 108 | \renewcommand*{\begincmd}{\doublespacing}} 109 | \DeclareOption{lot}{\renewcommand*{\makelot}{ 110 | \addcontentsline{toc}{section}{List of Tables}\listoftables}} 111 | \DeclareOption{lof}{\renewcommand*{\makelof}{ 112 | \addcontentsline{toc}{section}{List of Figures and Tables}\listoffigures}} 113 | \DeclareOption{los}{ 114 | \renewcommand*{\makelos}{ 115 | \RequirePackage{losymbol} 116 | \section*{List of Symbols\@mkboth {LIST OF SYMBOLS}{LIST OF SYMBOLS}} 117 | \@starttoc{los} 118 | \addcontentsline{toc}{section}{List of Symbols} 119 | } 120 | } 121 | \DeclareOption*{\PassOptionsToClass{\CurrentOption}{report}} 122 | \ProcessOptions 123 | \LoadClass{report} 124 | \setlength{\oddsidemargin}{0.2in} 125 | \setlength{\evensidemargin}{0.2in} 126 | \setlength{\topmargin}{0in} 127 | \setlength{\headheight}{0in} 128 | \setlength{\headsep}{0in} 129 | \setlength{\textheight}{8.9in} 130 | \setlength{\textwidth}{6.1in} 131 | \setlength{\footskip}{0.5in} 132 | \long\def\abstract#1{\gdef\@abstract{#1}} 133 | \AtBeginDocument{\begincmd} 134 | \endinput 135 | -------------------------------------------------------------------------------- /src/subs.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for performing text substitutions 2 | 3 | use crate::args::Args; 4 | use crate::comments::find_comment_index; 5 | use crate::format::{Pattern, State}; 6 | use crate::logging::{record_line_log, Log}; 7 | use crate::regexes; 8 | use crate::LINE_END; 9 | use log::Level; 10 | use log::LevelFilter; 11 | use std::path::Path; 12 | 13 | /// Remove multiple line breaks 14 | #[must_use] 15 | pub fn remove_extra_newlines(text: &str) -> String { 16 | let double_line_end = format!("{LINE_END}{LINE_END}"); 17 | regexes::RE_NEWLINES 18 | .replace_all(text, double_line_end) 19 | .to_string() 20 | } 21 | 22 | /// Replace tabs with spaces 23 | #[must_use] 24 | pub fn remove_tabs(text: &str, args: &Args) -> String { 25 | let replace = (0..args.tabsize).map(|_| " ").collect::(); 26 | text.replace('\t', &replace) 27 | } 28 | 29 | /// Remove trailing spaces from line endings 30 | #[must_use] 31 | pub fn remove_trailing_spaces(text: &str) -> String { 32 | regexes::RE_TRAIL.replace_all(text, LINE_END).to_string() 33 | } 34 | 35 | /// Remove trailing blank lines from file 36 | #[must_use] 37 | pub fn remove_trailing_blank_lines(text: &str) -> String { 38 | let mut new_text = text.trim_end().to_string(); 39 | new_text.push_str(LINE_END); 40 | new_text 41 | } 42 | 43 | /// Check if line contains content which be split onto a new line 44 | /// 45 | /// # Panics 46 | /// 47 | /// This function should not panic as we already check for a regex match 48 | /// before finding the match index and unwrapping. 49 | #[must_use] 50 | pub fn needs_split(line: &str, pattern: &Pattern) -> bool { 51 | // Don't split anything if the line contains a \verb|...| 52 | if pattern.contains_verb && regexes::VERBS.iter().any(|x| line.contains(x)) 53 | { 54 | return false; 55 | } 56 | 57 | // Check if we should format this line and if we've matched an environment. 58 | let contains_splittable_env = pattern.contains_splitting 59 | && regexes::RE_SPLITTING_SHARED_LINE.is_match(line); 60 | 61 | // If we're not ignoring and we've matched an environment ... 62 | if contains_splittable_env { 63 | // ... return `true` if the comment index is `None` 64 | // (which implies the split point must be in text), otherwise 65 | // compare the index of the comment with the split point. 66 | find_comment_index(line, pattern).is_none_or(|comment_index| { 67 | if regexes::RE_SPLITTING_SHARED_LINE_CAPTURE 68 | .captures(line) 69 | .unwrap() // Matched split point so no panic. 70 | .get(2) 71 | .unwrap() // Regex has 4 groups so index 2 is in bounds. 72 | .start() 73 | > comment_index 74 | { 75 | // If split point is past the comment index, don't split. 76 | false 77 | } else { 78 | // Otherwise, split point is before comment and we do split. 79 | true 80 | } 81 | }) 82 | } else { 83 | // If ignoring or didn't match an environment, don't need a new line. 84 | false 85 | } 86 | } 87 | 88 | /// Ensure lines are split correctly. 89 | /// 90 | /// Returns a tuple containing: 91 | /// 1. a reference to the line that was given, shortened because of the split 92 | /// 2. a reference to the part of the line that was split 93 | /// 94 | /// # Panics 95 | /// 96 | /// This function should not panic as all regexes are validated. 97 | pub fn split_line<'a>( 98 | line: &'a str, 99 | state: &State, 100 | file: &Path, 101 | args: &Args, 102 | logs: &mut Vec, 103 | ) -> (&'a str, &'a str) { 104 | let captures = regexes::RE_SPLITTING_SHARED_LINE_CAPTURE 105 | .captures(line) 106 | .unwrap(); 107 | 108 | let (line, [prev, rest, _]) = captures.extract(); 109 | 110 | if args.verbosity == LevelFilter::Trace { 111 | record_line_log( 112 | logs, 113 | Level::Trace, 114 | file, 115 | state.linum_new, 116 | state.linum_old, 117 | line, 118 | "Placing environment on new line.", 119 | ); 120 | } 121 | (prev, rest) 122 | } 123 | -------------------------------------------------------------------------------- /tests/phd_dissertation/target/puthesis.cls: -------------------------------------------------------------------------------- 1 | \NeedsTeXFormat{LaTeX2e} 2 | \ProvidesClass{puthesis} 3 | \RequirePackage{setspace} 4 | \RequirePackage{xcolor} 5 | \def\current@color{ Black} 6 | \newcounter{subyear} 7 | \setcounter{subyear}{\number\year} 8 | \def\submitted#1{\gdef\@submitted{#1}} 9 | \def\@submittedyear{\ifnum\month>10 \stepcounter{subyear}\thesubyear 10 | \else\thesubyear\fi} 11 | \def\@submittedmonth{\ifnum\month>10 January\else\ifnum\month>8 November 12 | \else\ifnum\month>6 September\else May\fi\fi\fi} 13 | \def\adviser#1{\gdef\@adviser{#1}} 14 | \long\def\@abstract{\@latex@error{No \noexpand\abstract given}\@ehc} 15 | \newcommand*{\frontmatter}{ 16 | %\pagenumbering{roman} 17 | } 18 | \newcommand*{\mainmatter}{ 19 | %\pagenumbering{arabic} 20 | } 21 | \newcommand*{\makelot}{} 22 | \newcommand*{\makelof}{} 23 | \newcommand*{\makelos}{} 24 | \newcommand*{\begincmd}{ 25 | \doublespacing 26 | \frontmatter\maketitlepage\makecopyrightpage\makeabstract 27 | \makeacknowledgments\makededication\tableofcontents\clearpage 28 | \makelot\clearpage\makelof\clearpage\makelos 29 | \clearpage\mainmatter} 30 | \def\@submitted{\@submittedmonth~\@submittedyear} 31 | \def\@dept{Operations Research and Financial Engineering} 32 | \def\@deptpref{Department of} 33 | \def\departmentprefix#1{\gdef\@deptpref{#1}} 34 | \def\department#1{\gdef\@dept{#1}} 35 | \long\def\acknowledgments#1{\gdef\@acknowledgments{#1}} 36 | \def\dedication#1{\gdef\@dedication{#1}} 37 | \newcommand{\maketitlepage}{{ 38 | \thispagestyle{empty} 39 | \sc 40 | \vspace*{0in} 41 | \begin{center} 42 | \LARGE \@title 43 | \end{center} 44 | \vspace{.6in} 45 | \begin{center} 46 | \@author 47 | \end{center} 48 | \vspace{.6in} 49 | \begin{center} 50 | A Dissertation \\ 51 | Presented to the Faculty \\ 52 | of Princeton University \\ 53 | in Candidacy for the Degree \\ 54 | of Doctor of Philosophy 55 | \end{center} 56 | \vspace{.3in} 57 | \begin{center} 58 | Recommended for Acceptance \\ 59 | by the \@deptpref \\ 60 | \@dept \\ 61 | Adviser: \@adviser 62 | \end{center} 63 | \vspace{.3in} 64 | \begin{center} 65 | \@submitted 66 | \end{center} 67 | \clearpage 68 | }} 69 | \newcommand*{\makecopyrightpage}{ 70 | \thispagestyle{empty} 71 | \vspace*{0in} 72 | \begin{center} 73 | \copyright\ Copyright by \@author, \number\year. \\ 74 | All rights reserved. 75 | \end{center} 76 | \clearpage} 77 | \newcommand*{\makeabstract}{ 78 | \newpage 79 | \addcontentsline{toc}{section}{Abstract} 80 | \begin{center} 81 | \Large \textbf{Abstract} 82 | \end{center} 83 | \@abstract 84 | \clearpage 85 | } 86 | \def\makeacknowledgments{ 87 | \ifx\@acknowledgments\undefined 88 | \else 89 | \addcontentsline{toc}{section}{Acknowledgments} 90 | \begin{center} 91 | \Large \textbf{Acknowledgments} 92 | \end{center} 93 | \@acknowledgments 94 | \clearpage 95 | \fi 96 | } 97 | \def\makededication{ 98 | \ifx\@dedication\undefined 99 | \else 100 | \vspace*{1.5in} 101 | \begin{flushright} 102 | \@dedication 103 | \end{flushright} 104 | \clearpage 105 | \fi 106 | } 107 | \DeclareOption{myorder}{ 108 | \renewcommand*{\begincmd}{\doublespacing}} 109 | \DeclareOption{lot}{\renewcommand*{\makelot}{ 110 | \addcontentsline{toc}{section}{List of Tables}\listoftables}} 111 | \DeclareOption{lof}{\renewcommand*{\makelof}{ 112 | \addcontentsline{toc}{section}{List of Figures and Tables}\listoffigures}} 113 | \DeclareOption{los}{ 114 | \renewcommand*{\makelos}{ 115 | \RequirePackage{losymbol} 116 | \section*{List of Symbols\@mkboth {LIST OF SYMBOLS}{LIST OF SYMBOLS}} 117 | \@starttoc{los} 118 | \addcontentsline{toc}{section}{List of Symbols} 119 | } 120 | } 121 | \DeclareOption*{\PassOptionsToClass{\CurrentOption}{report}} 122 | \ProcessOptions 123 | \LoadClass{report} 124 | \setlength{\oddsidemargin}{0.2in} 125 | \setlength{\evensidemargin}{0.2in} 126 | \setlength{\topmargin}{0in} 127 | \setlength{\headheight}{0in} 128 | \setlength{\headsep}{0in} 129 | \setlength{\textheight}{8.9in} 130 | \setlength{\textwidth}{6.1in} 131 | \setlength{\footskip}{0.5in} 132 | \long\def\abstract#1{\gdef\@abstract{#1}} 133 | \AtBeginDocument{\begincmd} 134 | \endinput 135 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use clap::{value_parser, Arg, ArgAction, Command}; 2 | use std::path::PathBuf; 3 | use ArgAction::{Append, SetTrue}; 4 | 5 | /// Construct the CLI command 6 | #[allow(clippy::too_many_lines)] 7 | #[must_use] 8 | pub fn get_cli_command() -> Command { 9 | Command::new("tex-fmt") 10 | .author("William George Underwood, wg.underwood13@gmail.com") 11 | .about(clap::crate_description!()) 12 | .version(clap::crate_version!()) 13 | .before_help(format!("tex-fmt {}", clap::crate_version!())) 14 | .arg( 15 | Arg::new("check") 16 | .short('c') 17 | .long("check") 18 | .action(SetTrue) 19 | .help("Check formatting, do not modify files"), 20 | ) 21 | .arg( 22 | Arg::new("print") 23 | .short('p') 24 | .long("print") 25 | .action(SetTrue) 26 | .help("Print to stdout, do not modify files"), 27 | ) 28 | .arg( 29 | Arg::new("fail-on-change") 30 | .short('f') 31 | .long("fail-on-change") 32 | .action(SetTrue) 33 | .help("Format files and return non-zero exit code if files are modified") 34 | ) 35 | .arg( 36 | Arg::new("nowrap") 37 | .short('n') 38 | .long("nowrap") 39 | .action(SetTrue) 40 | .help("Do not wrap long lines"), 41 | ) 42 | .arg( 43 | Arg::new("wraplen") 44 | .short('l') 45 | .long("wraplen") 46 | .value_name("N") 47 | .value_parser(value_parser!(u8)) 48 | .help("Line length for wrapping [default: 80]"), 49 | ) 50 | .arg( 51 | Arg::new("tabsize") 52 | .short('t') 53 | .long("tabsize") 54 | .value_name("N") 55 | .value_parser(value_parser!(u8)) 56 | .help("Number of characters to use as tab size [default: 2]"), 57 | ) 58 | .arg( 59 | Arg::new("usetabs") 60 | .long("usetabs") 61 | .action(SetTrue) 62 | .help("Use tabs instead of spaces for indentation"), 63 | ) 64 | .arg( 65 | Arg::new("stdin") 66 | .short('s') 67 | .long("stdin") 68 | .action(SetTrue) 69 | .help("Process stdin as a single file, output to stdout"), 70 | ) 71 | .arg( 72 | Arg::new("config") 73 | .long("config") 74 | .value_name("PATH") 75 | .value_parser(value_parser!(PathBuf)) 76 | .help("Path to config file") 77 | ) 78 | .arg( 79 | Arg::new("noconfig") 80 | .long("noconfig") 81 | .action(SetTrue) 82 | .help("Do not read any config file"), 83 | ) 84 | .arg( 85 | Arg::new("verbose") 86 | .short('v') 87 | .long("verbose") 88 | .action(SetTrue) 89 | .help("Show info messages"), 90 | ) 91 | .arg( 92 | Arg::new("quiet") 93 | .short('q') 94 | .long("quiet") 95 | .action(SetTrue) 96 | .help("Hide warning messages"), 97 | ) 98 | .arg( 99 | Arg::new("trace") 100 | .long("trace") 101 | .action(SetTrue) 102 | .help("Show trace messages"), 103 | ) 104 | .arg( 105 | Arg::new("completion") 106 | .long("completion") 107 | .value_name("SHELL") 108 | .value_parser(value_parser!(Shell)) 109 | .help("Generate shell completion script") 110 | ) 111 | .arg( 112 | Arg::new("man") 113 | .long("man") 114 | .action(SetTrue) 115 | .help("Generate man page"), 116 | ) 117 | .arg( 118 | Arg::new("args") 119 | .long("args") 120 | .action(SetTrue) 121 | .help("Print arguments passed to tex-fmt and exit"), 122 | ) 123 | .arg( 124 | Arg::new("files") 125 | .action(Append) 126 | .help("List of files to be formatted"), 127 | ) 128 | .arg( 129 | Arg::new("recursive") 130 | .short('r') 131 | .long("recursive") 132 | .action(SetTrue) 133 | .help("Recursively search for files to format") 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /completion/_tex-fmt.ps1: -------------------------------------------------------------------------------- 1 | 2 | using namespace System.Management.Automation 3 | using namespace System.Management.Automation.Language 4 | 5 | Register-ArgumentCompleter -Native -CommandName 'tex-fmt' -ScriptBlock { 6 | param($wordToComplete, $commandAst, $cursorPosition) 7 | 8 | $commandElements = $commandAst.CommandElements 9 | $command = @( 10 | 'tex-fmt' 11 | for ($i = 1; $i -lt $commandElements.Count; $i++) { 12 | $element = $commandElements[$i] 13 | if ($element -isnot [StringConstantExpressionAst] -or 14 | $element.StringConstantType -ne [StringConstantType]::BareWord -or 15 | $element.Value.StartsWith('-') -or 16 | $element.Value -eq $wordToComplete) { 17 | break 18 | } 19 | $element.Value 20 | }) -join ';' 21 | 22 | $completions = @(switch ($command) { 23 | 'tex-fmt' { 24 | [CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'Line length for wrapping [default: 80]') 25 | [CompletionResult]::new('--wraplen', '--wraplen', [CompletionResultType]::ParameterName, 'Line length for wrapping [default: 80]') 26 | [CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'Number of characters to use as tab size [default: 2]') 27 | [CompletionResult]::new('--tabsize', '--tabsize', [CompletionResultType]::ParameterName, 'Number of characters to use as tab size [default: 2]') 28 | [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Path to config file') 29 | [CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'Generate shell completion script') 30 | [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Check formatting, do not modify files') 31 | [CompletionResult]::new('--check', '--check', [CompletionResultType]::ParameterName, 'Check formatting, do not modify files') 32 | [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Print to stdout, do not modify files') 33 | [CompletionResult]::new('--print', '--print', [CompletionResultType]::ParameterName, 'Print to stdout, do not modify files') 34 | [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Format files and return non-zero exit code if files are modified') 35 | [CompletionResult]::new('--fail-on-change', '--fail-on-change', [CompletionResultType]::ParameterName, 'Format files and return non-zero exit code if files are modified') 36 | [CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'Do not wrap long lines') 37 | [CompletionResult]::new('--nowrap', '--nowrap', [CompletionResultType]::ParameterName, 'Do not wrap long lines') 38 | [CompletionResult]::new('--usetabs', '--usetabs', [CompletionResultType]::ParameterName, 'Use tabs instead of spaces for indentation') 39 | [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Process stdin as a single file, output to stdout') 40 | [CompletionResult]::new('--stdin', '--stdin', [CompletionResultType]::ParameterName, 'Process stdin as a single file, output to stdout') 41 | [CompletionResult]::new('--noconfig', '--noconfig', [CompletionResultType]::ParameterName, 'Do not read any config file') 42 | [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Show info messages') 43 | [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Show info messages') 44 | [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Hide warning messages') 45 | [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Hide warning messages') 46 | [CompletionResult]::new('--trace', '--trace', [CompletionResultType]::ParameterName, 'Show trace messages') 47 | [CompletionResult]::new('--man', '--man', [CompletionResultType]::ParameterName, 'Generate man page') 48 | [CompletionResult]::new('--args', '--args', [CompletionResultType]::ParameterName, 'Print arguments passed to tex-fmt and exit') 49 | [CompletionResult]::new('-r', '-r', [CompletionResultType]::ParameterName, 'Recursively search for files to format') 50 | [CompletionResult]::new('--recursive', '--recursive', [CompletionResultType]::ParameterName, 'Recursively search for files to format') 51 | [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') 52 | [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') 53 | [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') 54 | [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') 55 | break 56 | } 57 | }) 58 | 59 | $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | 60 | Sort-Object -Property ListItemText 61 | } 62 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | tex-fmt | LaTeX formatter 8 | 9 | 124 | 125 | 126 | 127 | 128 |
129 | 130 | 131 |
132 |

tex-fmt

133 |

An extremely fast LaTeX formatter

134 |
135 | 136 | 137 | 138 |
139 |
140 | 141 | 142 | 145 | 146 |
147 | 148 | 149 |
150 | 151 |
152 |

Input

153 | 154 |
155 | 156 |
157 |

Output

158 | 159 |
160 | 161 |
162 | 163 | 164 |
165 | 166 |
167 |

Config

168 | 169 |
170 | 171 |
172 |

Logs

173 | 174 |
175 | 176 |
177 | 178 | 179 | 180 | 181 | 182 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /src/wrap.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for wrapping long lines 2 | 3 | use crate::args::Args; 4 | use crate::comments::find_comment_index; 5 | use crate::format::{Pattern, State}; 6 | use crate::logging::{record_line_log, Log}; 7 | use crate::regexes::VERBS; 8 | use log::Level; 9 | use log::LevelFilter; 10 | use std::path::Path; 11 | 12 | /// String slice to start wrapped text lines 13 | pub const TEXT_LINE_START: &str = ""; 14 | /// String slice to start wrapped comment lines 15 | pub const COMMENT_LINE_START: &str = "% "; 16 | 17 | /// Check if a line needs wrapping 18 | #[must_use] 19 | pub fn needs_wrap(line: &str, indent_length: usize, args: &Args) -> bool { 20 | args.wrap && (line.chars().count() + indent_length > args.wraplen.into()) 21 | } 22 | 23 | fn is_wrap_point( 24 | i_byte: usize, 25 | c: char, 26 | prev_c: Option, 27 | inside_verb: bool, 28 | line_len: usize, 29 | args: &Args, 30 | ) -> bool { 31 | // Character c must be a valid wrapping character 32 | args.wrap_chars.contains(&c) 33 | // Must not be preceded by '\' 34 | && prev_c != Some('\\') 35 | // Do not break inside a \verb|...| 36 | && !inside_verb 37 | // No point breaking at the end of the line 38 | && (i_byte + 1 < line_len) 39 | } 40 | 41 | fn get_verb_end(verb_byte_start: Option, line: &str) -> Option { 42 | verb_byte_start.map(|v| { 43 | line[v..] 44 | .match_indices(['|', '+']) 45 | .nth(1) 46 | .map(|(i, _)| i + v) 47 | })? 48 | } 49 | 50 | fn is_inside_verb( 51 | i_byte: usize, 52 | contains_verb: bool, 53 | verb_start: Option, 54 | verb_end: Option, 55 | ) -> bool { 56 | if contains_verb { 57 | (verb_start.unwrap() <= i_byte) && (i_byte <= verb_end.unwrap()) 58 | } else { 59 | false 60 | } 61 | } 62 | 63 | /// Find the best place to break a long line. 64 | /// Provided as a *byte* index, not a *char* index. 65 | fn find_wrap_point( 66 | line: &str, 67 | indent_length: usize, 68 | args: &Args, 69 | pattern: &Pattern, 70 | ) -> Option { 71 | let mut wrap_point: Option = None; 72 | let mut prev_c: Option = None; 73 | let contains_verb = 74 | pattern.contains_verb && VERBS.iter().any(|x| line.contains(x)); 75 | let verb_start: Option = contains_verb 76 | .then(|| VERBS.iter().filter_map(|&x| line.find(x)).min().unwrap()); 77 | 78 | let verb_end = get_verb_end(verb_start, line); 79 | let mut after_non_percent = verb_start == Some(0); 80 | let wrap_boundary = usize::from(args.wrapmin) - indent_length; 81 | let line_len = line.len(); 82 | 83 | for (i_char, (i_byte, c)) in line.char_indices().enumerate() { 84 | if i_char >= wrap_boundary && wrap_point.is_some() { 85 | break; 86 | } 87 | // Special wrapping for lines containing \verb|...| 88 | let inside_verb = 89 | is_inside_verb(i_byte, contains_verb, verb_start, verb_end); 90 | if is_wrap_point(i_byte, c, prev_c, inside_verb, line_len, args) { 91 | if after_non_percent { 92 | // Get index of the byte after which 93 | // line break will be inserted. 94 | // Note this may not be a valid char index. 95 | let wrap_byte = i_byte + c.len_utf8() - 1; 96 | // Don't wrap here if this is the end of the line anyway 97 | if wrap_byte + 1 < line_len { 98 | wrap_point = Some(wrap_byte); 99 | } 100 | } 101 | } else if c != '%' { 102 | after_non_percent = true; 103 | } 104 | prev_c = Some(c); 105 | } 106 | 107 | wrap_point 108 | } 109 | 110 | /// Wrap a long line into a short prefix and a suffix 111 | pub fn apply_wrap<'a>( 112 | line: &'a str, 113 | indent_length: usize, 114 | state: &State, 115 | file: &Path, 116 | args: &Args, 117 | logs: &mut Vec, 118 | pattern: &Pattern, 119 | ) -> Option<[&'a str; 3]> { 120 | if args.verbosity == LevelFilter::Trace { 121 | record_line_log( 122 | logs, 123 | Level::Trace, 124 | file, 125 | state.linum_new, 126 | state.linum_old, 127 | line, 128 | "Wrapping long line.", 129 | ); 130 | } 131 | let wrap_point = find_wrap_point(line, indent_length, args, pattern); 132 | let comment_index = find_comment_index(line, pattern); 133 | 134 | match wrap_point { 135 | Some(p) if p <= args.wraplen.into() => {} 136 | _ => { 137 | record_line_log( 138 | logs, 139 | Level::Warn, 140 | file, 141 | state.linum_new, 142 | state.linum_old, 143 | line, 144 | "Line cannot be wrapped.", 145 | ); 146 | } 147 | } 148 | 149 | wrap_point.map(|p| { 150 | let this_line = &line[0..=p]; 151 | let next_line_start = comment_index.map_or("", |c| { 152 | if p > c { 153 | COMMENT_LINE_START 154 | } else { 155 | TEXT_LINE_START 156 | } 157 | }); 158 | let next_line = &line[p + 1..]; 159 | [this_line, next_line_start, next_line] 160 | }) 161 | } 162 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Read arguments from a config file 2 | 3 | use crate::args::{OptionArgs, TabChar}; 4 | use dirs::config_dir; 5 | use log::LevelFilter; 6 | use std::env::current_dir; 7 | use std::fs::{metadata, read_to_string}; 8 | use std::path::PathBuf; 9 | use toml::Table; 10 | 11 | /// Config file name 12 | const CONFIG: &str = "tex-fmt.toml"; 13 | 14 | /// Try finding a config file in various sources 15 | fn resolve_config_path(args: &OptionArgs) -> Option { 16 | // Do not read config file 17 | if args.noconfig == Some(true) { 18 | return None; 19 | } 20 | // Named path passed as cli arg 21 | if args.config.is_some() { 22 | return args.config.clone(); 23 | } 24 | // Config file in current directory 25 | if let Ok(mut config) = current_dir() { 26 | config.push(CONFIG); 27 | if config.exists() { 28 | return Some(config); 29 | } 30 | } 31 | // Config file at git repository root 32 | if let Some(mut config) = find_git_root() { 33 | config.push(CONFIG); 34 | if config.exists() { 35 | return Some(config); 36 | } 37 | } 38 | // Config file in user home config directory 39 | if let Some(mut config) = config_dir() { 40 | config.push("tex-fmt"); 41 | config.push(CONFIG); 42 | if config.exists() { 43 | return Some(config); 44 | } 45 | } 46 | None 47 | } 48 | 49 | /// Get the git repository root directory 50 | fn find_git_root() -> Option { 51 | let mut depth = 0; 52 | let mut current_dir = current_dir().unwrap(); 53 | while depth < 100 { 54 | depth += 1; 55 | if metadata(current_dir.join(".git")) 56 | .map(|m| m.is_dir()) 57 | .unwrap_or(false) 58 | { 59 | return Some(current_dir); 60 | } 61 | if !current_dir.pop() { 62 | break; 63 | } 64 | } 65 | None 66 | } 67 | 68 | /// Read content from a config file path 69 | /// 70 | /// # Panics 71 | /// 72 | /// This function panics if the config file cannot be read. 73 | #[must_use] 74 | pub fn get_config(args: &OptionArgs) -> Option<(PathBuf, String, String)> { 75 | let config_path = resolve_config_path(args); 76 | config_path.as_ref()?; 77 | let config_path_string = config_path 78 | .clone() 79 | .unwrap() 80 | .into_os_string() 81 | .into_string() 82 | .unwrap(); 83 | let config = read_to_string(config_path.clone().unwrap()).unwrap(); 84 | Some((config_path.unwrap(), config_path_string, config)) 85 | } 86 | 87 | fn parse_array_string(name: &str, config: &Table) -> Vec { 88 | config 89 | .get(name) 90 | .and_then(|v| v.as_array()) 91 | .unwrap_or(&vec![]) 92 | .iter() 93 | .filter_map(|v| v.as_str().map(String::from)) 94 | .collect() 95 | } 96 | 97 | fn string_to_char(s: &str) -> char { 98 | let mut chars = s.chars(); 99 | let c = chars.next().expect("String is empty"); 100 | assert!( 101 | chars.next().is_none(), 102 | "String contains more than one character", 103 | ); 104 | c 105 | } 106 | 107 | /// Parse arguments from a config file path 108 | /// 109 | /// # Panics 110 | /// 111 | /// This function panics if the config file cannot be parsed into TOML 112 | #[must_use] 113 | pub fn get_config_args( 114 | config: Option<(PathBuf, String, String)>, 115 | ) -> Option { 116 | config.as_ref()?; 117 | let (config_path, config_path_string, config) = config.unwrap(); 118 | let config = config.parse::().unwrap_or_else(|_| { 119 | panic!("Failed to read config file at {config_path_string}") 120 | }); 121 | 122 | let verbosity = match config.get("verbosity").map(|x| x.as_str().unwrap()) { 123 | Some("error" | "quiet") => Some(LevelFilter::Error), 124 | Some("warn") => Some(LevelFilter::Warn), 125 | Some("info" | "verbose") => Some(LevelFilter::Info), 126 | Some("trace") => Some(LevelFilter::Trace), 127 | _ => None, 128 | }; 129 | 130 | let tabchar = match config.get("tabchar").map(|x| x.as_str().unwrap()) { 131 | Some("tab") => Some(TabChar::Tab), 132 | Some("space") => Some(TabChar::Space), 133 | _ => None, 134 | }; 135 | 136 | // Read wrap_chars to Vec not Vec 137 | let wrap_chars: Vec = parse_array_string("wrap-chars", &config) 138 | .iter() 139 | .map(|c| string_to_char(c)) 140 | .collect(); 141 | 142 | let args = OptionArgs { 143 | check: config.get("check").map(|x| x.as_bool().unwrap()), 144 | print: config.get("print").map(|x| x.as_bool().unwrap()), 145 | fail_on_change: config 146 | .get("fail-on-change") 147 | .map(|x| x.as_bool().unwrap()), 148 | wrap: config.get("wrap").map(|x| x.as_bool().unwrap()), 149 | wraplen: config 150 | .get("wraplen") 151 | .map(|x| x.as_integer().unwrap().try_into().unwrap()), 152 | wrapmin: config 153 | .get("wrapmin") 154 | .map(|x| x.as_integer().unwrap().try_into().unwrap()), 155 | tabsize: config 156 | .get("tabsize") 157 | .map(|x| x.as_integer().unwrap().try_into().unwrap()), 158 | tabchar, 159 | stdin: config.get("stdin").map(|x| x.as_bool().unwrap()), 160 | config: Some(config_path), 161 | noconfig: None, 162 | lists: parse_array_string("lists", &config), 163 | verbatims: parse_array_string("verbatims", &config), 164 | no_indent_envs: parse_array_string("no-indent-envs", &config), 165 | wrap_chars, 166 | verbosity, 167 | arguments: None, 168 | files: vec![], 169 | recursive: None, 170 | }; 171 | Some(args) 172 | } 173 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for logging 2 | 3 | use crate::args::Args; 4 | use colored::{Color, Colorize}; 5 | use env_logger::Builder; 6 | use log::Level; 7 | use log::Level::{Debug, Error, Info, Trace, Warn}; 8 | use log::LevelFilter; 9 | use std::cmp::Reverse; 10 | use std::io::Write; 11 | use std::path::{Path, PathBuf}; 12 | use web_time::Instant; 13 | 14 | /// Holds a log entry 15 | #[derive(Debug)] 16 | pub struct Log { 17 | /// Log entry level 18 | pub level: Level, 19 | /// Time when the entry was logged 20 | pub time: Instant, 21 | /// File name associated with the entry 22 | pub file: PathBuf, 23 | /// Line number in the formatted file 24 | pub linum_new: Option, 25 | /// Line number in the original file 26 | pub linum_old: Option, 27 | /// Line content 28 | pub line: Option, 29 | /// Entry-specific message 30 | pub message: String, 31 | } 32 | 33 | /// Append a log to the logs list 34 | fn record_log( 35 | logs: &mut Vec, 36 | level: Level, 37 | file: &Path, 38 | linum_new: Option, 39 | linum_old: Option, 40 | line: Option, 41 | message: &str, 42 | ) { 43 | let log = Log { 44 | level, 45 | time: Instant::now(), 46 | file: file.to_path_buf(), 47 | linum_new, 48 | linum_old, 49 | line, 50 | message: message.to_string(), 51 | }; 52 | logs.push(log); 53 | } 54 | 55 | /// Append a file log to the logs list 56 | pub fn record_file_log( 57 | logs: &mut Vec, 58 | level: Level, 59 | file: &Path, 60 | message: &str, 61 | ) { 62 | record_log(logs, level, file, None, None, None, message); 63 | } 64 | 65 | /// Append a line log to the logs list 66 | pub fn record_line_log( 67 | logs: &mut Vec, 68 | level: Level, 69 | file: &Path, 70 | linum_new: usize, 71 | linum_old: usize, 72 | line: &str, 73 | message: &str, 74 | ) { 75 | record_log( 76 | logs, 77 | level, 78 | file, 79 | Some(linum_new), 80 | Some(linum_old), 81 | Some(line.to_string()), 82 | message, 83 | ); 84 | } 85 | 86 | /// Get the color of a log level 87 | const fn get_log_color(log_level: Level) -> Color { 88 | match log_level { 89 | Info => Color::Cyan, 90 | Warn => Color::Yellow, 91 | Error => Color::Red, 92 | Trace => Color::Green, 93 | Debug => panic!(), 94 | } 95 | } 96 | 97 | /// Start the logger 98 | pub fn init_logger(level_filter: LevelFilter) { 99 | Builder::new() 100 | .filter_level(level_filter) 101 | .format(|buf, record| { 102 | writeln!( 103 | buf, 104 | "{}: {}", 105 | record 106 | .level() 107 | .to_string() 108 | .color(get_log_color(record.level())) 109 | .bold(), 110 | record.args() 111 | ) 112 | }) 113 | .init(); 114 | } 115 | 116 | /// Sort and remove duplicates 117 | fn preprocess_logs(logs: &mut Vec) { 118 | logs.sort_by_key(|l| { 119 | ( 120 | l.level, 121 | l.linum_new, 122 | l.linum_old, 123 | l.message.clone(), 124 | Reverse(l.time), 125 | ) 126 | }); 127 | logs.dedup_by(|a, b| { 128 | ( 129 | a.level, 130 | &a.file, 131 | a.linum_new, 132 | a.linum_old, 133 | &a.line, 134 | &a.message, 135 | ) == ( 136 | b.level, 137 | &b.file, 138 | b.linum_new, 139 | b.linum_old, 140 | &b.line, 141 | &b.message, 142 | ) 143 | }); 144 | logs.sort_by_key(|l| l.time); 145 | } 146 | 147 | /// Format a log entry 148 | fn format_log(log: &Log) -> String { 149 | let linum_new = log 150 | .linum_new 151 | .map_or_else(String::new, |i| format!("Line {i} ")); 152 | 153 | let linum_old = log 154 | .linum_old 155 | .map_or_else(String::new, |i| format!("({i}). ")); 156 | 157 | let line = log 158 | .line 159 | .as_ref() 160 | .map_or_else(String::new, |l| l.trim_start().to_string()); 161 | 162 | let log_string = format!( 163 | "{}{}{} {}", 164 | linum_new.white().bold(), 165 | linum_old.white().bold(), 166 | log.message.yellow().bold(), 167 | line, 168 | ); 169 | log_string 170 | } 171 | 172 | /// Format all of the logs collected 173 | #[allow(clippy::similar_names)] 174 | pub fn format_logs(logs: &mut Vec, args: &Args) -> String { 175 | preprocess_logs(logs); 176 | let mut logs_string = String::new(); 177 | for log in logs { 178 | if log.level <= args.verbosity { 179 | let log_string = format_log(log); 180 | logs_string.push_str(&log_string); 181 | logs_string.push('\n'); 182 | } 183 | } 184 | logs_string 185 | } 186 | 187 | /// Print all of the logs collected 188 | /// 189 | /// # Panics 190 | /// 191 | /// This function panics if the file path does not exist 192 | pub fn print_logs(logs: &mut Vec) { 193 | preprocess_logs(logs); 194 | for log in logs { 195 | let log_string = format!( 196 | "{} {}: {}", 197 | "tex-fmt".magenta().bold(), 198 | log.file.to_str().unwrap().blue().bold(), 199 | format_log(log), 200 | ); 201 | 202 | match log.level { 203 | Error => log::error!("{log_string}"), 204 | Warn => log::warn!("{log_string}"), 205 | Info => log::info!("{log_string}"), 206 | Trace => log::trace!("{log_string}"), 207 | Debug => panic!(), 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use crate::args::*; 2 | use crate::cli::*; 3 | use crate::config::*; 4 | use crate::format::format_file; 5 | use crate::logging::*; 6 | use colored::Colorize; 7 | use merge::Merge; 8 | use similar::{ChangeTag, TextDiff}; 9 | use std::fs; 10 | use std::path::PathBuf; 11 | 12 | fn test_file( 13 | source_file: &PathBuf, 14 | target_file: &PathBuf, 15 | config_file: Option<&PathBuf>, 16 | cli_file: Option<&PathBuf>, 17 | ) -> bool { 18 | // Get arguments from CLI file 19 | let mut args = match cli_file { 20 | Some(f) => { 21 | let cli_args = fs::read_to_string(f).unwrap(); 22 | let cli_args = cli_args.strip_suffix("\n").unwrap(); 23 | let mut cli_args: Vec<&str> = cli_args.split_whitespace().collect(); 24 | cli_args.insert(0, "tex-fmt"); 25 | let matches = 26 | get_cli_command().try_get_matches_from(&cli_args).unwrap(); 27 | get_cli_args(Some(matches)) 28 | } 29 | None => OptionArgs::new(), 30 | }; 31 | 32 | // Merge arguments from config file 33 | args.config = config_file.cloned(); 34 | let config = get_config(&args); 35 | let config_args = get_config_args(config); 36 | if let Some(c) = config_args { 37 | args.merge(c); 38 | } 39 | 40 | // Merge in default arguments 41 | args.merge(OptionArgs::default()); 42 | let args = Args::from(args); 43 | 44 | // Run tex-fmt 45 | let mut logs = Vec::::new(); 46 | let source_text = fs::read_to_string(source_file).unwrap(); 47 | let target_text = fs::read_to_string(target_file).unwrap(); 48 | let fmt_source_text = 49 | format_file(&source_text, source_file, &args, &mut logs); 50 | 51 | if fmt_source_text != target_text { 52 | println!( 53 | "{} {} -> {}", 54 | "fail".red().bold(), 55 | source_file.to_str().unwrap().yellow().bold(), 56 | target_file.to_str().unwrap().yellow().bold() 57 | ); 58 | let diff = TextDiff::from_lines(&fmt_source_text, &target_text); 59 | for change in diff.iter_all_changes() { 60 | match change.tag() { 61 | ChangeTag::Delete => print!( 62 | "{} {}", 63 | format!("@ {:>3}:", change.old_index().unwrap()) 64 | .blue() 65 | .bold(), 66 | format!("- {change}").red().bold(), 67 | ), 68 | ChangeTag::Insert => print!( 69 | "{} {}", 70 | format!("@ {:>3}:", change.new_index().unwrap()) 71 | .blue() 72 | .bold(), 73 | format!("+ {change}").green().bold(), 74 | ), 75 | ChangeTag::Equal => {} 76 | } 77 | } 78 | } 79 | 80 | fmt_source_text == target_text 81 | } 82 | 83 | fn read_files_from_dir(dir: &PathBuf) -> Vec { 84 | let mut files: Vec = fs::read_dir(dir) 85 | .unwrap() 86 | .map(|f| f.unwrap().file_name().into_string().unwrap()) 87 | .collect(); 88 | files.sort(); 89 | files 90 | } 91 | 92 | fn get_config_file(dir: &fs::DirEntry) -> Option { 93 | let config_file = dir.path().join("tex-fmt.toml"); 94 | if config_file.exists() { 95 | Some(config_file) 96 | } else { 97 | None 98 | } 99 | } 100 | 101 | fn get_cli_file(dir: &fs::DirEntry) -> Option { 102 | let cli_file = dir.path().join("cli.txt"); 103 | if cli_file.exists() { 104 | Some(cli_file) 105 | } else { 106 | None 107 | } 108 | } 109 | 110 | fn test_source_target( 111 | source_file: &PathBuf, 112 | target_file: &PathBuf, 113 | config_file: Option<&PathBuf>, 114 | cli_file: Option<&PathBuf>, 115 | ) -> bool { 116 | let mut pass = true; 117 | if !test_file(target_file, target_file, config_file, cli_file) { 118 | print!( 119 | "{}", 120 | format!( 121 | "Config file: {config_file:?}\n\ 122 | CLI file: {cli_file:?}\n\ 123 | " 124 | ) 125 | .yellow() 126 | .bold() 127 | ); 128 | pass = false; 129 | } 130 | 131 | if !test_file(source_file, target_file, config_file, cli_file) { 132 | print!( 133 | "{}", 134 | format!( 135 | "Config file: {config_file:?}\n\ 136 | CLI file: {cli_file:?}\n\ 137 | " 138 | ) 139 | .yellow() 140 | .bold() 141 | ); 142 | pass = false; 143 | } 144 | pass 145 | } 146 | 147 | fn run_tests_in_dir(test_dir: &fs::DirEntry) -> bool { 148 | let mut pass = true; 149 | let config_file = get_config_file(test_dir); 150 | let cli_file = get_cli_file(test_dir); 151 | let source_dir = test_dir.path().join("source/"); 152 | let source_files = read_files_from_dir(&source_dir); 153 | let target_dir = test_dir.path().join("target/"); 154 | let target_files = read_files_from_dir(&target_dir); 155 | 156 | // Source and target file names should match 157 | #[allow(clippy::manual_assert)] 158 | if source_files != target_files { 159 | panic!("Source and target file names differ for {test_dir:?}") 160 | } 161 | 162 | // Test file formatting 163 | for file in source_files { 164 | let source_file = test_dir.path().join("source").join(&file); 165 | let target_file = test_dir.path().join("target").join(&file); 166 | 167 | // If both config and cli exist, either alone should work 168 | if config_file.is_some() && cli_file.is_some() { 169 | pass &= test_source_target( 170 | &source_file, 171 | &target_file, 172 | config_file.as_ref(), 173 | None, 174 | ); 175 | pass &= test_source_target( 176 | &source_file, 177 | &target_file, 178 | None, 179 | cli_file.as_ref(), 180 | ); 181 | } 182 | 183 | // Pass both config and cli, even if one or more are None 184 | pass &= test_source_target( 185 | &source_file, 186 | &target_file, 187 | config_file.as_ref(), 188 | cli_file.as_ref(), 189 | ); 190 | } 191 | 192 | pass 193 | } 194 | 195 | #[test] 196 | fn test_all() { 197 | let mut pass = true; 198 | let test_dirs = fs::read_dir("./tests/").unwrap(); 199 | for test_dir in test_dirs { 200 | pass &= run_tests_in_dir(&test_dir.unwrap()); 201 | } 202 | 203 | assert!(pass); 204 | } 205 | 206 | #[test] 207 | #[ignore] 208 | fn test_subset() { 209 | let test_names = [ 210 | //"wrap_chars", 211 | //"cv", 212 | //"short_document", 213 | //"wrap", 214 | "verb", 215 | ]; 216 | let mut pass = true; 217 | let test_dirs = fs::read_dir("./tests/").unwrap().filter(|d| { 218 | test_names.iter().any(|t| { 219 | d.as_ref() 220 | .unwrap() 221 | .file_name() 222 | .into_string() 223 | .unwrap() 224 | .contains(t) 225 | }) 226 | }); 227 | for test_dir in test_dirs { 228 | pass &= run_tests_in_dir(&test_dir.unwrap()); 229 | } 230 | assert!(pass); 231 | } 232 | -------------------------------------------------------------------------------- /tests/cv/source/cv.tex: -------------------------------------------------------------------------------- 1 | % !TeX program = lualatex 2 | 3 | \documentclass{wgu-cv} 4 | 5 | \yourname{William G Underwood} 6 | \youraddress{ 7 | ORFE Department, 8 | Sherrerd Hall, 9 | Charlton Street, 10 | Princeton, 11 | NJ 08544, 12 | USA 13 | } 14 | \youremail{wgu2@princeton.edu} 15 | \yourwebsite{wgunderwood.github.io} 16 | 17 | \begin{document} 18 | 19 | \maketitle 20 | 21 | \section{Employment} 22 | 23 | \subsection{Postdoctoral Research Associate in Statistics} 24 | {Jul 2024 -- Jul 2026} 25 | \subsubsection{University of Cambridge} 26 | 27 | \begin{itemize} 28 | \item Advisor: Richard Samworth, 29 | Department of Pure Mathematics and Mathematical Statistics 30 | \item Funding: European Research Council Advanced Grant 101019498 31 | \end{itemize} 32 | 33 | \subsection{Assistant in Instruction} 34 | {Sep 2020 -- May 2024} 35 | \subsubsection{Princeton University} 36 | 37 | \begin{itemize} 38 | 39 | \item 40 | ORF 499: 41 | Senior Thesis, 42 | Spring 2024 43 | 44 | \item 45 | ORF 498: 46 | Senior Independent Research Foundations, 47 | Fall 2023 48 | 49 | \item 50 | SML 201: 51 | Introduction to Data Science, 52 | Fall 2023 53 | 54 | \item 55 | ORF 363: 56 | Computing and Optimization, 57 | Spring 2023, Fall 2020 58 | 59 | \item 60 | ORF 524: 61 | Statistical Theory and Methods, 62 | Fall 2022, Fall 2021 63 | 64 | \item 65 | ORF 526: 66 | Probability Theory, 67 | Fall 2022 68 | 69 | \item 70 | ORF 245: 71 | Fundamentals of Statistics, 72 | Spring 2021 73 | 74 | \end{itemize} 75 | 76 | \section{Education} 77 | 78 | \subsection{PhD in Operations Research \& Financial Engineering} 79 | {Sep 2019 -- May 2024} 80 | \subsubsection{Princeton University} 81 | 82 | \begin{itemize} 83 | \item Dissertation: 84 | Estimation and Inference in Modern Nonparametric Statistics 85 | \item Advisor: 86 | Matias Cattaneo, Department of Operations Research \& Financial Engineering 87 | \end{itemize} 88 | 89 | \subsection{MA in Operations Research \& Financial Engineering} 90 | {Sep 2019 -- Sep 2021} 91 | \subsubsection{Princeton University} 92 | 93 | \subsection{MMath in Mathematics \& Statistics} 94 | {Oct 2015 -- Jun 2019} 95 | \subsubsection{University of Oxford} 96 | 97 | \begin{itemize} 98 | \item Dissertation: 99 | Motif-Based Spectral Clustering of Weighted Directed Networks 100 | \item Supervisor: 101 | Mihai Cucuringu, 102 | Department of Statistics 103 | \end{itemize} 104 | 105 | \section{Research \& publications} 106 | 107 | \subsection{Articles}{} 108 | \begin{itemize} 109 | 110 | \item Uniform inference for kernel density estimators with dyadic data, 111 | with M D Cattaneo and Y Feng. 112 | \emph{Journal of the American Statistical Association}, forthcoming, 2024. 113 | \arxiv{2201.05967}. 114 | 115 | \item Motif-based spectral clustering of weighted directed networks, 116 | with A Elliott and M Cucuringu. 117 | \emph{Applied Network Science}, 5(62), 2020. 118 | \arxiv{2004.01293}. 119 | 120 | \item Simple Poisson PCA: an algorithm for (sparse) feature extraction 121 | with simultaneous dimension determination, 122 | with L Smallman and A Artemiou. 123 | \emph{Computational Statistics}, 35:559--577, 2019. 124 | 125 | \end{itemize} 126 | 127 | \subsection{Preprints}{} 128 | \begin{itemize} 129 | 130 | \item Inference with Mondrian random forests, 131 | with M D Cattaneo and J M Klusowski, 2023. \\ 132 | \arxiv{2310.09702}. 133 | 134 | \item Yurinskii's coupling for martingales, 135 | with M D Cattaneo and R P Masini. 136 | \emph{Annals of Statistics}, reject and resubmit, 2023. 137 | \arxiv{2210.00362}. 138 | 139 | \end{itemize} 140 | 141 | \pagebreak 142 | 143 | \subsection{Works in progress}{} 144 | \begin{itemize} 145 | 146 | \item Higher-order extensions to the Lindeberg method, 147 | with M D Cattaneo and R P Masini. 148 | 149 | \item Adaptive Mondrian random forests, 150 | with M D Cattaneo, R Chandak and J M Klusowski. 151 | \end{itemize} 152 | 153 | \subsection{Presentations}{} 154 | \begin{itemize} 155 | 156 | \item Statistics Seminar, University of Pittsburgh, February 2024 157 | \item Statistics Seminar, University of Illinois, January 2024 158 | \item Statistics Seminar, University of Michigan, January 2024 159 | \item PhD Poster Session, Two Sigma Investments, July 2023 160 | \item Research Symposium, Two Sigma Investments, June 2022 161 | \item Statistics Laboratory, Princeton University, September 2021 162 | \end{itemize} 163 | 164 | \subsection{Software}{} 165 | \begin{itemize} 166 | 167 | \item MondrianForests: Mondrian random forests in Julia, 2023. \\ 168 | \github{wgunderwood/MondrianForests.jl} 169 | 170 | \item DyadicKDE: dyadic kernel density estimation in Julia, 2022. \\ 171 | \github{wgunderwood/DyadicKDE.jl} 172 | 173 | \item motifcluster: motif-based spectral clustering 174 | in R, Python and Julia, 2020. \\ 175 | \github{wgunderwood/motifcluster} 176 | 177 | \end{itemize} 178 | 179 | \section{Awards \& funding} 180 | \vspace{-0.22cm} 181 | 182 | \begin{itemize} 183 | \item School of Engineering and Applied Science Award for Excellence, 184 | Princeton University 185 | \hfill 2022% 186 | \item Francis Robbins Upton Fellowship in Engineering, 187 | Princeton University 188 | \hfill 2019% 189 | \item Royal Statistical Society Prize, 190 | Royal Statistical Society \& University of Oxford 191 | \hfill 2019% 192 | \item Gibbs Statistics Prize, 193 | University of Oxford 194 | \hfill 2019% 195 | \item James Fund for Mathematics Research Grant, 196 | St John's College, University of Oxford 197 | \hfill 2017% 198 | \item Casberd Scholarship, 199 | St John's College, University of Oxford 200 | \hfill 2016% 201 | \end{itemize} 202 | 203 | \section{Professional experience} 204 | 205 | \subsection{Quantitative Research Intern} 206 | {Jun 2023 -- Aug 2023} 207 | \subsubsection{Two Sigma Investments} 208 | \vspace{-0.20cm} 209 | 210 | \subsection{Machine Learning Consultant} 211 | {Oct 2018 -- Nov 2018} 212 | \subsubsection{Mercury Digital Assets} 213 | \vspace{-0.18cm} 214 | 215 | \subsection{Educational Consultant} 216 | {Feb 2018 -- Sep 2018} 217 | \subsubsection{Polaris \& Dawn} 218 | \vspace{-0.20cm} 219 | 220 | \subsection{Premium Tutor} 221 | {Feb 2016 -- Oct 2018} 222 | \subsubsection{MyTutor} 223 | \vspace{-0.20cm} 224 | 225 | \subsection{Statistics \& Machine Learning Researcher} 226 | {Aug 2017 -- Sep 2017} 227 | \subsubsection{Cardiff University} 228 | \vspace{-0.20cm} 229 | 230 | \subsection{Data Science Intern} 231 | {Jun 2017 -- Aug 2017} 232 | \subsubsection{Rolls-Royce} 233 | \vspace{-0.20cm} 234 | 235 | \subsection{Peer review}{} 236 | 237 | \emph{Econometric Theory, 238 | Journal of the American Statistical Association, 239 | Journal of Business \& Economic Statistics, 240 | Journal of Causal Inference, 241 | Journal of Econometrics, 242 | Operations Research.} 243 | 244 | \section{References} 245 | \vspace{-0.22cm} 246 | 247 | \begin{itemize} 248 | 249 | \item 250 | Matias Cattaneo, 251 | Professor, 252 | ORFE, 253 | Princeton University 254 | 255 | \item 256 | Jason Klusowski, 257 | Assistant Professor, 258 | ORFE, 259 | Princeton University 260 | 261 | \item 262 | Jianqing Fan, 263 | Professor, 264 | ORFE, 265 | Princeton University 266 | 267 | \item 268 | Ricardo Masini, 269 | Assistant Professor, 270 | Statistics, 271 | University of California, Davis 272 | 273 | \end{itemize} 274 | 275 | \end{document} 276 | -------------------------------------------------------------------------------- /tests/cv/target/cv.tex: -------------------------------------------------------------------------------- 1 | % !TeX program = lualatex 2 | 3 | \documentclass{wgu-cv} 4 | 5 | \yourname{William G Underwood} 6 | \youraddress{ 7 | ORFE Department, 8 | Sherrerd Hall, 9 | Charlton Street, 10 | Princeton, 11 | NJ 08544, 12 | USA 13 | } 14 | \youremail{wgu2@princeton.edu} 15 | \yourwebsite{wgunderwood.github.io} 16 | 17 | \begin{document} 18 | 19 | \maketitle 20 | 21 | \section{Employment} 22 | 23 | \subsection{Postdoctoral Research Associate in Statistics} 24 | {Jul 2024 -- Jul 2026} 25 | \subsubsection{University of Cambridge} 26 | 27 | \begin{itemize} 28 | \item Advisor: Richard Samworth, 29 | Department of Pure Mathematics and Mathematical Statistics 30 | \item Funding: European Research Council Advanced Grant 101019498 31 | \end{itemize} 32 | 33 | \subsection{Assistant in Instruction} 34 | {Sep 2020 -- May 2024} 35 | \subsubsection{Princeton University} 36 | 37 | \begin{itemize} 38 | 39 | \item 40 | ORF 499: 41 | Senior Thesis, 42 | Spring 2024 43 | 44 | \item 45 | ORF 498: 46 | Senior Independent Research Foundations, 47 | Fall 2023 48 | 49 | \item 50 | SML 201: 51 | Introduction to Data Science, 52 | Fall 2023 53 | 54 | \item 55 | ORF 363: 56 | Computing and Optimization, 57 | Spring 2023, Fall 2020 58 | 59 | \item 60 | ORF 524: 61 | Statistical Theory and Methods, 62 | Fall 2022, Fall 2021 63 | 64 | \item 65 | ORF 526: 66 | Probability Theory, 67 | Fall 2022 68 | 69 | \item 70 | ORF 245: 71 | Fundamentals of Statistics, 72 | Spring 2021 73 | 74 | \end{itemize} 75 | 76 | \section{Education} 77 | 78 | \subsection{PhD in Operations Research \& Financial Engineering} 79 | {Sep 2019 -- May 2024} 80 | \subsubsection{Princeton University} 81 | 82 | \begin{itemize} 83 | \item Dissertation: 84 | Estimation and Inference in Modern Nonparametric Statistics 85 | \item Advisor: 86 | Matias Cattaneo, Department of Operations Research \& Financial Engineering 87 | \end{itemize} 88 | 89 | \subsection{MA in Operations Research \& Financial Engineering} 90 | {Sep 2019 -- Sep 2021} 91 | \subsubsection{Princeton University} 92 | 93 | \subsection{MMath in Mathematics \& Statistics} 94 | {Oct 2015 -- Jun 2019} 95 | \subsubsection{University of Oxford} 96 | 97 | \begin{itemize} 98 | \item Dissertation: 99 | Motif-Based Spectral Clustering of Weighted Directed Networks 100 | \item Supervisor: 101 | Mihai Cucuringu, 102 | Department of Statistics 103 | \end{itemize} 104 | 105 | \section{Research \& publications} 106 | 107 | \subsection{Articles}{} 108 | \begin{itemize} 109 | 110 | \item Uniform inference for kernel density estimators with dyadic data, 111 | with M D Cattaneo and Y Feng. 112 | \emph{Journal of the American Statistical Association}, forthcoming, 2024. 113 | \arxiv{2201.05967}. 114 | 115 | \item Motif-based spectral clustering of weighted directed networks, 116 | with A Elliott and M Cucuringu. 117 | \emph{Applied Network Science}, 5(62), 2020. 118 | \arxiv{2004.01293}. 119 | 120 | \item Simple Poisson PCA: an algorithm for (sparse) feature extraction 121 | with simultaneous dimension determination, 122 | with L Smallman and A Artemiou. 123 | \emph{Computational Statistics}, 35:559--577, 2019. 124 | 125 | \end{itemize} 126 | 127 | \subsection{Preprints}{} 128 | \begin{itemize} 129 | 130 | \item Inference with Mondrian random forests, 131 | with M D Cattaneo and J M Klusowski, 2023. \\ 132 | \arxiv{2310.09702}. 133 | 134 | \item Yurinskii's coupling for martingales, 135 | with M D Cattaneo and R P Masini. 136 | \emph{Annals of Statistics}, reject and resubmit, 2023. 137 | \arxiv{2210.00362}. 138 | 139 | \end{itemize} 140 | 141 | \pagebreak 142 | 143 | \subsection{Works in progress}{} 144 | \begin{itemize} 145 | 146 | \item Higher-order extensions to the Lindeberg method, 147 | with M D Cattaneo and R P Masini. 148 | 149 | \item Adaptive Mondrian random forests, 150 | with M D Cattaneo, R Chandak and J M Klusowski. 151 | \end{itemize} 152 | 153 | \subsection{Presentations}{} 154 | \begin{itemize} 155 | 156 | \item Statistics Seminar, University of Pittsburgh, February 2024 157 | \item Statistics Seminar, University of Illinois, January 2024 158 | \item Statistics Seminar, University of Michigan, January 2024 159 | \item PhD Poster Session, Two Sigma Investments, July 2023 160 | \item Research Symposium, Two Sigma Investments, June 2022 161 | \item Statistics Laboratory, Princeton University, September 2021 162 | \end{itemize} 163 | 164 | \subsection{Software}{} 165 | \begin{itemize} 166 | 167 | \item MondrianForests: Mondrian random forests in Julia, 2023. \\ 168 | \github{wgunderwood/MondrianForests.jl} 169 | 170 | \item DyadicKDE: dyadic kernel density estimation in Julia, 2022. \\ 171 | \github{wgunderwood/DyadicKDE.jl} 172 | 173 | \item motifcluster: motif-based spectral clustering 174 | in R, Python and Julia, 2020. \\ 175 | \github{wgunderwood/motifcluster} 176 | 177 | \end{itemize} 178 | 179 | \section{Awards \& funding} 180 | \vspace{-0.22cm} 181 | 182 | \begin{itemize} 183 | \item School of Engineering and Applied Science Award for Excellence, 184 | Princeton University 185 | \hfill 2022% 186 | \item Francis Robbins Upton Fellowship in Engineering, 187 | Princeton University 188 | \hfill 2019% 189 | \item Royal Statistical Society Prize, 190 | Royal Statistical Society \& University of Oxford 191 | \hfill 2019% 192 | \item Gibbs Statistics Prize, 193 | University of Oxford 194 | \hfill 2019% 195 | \item James Fund for Mathematics Research Grant, 196 | St John's College, University of Oxford 197 | \hfill 2017% 198 | \item Casberd Scholarship, 199 | St John's College, University of Oxford 200 | \hfill 2016% 201 | \end{itemize} 202 | 203 | \section{Professional experience} 204 | 205 | \subsection{Quantitative Research Intern} 206 | {Jun 2023 -- Aug 2023} 207 | \subsubsection{Two Sigma Investments} 208 | \vspace{-0.20cm} 209 | 210 | \subsection{Machine Learning Consultant} 211 | {Oct 2018 -- Nov 2018} 212 | \subsubsection{Mercury Digital Assets} 213 | \vspace{-0.18cm} 214 | 215 | \subsection{Educational Consultant} 216 | {Feb 2018 -- Sep 2018} 217 | \subsubsection{Polaris \& Dawn} 218 | \vspace{-0.20cm} 219 | 220 | \subsection{Premium Tutor} 221 | {Feb 2016 -- Oct 2018} 222 | \subsubsection{MyTutor} 223 | \vspace{-0.20cm} 224 | 225 | \subsection{Statistics \& Machine Learning Researcher} 226 | {Aug 2017 -- Sep 2017} 227 | \subsubsection{Cardiff University} 228 | \vspace{-0.20cm} 229 | 230 | \subsection{Data Science Intern} 231 | {Jun 2017 -- Aug 2017} 232 | \subsubsection{Rolls-Royce} 233 | \vspace{-0.20cm} 234 | 235 | \subsection{Peer review}{} 236 | 237 | \emph{Econometric Theory, 238 | Journal of the American Statistical Association, 239 | Journal of Business \& Economic Statistics, 240 | Journal of Causal Inference, 241 | Journal of Econometrics, 242 | Operations Research.} 243 | 244 | \section{References} 245 | \vspace{-0.22cm} 246 | 247 | \begin{itemize} 248 | 249 | \item 250 | Matias Cattaneo, 251 | Professor, 252 | ORFE, 253 | Princeton University 254 | 255 | \item 256 | Jason Klusowski, 257 | Assistant Professor, 258 | ORFE, 259 | Princeton University 260 | 261 | \item 262 | Jianqing Fan, 263 | Professor, 264 | ORFE, 265 | Princeton University 266 | 267 | \item 268 | Ricardo Masini, 269 | Assistant Professor, 270 | Statistics, 271 | University of California, Davis 272 | 273 | \end{itemize} 274 | 275 | \end{document} 276 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # v0.5.6 2 | 3 | - Gate web functionality behind a crate feature. 4 | - Fix bug with `wrapmin` configuration option being ignored. 5 | - Handle `\mintinline` as `\verb`. 6 | - Fix bug with file extension handling 7 | 8 | # v0.5.5 9 | 10 | - Implement recursive file formatting with `--recursive` or `-r`. 11 | - Format current directory with `tex-fmt` (no explicit files passed). 12 | - Fix bug when wrapping lines containing non-ASCII characters. 13 | - Add warning for first negative indent, if any. 14 | - Improve performance by using `RegexSet` for multiple matches. 15 | - Faster indent calculation by avoiding repeated delimiter counting. 16 | - File names handled internally with `PathBuf` rather than `String`. 17 | 18 | # v0.5.4 19 | 20 | ## Allow custom non-indented environments 21 | 22 | - Pass `no-indent-envs = ["mydocument"]` in `tex-fmt.toml`. 23 | - Then no indentation is applied inside `\begin{mydocument}...\end{mydocument}`. 24 | - This is the default behaviour for `\begin{document}...\end{document}`. 25 | 26 | ## Allow custom verbatim environments 27 | 28 | - Pass `verbatims = ["myverbatim"]` in `tex-fmt.toml`. 29 | - All formatting is then skipped inside `\begin{myverbatim}...\end{myverbatim}`. 30 | 31 | ## Improve formatting when using `\verb|...|` 32 | 33 | - Lines are not broken inside `\verb|...|`. 34 | - Environments inside `\verb|...|` do not trigger new lines. 35 | - No indenting applied to lines containing `\verb|...|`. 36 | 37 | ## Minor changes 38 | 39 | - Fix output bug in profiling script at `extra/prof.sh`. 40 | - Add link to [docs.rs](https://docs.rs/tex-fmt/latest/tex_fmt/) page in README. 41 | - Add logo to [docs.rs](https://docs.rs/tex-fmt/latest/tex_fmt/) page. 42 | - Add better tests for config file options and CLI arguments. 43 | - Improve documentation of config file options and CLI arguments in README. 44 | 45 | # v0.5.3 46 | 47 | - Add `--fail-on-change` flag. 48 | - Deploy web app for using tex-fmt in a browser. 49 | - Simplify testing structure. 50 | - Switch nix flake input to nixpkgs-unstable. 51 | - Update README with GitHub Action, mason.nvim, Debian, bibtex-tidy, pre-commit. 52 | 53 | # v0.5.2 54 | 55 | - Fix critical bug with config files missing the `lists` field. 56 | - Trim trailing newlines. 57 | 58 | # v0.5.1 59 | 60 | - Custom list environments can be passed using the `lists` option in the config file. 61 | - Allow `verbosity = "info"` in the config file. 62 | - Fixed a bug with configuration values being incorrectly reset. 63 | 64 | # v0.5.0 65 | 66 | Version v0.5.0 is a major release, including breaking changes and substantial new features. 67 | 68 | ## Changes to existing CLI options 69 | - The option to disable line wrapping has been changed from `--keep` to `--nowrap`. 70 | - The option to set the number of characters used per indentation level has been changed from `--tab` to `--tabsize`. 71 | - The option to set the maximum line length for wrapping has been changed from `--wrap` to `--wraplen`. 72 | - See below for information on the new `--config`, `--noconfig`, `--man`, `--completion`, and `--args` flags. 73 | 74 | ## Configuration file support 75 | Configuring tex-fmt can now be achieved using a configuration file as well as CLI arguments. The configuration file can be read from a user-specified path with `--config `, from the current working directory, from the root of the current git repository, or from the user's configuration directory, in order of decreasing priority. Arguments passed on the command line will always override those specified in configuration files. Configuration files can be disabled by passing `--noconfig`. 76 | 77 | ## Man pages 78 | Man pages can be generated using the `--man` flag. Pre-built man pages are also available for download from the GitHub repository. 79 | 80 | ## Shell completion 81 | Completion files for popular shells, including bash, fish, zsh, elvish and PowerShell, can be generated using the `--completion ` flag. Pre-built completion scripts are also available for download from the GitHub repository. 82 | 83 | ## Minor changes 84 | - Arguments passed to tex-fmt can be inspected by passing `--args` 85 | - Fixed bug with `\itemsep` matching the `\item` pattern 86 | - Added last non-indented line number to "Indent did not return to zero" error messages 87 | - Removed LTO optimization to improve compile time with minimal effect on run time 88 | - If duplicate file names are provided, they are now removed before formatting 89 | - Added LLF to the list of existing tools 90 | - Changed order of options in help dialogs 91 | 92 | # v0.4.7 93 | 94 | - Fix bug with `--stdin` adding newlines at EOF 95 | - Fix logic for ignoring verbatim environments 96 | - Ensure sectioning commands begin on new lines 97 | - Various performance improvements 98 | - Add NEWS.md for release notes 99 | - Ensure all test files successfully compile to PDFs 100 | - Better documentation of options in README.md 101 | 102 | # v0.4.6 103 | 104 | - Added `--wrap` flag to choose line length for wrapping 105 | - Significant changes to central formatting logic to reduce memory allocations 106 | - Treat comment environments as verbatim 107 | - Improved performance with finding comments in source code 108 | 109 | # v0.4.5 110 | 111 | - Added `--usetabs` to use tabs instead of spaces for indentation 112 | - Fixed a bug with unicode graphemes and comment handling 113 | - Main function now returns `std::process::ExitCode` for a cleaner exit 114 | - Reduced memory allocation in comment handling logic 115 | - Reduced memory allocation when indenting lines 116 | - Caching of pattern matches reduces number of regex searches 117 | 118 | # v0.4.4 119 | 120 | - Added `--tab` flag for variable tab size [default: 2] 121 | - Fixed bug with incorrect line numbers being printed 122 | - Fixed bug with quadratic complexity of comment checking 123 | - Added Arch User Repository support 124 | - Added VS Code support 125 | - Improved performance by moving environment checking inside main loop 126 | - Upgraded Cargo dependencies 127 | - Included LTO optimization on the release build 128 | 129 | # v0.4.3 130 | 131 | - Switch output text coloring to the `colored` crate. 132 | - Add `--stdin` flag to read input from stdin (and output to stdout). 133 | 134 | # v0.4.2 135 | 136 | - Added `--quiet` flag to suppress warning messages 137 | - Allow `tex-fmt main` for `tex-fmt main.tex` 138 | - Internal documentation 139 | - Improved performance 140 | - Added more Clippy lints 141 | 142 | # v0.4.1 143 | 144 | - Added binary archives to GitHub release 145 | 146 | # v0.4.0 147 | 148 | ## Breaking change 149 | The logic for line wrapping has been changed. Previously, for lines longer than 150 | 80 characters, we would break the line at suitable points into chunks of no 151 | more than 80 characters. Then another round of indenting was applied, and this 152 | would often push the length back over 80 characters. A subsequent round of 153 | wrapping was therefore required, and often led to the creation of very short 154 | lines (#6). 155 | 156 | The new approach is to take lines longer than 80 characters and remove the 157 | first segment up to 70 characters, pushing the resulting two lines back onto 158 | the queue. When indenting is then reapplied, the lines typically do not go over 159 | 80 characters unless the indentation is very deep. However, some lines may now 160 | be truncated to 70 characters rather than 80. 161 | 162 | ## Other updates 163 | 164 | - Added a `--keep` flag to disable line wrapping (#10) 165 | - Improved the central algorithm to avoid multiple passes and improve run-time 166 | performance (#7) 167 | - Only write the file to disk if the formatting returns a different string, to 168 | avoid unnecessary editing of modification times 169 | 170 | # v0.3.1 171 | 172 | - Updated README 173 | - Added project logo 174 | 175 | # v0.3.0 176 | 177 | - Added a `--check` flag to check if file is correctly formatted 178 | - Fixed bug with line wrapping giving up early 179 | - Shell scripts verified with shellcheck 180 | - Refactored variable names 181 | - Some performance improvements 182 | 183 | # v0.2.2 184 | 185 | Bump version number 186 | 187 | # v0.2.1 188 | 189 | Bump version number 190 | 191 | # v0.2.0 192 | 193 | Bump version number 194 | -------------------------------------------------------------------------------- /extra/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 1980-01-01T00:00:00+00:00 10 | image/svg+xml 11 | 12 | 13 | Matplotlib v3.8.4, https://matplotlib.org/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 31 | 32 | 33 | 34 | 41 | 42 | 43 | 50 | 51 | 52 | 59 | 60 | 61 | 68 | 69 | 70 | 71 | 72 | 79 | 80 | 81 | 86 | 87 | 88 | 91 | 92 | 93 | 96 | 97 | 98 | 101 | 102 | 103 | 106 | 107 | 108 | 109 | 110 | 111 | 134 | 165 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 267 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | -------------------------------------------------------------------------------- /tests/masters_dissertation/source/ociamthesis.cls: -------------------------------------------------------------------------------- 1 | % ociamthesis v2.2 2 | % By Keith A. Gillow 3 | % Version 1.0 released 26/11/1997 4 | %-------------------------- identification --------------------- 5 | \NeedsTeXFormat{LaTeX2e} 6 | \ProvidesClass{ociamthesis}[2010/11/22 v2.2 OCIAM thesis class] 7 | %-------------------------- initial code ----------------------- 8 | \def\logoversion{squarelogo} 9 | \DeclareOption{beltcrest}{\def\logoversion{beltcrest}} 10 | \DeclareOption{shieldcrest}{\def\logoversion{shieldcrest}} 11 | \DeclareOption*{\PassOptionsToClass{\CurrentOption}{report}} 12 | \ProcessOptions\relax 13 | \LoadClass[a4paper]{report} 14 | % As an alternative to the above could use next line for twosided output 15 | %\LoadClass[a4paper,twoside,openright]{report} 16 | 17 | \RequirePackage{graphicx} % needed for latest frontpage logo 18 | \RequirePackage{ifthen} % needed for option parsing for logo 19 | 20 | \raggedbottom 21 | 22 | %define the default submitted text 23 | \newcommand{\submittedtext}{{A thesis submitted for the degree of}} 24 | 25 | % 26 | % DECLARATIONS 27 | % 28 | % These macros are used to declare arguments needed for the 29 | % construction of the title page and other preamble. 30 | 31 | % The year and term the thesis is submitted 32 | \def\degreedate#1{\gdef\@degreedate{#1}} 33 | % The full (unabbreviated) name of the degree 34 | \def\degree#1{\gdef\@degree{#1}} 35 | % The name of your Oxford college (e.g. Christ Church, Pembroke) 36 | \def\college#1{\gdef\@college{#1}} 37 | 38 | 39 | % 40 | % Setup chosen crest/logo 41 | % 42 | 43 | \ifthenelse{\equal{\logoversion}{shieldcrest}}% 44 | { 45 | % Traditional Oxford shield crest 46 | %Using latex metafont (Mathematical Institute system) 47 | \font\crestfont=oxcrest40 scaled\magstep3 48 | \def\logo{{\crestfont \char1}} 49 | %For comlab system replace 1st line above with 50 | %\font\crestfont=crest scaled\magstep3 51 | }{} 52 | 53 | \ifthenelse{\equal{\logoversion}{beltcrest}}% 54 | { 55 | % Newer Oxford Belt crest 56 | %Using latex metafont (Mathematical Institute system) 57 | \font\beltcrestfont=oxbeltcrest 58 | \def\logo{{\beltcrestfont \char0}} 59 | %For comlab system replace 1st line above with 60 | %\font\beltcrestfont=newcrest 61 | }{} 62 | 63 | \ifthenelse{\equal{\logoversion}{squarelogo}}% 64 | { 65 | % Latest Logo, Square version (the default!) 66 | % you need an oxlogo.eps or oxlogo.pdf file as appropriate 67 | \def\logo{{ 68 | %\includegraphics[width=32mm,draft=false]{../graphics/branding/oxlogo} 69 | }} 70 | }{} 71 | 72 | % 73 | % Define text area of page and margin offsets 74 | % 75 | \setlength{\topmargin}{0.0in} %0.0in 76 | \setlength{\oddsidemargin}{0.167in} % 0.33in 77 | \setlength{\evensidemargin}{-0.08in} %-0.08in 78 | \setlength{\textheight}{9.2in} %9.0in 79 | \setlength{\textwidth}{6.0in} %6.0in 80 | \setlength{\headheight}{15pt} % not set 81 | \setlength{\voffset}{-0.2in} % not set 82 | 83 | % 84 | % Environments 85 | % 86 | 87 | % This macro define an environment for front matter that is always 88 | % single column even in a double-column document. 89 | 90 | \newenvironment{alwayssingle}{% 91 | \@restonecolfalse 92 | \if@twocolumn\@restonecoltrue\onecolumn 93 | \else\if@openright\cleardoublepage\else\clearpage\fi 94 | \fi}% 95 | {\if@restonecol\twocolumn 96 | \else\newpage\thispagestyle{empty}\fi} 97 | 98 | %define title page layout 99 | \renewcommand{\maketitle}{% 100 | \begin{alwayssingle} 101 | \renewcommand{\footnotesize}{\small} 102 | \renewcommand{\footnoterule}{\relax} 103 | \thispagestyle{empty} 104 | \null\vfill 105 | \begin{center} 106 | { \Huge {\bfseries {\@title}} \par} 107 | {\large \vspace*{40mm} {\logo \par} \vspace*{25mm}} 108 | {{\Large \@author} \par} 109 | {\large \vspace*{1.5ex} % 1ex 110 | {{\@college} \par} 111 | \vspace*{1ex} 112 | {University of Oxford \par} 113 | \vspace*{25mm} 114 | {{\submittedtext} \par} 115 | \vspace*{1ex} 116 | {\it {\@degree} \par} 117 | \vspace*{2ex} 118 | {\@degreedate}} 119 | \end{center} 120 | \null\vfill 121 | \end{alwayssingle}} 122 | 123 | % DEDICATION 124 | % 125 | % The dedication environment makes sure the dedication gets its 126 | % own page and is set out in verse format. 127 | 128 | \newenvironment{dedication} 129 | {\begin{alwayssingle} 130 | \thispagestyle{empty} 131 | \begin{center} 132 | \vspace*{1.5cm} 133 | {\LARGE } 134 | \end{center} 135 | \vspace{0.5cm} 136 | \begin{verse}\begin{center}} 137 | {\end{center}\end{verse}\end{alwayssingle}} 138 | 139 | 140 | % ACKNOWLEDGEMENTS 141 | % 142 | % The acknowledgements environment puts a large, bold, centered 143 | % "Acknowledgements" label at the top of the page. The acknowledgements 144 | % themselves appear in a quote environment, i.e. tabbed in at both sides, and 145 | % on its own page. 146 | 147 | \newenvironment{acknowledgements} 148 | {\begin{alwayssingle} \thispagestyle{empty} 149 | \begin{center} 150 | \vspace*{1.5cm} 151 | {\Large \bfseries Acknowledgements} 152 | \end{center} 153 | \vspace{0.5cm} 154 | \begin{quote}} 155 | {\end{quote}\end{alwayssingle}} 156 | 157 | % The acknowledgementslong environment puts a large, bold, centered 158 | % "Acknowledgements" label at the top of the page. The acknowledgement itself 159 | % does not appears in a quote environment so you can get more in. 160 | 161 | \newenvironment{acknowledgementslong} 162 | {\begin{alwayssingle} \thispagestyle{empty} 163 | \begin{center} 164 | \vspace*{1.5cm} 165 | {\Large \bfseries Acknowledgements} 166 | \end{center} 167 | \vspace{0.5cm}} 168 | {\end{alwayssingle}} 169 | 170 | % STATEMENT OF ORIGINALITY (AS SUGGESTED BY GSW) 171 | % 172 | % The originality environment puts a large, bold, centered 173 | % "Statement of originality" label at the top of the page. The statement 174 | % of originality itself appears in a quote environment, i.e. tabbed in at 175 | % both sides, and on its own page. 176 | 177 | \newenvironment{originality} 178 | {\begin{alwayssingle} \thispagestyle{empty} 179 | \begin{center} 180 | \vspace*{1.5cm} 181 | {\Large \bfseries Statement of Originality} 182 | \end{center} 183 | \vspace{0.5cm} 184 | \begin{quote}} 185 | {\end{quote}\end{alwayssingle}} 186 | 187 | % The originalitylong environment puts a large, bold, centered 188 | % "Statement of originality" label at the top of the page. The statement 189 | % of originality itself does not appears in a quote environment so you can 190 | % get more in. 191 | 192 | \newenvironment{originalitylong} 193 | {\begin{alwayssingle} \thispagestyle{empty} 194 | \begin{center} 195 | \vspace*{1.5cm} 196 | {\Large \bfseries Statement of Originality} 197 | \end{center} 198 | \vspace{0.5cm}} 199 | {\end{alwayssingle}} 200 | 201 | 202 | %ABSTRACT 203 | % 204 | %The abstract environment puts a large, bold, centered "Abstract" label at 205 | %the top of the page. The abstract itself appears in a quote environment, 206 | %i.e. tabbed in at both sides, and on its own page. 207 | 208 | \renewenvironment{abstract} {\begin{alwayssingle} \thispagestyle{empty} 209 | \begin{center} 210 | \vspace*{1.5cm} 211 | {\Large \bfseries Abstract} 212 | \end{center} 213 | \vspace{0.5cm} 214 | \begin{quote}} 215 | {\end{quote}\end{alwayssingle}} 216 | 217 | %The abstractlong environment puts a large, bold, centered "Abstract" label at 218 | %the top of the page. The abstract itself does not appears in a quote 219 | %environment so you can get more in. 220 | 221 | \newenvironment{abstractlong} {\begin{alwayssingle} \thispagestyle{empty} 222 | \begin{center} 223 | \vspace*{1.5cm} 224 | {\Large \bfseries Abstract} 225 | \end{center} 226 | \vspace{0.5cm}} 227 | {\end{alwayssingle}} 228 | 229 | %The abstractseparate environment is for running of a page with the abstract 230 | %on including title and author etc as required to be handed in separately 231 | 232 | \newenvironment{abstractseparate} {\begin{alwayssingle} \thispagestyle{empty} 233 | \vspace*{-1in} 234 | \begin{center} 235 | { \Large {\bfseries {\@title}} \par} 236 | {{\large \vspace*{1ex} \@author} \par} 237 | {\large \vspace*{1ex} 238 | {{\@college} \par} 239 | {University of Oxford \par} 240 | \vspace*{1ex} 241 | {{\it \submittedtext} \par} 242 | {\it {\@degree} \par} 243 | \vspace*{2ex} 244 | {\@degreedate}} 245 | \end{center}} 246 | {\end{alwayssingle}} 247 | 248 | %ROMANPAGES 249 | % 250 | % The romanpages environment set the page numbering to lowercase roman one 251 | % for the contents and figures lists. It also resets 252 | % page-numbering for the remainder of the dissertation (arabic, starting at 1). 253 | 254 | \newenvironment{romanpages} 255 | {\cleardoublepage\setcounter{page}{1}\renewcommand{\thepage}{\roman{page}}} 256 | {\cleardoublepage\renewcommand{\thepage}{\arabic{page}}\setcounter{page}{1}} 257 | -------------------------------------------------------------------------------- /tests/masters_dissertation/target/ociamthesis.cls: -------------------------------------------------------------------------------- 1 | % ociamthesis v2.2 2 | % By Keith A. Gillow 3 | % Version 1.0 released 26/11/1997 4 | %-------------------------- identification --------------------- 5 | \NeedsTeXFormat{LaTeX2e} 6 | \ProvidesClass{ociamthesis}[2010/11/22 v2.2 OCIAM thesis class] 7 | %-------------------------- initial code ----------------------- 8 | \def\logoversion{squarelogo} 9 | \DeclareOption{beltcrest}{\def\logoversion{beltcrest}} 10 | \DeclareOption{shieldcrest}{\def\logoversion{shieldcrest}} 11 | \DeclareOption*{\PassOptionsToClass{\CurrentOption}{report}} 12 | \ProcessOptions\relax 13 | \LoadClass[a4paper]{report} 14 | % As an alternative to the above could use next line for twosided output 15 | %\LoadClass[a4paper,twoside,openright]{report} 16 | 17 | \RequirePackage{graphicx} % needed for latest frontpage logo 18 | \RequirePackage{ifthen} % needed for option parsing for logo 19 | 20 | \raggedbottom 21 | 22 | %define the default submitted text 23 | \newcommand{\submittedtext}{{A thesis submitted for the degree of}} 24 | 25 | % 26 | % DECLARATIONS 27 | % 28 | % These macros are used to declare arguments needed for the 29 | % construction of the title page and other preamble. 30 | 31 | % The year and term the thesis is submitted 32 | \def\degreedate#1{\gdef\@degreedate{#1}} 33 | % The full (unabbreviated) name of the degree 34 | \def\degree#1{\gdef\@degree{#1}} 35 | % The name of your Oxford college (e.g. Christ Church, Pembroke) 36 | \def\college#1{\gdef\@college{#1}} 37 | 38 | % 39 | % Setup chosen crest/logo 40 | % 41 | 42 | \ifthenelse{\equal{\logoversion}{shieldcrest}}% 43 | { 44 | % Traditional Oxford shield crest 45 | %Using latex metafont (Mathematical Institute system) 46 | \font\crestfont=oxcrest40 scaled\magstep3 47 | \def\logo{{\crestfont \char1}} 48 | %For comlab system replace 1st line above with 49 | %\font\crestfont=crest scaled\magstep3 50 | }{} 51 | 52 | \ifthenelse{\equal{\logoversion}{beltcrest}}% 53 | { 54 | % Newer Oxford Belt crest 55 | %Using latex metafont (Mathematical Institute system) 56 | \font\beltcrestfont=oxbeltcrest 57 | \def\logo{{\beltcrestfont \char0}} 58 | %For comlab system replace 1st line above with 59 | %\font\beltcrestfont=newcrest 60 | }{} 61 | 62 | \ifthenelse{\equal{\logoversion}{squarelogo}}% 63 | { 64 | % Latest Logo, Square version (the default!) 65 | % you need an oxlogo.eps or oxlogo.pdf file as appropriate 66 | \def\logo{{ 67 | %\includegraphics[width=32mm,draft=false]{../graphics/branding/oxlogo} 68 | }} 69 | }{} 70 | 71 | % 72 | % Define text area of page and margin offsets 73 | % 74 | \setlength{\topmargin}{0.0in} %0.0in 75 | \setlength{\oddsidemargin}{0.167in} % 0.33in 76 | \setlength{\evensidemargin}{-0.08in} %-0.08in 77 | \setlength{\textheight}{9.2in} %9.0in 78 | \setlength{\textwidth}{6.0in} %6.0in 79 | \setlength{\headheight}{15pt} % not set 80 | \setlength{\voffset}{-0.2in} % not set 81 | 82 | % 83 | % Environments 84 | % 85 | 86 | % This macro define an environment for front matter that is always 87 | % single column even in a double-column document. 88 | 89 | \newenvironment{alwayssingle}{% 90 | \@restonecolfalse 91 | \if@twocolumn\@restonecoltrue\onecolumn 92 | \else\if@openright\cleardoublepage\else\clearpage\fi 93 | \fi}% 94 | {\if@restonecol\twocolumn 95 | \else\newpage\thispagestyle{empty}\fi} 96 | 97 | %define title page layout 98 | \renewcommand{\maketitle}{% 99 | \begin{alwayssingle} 100 | \renewcommand{\footnotesize}{\small} 101 | \renewcommand{\footnoterule}{\relax} 102 | \thispagestyle{empty} 103 | \null\vfill 104 | \begin{center} 105 | { \Huge {\bfseries {\@title}} \par} 106 | {\large \vspace*{40mm} {\logo \par} \vspace*{25mm}} 107 | {{\Large \@author} \par} 108 | {\large \vspace*{1.5ex} % 1ex 109 | {{\@college} \par} 110 | \vspace*{1ex} 111 | {University of Oxford \par} 112 | \vspace*{25mm} 113 | {{\submittedtext} \par} 114 | \vspace*{1ex} 115 | {\it {\@degree} \par} 116 | \vspace*{2ex} 117 | {\@degreedate}} 118 | \end{center} 119 | \null\vfill 120 | \end{alwayssingle}} 121 | 122 | % DEDICATION 123 | % 124 | % The dedication environment makes sure the dedication gets its 125 | % own page and is set out in verse format. 126 | 127 | \newenvironment{dedication} 128 | { 129 | \begin{alwayssingle} 130 | \thispagestyle{empty} 131 | \begin{center} 132 | \vspace*{1.5cm} 133 | {\LARGE } 134 | \end{center} 135 | \vspace{0.5cm} 136 | \begin{verse} 137 | \begin{center}} 138 | { 139 | \end{center} 140 | \end{verse} 141 | \end{alwayssingle}} 142 | 143 | % ACKNOWLEDGEMENTS 144 | % 145 | % The acknowledgements environment puts a large, bold, centered 146 | % "Acknowledgements" label at the top of the page. The acknowledgements 147 | % themselves appear in a quote environment, i.e. tabbed in at both sides, and 148 | % on its own page. 149 | 150 | \newenvironment{acknowledgements} 151 | { 152 | \begin{alwayssingle} \thispagestyle{empty} 153 | \begin{center} 154 | \vspace*{1.5cm} 155 | {\Large \bfseries Acknowledgements} 156 | \end{center} 157 | \vspace{0.5cm} 158 | \begin{quote}} 159 | { 160 | \end{quote} 161 | \end{alwayssingle}} 162 | 163 | % The acknowledgementslong environment puts a large, bold, centered 164 | % "Acknowledgements" label at the top of the page. The acknowledgement itself 165 | % does not appears in a quote environment so you can get more in. 166 | 167 | \newenvironment{acknowledgementslong} 168 | { 169 | \begin{alwayssingle} \thispagestyle{empty} 170 | \begin{center} 171 | \vspace*{1.5cm} 172 | {\Large \bfseries Acknowledgements} 173 | \end{center} 174 | \vspace{0.5cm}} 175 | { 176 | \end{alwayssingle}} 177 | 178 | % STATEMENT OF ORIGINALITY (AS SUGGESTED BY GSW) 179 | % 180 | % The originality environment puts a large, bold, centered 181 | % "Statement of originality" label at the top of the page. The statement 182 | % of originality itself appears in a quote environment, i.e. tabbed in at 183 | % both sides, and on its own page. 184 | 185 | \newenvironment{originality} 186 | { 187 | \begin{alwayssingle} \thispagestyle{empty} 188 | \begin{center} 189 | \vspace*{1.5cm} 190 | {\Large \bfseries Statement of Originality} 191 | \end{center} 192 | \vspace{0.5cm} 193 | \begin{quote}} 194 | { 195 | \end{quote} 196 | \end{alwayssingle}} 197 | 198 | % The originalitylong environment puts a large, bold, centered 199 | % "Statement of originality" label at the top of the page. The statement 200 | % of originality itself does not appears in a quote environment so you can 201 | % get more in. 202 | 203 | \newenvironment{originalitylong} 204 | { 205 | \begin{alwayssingle} \thispagestyle{empty} 206 | \begin{center} 207 | \vspace*{1.5cm} 208 | {\Large \bfseries Statement of Originality} 209 | \end{center} 210 | \vspace{0.5cm}} 211 | { 212 | \end{alwayssingle}} 213 | 214 | %ABSTRACT 215 | % 216 | %The abstract environment puts a large, bold, centered "Abstract" label at 217 | %the top of the page. The abstract itself appears in a quote environment, 218 | %i.e. tabbed in at both sides, and on its own page. 219 | 220 | \renewenvironment{abstract} { 221 | \begin{alwayssingle} \thispagestyle{empty} 222 | \begin{center} 223 | \vspace*{1.5cm} 224 | {\Large \bfseries Abstract} 225 | \end{center} 226 | \vspace{0.5cm} 227 | \begin{quote}} 228 | { 229 | \end{quote} 230 | \end{alwayssingle}} 231 | 232 | %The abstractlong environment puts a large, bold, centered "Abstract" label at 233 | %the top of the page. The abstract itself does not appears in a quote 234 | %environment so you can get more in. 235 | 236 | \newenvironment{abstractlong} { 237 | \begin{alwayssingle} \thispagestyle{empty} 238 | \begin{center} 239 | \vspace*{1.5cm} 240 | {\Large \bfseries Abstract} 241 | \end{center} 242 | \vspace{0.5cm}} 243 | { 244 | \end{alwayssingle}} 245 | 246 | %The abstractseparate environment is for running of a page with the abstract 247 | %on including title and author etc as required to be handed in separately 248 | 249 | \newenvironment{abstractseparate} { 250 | \begin{alwayssingle} \thispagestyle{empty} 251 | \vspace*{-1in} 252 | \begin{center} 253 | { \Large {\bfseries {\@title}} \par} 254 | {{\large \vspace*{1ex} \@author} \par} 255 | {\large \vspace*{1ex} 256 | {{\@college} \par} 257 | {University of Oxford \par} 258 | \vspace*{1ex} 259 | {{\it \submittedtext} \par} 260 | {\it {\@degree} \par} 261 | \vspace*{2ex} 262 | {\@degreedate}} 263 | \end{center}} 264 | { 265 | \end{alwayssingle}} 266 | 267 | %ROMANPAGES 268 | % 269 | % The romanpages environment set the page numbering to lowercase roman one 270 | % for the contents and figures lists. It also resets 271 | % page-numbering for the remainder of the dissertation (arabic, starting at 1). 272 | 273 | \newenvironment{romanpages} 274 | {\cleardoublepage\setcounter{page}{1}\renewcommand{\thepage}{\roman{page}}} 275 | {\cleardoublepage\renewcommand{\thepage}{\arabic{page}}\setcounter{page}{1}} 276 | -------------------------------------------------------------------------------- /src/indent.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for indenting source lines 2 | 3 | use crate::args::Args; 4 | use crate::comments::{find_comment_index, remove_comment}; 5 | use crate::format::{Pattern, State}; 6 | use crate::logging::{record_line_log, Log}; 7 | use crate::regexes::{ENV_BEGIN, ENV_END, ITEM, VERBS}; 8 | use core::cmp::max; 9 | use log::Level; 10 | use log::LevelFilter; 11 | use std::path::Path; 12 | 13 | /// Opening delimiters 14 | const OPENS: [char; 3] = ['{', '(', '[']; 15 | /// Closing delimiters 16 | const CLOSES: [char; 3] = ['}', ')', ']']; 17 | 18 | /// Information on the indentation state of a line 19 | #[derive(Debug, Clone)] 20 | pub struct Indent { 21 | /// The indentation level of a line 22 | pub actual: i8, 23 | /// The visual indentation level of a line 24 | pub visual: i8, 25 | } 26 | 27 | impl Indent { 28 | /// Construct a new indentation state 29 | #[must_use] 30 | pub const fn new() -> Self { 31 | Self { 32 | actual: 0, 33 | visual: 0, 34 | } 35 | } 36 | } 37 | 38 | impl Default for Indent { 39 | fn default() -> Self { 40 | Self::new() 41 | } 42 | } 43 | 44 | /// Calculate total indentation change due to the current line 45 | fn get_diff( 46 | line: &str, 47 | pattern: &Pattern, 48 | lists_begin: &[String], 49 | lists_end: &[String], 50 | no_indent_envs_begin: &[String], 51 | no_indent_envs_end: &[String], 52 | ) -> i8 { 53 | // Do not indent if line contains \verb|...| 54 | if pattern.contains_verb && VERBS.iter().any(|x| line.contains(x)) { 55 | return 0; 56 | } 57 | 58 | // Indentation for environments 59 | let mut diff: i8 = 0; 60 | if pattern.contains_env_begin && line.contains(ENV_BEGIN) { 61 | if no_indent_envs_begin.iter().any(|r| line.contains(r)) { 62 | return 0; 63 | } 64 | diff += 1; 65 | diff += i8::from(lists_begin.iter().any(|r| line.contains(r))); 66 | } else if pattern.contains_env_end && line.contains(ENV_END) { 67 | if no_indent_envs_end.iter().any(|r| line.contains(r)) { 68 | return 0; 69 | } 70 | diff -= 1; 71 | diff -= i8::from(lists_end.iter().any(|r| line.contains(r))); 72 | } 73 | 74 | diff 75 | } 76 | 77 | /// Calculate dedentation for the current line 78 | fn get_back( 79 | line: &str, 80 | pattern: &Pattern, 81 | state: &State, 82 | lists_end: &[String], 83 | no_indent_envs_end: &[String], 84 | ) -> i8 { 85 | // Only need to dedent if indentation is present 86 | if state.indent.actual == 0 { 87 | return 0; 88 | } 89 | let mut back: i8 = 0; 90 | 91 | // Don't apply any indenting if a \verb|...| is present 92 | if pattern.contains_verb && VERBS.iter().any(|x| line.contains(x)) { 93 | return 0; 94 | } 95 | 96 | // Calculate dedentation for environments 97 | if pattern.contains_env_end && line.contains(ENV_END) { 98 | // Some environments are not indented 99 | if no_indent_envs_end.iter().any(|r| line.contains(r)) { 100 | return 0; 101 | } 102 | // List environments get double indents for indenting items 103 | for r in lists_end { 104 | if line.contains(r) { 105 | return 2; 106 | } 107 | } 108 | // Other environments get single indents 109 | back = 1; 110 | } else if pattern.contains_item && line.contains(ITEM) { 111 | // Deindent items to make the rest of item environment appear indented 112 | back += 1; 113 | } 114 | 115 | back 116 | } 117 | 118 | /// Calculate delimiter-based indent and dedent together for performance 119 | fn get_diff_back_delim(line: &str) -> (i8, i8) { 120 | let mut diff: i8 = 0; 121 | let mut back: i8 = 0; 122 | for c in line.chars() { 123 | diff -= i8::from(OPENS.contains(&c)); 124 | diff += i8::from(CLOSES.contains(&c)); 125 | back = max(diff, back); 126 | } 127 | (-diff, back) 128 | } 129 | 130 | /// Calculate indentation properties of the current line 131 | #[allow(clippy::too_many_arguments)] 132 | fn get_indent( 133 | line: &str, 134 | prev_indent: &Indent, 135 | pattern: &Pattern, 136 | state: &State, 137 | lists_begin: &[String], 138 | lists_end: &[String], 139 | no_indent_envs_begin: &[String], 140 | no_indent_envs_end: &[String], 141 | ) -> Indent { 142 | let mut diff = get_diff( 143 | line, 144 | pattern, 145 | lists_begin, 146 | lists_end, 147 | no_indent_envs_begin, 148 | no_indent_envs_end, 149 | ); 150 | let mut back = 151 | get_back(line, pattern, state, lists_end, no_indent_envs_end); 152 | let diff_back_delim = get_diff_back_delim(line); 153 | diff += diff_back_delim.0; 154 | back += diff_back_delim.1; 155 | let actual = prev_indent.actual + diff; 156 | let visual = prev_indent.actual - back; 157 | Indent { actual, visual } 158 | } 159 | 160 | /// Calculates the indent for `line` based on its contents. 161 | /// This functions saves the calculated [Indent], which might be 162 | /// negative, to the given [State], and then ensures that the returned 163 | /// [Indent] is non-negative. 164 | #[allow(clippy::too_many_arguments)] 165 | pub fn calculate_indent( 166 | line: &str, 167 | state: &mut State, 168 | logs: &mut Vec, 169 | file: &Path, 170 | args: &Args, 171 | pattern: &Pattern, 172 | lists_begin: &[String], 173 | lists_end: &[String], 174 | no_indent_envs_begin: &[String], 175 | no_indent_envs_end: &[String], 176 | ) -> Indent { 177 | // Calculate the new indent by first removing the comment from the line 178 | // (if there is one) to ignore diffs from characters in there. 179 | let comment_index = find_comment_index(line, pattern); 180 | let line_strip = remove_comment(line, comment_index); 181 | let mut indent = get_indent( 182 | line_strip, 183 | &state.indent, 184 | pattern, 185 | state, 186 | lists_begin, 187 | lists_end, 188 | no_indent_envs_begin, 189 | no_indent_envs_end, 190 | ); 191 | 192 | // Record the indent to the logs. 193 | if args.verbosity == LevelFilter::Trace { 194 | record_line_log( 195 | logs, 196 | Level::Trace, 197 | file, 198 | state.linum_new, 199 | state.linum_old, 200 | line, 201 | &format!( 202 | "Indent: actual = {}, visual = {}:", 203 | indent.actual, indent.visual 204 | ), 205 | ); 206 | } 207 | 208 | // Save the indent to the state. Note, this indent might be negative; 209 | // it is saved without correction so that this is 210 | // not forgotten for the next iterations. 211 | state.indent = indent.clone(); 212 | 213 | // Update the last zero-indented line for use in error messages. 214 | if indent.visual == 0 && state.linum_new > state.linum_last_zero_indent { 215 | state.linum_last_zero_indent = state.linum_new; 216 | } 217 | 218 | // However, we can't negatively indent a line. 219 | // So we log the negative indent and reset the values to 0. 220 | if (indent.visual < 0) || (indent.actual < 0) { 221 | record_line_log( 222 | logs, 223 | Level::Warn, 224 | file, 225 | state.linum_new, 226 | state.linum_old, 227 | line, 228 | "Indent is negative.", 229 | ); 230 | indent.actual = indent.actual.max(0); 231 | indent.visual = indent.visual.max(0); 232 | 233 | // If this is the first negatively indented line, record in the state 234 | if state.linum_first_negative_indent.is_none() { 235 | state.linum_first_negative_indent = Some(state.linum_new); 236 | } 237 | } 238 | 239 | indent 240 | } 241 | 242 | /// Apply the given indentation to a line 243 | #[must_use] 244 | pub fn apply_indent( 245 | line: &str, 246 | indent: &Indent, 247 | args: &Args, 248 | indent_char: &str, 249 | ) -> String { 250 | let first_non_whitespace = line.chars().position(|c| !c.is_whitespace()); 251 | 252 | // If line is blank, return an empty line 253 | if first_non_whitespace.is_none() { 254 | return String::new(); 255 | } 256 | 257 | // If line is correctly indented, return it directly 258 | #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] 259 | let n_indent_chars = (indent.visual * args.tabsize as i8) as usize; 260 | if first_non_whitespace == Some(n_indent_chars) { 261 | return line.into(); 262 | } 263 | 264 | // Otherwise, allocate enough memory to fit line with the added 265 | // indentation and insert the appropriate string slices 266 | let trimmed_line = line.trim_start(); 267 | let mut new_line = 268 | String::with_capacity(trimmed_line.len() + n_indent_chars); 269 | for idx in 0..n_indent_chars { 270 | new_line.insert_str(idx, indent_char); 271 | } 272 | new_line.insert_str(n_indent_chars, trimmed_line); 273 | new_line 274 | } 275 | --------------------------------------------------------------------------------