monotone

monotone Mtn Source Tree

Root/tester.cc

1#include "base.hh"
2#include "lua.h"
3#include "lualib.h"
4#include "lauxlib.h"
5
6#include "lua.hh"
7#include "platform.hh"
8#include "sanity.hh"
9
10#include <cstdlib>
11#include <ctime>
12#include <cerrno>
13#include <map>
14#include <utility>
15#include <vector>
16
17/* for mkdir() */
18#include <sys/stat.h>
19#include <sys/types.h>
20
21#ifdef WIN32
22/* For _mktemp() */
23#include <io.h>
24#define mktemp(t) _mktemp(t)
25/* For _mkdir() */
26#include <direct.h>
27#define mkdir(d,m) _mkdir(d)
28#endif
29
30#ifdef WIN32
31#define WIN32_LEAN_AND_MEAN // we don't need the GUI interfaces
32#include <windows.h>
33#else
34#include <unistd.h>
35#include <fcntl.h>
36#include <errno.h>
37#endif
38
39// defined in testlib.c, generated from testlib.lua
40extern char const testlib_constant[];
41
42using std::string;
43using std::map;
44using std::memcpy;
45using std::getenv;
46using std::exit;
47using std::make_pair;
48using std::vector;
49using std::time_t;
50
51// Lua uses the c i/o functions, so we need to too.
52struct tester_sanity : public sanity
53{
54 void inform_log(std::string const &msg)
55 {fprintf(stdout, "%s", msg.c_str());}
56 void inform_message(std::string const &msg)
57 {fprintf(stdout, "%s", msg.c_str());};
58 void inform_warning(std::string const &msg)
59 {fprintf(stderr, "warning: %s", msg.c_str());};
60 void inform_error(std::string const &msg)
61 {fprintf(stderr, "error: %s", msg.c_str());};
62};
63tester_sanity real_sanity;
64sanity & global_sanity = real_sanity;
65
66
67void make_accessible(string const &name)
68{
69#ifdef WIN32
70
71 DWORD attrs = GetFileAttributes(name.c_str());
72 E(attrs != INVALID_FILE_ATTRIBUTES,
73 F("GetFileAttributes(%s) failed: %s") % name % os_strerror(GetLastError()));
74
75 E(SetFileAttributes(name.c_str(), attrs & ~FILE_ATTRIBUTE_READONLY),
76 F("SetFileAttributes(%s) failed: %s") % name % os_strerror(GetLastError()));
77
78#else
79
80 struct stat st;
81 if (stat(name.c_str(), &st) != 0)
82 {
83 const int err = errno;
84 E(false, F("stat(%s) failed: %s") % name % os_strerror(err));
85 }
86
87 mode_t new_mode = st.st_mode;
88 if (S_ISDIR(st.st_mode))
89 new_mode |= S_IEXEC;
90 new_mode |= S_IREAD | S_IWRITE;
91
92 if (chmod(name.c_str(), new_mode) != 0)
93 {
94 const int err = errno;
95 E(false, F("chmod(%s) failed: %s") % name % os_strerror(err));
96
97 }
98
99#endif
100}
101
102time_t get_last_write_time(string const & name)
103{
104#ifdef WIN32
105
106 HANDLE h = CreateFile(name.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL,
107 OPEN_EXISTING, 0, NULL);
108 E(h != INVALID_HANDLE_VALUE,
109 F("CreateFile(%s) failed: %s") % name % os_strerror(GetLastError()));
110
111 FILETIME ft;
112 E(GetFileTime(h, NULL, NULL, &ft),
113 F("GetFileTime(%s) failed: %s") % name % os_strerror(GetLastError()));
114
115 CloseHandle(h);
116
117 // A FILETIME is a 64-bit quantity (represented as a pair of DWORDs)
118 // representing the number of 100-nanosecond intervals elapsed since
119 // 12:00 AM, January 1, 1601 UTC. A time_t is the same as it is for
120 // Unix: seconds since 12:00 AM, January 1, 1970 UTC. The offset is
121 // taken verbatim from MSDN.
122 LONGLONG ft64 = ((LONGLONG)ft.dwHighDateTime) << 32 + ft.dwLowDateTime;
123 return (time_t)((ft64/10000000) - 11644473600LL);
124
125#else
126
127 struct stat st;
128 if (stat(name.c_str(), &st) != 0)
129 {
130 const int err = errno;
131 E(false, F("stat(%s) failed: %s") % name % os_strerror(err));
132 }
133
134 return st.st_mtime;
135
136#endif
137}
138
139void do_copy_file(string const & from, string const & to)
140{
141#ifdef WIN32
142 // For once something is easier with Windows.
143 E(CopyFile(from.c_str(), to.c_str(), true),
144 F("copy %s to %s: %s") % from % to % os_strerror(GetLastError()));
145
146#else
147 char buf[32768];
148 int ifd, ofd;
149 ifd = open(from.c_str(), O_RDONLY);
150 const int err = errno;
151 E(ifd >= 0, F("open %s: %s") % from % os_strerror(err));
152 struct stat st;
153 st.st_mode = 0666; // sane default if fstat fails
154 fstat(ifd, &st);
155 ofd = open(to.c_str(), O_WRONLY|O_CREAT|O_EXCL, st.st_mode);
156 if (ofd < 0)
157 {
158 const int err = errno;
159 close(ifd);
160 E(false, F("open %s: %s") % to % os_strerror(err));
161 }
162
163 ssize_t nread, nwrite;
164 int ndead;
165 for (;;)
166 {
167 nread = read(ifd, buf, 32768);
168 if (nread < 0)
169 goto read_error;
170 if (nread == 0)
171 break;
172
173 nwrite = 0;
174 ndead = 0;
175 do
176 {
177 ssize_t nw = write(ofd, buf + nwrite, nread - nwrite);
178 if (nw < 0)
179 goto write_error;
180 if (nw == 0)
181 ndead++;
182 if (ndead == 4)
183 goto spinning;
184 nwrite += nw;
185 }
186 while (nwrite < nread);
187 }
188 close(ifd);
189 close(ofd);
190 return;
191
192 read_error:
193 {
194 int err = errno;
195 close(ifd);
196 close(ofd);
197 E(false, F("read error copying %s to %s: %s")
198 % from % to % os_strerror(err));
199 }
200 write_error:
201 {
202 int err = errno;
203 close(ifd);
204 close(ofd);
205 E(false, F("write error copying %s to %s: %s")
206 % from % to % os_strerror(err));
207 }
208 spinning:
209 {
210 close(ifd);
211 close(ofd);
212 E(false, F("abandoning copy of %s to %s after four zero-length writes")
213 % from % to);
214 }
215
216#endif
217}
218
219
220void set_env(char const * var, char const * val)
221{
222#if defined(WIN32)
223 SetEnvironmentVariable(var, val);
224#elif defined(HAVE_SETENV)
225 setenv(var, val, 1);
226#elif defined(HAVE_PUTENV)
227 // note: this leaks memory, but the tester is short lived so it probably
228 // doesn't matter much.
229 string * tempstr = new string(var);
230 tempstr->append("=");
231 tempstr->append(val);
232 putenv(const_cast<char *>(tempstr->c_str()));
233#else
234#error set_env needs to be ported to this platform
235#endif
236}
237
238void unset_env(char const * var)
239{
240#if defined(WIN32)
241 SetEnvironmentVariable(var, 0);
242#elif defined(HAVE_UNSETENV)
243 unsetenv(var);
244#else
245#error unset_env needs to be ported to this platform
246#endif
247}
248
249string basename(string const & s)
250{
251 string::size_type sep = s.rfind('/');
252 if (sep == string::npos)
253 return s; // force use of short circuit
254 if (sep == s.size())
255 return "";
256 return s.substr(sep + 1);
257}
258
259string dirname(string const & s)
260{
261 string::size_type sep = s.rfind('/');
262 if (sep == string::npos)
263 return ".";
264 if (sep == s.size() - 1) // dirname() of the root directory is itself
265 return s;
266
267 return s.substr(0, sep);
268}
269
270#if !defined(HAVE_MKDTEMP)
271static char * _impl_mkdtemp(char * templ)
272{
273 char * tmpdir = new char[strlen(templ) + 1];
274 char * result = 0;
275
276 /* There's a possibility that the name returned by mktemp() will already
277 be created by someone else, a typical race condition. However, since
278 mkdir() will not clobber an already existing file or directory, we
279 can simply loop until we find a suitable name. There IS a very small
280 risk that we loop endlessly, but that's under extreme conditions, and
281 the problem is likely to really be elsewhere... */
282 do
283 {
284 strcpy(tmpdir, templ);
285 result = mktemp(tmpdir);
286 if (result && mkdir(tmpdir, 0700) != 0)
287 {
288 result = 0;
289 }
290 }
291 while(!result && errno == EEXIST);
292
293 if (result)
294 {
295 strcpy(templ, result);
296 result = templ;
297 }
298
299 delete [] tmpdir;
300 return result;
301}
302
303#define mkdtemp _impl_mkdtemp
304#endif
305
306char * do_mkdtemp(char const * parent)
307{
308 char * tmpdir = new char[strlen(parent) + sizeof "/mtXXXXXX"];
309
310 strcpy(tmpdir, parent);
311 strcat(tmpdir, "/mtXXXXXX");
312
313 char * result = mkdtemp(tmpdir);
314 const int err = errno;
315
316 E(result != 0,
317 F("mkdtemp(%s) failed: %s") % tmpdir % os_strerror(err));
318 I(result == tmpdir);
319 return tmpdir;
320}
321
322#if !defined(HAVE_MKDTEMP)
323#undef mkdtemp
324#endif
325
326map<string, string> orig_env_vars;
327
328string source_dir;
329string run_dir;
330
331static int panic_thrower(lua_State * st)
332{
333 throw oops("lua error");
334}
335
336// N.B. some of this code is copied from file_io.cc
337
338namespace
339{
340 struct fill_vec : public dirent_consumer
341 {
342 fill_vec(vector<string> & v) : v(v) { v.clear(); }
343 virtual void consume(char const * s)
344 { v.push_back(s); }
345
346 private:
347 vector<string> & v;
348 };
349
350 struct file_deleter : public dirent_consumer
351 {
352 file_deleter(string const & p) : parent(p) {}
353 virtual void consume(char const * f)
354 {
355 string e(parent + "/" + f);
356 make_accessible(e);
357 do_remove(e);
358 }
359
360 private:
361 string const & parent;
362 };
363
364 struct file_accessible_maker : public dirent_consumer
365 {
366 file_accessible_maker(string const & p) : parent(p) {}
367 virtual void consume(char const * f)
368 { make_accessible(parent + "/" + f); }
369
370 private:
371 string const & parent;
372 };
373
374 struct file_copier : public dirent_consumer
375 {
376 file_copier(string const & f, string const & t) : from(f), to(t) {}
377 virtual void consume(char const * f)
378 {
379 do_copy_file(from + "/" + f, to + "/" + f);
380 }
381
382 private:
383 string const & from;
384 string const & to;
385 };
386}
387
388void do_remove_recursive(string const & p)
389{
390 switch (get_path_status(p))
391 {
392 case path::directory:
393 {
394 make_accessible(p);
395 vector<string> subdirs;
396 struct fill_vec get_subdirs(subdirs);
397 struct file_deleter del_files(p);
398
399 do_read_directory(p, del_files, get_subdirs, del_files);
400 for(vector<string>::const_iterator i = subdirs.begin();
401 i != subdirs.end(); i++)
402 do_remove_recursive(p + "/" + *i);
403 do_remove(p);
404 }
405 return;
406
407 case path::file:
408 make_accessible(p);
409 do_remove(p);
410 return;
411
412 case path::nonexistent:
413 return;
414 }
415}
416
417void do_make_tree_accessible(string const & p)
418{
419 switch (get_path_status(p))
420 {
421 case path::directory:
422 {
423 make_accessible(p);
424 vector<string> subdirs;
425 struct fill_vec get_subdirs(subdirs);
426 struct file_accessible_maker access_files(p);
427
428 do_read_directory(p, access_files, get_subdirs, access_files);
429 for(vector<string>::const_iterator i = subdirs.begin();
430 i != subdirs.end(); i++)
431 do_make_tree_accessible(p + "/" + *i);
432 }
433 return;
434
435 case path::file:
436 make_accessible(p);
437 return;
438
439 case path::nonexistent:
440 return;
441 }
442}
443
444void do_copy_recursive(string const & from, string to)
445{
446 path::status fromstat = get_path_status(from);
447
448 E(fromstat != path::nonexistent,
449 F("Source '%s' for copy does not exist") % from);
450
451 switch (get_path_status(to))
452 {
453 case path::nonexistent:
454 if (fromstat == path::directory)
455 do_mkdir(to);
456 break;
457
458 case path::file:
459 do_remove(to);
460 if (fromstat == path::directory)
461 do_mkdir(to);
462 break;
463
464 case path::directory:
465 to = to + "/" + basename(from);
466 break;
467 }
468
469 if (fromstat == path::directory)
470 {
471 vector<string> subdirs, specials;
472 struct fill_vec get_subdirs(subdirs), get_specials(specials);
473 struct file_copier copy_files(from, to);
474
475 do_read_directory(from, copy_files, get_subdirs, get_specials);
476 E(specials.empty(), F("cannot copy special files in '%s'") % from);
477 for (vector<string>::const_iterator i = subdirs.begin();
478 i != subdirs.end(); i++)
479 do_copy_recursive(from + "/" + *i, to + "/" + *i);
480 }
481 else
482 do_copy_file(from, to);
483}
484
485LUAEXT(posix_umask, )
486{
487#ifdef WIN32
488 lua_pushnil(L);
489 return 1;
490#else
491 unsigned int from = (unsigned int)luaL_checknumber(L, -1);
492 mode_t mask = 64*((from / 100) % 10) + 8*((from / 10) % 10) + (from % 10);
493 mode_t oldmask = umask(mask);
494 int res = 100*(oldmask/64) + 10*((oldmask/8) % 8) + (oldmask % 8);
495 lua_pushnumber(L, res);
496 return 1;
497#endif
498}
499
500LUAEXT(go_to_test_dir, )
501{
502 try
503 {
504 string tname = basename(luaL_checkstring(L, -1));
505 string testdir = run_dir + "/" + tname;
506 do_remove_recursive(testdir);
507 do_mkdir(testdir);
508 change_current_working_dir(testdir);
509 lua_pushstring(L, testdir.c_str());
510 lua_pushstring(L, tname.c_str());
511 return 2;
512 }
513 catch(informative_failure & e)
514 {
515 lua_pushnil(L);
516 return 1;
517 }
518}
519
520LUAEXT(chdir, )
521{
522 try
523 {
524 string from = get_current_working_dir();
525 change_current_working_dir(luaL_checkstring(L, -1));
526 lua_pushstring(L, from.c_str());
527 return 1;
528 }
529 catch(informative_failure & e)
530 {
531 lua_pushnil(L);
532 return 1;
533 }
534}
535
536LUAEXT(clean_test_dir, )
537{
538 try
539 {
540 string tname = basename(luaL_checkstring(L, -1));
541 string testdir = run_dir + "/" + tname;
542 change_current_working_dir(run_dir);
543 do_remove_recursive(testdir);
544 lua_pushboolean(L, true);
545 return 1;
546 }
547 catch(informative_failure & e)
548 {
549 lua_pushnil(L);
550 return 1;
551 }
552}
553
554LUAEXT(remove_recursive, )
555{
556 try
557 {
558 do_remove_recursive(luaL_checkstring(L, -1));
559 lua_pushboolean(L, true);
560 return 1;
561 }
562 catch(informative_failure & e)
563 {
564 lua_pushboolean(L, false);
565 lua_pushstring(L, e.what());
566 return 2;
567 }
568}
569
570LUAEXT(make_tree_accessible, )
571{
572 try
573 {
574 do_make_tree_accessible(luaL_checkstring(L, -1));
575 lua_pushboolean(L, true);
576 return 1;
577 }
578 catch(informative_failure & e)
579 {
580 lua_pushboolean(L, false);
581 lua_pushstring(L, e.what());
582 return 2;
583 }
584}
585
586LUAEXT(copy_recursive, )
587{
588 try
589 {
590 string from(luaL_checkstring(L, -2));
591 string to(luaL_checkstring(L, -1));
592 do_copy_recursive(from, to);
593 lua_pushboolean(L, true);
594 return 1;
595 }
596 catch(informative_failure & e)
597 {
598 lua_pushboolean(L, false);
599 lua_pushstring(L, e.what());
600 return 2;
601 }
602}
603
604LUAEXT(leave_test_dir, )
605{
606 try
607 {
608 change_current_working_dir(run_dir);
609 lua_pushboolean(L, true);
610 return 1;
611 }
612 catch(informative_failure & e)
613 {
614 lua_pushnil(L);
615 return 1;
616 }
617}
618
619LUAEXT(mkdir, )
620{
621 try
622 {
623 char const * dirname = luaL_checkstring(L, -1);
624 do_mkdir(dirname);
625 lua_pushboolean(L, true);
626 return 1;
627 }
628 catch(informative_failure & e)
629 {
630 lua_pushnil(L);
631 return 1;
632 }
633}
634
635LUAEXT(make_temp_dir, )
636{
637 try
638 {
639 char const * parent;
640 parent = getenv("TMPDIR");
641 if (parent == 0)
642 parent = getenv("TEMP");
643 if (parent == 0)
644 parent = getenv("TMP");
645 if (parent == 0)
646 parent = "/tmp";
647
648 char * tmpdir = do_mkdtemp(parent);
649 lua_pushstring(L, tmpdir);
650 delete [] tmpdir;
651 return 1;
652 }
653 catch(informative_failure & e)
654 {
655 lua_pushnil(L);
656 return 1;
657 }
658}
659
660
661LUAEXT(mtime, )
662{
663 try
664 {
665 char const * file = luaL_checkstring(L, -1);
666
667 time_t t = get_last_write_time(file);
668 if (t == time_t(-1))
669 lua_pushnil(L);
670 else
671 lua_pushnumber(L, t);
672 return 1;
673 }
674 catch(informative_failure & e)
675 {
676 lua_pushnil(L);
677 return 1;
678 }
679}
680
681LUAEXT(exists, )
682{
683 try
684 {
685 char const * name = luaL_checkstring(L, -1);
686 switch (get_path_status(name))
687 {
688 case path::nonexistent: lua_pushboolean(L, false); break;
689 case path::file:
690 case path::directory: lua_pushboolean(L, true); break;
691 }
692 }
693 catch(informative_failure & e)
694 {
695 lua_pushnil(L);
696 }
697 return 1;
698}
699
700LUAEXT(isdir, )
701{
702 try
703 {
704 char const * name = luaL_checkstring(L, -1);
705 switch (get_path_status(name))
706 {
707 case path::nonexistent:
708 case path::file: lua_pushboolean(L, false); break;
709 case path::directory: lua_pushboolean(L, true); break;
710 }
711 }
712 catch(informative_failure & e)
713 {
714 lua_pushnil(L);
715 }
716 return 1;
717}
718
719namespace
720{
721 struct build_table : public dirent_consumer
722 {
723 build_table(lua_State * st) : st(st), n(1)
724 {
725 lua_newtable(st);
726 }
727 virtual void consume(const char *s)
728 {
729 lua_pushstring(st, s);
730 lua_rawseti(st, -2, n);
731 n++;
732 }
733 private:
734 lua_State * st;
735 unsigned int n;
736 };
737}
738
739LUAEXT(read_directory, )
740{
741 int top = lua_gettop(L);
742 try
743 {
744 string path(luaL_checkstring(L, -1));
745 build_table tbl(L);
746
747 do_read_directory(path, tbl, tbl, tbl);
748 }
749 catch(informative_failure &)
750 {
751 // discard the table and any pending path element
752 lua_settop(L, top);
753 lua_pushnil(L);
754 }
755 catch (...)
756 {
757 lua_settop(L, top);
758 throw;
759 }
760 return 1;
761}
762
763LUAEXT(get_source_dir, )
764{
765 lua_pushstring(L, source_dir.c_str());
766 return 1;
767}
768
769LUAEXT(save_env, )
770{
771 orig_env_vars.clear();
772 return 0;
773}
774
775LUAEXT(restore_env, )
776{
777 for (map<string,string>::const_iterator i = orig_env_vars.begin();
778 i != orig_env_vars.end(); ++i)
779 set_env(i->first.c_str(), i->second.c_str());
780 orig_env_vars.clear();
781 return 0;
782}
783
784LUAEXT(set_env, )
785{
786 char const * var = luaL_checkstring(L, -2);
787 char const * val = luaL_checkstring(L, -1);
788 if (orig_env_vars.find(string(var)) == orig_env_vars.end()) {
789 char const * old = getenv(var);
790 if (old)
791 orig_env_vars.insert(make_pair(string(var), string(old)));
792 else
793 orig_env_vars.insert(make_pair(string(var), ""));
794 }
795 set_env(var, val);
796 return 0;
797}
798
799LUAEXT(unset_env, )
800{
801 char const * var = luaL_checkstring(L, -1);
802 if (orig_env_vars.find(string(var)) == orig_env_vars.end()) {
803 char const * old = getenv(var);
804 if (old)
805 orig_env_vars.insert(make_pair(string(var), string(old)));
806 else
807 orig_env_vars.insert(make_pair(string(var), ""));
808 }
809 unset_env(var);
810 return 0;
811}
812
813LUAEXT(timed_wait, )
814{
815 pid_t pid = static_cast<pid_t>(luaL_checknumber(L, -2));
816 int time = static_cast<int>(luaL_checknumber(L, -1));
817 int res;
818 int ret;
819 ret = process_wait(pid, &res, time);
820 lua_pushnumber(L, res);
821 lua_pushnumber(L, ret);
822 return 2;
823}
824
825LUAEXT(require_not_root, )
826{
827#ifdef WIN32
828 bool running_as_root = false;
829#else
830 bool running_as_root = !geteuid();
831#endif
832 // E() doesn't work here, I just get "warning: " in the
833 // output. Why?
834 if (running_as_root)
835 {
836 P(F("This test suite cannot be run as the root user.\n"
837 "Please try again with a normal user account.\n"));
838 exit(1);
839 }
840 return 0;
841}
842
843int main(int argc, char **argv)
844{
845 int retcode = 2;
846 lua_State *st = 0;
847 try{
848// global_sanity.set_debug();
849 string testfile;
850 string firstdir;
851 bool needhelp = false;
852 for (int i = 1; i < argc; ++i)
853 if (string(argv[i]) == "--help" || string(argv[i]) == "-h")
854 needhelp = true;
855 if (argc > 1 && !needhelp)
856 {
857 firstdir = get_current_working_dir();
858 run_dir = firstdir + "/tester_dir";
859 do_remove_recursive(run_dir);
860 do_mkdir(run_dir);
861
862 testfile = argv[1];
863 change_current_working_dir(dirname(testfile));
864 source_dir = get_current_working_dir();
865 testfile = source_dir + "/" + basename(testfile);
866
867 change_current_working_dir(run_dir);
868 }
869 else
870 {
871 P(F("Usage: %s test-file [arguments]\n") % argv[0]);
872 P(F("\t-h print this message\n"));
873 P(F("\t-l print test names only; don't run them\n"));
874 P(F("\t-d don't clean the scratch directories\n"));
875 P(F("\tnum run a specific test\n"));
876 P(F("\tnum..num run tests in a range\n"));
877 P(F("\t if num is negative, count back from the end\n"));
878 P(F("\tregex run tests with matching names\n"));
879 return 1;
880 }
881 st = luaL_newstate();
882 lua_atpanic (st, &panic_thrower);
883 luaL_openlibs(st);
884 add_functions(st);
885
886 lua_pushstring(st, "initial_dir");
887 lua_pushstring(st, firstdir.c_str());
888 lua_settable(st, LUA_GLOBALSINDEX);
889
890 try
891 {
892 run_string(st, testlib_constant, "tester builtin functions");
893 run_file(st, testfile.c_str());
894 Lua ll(st);
895 ll.func("run_tests");
896 ll.push_table();
897 for (int i = 2; i < argc; ++i)
898 {
899 ll.push_int(i-1);
900 ll.push_str(argv[i]);
901 ll.set_table();
902 }
903 ll.call(1,1)
904 .extract_int(retcode);
905 }
906 catch (std::exception &e)
907 {
908 P(F("Error: %s") % e.what());
909 }
910 } catch (informative_failure & e) {
911 P(F("Error: %s\n") % e.what());
912 retcode = 1;
913 } catch (std::logic_error & e) {
914 P(F("Invariant failure: %s\n") % e.what());
915 retcode = 3;
916 }
917 if (st)
918 lua_close(st);
919 return retcode;
920}
921
922// Local Variables:
923// mode: C++
924// fill-column: 76
925// c-file-style: "gnu"
926// indent-tabs-mode: nil
927// End:
928// vim: et:sw=2:sts=2:ts=2:cino=>2s,{s,\:s,+s,t0,g0,^-2,e-2,n-2,p2s,(0,=s:

Archive Download this file

Branches

Tags

Quick Links:     www.monotone.ca    -     Downloads    -     Documentation    -     Wiki    -     Code Forge    -     Build Status