diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15f5047 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI +on: + pull_request: + push: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + emacs_version: ['26.1', '28.2', '29.4', '30.2'] + steps: + - uses: actions/checkout@v4 + - uses: jcs090218/setup-emacs@master + with: + version: ${{ matrix.emacs_version }} + - uses: emacs-eask/setup-eask@master + with: + version: 'snapshot' + - run: eask package + - run: eask install-deps + - run: eask compile + - run: eask lint package + - run: eask test buttercup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..271496d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.elc +.deps +.compile +.eask/ +.cask/ +dist/ diff --git a/Easkfile b/Easkfile new file mode 100644 index 0000000..05cfbef --- /dev/null +++ b/Easkfile @@ -0,0 +1,17 @@ +(package "ttl-mode" + "0.2.0" + "Major mode for Turtle (and Notation 3)") + +(website-url "https://github.com/raszi/ttl-mode") +(keywords "languages" "data") + +(package-file "ttl-mode.el") + +(source "gnu") +(source "melpa") + +(depends-on "emacs" "26.1") + +(development + (depends-on "package-lint") + (depends-on "buttercup")) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..31bc09e --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: lint test clean + +.deps: Easkfile + eask install-deps + @touch .deps + +.compile: .deps ttl-mode.el + eask compile + @touch .compile + +lint: .deps + eask lint package + +test: .compile + eask test buttercup + +clean: + eask clean all + @rm -f .deps .compile diff --git a/test/ttl-mode-test.el b/test/ttl-mode-test.el new file mode 100644 index 0000000..c052ff3 --- /dev/null +++ b/test/ttl-mode-test.el @@ -0,0 +1,209 @@ +;;; ttl-mode-test.el --- Tests for ttl-mode -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Buttercup tests for ttl-mode. + +;;; Code: + +(require 'buttercup) +(require 'ttl-mode) + +(defun ttl-lines (&rest lines) + "Join LINES with newlines into one buffer-ready string. +The result ends with a newline, like a text file, so fixtures can be +written one line per string instead of as a flush-left literal." + (concat (mapconcat #'identity lines "\n") "\n")) + +(defun ttl-test-reindent (text) + "Return TEXT after re-indenting every line in `ttl-mode'." + (with-temp-buffer + (insert text) + (ttl-mode) + (indent-region (point-min) (point-max)) + (buffer-string))) + +(defun ttl-test-face-at (text search) + "Fontify TEXT in `ttl-mode' and return the face where SEARCH begins." + (with-temp-buffer + (insert text) + (ttl-mode) + (font-lock-ensure) + (goto-char (point-min)) + (search-forward search) + (get-text-property (match-beginning 0) 'face))) + +(describe "ttl-mode indentation is idempotent" + (dolist (case `((:desc "prefixes and a simple statement" + :text ,(ttl-lines + "@prefix ex: ." + "@prefix sh: ." + "" + "ex:s a ex:Thing ;" + " ex:p ex:o ;" + " ex:q 1 .")) + (:desc "blank nodes written inline with the predicate" + :text ,(ttl-lines + ":Shape a sh:NodeShape ;" + " sh:property [ sh:datatype xsd:string ;" + " sh:maxCount 1 ;" + " sh:path rdfs:label ]," + " [ sh:datatype xsd:double ;" + " sh:path :rate ] ;" + " sh:targetClass :X .")) + (:desc "a blank node opened at end of line" + :text ,(ttl-lines + "ex:s ex:p [" + " ex:a 1 ;" + " ex:b 2 ] .")) + (:desc "a TriG named graph" + :text ,(ttl-lines + "ex:g {" + " ex:s ex:p ex:o ;" + " ex:q ex:r ." + "}")) + (:desc "a comment line between statements" + :text ,(ttl-lines + "ex:s ex:p ex:o ;" + " # a comment" + " ex:q 1 .")) + (:desc "a hash inside a resource is not a comment" + :text ,(ttl-lines + "ex:s ex:p ;" + " ex:q 1 .")) + (:desc "a closing bracket on its own line aligns under its opener" + :text ,(ttl-lines + "ex:s ex:p [" + " ex:a 1 ;" + " ex:b 2" + "] .")) + (:desc "several brackets opened on one line count as one step" + :text ,(ttl-lines + "ex:s ex:p [ ex:q [ ex:a 1 ;" + " ex:b 2 ] ] .")))) + (let ((desc (plist-get case :desc)) + (text (plist-get case :text))) + (it desc + (expect (ttl-test-reindent text) :to-equal text))))) + +(describe "ttl-mode indentation converges" + (it "restores nesting a flattened blank-node list lost" + (expect + (ttl-test-reindent + (ttl-lines + ":Shape a sh:NodeShape ;" + " sh:property [ sh:datatype xsd:string ;" + " sh:maxCount 1 ;" + " sh:path rdfs:label ]," + " [ sh:datatype xsd:double ;" + " sh:path :rate ] ;" + " sh:targetClass :X .")) + :to-equal + (ttl-lines + ":Shape a sh:NodeShape ;" + " sh:property [ sh:datatype xsd:string ;" + " sh:maxCount 1 ;" + " sh:path rdfs:label ]," + " [ sh:datatype xsd:double ;" + " sh:path :rate ] ;" + " sh:targetClass :X ."))) + + (it "moves a mis-indented first line to column 0" + (expect (ttl-test-reindent " ex:s ex:p ex:o .\n") + :to-equal "ex:s ex:p ex:o .\n")) + + (it "snaps a mis-indented @prefix to column 0" + (expect (ttl-test-reindent " @prefix ex: .\n") + :to-equal "@prefix ex: .\n"))) + +(describe "ttl-mode comment syntax" + (it "treats a hash after whitespace as a comment" + (with-temp-buffer + (insert "ex:s ex:p ex:o . # a comment\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "a comment") + (expect (ttl-in-comment) :to-be-truthy))) + + (it "leaves a hash inside a resource alone" + (with-temp-buffer + (insert "ex:s ex:p .\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "#frag") + (expect (ttl-in-comment) :to-be nil))) + + (it "treats a hash at the very start of the buffer as a comment" + (with-temp-buffer + (insert "# leading comment\nex:s ex:p ex:o .\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "leading") + (expect (ttl-in-comment) :to-be-truthy)))) + +(describe "ttl-in-blank-node" + (it "is truthy inside square brackets" + (with-temp-buffer + (insert "ex:s ex:p [ ex:a 1 ] .\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "ex:a") + (expect (ttl-in-blank-node) :to-be-truthy))) + + (it "is nil in an ordinary statement" + (with-temp-buffer + (insert "ex:s ex:p ex:o .\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "ex:o") + (expect (ttl-in-blank-node) :to-be nil)))) + +(describe "ttl-in-string" + (it "treats single-quoted text as a string" + (with-temp-buffer + (insert "ex:s ex:p 'hello world' .\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "hello") + (expect (ttl-in-string) :to-be-truthy))) + + (it "is nil outside strings" + (with-temp-buffer + (insert "ex:s ex:p ex:o .\n") + (ttl-mode) + (goto-char (point-min)) + (search-forward "ex:o") + (expect (ttl-in-string) :to-be nil)))) + +(describe "ttl-electric-dot" + (it "refuses to insert a period inside a blank node" + (with-temp-buffer + (insert "ex:s ex:p [ ex:a 1 ") + (ttl-mode) + (goto-char (point-max)) + (let ((before (buffer-string))) + (ttl-electric-dot) + (expect (buffer-string) :to-equal before)))) + + ;; jeeger/ttl-mode issue #3: a dot belongs in a string even in a blank node. + (it "allows a period inside a string within a blank node" + (with-temp-buffer + (insert "ex:s ex:p [ ex:a 'text") + (ttl-mode) + (goto-char (point-max)) + (let ((before (buffer-string))) + (ttl-electric-dot) + (expect (buffer-string) :to-equal (concat before ".")))))) + +(describe "ttl-mode highlighting" + ;; jeeger/ttl-mode issue #5: a dash is a valid prefix character. + (it "highlights a prefix containing a dash" + (expect (ttl-test-face-at "my-prefix:thing a ex:Y ." "my-prefix") + :to-be 'font-lock-type-face)) + + ;; jeeger/ttl-mode issue #4: quoted literals are strings. + (it "highlights a single-quoted literal as a string" + (expect (ttl-test-face-at "ex:s ex:p 'a literal' ." "literal") + :to-be 'font-lock-string-face))) + +;;; ttl-mode-test.el ends here diff --git a/ttl-mode.el b/ttl-mode.el index 984ff5b..d08a4da 100644 --- a/ttl-mode.el +++ b/ttl-mode.el @@ -1,4 +1,12 @@ -;;; ttl-mode.el --- mode for Turtle (and Notation 3) +;;; ttl-mode.el --- Major mode for Turtle (and Notation 3) -*- lexical-binding: t; -*- + +;; Author: Hugo Haas, Norman Gray, Daniel Gerber, Peter Vasil +;; Maintainer: Jan Seeger +;; URL: https://github.com/jeeger/ttl-mode +;; Version: 0.2.0 +;; Package-Requires: ((emacs "26.1")) +;; Keywords: languages, data + ;; ttl-mode.el is released under the terms of the two-clause BSD licence: ;; ;; Copyright 2003-2007, Hugo Haas @@ -58,27 +66,17 @@ (require 'cl-lib) -(defgroup ttl nil "Customization for ttl-mode" :group 'text) +(defgroup ttl nil "Customization for ttl-mode." :group 'text) (defcustom ttl-indent-level 4 "Number of spaces for each indentation step in `ttl-mode'." :type 'integer) (defcustom ttl-electric-punctuation t - "If non-nil, `\;' or `\.' will self insert, reindent the line, and do a newline. (To insert while t, do: \\[quoted-insert] \;)." - :type 'boolean) - -(defcustom ttl-indent-on-idle-timer t - "If non-nil, will automatically indent a line after `ttl-idle-timer-timeout'." + "If non-nil, `\;' or `\.' self-insert, reindent, and open a new line. +To insert one literally while enabled, use \\[quoted-insert]." :type 'boolean) -(defcustom ttl-indent-idle-timer-period 2 - "If `ttl-indent-on-idle-timer' is non-nil, indent after EMACS has been idle for this many seconds." - :type 'integer) - - -(defvar ttl-indent-idle-timer nil "TTL-mode autoindent idle timer if idle auto indentation is used (`ttl-indent-on-idle-timer' is non-nil).") - ;;;###autoload (define-derived-mode ttl-mode prog-mode "N3/Turtle mode" "Major mode for Turtle RDF documents." @@ -91,12 +89,12 @@ (setq font-lock-defaults `((,(regexp-opt '("@prefix" "@base" "@keywords" "PREFIX" "BASE" "@forAll" "@forSome" "true" "false" "a") 'symbols) ;keywords ("\\^\\^[^,;.]+" 0 font-lock-preprocessor-face t) ;literal types - ("\\?[[:word:]_]+" 0 font-lock-variable-name-face) ;Existentially quantified variables + ("\\?[[:word:]_]+" 0 font-lock-variable-name-face) ;Existentially quantified variables ("@[[:word:]_]+" . font-lock-preprocessor-face) ;languages - ; Does not work with iris containing multiple colons (one:two:three is apparently allowed.) + ;; Does not work with iris containing multiple colons (one:two:three is apparently allowed.) ("\\(:?[-[:alnum:]]+\\|_\\)?:" . font-lock-type-face) ;prefix (":\\([[:word:]_-]+\\)\\>" 1 font-lock-constant-face nil) ;suffix - ;; TODO: This incorrectly highlights resources in strings. + ;; TODO: This incorrectly highlights resources in strings. ("<.*?>" 0 font-lock-function-name-face t) ;resources ("[,;.]" 0 font-lock-keyword-face)))) @@ -104,16 +102,11 @@ (set (make-local-variable 'indent-line-function) 'ttl-indent-line) (set (make-local-variable 'indent-tabs-mode) nil) (set (make-local-variable 'syntax-propertize-function) 'ttl-propertize-comments) - (setq show-trailing-whitespace t) - (if (and ttl-indent-on-idle-timer (not ttl-indent-idle-timer)) - (setq ttl-indent-idle-timer (run-with-idle-timer ttl-indent-idle-timer-period t 'ttl-idle-indent)) - (when ttl-indent-idle-timer - (setq ttl-indent-idle-timer (cancel-timer ttl-indent-idle-timer))))) + (setq show-trailing-whitespace t)) ;; electric punctuation (define-key ttl-mode-map (kbd "\,") 'ttl-electric-comma) (define-key ttl-mode-map (kbd "\;") 'ttl-electric-semicolon) -(define-key ttl-mode-map (kbd "\,") 'ttl-electric-comma) (define-key ttl-mode-map (kbd "\.") 'ttl-electric-dot) (define-key ttl-mode-map [backspace] 'ttl-hungry-delete-backwards) @@ -121,17 +114,16 @@ ;; Could be replaced with a call to syntax-propertize-rules. See ;; https://emacs.stackexchange.com/questions/36909/how-can-i-make-syntax-propertize-skip-part-of-the-buffer (defun ttl-propertize-comments (start end) - "Set the syntax class to `comment-start` for all hashes that are prepended by a space between START and END." + "Give comment syntax to each `#' that begins a comment. +A `#' begins a comment when preceded by a space, a newline, or the +buffer start. Operates on the region between START and END." (save-excursion (goto-char start) (save-match-data (while (search-forward "#" end t) - (let ((char-before (buffer-substring-no-properties - (max (point-min) (- (point) 2)) - (min (point-max) (- (point) 1))))) - (when (or (equal char-before " ") - (equal char-before "\n")) - (put-text-property (match-beginning 0) (match-end 0) 'syntax-table '(11)))))))) + (when (memq (char-before (match-beginning 0)) '(?\s ?\n nil)) + (put-text-property (match-beginning 0) (match-end 0) + 'syntax-table '(11))))))) (defun ttl-indent-line () "Indent current line." @@ -141,48 +133,45 @@ (or (ttl-calculate-indentation) 0))) (move-to-column (max (current-indentation) (current-column)))) -(defun ttl-idle-indent () - "Indent the current line, and check you're in an ttl-mode buffer." - (when (eq major-mode 'ttl-mode) - (ttl-indent-line))) - (defun ttl-calculate-indentation () "Calculate the indentation for the current line." (save-excursion (backward-to-indentation 0) (cl-destructuring-bind - (last-indent last-character after-prefix) - (save-excursion (ttl-skip-uninteresting-lines) (list (current-indentation) (char-before) (ttl-in-prefix-line))) + (last-indent last-character after-prefix) + (save-excursion (ttl-skip-uninteresting-lines) (list (current-indentation) (char-before) (ttl-in-prefix-line))) (let* ((syntax-info (syntax-ppss)) - (base-indent (* ttl-indent-level (ttl-adjusted-paren-depth (nth 9 syntax-info))))) - (cond - ;; in multiline string - ((nth 3 syntax-info) (current-indentation)) - ;; First line in buffer. - ((= (line-number-at-pos (point) t) 1) 0) - ;; beginning of stanza - ((and (or (looking-at "@") ; @prefix, @base, @keywords. - (looking-at "PREFIX") - (looking-at "BASE")) - (not (looking-at "\\(@forSome\\)\\|\\(@forAll\\)"))) ; @forAll and @forSome should be indented normally. - 0) - ;; ((looking-at "#") base-indent) - ((looking-at "[])}]") ; Indent to level of matching parenthesis. - (save-excursion - (goto-char (nth 1 syntax-info)) - (current-indentation))) - ((and (not (bobp)) - (or (ttl-first-line-of ?\[ last-character) - (ttl-first-line-of ?\( last-character) + (base-indent (* ttl-indent-level (ttl-adjusted-paren-depth (nth 9 syntax-info))))) + (cond + ;; in multiline string + ((nth 3 syntax-info) (current-indentation)) + ;; First line in buffer. + ((= (line-number-at-pos (point) t) 1) 0) + ;; beginning of stanza + ((and (or (looking-at "@") ; @prefix, @base, @keywords. + (looking-at "PREFIX") + (looking-at "BASE")) + (not (looking-at "\\(@forSome\\)\\|\\(@forAll\\)"))) ; @forAll and @forSome should be indented normally. + 0) + ;; ((looking-at "#") base-indent) + ((looking-at "[])}]") ; Indent to level of matching parenthesis. + (save-excursion + (goto-char (nth 1 syntax-info)) + (current-indentation))) + ((and (not (bobp)) + (or (ttl-first-line-of ?\( last-character) (ttl-first-line-of ?\{ last-character))) - (+ last-indent ttl-indent-level)) - ((eq ?. last-character) base-indent) + (+ last-indent ttl-indent-level)) + ((eq ?. last-character) base-indent) (after-prefix 0) - ((ttl-in-blank-node) base-indent) - (last-character (+ base-indent ttl-indent-level))))))) + ;; A blank node's predicates and every object after the first in a + ;; comma list sit two levels in, so they clear the predicate column. + ((or (eq ?\, last-character) (ttl-in-blank-node)) + (+ base-indent (* 2 ttl-indent-level))) + (last-character (+ base-indent ttl-indent-level))))))) (defun ttl-adjusted-paren-depth (parenpos) - "Calculate parenthesis depth from PARENPOS, ignoring parentheses on the same line." + "Count parenthesis depth from PARENPOS, ignoring parens on the same line." ;; Just enough common lisp to be dangerous. (length (delete-dups (cl-loop for pos in parenpos collect (line-number-at-pos pos))))) @@ -204,15 +193,14 @@ (string-match (rx (and string-start (* blank) line-end)) (thing-at-point 'line)))) (forward-line -1)) ;; Then, go to last non-comment-character - (if (search-forward " #" (point-at-eol) t) + (if (search-forward " #" (line-end-position) t) (backward-char 2) (end-of-line))) - (defun ttl-insulate () "Return non-nil if this location should not be electrified." (or (not ttl-electric-punctuation) - (let '(s (syntax-ppss)) + (let ((s (syntax-ppss))) (or (nth 3 s) (nth 4 s) (ttl-in-resource))))) @@ -220,8 +208,8 @@ (defun ttl-last-bracket-is (brack) "Is the last bracket equal to BRACK?" (let ((list-start (nth 1 (syntax-ppss)))) - (and list-start - (equal (char-after list-start) brack)))) + (and list-start + (equal (char-after list-start) brack)))) (defun ttl-first-line-of (brack lastchar) "Whether we are in the first line of a node introduced by BRACK. @@ -233,7 +221,7 @@ LASTCHAR is the last character of the preceding line." (defun ttl-in-blank-node () "Is point within a blank node, marked by [...]?" (ttl-last-bracket-is ?\[)) - + (defun ttl-in-graph () "Is point within a graph?" (ttl-last-bracket-is ?\{)) @@ -277,8 +265,8 @@ LASTCHAR is the last character of the preceding line." "Insert spaced dot, insert newline, indent." (interactive) (if (and (ttl-in-blank-node) - (not (ttl-in-comment)) - (not (ttl-in-string))) + (not (ttl-in-comment)) + (not (ttl-in-string))) (message "No period in blank nodes.") (if (ttl-insulate) (insert ".") (if (not (looking-back " " 1)) (insert " "))