1# vim:fileencoding=utf-8:noet
2from __future__ import (unicode_literals, division, absolute_import, print_function)
3
4import os
5import logging
6
7from collections import defaultdict
8from itertools import chain
9from functools import partial
10
11from powerline import generate_config_finder, get_config_paths, load_config
12from powerline.segments.vim import vim_modes
13from powerline.lib.dict import mergedicts_copy
14from powerline.lib.config import ConfigLoader
15from powerline.lib.unicode import unicode
16from powerline.lib.path import join
17from powerline.lint.markedjson import load
18from powerline.lint.markedjson.error import echoerr, EchoErr, MarkedError
19from powerline.lint.checks import (check_matcher_func, check_ext, check_config, check_top_theme,
20                                   check_color, check_translated_group_name, check_group,
21                                   check_segment_module, check_exinclude_function, type_keys,
22                                   check_segment_function, check_args, get_one_segment_function,
23                                   check_highlight_groups, check_highlight_group, check_full_segment_data,
24                                   get_all_possible_functions, check_segment_data_key, register_common_name,
25                                   highlight_group_spec, check_log_file_level, check_logging_handler)
26from powerline.lint.spec import Spec
27from powerline.lint.context import Context
28
29
30def open_file(path):
31	return open(path, 'rb')
32
33
34def generate_json_config_loader(lhadproblem):
35	def load_json_config(config_file_path, load=load, open_file=open_file):
36		with open_file(config_file_path) as config_file_fp:
37			r, hadproblem = load(config_file_fp)
38			if hadproblem:
39				lhadproblem[0] = True
40			return r
41	return load_json_config
42
43
44function_name_re = '^(\w+\.)*[a-zA-Z_]\w*$'
45
46
47divider_spec = Spec().printable().len(
48	'le', 3, (lambda value: 'Divider {0!r} is too large!'.format(value))).copy
49ext_theme_spec = Spec().type(unicode).func(lambda *args: check_config('themes', *args)).copy
50top_theme_spec = Spec().type(unicode).func(check_top_theme).copy
51ext_spec = Spec(
52	colorscheme=Spec().type(unicode).func(
53		(lambda *args: check_config('colorschemes', *args))
54	),
55	theme=ext_theme_spec(),
56	top_theme=top_theme_spec().optional(),
57).copy
58gen_components_spec = (lambda *components: Spec().list(Spec().type(unicode).oneof(set(components))))
59log_level_spec = Spec().re('^[A-Z]+$').func(
60	(lambda value, *args: (True, True, not hasattr(logging, value))),
61	(lambda value: 'unknown debugging level {0}'.format(value))
62).copy
63log_format_spec = Spec().type(unicode).copy
64main_spec = (Spec(
65	common=Spec(
66		default_top_theme=top_theme_spec().optional(),
67		term_truecolor=Spec().type(bool).optional(),
68		term_escape_style=Spec().type(unicode).oneof(set(('auto', 'xterm', 'fbterm'))).optional(),
69		# Python is capable of loading from zip archives. Thus checking path
70		# only for existence of the path, not for it being a directory
71		paths=Spec().list(
72			(lambda value, *args: (True, True, not os.path.exists(os.path.expanduser(value.value)))),
73			(lambda value: 'path does not exist: {0}'.format(value))
74		).optional(),
75		log_file=Spec().either(
76			Spec().type(unicode).func(
77				(
78					lambda value, *args: (
79						True,
80						True,
81						not os.path.isdir(os.path.dirname(os.path.expanduser(value)))
82					)
83				),
84				(lambda value: 'directory does not exist: {0}'.format(os.path.dirname(value)))
85			),
86			Spec().list(Spec().either(
87				Spec().type(unicode, type(None)),
88				Spec().tuple(
89					Spec().re(function_name_re).func(check_logging_handler),
90					Spec().tuple(
91						Spec().type(list).optional(),
92						Spec().type(dict).optional(),
93					),
94					log_level_spec().func(check_log_file_level).optional(),
95					log_format_spec().optional(),
96				),
97			))
98		).optional(),
99		log_level=log_level_spec().optional(),
100		log_format=log_format_spec().optional(),
101		interval=Spec().either(Spec().cmp('gt', 0.0), Spec().type(type(None))).optional(),
102		reload_config=Spec().type(bool).optional(),
103		watcher=Spec().type(unicode).oneof(set(('auto', 'inotify', 'stat'))).optional(),
104	).context_message('Error while loading common configuration (key {key})'),
105	ext=Spec(
106		vim=ext_spec().update(
107			components=gen_components_spec('statusline', 'tabline').optional(),
108			local_themes=Spec(
109				__tabline__=ext_theme_spec(),
110			).unknown_spec(
111				Spec().re(function_name_re).func(partial(check_matcher_func, 'vim')),
112				ext_theme_spec()
113			),
114		).optional(),
115		ipython=ext_spec().update(
116			local_themes=Spec(
117				in2=ext_theme_spec(),
118				out=ext_theme_spec(),
119				rewrite=ext_theme_spec(),
120			),
121		).optional(),
122		shell=ext_spec().update(
123			components=gen_components_spec('tmux', 'prompt').optional(),
124			local_themes=Spec(
125				continuation=ext_theme_spec(),
126				select=ext_theme_spec(),
127			),
128		).optional(),
129		wm=ext_spec().update(
130			local_themes=Spec().unknown_spec(
131				Spec().re('^[0-9A-Za-z-]+$'),
132				ext_theme_spec()
133			).optional(),
134			update_interval=Spec().cmp('gt', 0.0).optional(),
135		).optional(),
136	).unknown_spec(
137		check_ext,
138		ext_spec(),
139	).context_message('Error while loading extensions configuration (key {key})'),
140).context_message('Error while loading main configuration'))
141
142term_color_spec = Spec().unsigned().cmp('le', 255).copy
143true_color_spec = Spec().re(
144	'^[0-9a-fA-F]{6}$',
145	(lambda value: '"{0}" is not a six-digit hexadecimal unsigned integer written as a string'.format(value))
146).copy
147colors_spec = (Spec(
148	colors=Spec().unknown_spec(
149		Spec().ident(),
150		Spec().either(
151			Spec().tuple(term_color_spec(), true_color_spec()),
152			term_color_spec()
153		)
154	).context_message('Error while checking colors (key {key})'),
155	gradients=Spec().unknown_spec(
156		Spec().ident(),
157		Spec().tuple(
158			Spec().len('gt', 1).list(term_color_spec()),
159			Spec().len('gt', 1).list(true_color_spec()).optional(),
160		)
161	).context_message('Error while checking gradients (key {key})'),
162).context_message('Error while loading colors configuration'))
163
164
165color_spec = Spec().type(unicode).func(check_color).copy
166name_spec = Spec().type(unicode).len('gt', 0).optional().copy
167group_name_spec = Spec().ident().copy
168group_spec = Spec().either(Spec(
169	fg=color_spec(),
170	bg=color_spec(),
171	attrs=Spec().list(Spec().type(unicode).oneof(set(('bold', 'italic', 'underline')))),
172), group_name_spec().func(check_group)).copy
173groups_spec = Spec().unknown_spec(
174	group_name_spec(),
175	group_spec(),
176).context_message('Error while loading groups (key {key})').copy
177colorscheme_spec = (Spec(
178	name=name_spec(),
179	groups=groups_spec(),
180).context_message('Error while loading coloscheme'))
181mode_translations_value_spec = Spec(
182	colors=Spec().unknown_spec(
183		color_spec(),
184		color_spec(),
185	).optional(),
186	groups=Spec().unknown_spec(
187		group_name_spec().func(check_translated_group_name),
188		group_spec(),
189	).optional(),
190).copy
191top_colorscheme_spec = (Spec(
192	name=name_spec(),
193	groups=groups_spec(),
194	mode_translations=Spec().unknown_spec(
195		Spec().type(unicode),
196		mode_translations_value_spec(),
197	).optional().context_message('Error while loading mode translations (key {key})').optional(),
198).context_message('Error while loading top-level coloscheme'))
199vim_mode_spec = Spec().oneof(set(list(vim_modes) + ['nc', 'tab_nc', 'buf_nc'])).copy
200vim_colorscheme_spec = (Spec(
201	name=name_spec(),
202	groups=groups_spec(),
203	mode_translations=Spec().unknown_spec(
204		vim_mode_spec(),
205		mode_translations_value_spec(),
206	).optional().context_message('Error while loading mode translations (key {key})'),
207).context_message('Error while loading vim colorscheme'))
208shell_mode_spec = Spec().re('^(?:[\w\-]+|\.safe)$').copy
209shell_colorscheme_spec = (Spec(
210	name=name_spec(),
211	groups=groups_spec(),
212	mode_translations=Spec().unknown_spec(
213		shell_mode_spec(),
214		mode_translations_value_spec(),
215	).optional().context_message('Error while loading mode translations (key {key})'),
216).context_message('Error while loading shell colorscheme'))
217
218
219args_spec = Spec(
220	pl=Spec().error('pl object must be set by powerline').optional(),
221	segment_info=Spec().error('Segment info dictionary must be set by powerline').optional(),
222).unknown_spec(Spec(), Spec()).optional().copy
223segment_module_spec = Spec().type(unicode).func(check_segment_module).optional().copy
224exinclude_spec = Spec().re(function_name_re).func(check_exinclude_function).copy
225segment_spec_base = Spec(
226	name=Spec().re('^[a-zA-Z_]\w*$').optional(),
227	function=Spec().re(function_name_re).func(check_segment_function).optional(),
228	exclude_modes=Spec().list(vim_mode_spec()).optional(),
229	include_modes=Spec().list(vim_mode_spec()).optional(),
230	exclude_function=exinclude_spec().optional(),
231	include_function=exinclude_spec().optional(),
232	draw_hard_divider=Spec().type(bool).optional(),
233	draw_soft_divider=Spec().type(bool).optional(),
234	draw_inner_divider=Spec().type(bool).optional(),
235	display=Spec().type(bool).optional(),
236	module=segment_module_spec(),
237	priority=Spec().type(int, float, type(None)).optional(),
238	after=Spec().printable().optional(),
239	before=Spec().printable().optional(),
240	width=Spec().either(Spec().unsigned(), Spec().cmp('eq', 'auto')).optional(),
241	align=Spec().oneof(set('lr')).optional(),
242	args=args_spec().func(lambda *args, **kwargs: check_args(get_one_segment_function, *args, **kwargs)),
243	contents=Spec().printable().optional(),
244	highlight_groups=Spec().list(
245		highlight_group_spec().re(
246			'^(?:(?!:divider$).)+$',
247			(lambda value: 'it is recommended that only divider highlight group names end with ":divider"')
248		)
249	).func(check_highlight_groups).optional(),
250	divider_highlight_group=highlight_group_spec().func(check_highlight_group).re(
251		':divider$',
252		(lambda value: 'it is recommended that divider highlight group names end with ":divider"')
253	).optional(),
254).func(check_full_segment_data).copy
255subsegment_spec = segment_spec_base().update(
256	type=Spec().oneof(set((key for key in type_keys if key != 'segment_list'))).optional(),
257)
258segment_spec = segment_spec_base().update(
259	type=Spec().oneof(type_keys).optional(),
260	segments=Spec().optional().list(subsegment_spec),
261)
262segments_spec = Spec().optional().list(segment_spec).copy
263segdict_spec = Spec(
264	left=segments_spec().context_message('Error while loading segments from left side (key {key})'),
265	right=segments_spec().context_message('Error while loading segments from right side (key {key})'),
266).func(
267	(lambda value, *args: (True, True, not (('left' in value) or ('right' in value)))),
268	(lambda value: 'segments dictionary must contain either left, right or both keys')
269).context_message('Error while loading segments (key {key})').copy
270divside_spec = Spec(
271	hard=divider_spec(),
272	soft=divider_spec(),
273).copy
274segment_data_value_spec = Spec(
275	after=Spec().printable().optional(),
276	before=Spec().printable().optional(),
277	display=Spec().type(bool).optional(),
278	args=args_spec().func(lambda *args, **kwargs: check_args(get_all_possible_functions, *args, **kwargs)),
279	contents=Spec().printable().optional(),
280).copy
281dividers_spec = Spec(
282	left=divside_spec(),
283	right=divside_spec(),
284).copy
285spaces_spec = Spec().unsigned().cmp(
286	'le', 2, (lambda value: 'Are you sure you need such a big ({0}) number of spaces?'.format(value))
287).copy
288common_theme_spec = Spec(
289	default_module=segment_module_spec().optional(),
290	cursor_space=Spec().type(int, float).cmp('le', 100).cmp('gt', 0).optional(),
291	cursor_columns=Spec().type(int).cmp('gt', 0).optional(),
292).context_message('Error while loading theme').copy
293top_theme_spec = common_theme_spec().update(
294	dividers=dividers_spec(),
295	spaces=spaces_spec(),
296	use_non_breaking_spaces=Spec().type(bool).optional(),
297	segment_data=Spec().unknown_spec(
298		Spec().func(check_segment_data_key),
299		segment_data_value_spec(),
300	).optional().context_message('Error while loading segment data (key {key})'),
301)
302main_theme_spec = common_theme_spec().update(
303	dividers=dividers_spec().optional(),
304	spaces=spaces_spec().optional(),
305	segment_data=Spec().unknown_spec(
306		Spec().func(check_segment_data_key),
307		segment_data_value_spec(),
308	).optional().context_message('Error while loading segment data (key {key})'),
309)
310theme_spec = common_theme_spec().update(
311	dividers=dividers_spec().optional(),
312	spaces=spaces_spec().optional(),
313	segment_data=Spec().unknown_spec(
314		Spec().func(check_segment_data_key),
315		segment_data_value_spec(),
316	).optional().context_message('Error while loading segment data (key {key})'),
317	segments=segdict_spec().update(above=Spec().list(segdict_spec()).optional()),
318)
319
320
321def register_common_names():
322	register_common_name('player', 'powerline.segments.common.players', '_player')
323
324
325def load_json_file(path):
326	with open_file(path) as F:
327		try:
328			config, hadproblem = load(F)
329		except MarkedError as e:
330			return True, None, str(e)
331		else:
332			return hadproblem, config, None
333
334
335def updated_with_config(d):
336	hadproblem, config, error = load_json_file(d['path'])
337	d.update(
338		hadproblem=hadproblem,
339		config=config,
340		error=error,
341	)
342	return d
343
344
345def find_all_ext_config_files(search_paths, subdir):
346	for config_root in search_paths:
347		top_config_subpath = join(config_root, subdir)
348		if not os.path.isdir(top_config_subpath):
349			if os.path.exists(top_config_subpath):
350				yield {
351					'error': 'Path {0} is not a directory'.format(top_config_subpath),
352					'path': top_config_subpath,
353				}
354			continue
355		for ext_name in os.listdir(top_config_subpath):
356			ext_path = os.path.join(top_config_subpath, ext_name)
357			if not os.path.isdir(ext_path):
358				if ext_name.endswith('.json') and os.path.isfile(ext_path):
359					yield updated_with_config({
360						'error': False,
361						'path': ext_path,
362						'name': ext_name[:-5],
363						'ext': None,
364						'type': 'top_' + subdir,
365					})
366				else:
367					yield {
368						'error': 'Path {0} is not a directory or configuration file'.format(ext_path),
369						'path': ext_path,
370					}
371				continue
372			for config_file_name in os.listdir(ext_path):
373				config_file_path = os.path.join(ext_path, config_file_name)
374				if config_file_name.endswith('.json') and os.path.isfile(config_file_path):
375					yield updated_with_config({
376						'error': False,
377						'path': config_file_path,
378						'name': config_file_name[:-5],
379						'ext': ext_name,
380						'type': subdir,
381					})
382				else:
383					yield {
384						'error': 'Path {0} is not a configuration file'.format(config_file_path),
385						'path': config_file_path,
386					}
387
388
389def dict2(d):
390	return defaultdict(dict, ((k, dict(v)) for k, v in d.items()))
391
392
393def check(paths=None, debug=False, echoerr=echoerr, require_ext=None):
394	'''Check configuration sanity
395
396	:param list paths:
397		Paths from which configuration should be loaded.
398	:param bool debug:
399		Determines whether some information useful for debugging linter should
400		be output.
401	:param function echoerr:
402		Function that will be used to echo the error(s). Should accept four
403		optional keyword parameters: ``problem`` and ``problem_mark``, and
404		``context`` and ``context_mark``.
405	:param str require_ext:
406		Require configuration for some extension to be present.
407
408	:return:
409		``False`` if user configuration seems to be completely sane and ``True``
410		if some problems were found.
411	'''
412	hadproblem = False
413
414	register_common_names()
415	search_paths = paths or get_config_paths()
416	find_config_files = generate_config_finder(lambda: search_paths)
417
418	logger = logging.getLogger('powerline-lint')
419	logger.setLevel(logging.DEBUG if debug else logging.ERROR)
420	logger.addHandler(logging.StreamHandler())
421
422	ee = EchoErr(echoerr, logger)
423
424	if require_ext:
425		used_main_spec = main_spec.copy()
426		try:
427			used_main_spec['ext'][require_ext].required()
428		except KeyError:
429			used_main_spec['ext'][require_ext] = ext_spec()
430	else:
431		used_main_spec = main_spec
432
433	lhadproblem = [False]
434	load_json_config = generate_json_config_loader(lhadproblem)
435
436	config_loader = ConfigLoader(run_once=True, load=load_json_config)
437
438	lists = {
439		'colorschemes': set(),
440		'themes': set(),
441		'exts': set(),
442	}
443	found_dir = {
444		'themes': False,
445		'colorschemes': False,
446	}
447	config_paths = defaultdict(lambda: defaultdict(dict))
448	loaded_configs = defaultdict(lambda: defaultdict(dict))
449	for d in chain(
450		find_all_ext_config_files(search_paths, 'colorschemes'),
451		find_all_ext_config_files(search_paths, 'themes'),
452	):
453		if d['error']:
454			hadproblem = True
455			ee(problem=d['error'])
456			continue
457		if d['hadproblem']:
458			hadproblem = True
459		if d['ext']:
460			found_dir[d['type']] = True
461			lists['exts'].add(d['ext'])
462			if d['name'] == '__main__':
463				pass
464			elif d['name'].startswith('__') or d['name'].endswith('__'):
465				hadproblem = True
466				ee(problem='File name is not supposed to start or end with “__”: {0}'.format(
467					d['path']))
468			else:
469				lists[d['type']].add(d['name'])
470			config_paths[d['type']][d['ext']][d['name']] = d['path']
471			loaded_configs[d['type']][d['ext']][d['name']] = d['config']
472		else:
473			config_paths[d['type']][d['name']] = d['path']
474			loaded_configs[d['type']][d['name']] = d['config']
475
476	for typ in ('themes', 'colorschemes'):
477		if not found_dir[typ]:
478			hadproblem = True
479			ee(problem='Subdirectory {0} was not found in paths {1}'.format(typ, ', '.join(search_paths)))
480
481	diff = set(config_paths['colorschemes']) - set(config_paths['themes'])
482	if diff:
483		hadproblem = True
484		for ext in diff:
485			typ = 'colorschemes' if ext in config_paths['themes'] else 'themes'
486			if not config_paths['top_' + typ] or typ == 'themes':
487				ee(problem='{0} extension {1} not present in {2}'.format(
488					ext,
489					'configuration' if (
490						ext in loaded_configs['themes'] and ext in loaded_configs['colorschemes']
491					) else 'directory',
492					typ,
493				))
494
495	try:
496		main_config = load_config('config', find_config_files, config_loader)
497	except IOError:
498		main_config = {}
499		ee(problem='Configuration file not found: config.json')
500		hadproblem = True
501	except MarkedError as e:
502		main_config = {}
503		ee(problem=str(e))
504		hadproblem = True
505	else:
506		if used_main_spec.match(
507			main_config,
508			data={'configs': config_paths, 'lists': lists},
509			context=Context(main_config),
510			echoerr=ee
511		)[1]:
512			hadproblem = True
513
514	import_paths = [os.path.expanduser(path) for path in main_config.get('common', {}).get('paths', [])]
515
516	try:
517		colors_config = load_config('colors', find_config_files, config_loader)
518	except IOError:
519		colors_config = {}
520		ee(problem='Configuration file not found: colors.json')
521		hadproblem = True
522	except MarkedError as e:
523		colors_config = {}
524		ee(problem=str(e))
525		hadproblem = True
526	else:
527		if colors_spec.match(colors_config, context=Context(colors_config), echoerr=ee)[1]:
528			hadproblem = True
529
530	if lhadproblem[0]:
531		hadproblem = True
532
533	top_colorscheme_configs = dict(loaded_configs['top_colorschemes'])
534	data = {
535		'ext': None,
536		'top_colorscheme_configs': top_colorscheme_configs,
537		'ext_colorscheme_configs': {},
538		'colors_config': colors_config
539	}
540	for colorscheme, config in loaded_configs['top_colorschemes'].items():
541		data['colorscheme'] = colorscheme
542		if top_colorscheme_spec.match(config, context=Context(config), data=data, echoerr=ee)[1]:
543			hadproblem = True
544
545	ext_colorscheme_configs = dict2(loaded_configs['colorschemes'])
546	for ext, econfigs in ext_colorscheme_configs.items():
547		data = {
548			'ext': ext,
549			'top_colorscheme_configs': top_colorscheme_configs,
550			'ext_colorscheme_configs': ext_colorscheme_configs,
551			'colors_config': colors_config,
552		}
553		for colorscheme, config in econfigs.items():
554			data['colorscheme'] = colorscheme
555			if ext == 'vim':
556				spec = vim_colorscheme_spec
557			elif ext == 'shell':
558				spec = shell_colorscheme_spec
559			else:
560				spec = colorscheme_spec
561			if spec.match(config, context=Context(config), data=data, echoerr=ee)[1]:
562				hadproblem = True
563
564	colorscheme_configs = {}
565	for ext in lists['exts']:
566		colorscheme_configs[ext] = {}
567		for colorscheme in lists['colorschemes']:
568			econfigs = ext_colorscheme_configs[ext]
569			ecconfigs = econfigs.get(colorscheme)
570			mconfigs = (
571				top_colorscheme_configs.get(colorscheme),
572				econfigs.get('__main__'),
573				ecconfigs,
574			)
575			if not (mconfigs[0] or mconfigs[2]):
576				continue
577			config = None
578			for mconfig in mconfigs:
579				if not mconfig:
580					continue
581				if config:
582					config = mergedicts_copy(config, mconfig)
583				else:
584					config = mconfig
585			colorscheme_configs[ext][colorscheme] = config
586
587	theme_configs = dict2(loaded_configs['themes'])
588	top_theme_configs = dict(loaded_configs['top_themes'])
589	for ext, configs in theme_configs.items():
590		data = {
591			'ext': ext,
592			'colorscheme_configs': colorscheme_configs,
593			'import_paths': import_paths,
594			'main_config': main_config,
595			'top_themes': top_theme_configs,
596			'ext_theme_configs': configs,
597			'colors_config': colors_config
598		}
599		for theme, config in configs.items():
600			data['theme'] = theme
601			if theme == '__main__':
602				data['theme_type'] = 'main'
603				spec = main_theme_spec
604			else:
605				data['theme_type'] = 'regular'
606				spec = theme_spec
607			if spec.match(config, context=Context(config), data=data, echoerr=ee)[1]:
608				hadproblem = True
609
610	for top_theme, config in top_theme_configs.items():
611		data = {
612			'ext': None,
613			'colorscheme_configs': colorscheme_configs,
614			'import_paths': import_paths,
615			'main_config': main_config,
616			'theme_configs': theme_configs,
617			'ext_theme_configs': None,
618			'colors_config': colors_config
619		}
620		data['theme_type'] = 'top'
621		data['theme'] = top_theme
622		if top_theme_spec.match(config, context=Context(config), data=data, echoerr=ee)[1]:
623			hadproblem = True
624
625	return hadproblem
626