/* peak_trc.c * * Wiretap Library * Copyright (c) 1998 by Gilbert Ramirez * * Support for TRC log file format generated by several CAN tools from PEAK Gmbh * Copyright (c) 2024 by Miklos Marton * * File format description: * https://www.peak-system.com/produktcd/Pdf/English/PEAK_CAN_TRC_File_Format.pdf * * SPDX-License-Identifier: GPL-2.0-or-later */ #define WS_LOG_DOMAIN "peak-trc" #include #include #include #include #include #include #include #include #include #include "peak-trc.h" #include "socketcan.h" #define PEAK_TRC_MAX_LINE_SIZE 4096 // J1939 logs could contain long lines typedef enum { FileVersion, StartTime, ColumnHeader, Comment, Data } peak_trc_parse_state_t; typedef enum { Col_BusNumber, // 0 Col_BusNumber_v1, // 1 Col_Direction, // 2 Col_CanId, // 3 Col_DLC, // 4 Col_MessageNumber, // 5 Col_TimeOffset_v1_0, // 6 Col_TimeOffset, // 7 Col_Reserved, // 8 Col_MessageType, // 9 Col_ErrorOrRtr_v1p0, // 10 // keep it to the last one to get the Col_Data_v1_0, // 11 Col_Data, // 12 Col_Invalid // 13 } peak_trc_column_type_t; typedef struct { peak_trc_column_type_t type; char code; char* regex; } peak_trc_column_map_t; typedef struct { time_t trace_start; time_t trace_start_nsec; uint8_t column_positions[Col_Invalid]; uint8_t file_version_major, file_version_minor; GRegex* data_matcher; } peak_trc_state_t; static int peak_trc_file_type_subtype = -1; static const peak_trc_column_map_t colmap[] = { {Col_BusNumber, 'B', "\\s*(([0-9]*)|-)"}, /* This isn't a real column type, so give it a "type" that shouldn't be used */ {Col_BusNumber_v1, 1, "\\s*([0-9]*)"}, {Col_Direction, 'd', "\\s*(Rx|Tx|Warng|Error)"}, /* This isn't a real column type, so give it a "type" that shouldn't be used */ {Col_Data_v1_0, 2, "\\s?(((--|[0-9A-F]{2})\\s?)*)"}, {Col_Data, 'D', "\\s?((((--|[0-9A-F]{2})\\s?))*)"}, {Col_CanId, 'I', "\\s*([0-9A-F]*)"}, {Col_DLC, 'l', "\\s*([0-9]*)"}, {Col_DLC, 'L', "\\s*([0-9]*)"}, {Col_MessageNumber, 'N', "\\s*([0-9]*)\\)"}, {Col_TimeOffset, 'O', "\\s*([0-9]*\\.[0-9]*)"}, /* This isn't a real column type, so give it a "type" that shouldn't be used */ {Col_TimeOffset_v1_0, 3, "\\s*([0-9]*)"}, {Col_Reserved, 'R', "\\s*(-)"}, {Col_MessageType, 'T', "\\s*([A-Z]{2})"}, /* This isn't a real column, so give it a "type" that shouldn't be used */ {Col_ErrorOrRtr_v1p0, 4, "\\s*(ERROR|RTR)?"}, {Col_Invalid, 0, ""}, }; void register_peak_trc(void); static wtap_open_return_val peak_trc_parse(wtap* wth, peak_trc_state_t* state, gint64* offset, int* err, char** err_info) { gint64 seek_off = 0; bool found_timestamp = false; char line_buffer[PEAK_TRC_MAX_LINE_SIZE]; /* 1.0 file format does not have version info or column line, * so assume that until told otherwise */ int major = 1; int minor = 0; ws_debug("%s: Trying peak_trc file decoder\n", G_STRFUNC); /* Initial start time until we find it in the header */ state->trace_start = time(NULL); state->trace_start_nsec = 0; state->file_version_major = 1; state->file_version_minor = 0; /* Initialize columns to "invalid" */ for (int i = 0; i < Col_Invalid; i++) state->column_positions[i] = Col_Invalid; /* Bail if the end of the file is here and it hasn't been determined it's a PEAK file */ while (!file_eof(wth->fh)) { seek_off = file_tell(wth->fh); ws_debug("%s: Starting parser at offset %" PRIi64 "\n", G_STRFUNC, seek_off); if (file_gets(line_buffer, PEAK_TRC_MAX_LINE_SIZE, wth->fh) == NULL) { /* Error reading file, bail out */ *err = file_error(wth->fh, err_info); if (*err != 0 && *err != WTAP_ERR_SHORT_READ) return WTAP_OPEN_ERROR; return WTAP_OPEN_NOT_MINE; } g_strstrip(line_buffer); size_t line_len = strlen(line_buffer); /* Ignore empty lines */ if (line_len == 0) continue; /* The intention here is to get through all of the "header" which consists of * "commented" lines (notated with a ;) * Once the header is complete, parsing can be confident it's a PEAK file */ if (line_buffer[0] != ';') { /* Two possibilities: * 1. This is the start of the actual packet data and header parsing is complete * 2. This isn't a PEAK file * * Determine this by if the timestamp has been found */ if (!found_timestamp) return WTAP_OPEN_NOT_MINE; /* Valid PEAK file */ GString* regex_str = g_string_new(NULL); *offset = seek_off; /* Save off the data found in the header */ state->file_version_major = (uint8_t)major; state->file_version_minor = (uint8_t)minor; /* File version 1.x doesn't explicitly provide column order information */ if (state->file_version_major == 1) { state->column_positions[Col_MessageNumber] = 1; switch(state->file_version_minor) { case 0: state->column_positions[Col_TimeOffset_v1_0] = 2; state->column_positions[Col_CanId] = 3; state->column_positions[Col_DLC] = 4; state->column_positions[Col_ErrorOrRtr_v1p0] = 5; state->column_positions[Col_Data_v1_0] = 6; break; case 1: state->column_positions[Col_TimeOffset] = 2; state->column_positions[Col_Direction] = 3; state->column_positions[Col_CanId] = 4; state->column_positions[Col_DLC] = 5; state->column_positions[Col_ErrorOrRtr_v1p0] = 6; state->column_positions[Col_Data] = 7; break; case 2: state->column_positions[Col_TimeOffset] = 2; state->column_positions[Col_BusNumber_v1] = 3; state->column_positions[Col_Direction] = 4; state->column_positions[Col_CanId] = 5; state->column_positions[Col_DLC] = 6; state->column_positions[Col_ErrorOrRtr_v1p0] = 7; state->column_positions[Col_Data] = 8; break; default: state->column_positions[Col_TimeOffset] = 2; state->column_positions[Col_BusNumber_v1] = 3; state->column_positions[Col_Direction] = 4; state->column_positions[Col_CanId] = 5; state->column_positions[Col_Reserved] = 6; state->column_positions[Col_DLC] = 7; state->column_positions[Col_ErrorOrRtr_v1p0] = 8; state->column_positions[Col_Data] = 9; break; } for (int column_index = 0; column_index < Col_Invalid; column_index++) { for (int col_type = Col_BusNumber; col_type < Col_Invalid; col_type++) { if (state->column_positions[col_type] == column_index) { for (const peak_trc_column_map_t * item = colmap; item->type != Col_Invalid; item++) { if ((int)item->type == col_type) { g_string_append(regex_str, item->regex); break; } } } } } state->data_matcher = g_regex_new(regex_str->str, (GRegexCompileFlags)(G_REGEX_OPTIMIZE | G_REGEX_RAW), (GRegexMatchFlags)0, NULL); g_string_free(regex_str, TRUE); } return WTAP_OPEN_MINE; } if (g_str_has_prefix(line_buffer, ";$FILEVERSION=")) { if (sscanf(line_buffer, ";$FILEVERSION=%d.%d\r\n", &major, &minor) != 2) return WTAP_OPEN_NOT_MINE; } else if (g_str_has_prefix(line_buffer, ";$STARTTIME=")) { /* $STARTTIME keyword to store the absolute start time of the trace file: Format: Floating point, decimal separator is a point. Value: Integral part = Number of days that have passed since 30. December 1899. Fractional Part = Fraction of a 24-hour day that has elapsed, resolution is 1 millisecond. */ gchar** parts = g_strsplit(line_buffer, "=", 2); if (parts[1] == NULL) { /* At this point, the file is probably a PEAK file that is poorly formatted, so bail with an error */ *err_info = ws_strdup("peak_trc: unable to parse start time"); g_strfreev(parts); return WTAP_OPEN_ERROR; } /* Convert to a more "standard" timestamp */ double days_since_epoch = g_strtod(parts[1], NULL); days_since_epoch -= 25569.0; time_t ts = 0; struct tm* t = gmtime(&ts); t->tm_sec += (int)(days_since_epoch * 24 * 3600); state->trace_start = mktime(t); state->trace_start_nsec = 0; found_timestamp = true; g_strfreev(parts); } else if (g_str_has_prefix(line_buffer, ";$COLUMNS=")) { gchar** parts = g_strsplit(line_buffer, "=", 2); if (parts[1] == NULL) { /* At this point, the file is probably a PEAK file that is poorly formatted, so bail with an error */ *err_info = ws_strdup("peak_trc: unable to parse column definitions"); g_strfreev(parts); return WTAP_OPEN_ERROR; } gchar** columns = g_strsplit(parts[1], ",", 0); int column_index = 0; for (gchar** iter = columns; *iter != NULL; iter++) { size_t col_length = strlen(*iter); if (col_length > 1) { *err_info = ws_strdup_printf("peak_trc: unknown column definition in the $COLUMNS line: '%s'", *iter); g_strfreev(columns); g_strfreev(parts); return WTAP_OPEN_ERROR; } if (col_length == 0) { //Done ? break; } /* Assign the column value if found */ for (const peak_trc_column_map_t* item = colmap; item->type != Col_Invalid; item++) { if ((*iter)[0] == item->code) { // column type matched state->column_positions[item->type] = column_index; break; } } column_index++; } g_strfreev(columns); g_strfreev(parts); } else if (line_len >= 2) { /* The rest of the "keywords" are separated by whitespace after the initial ';' */ char* keyword = &line_buffer[1]; while (iswspace(*keyword)) keyword++; if (g_str_has_prefix(keyword, "Start time:")) { /* Use the $STARTTIME if it has already been found */ if (!found_timestamp) { int day, month, year, hour, minute, second, millisecond; if (sscanf(keyword, "Start time: %d.%d.%d %d:%d:%d.%d", &month, &day, &year, &hour, &minute, &second, &millisecond) != 7) { *err_info = ws_strdup("peak_trc: unable to parse start time"); return WTAP_OPEN_ERROR; } struct tm t; t.tm_sec = second; t.tm_min = minute; t.tm_hour = hour; t.tm_mday = day; t.tm_mon = month-1; t.tm_year = year-1900; t.tm_wday = 0; t.tm_yday = 0; t.tm_isdst = -1; state->trace_start = mktime(&t); state->trace_start_nsec = millisecond*1000000; found_timestamp = true; } } } } return WTAP_OPEN_NOT_MINE; } static bool peak_trc_read_packet_v1(wtap* wth, peak_trc_state_t* state, wtap_can_msg_t* peak_msg, char* line_buffer) { // pre 2.0 packets use regexp with fixed column order with a regexp GMatchInfo* match_info = NULL; bool found = g_regex_match(state->data_matcher, line_buffer, 0, &match_info); int column_count = g_match_info_get_match_count(match_info); if (!found || column_count < 5) { /* Not a valid v1 packet */ g_match_info_free(match_info); return false; } for (int col = Col_BusNumber; col < Col_Invalid; col++) { if (state->column_positions[col] >= column_count || state->column_positions[col] == Col_Invalid) continue; gchar* column_text = g_match_info_fetch(match_info, state->column_positions[col]); if (column_text == NULL) { g_match_info_free(match_info); return false; } switch (col) { case Col_Direction: if (strcmp(column_text, "Warng") == 0) { // Warning lines cannot be interpreted in Wireshark needs to be skipped g_free(column_text); g_match_info_free(match_info); return false; } if (strcmp(column_text, "Error") == 0) { peak_msg->type = MSG_TYPE_ERR; } break; case Col_CanId: peak_msg->id = (guint32)g_ascii_strtoull(column_text, NULL, 16); break; case Col_ErrorOrRtr_v1p0: if (state->file_version_minor == 1 && strcmp(column_text, "RTR") == 0) { peak_msg->type = MSG_TYPE_STD_RTR; } break; case Col_TimeOffset: case Col_TimeOffset_v1_0: { double tsd = g_ascii_strtod(column_text, NULL) / 1000.0; // parse to seconds tsd += state->trace_start; peak_msg->ts.secs = (guint64)tsd; peak_msg->ts.nsecs = (int)((tsd - (guint64)tsd) * 1000000000); } break; case Col_DLC: peak_msg->data.length = (uint8_t)g_ascii_strtoull(column_text, NULL, 10); break; case Col_BusNumber_v1: { char* interface_name = wmem_strdup_printf(NULL, "interface%s", column_text); peak_msg->interface_id = wtap_socketcan_find_or_create_new_interface(wth, interface_name); wmem_free(NULL, interface_name); break; } case Col_Data: case Col_Data_v1_0: { gchar* err_or_rtr = g_match_info_fetch(match_info, state->column_positions[Col_ErrorOrRtr_v1p0]); gchar** bytes = g_strsplit(g_strstrip(column_text), " ", CAN_MAX_DLEN); // 1.0 format has an ERROR prefix in the data bytes, 1.1 has a separate columns for message type if ((g_str_has_prefix(err_or_rtr, "ERROR")) || (state->file_version_minor == 1 && peak_msg->type == MSG_TYPE_ERR)) { peak_msg->type = MSG_TYPE_ERR; /* For LINUX_CAN_ERR the length has to be CAN_MAX_DLEN */ peak_msg->data.length = CAN_MAX_DLEN; /* Clear the data of the message to populate it with SocketCAN meta data */ memset(peak_msg->data.data, 0, CAN_MAX_DLEN); guint32 old_id = peak_msg->id; peak_msg->id = 0; // ID: Type of Error Frame switch (old_id) { case 1: // 1 = Bit Error peak_msg->id |= CAN_ERR_PROT_BIT; break; case 2: // 2 = Form Error peak_msg->id |= CAN_ERR_PROT_FORM; break; case 4: // 4 = Stuff Error peak_msg->id |= CAN_ERR_PROT_STUFF; break; } //Translate PEAK error data into SocketCAN meta data for supported error types if ((peak_msg->id | (CAN_ERR_PROT_FORM& CAN_ERR_PROT_STUFF)) != 0) { // Data Byte 0: Direction uint8_t byte0 = (uint8_t)g_ascii_strtoull(bytes[0], NULL, 16); if (byte0 < 2) { //0 = Error occurred while sending //1 = Error occurred while receiving. peak_msg->data.data[2] |= CAN_ERR_PROT_TX; } // Data Byte 1: Current Position in Bit Stream uint8_t bit_pos = (uint8_t)g_ascii_strtoull(bytes[1], NULL, 16); if (bit_pos == 28) { peak_msg->data.data[2] |= CAN_ERR_PROT_OVERLOAD; } else { // magically SocketCAN and PEAK error bit positions are identical with some exceptions peak_msg->data.data[3] = bit_pos; if (peak_msg->data.data[3] == 23) peak_msg->data.data[3] = 0; // Error position not present in SocketCAN } } } else if (g_str_has_prefix(err_or_rtr, "RTR") || (state->file_version_minor == 1 && peak_msg->type == MSG_TYPE_STD_RTR)) { peak_msg->type = MSG_TYPE_STD_RTR; peak_msg->data.length = 0; } else { peak_msg->type = MSG_TYPE_STD; if (bytes) { for (int byte_i = 0; ((byte_i < CAN_MAX_DLEN) && (bytes[byte_i] != 0)); byte_i++) peak_msg->data.data[byte_i] = (uint8_t)g_ascii_strtoull(bytes[byte_i], NULL, 16); } } g_strfreev(bytes); g_free(err_or_rtr); break; } } g_free(column_text); } #ifdef WS_DEBUG for (int i = 0; i < column_count; i++) ws_debug("%d: %s\n", i, g_match_info_fetch(match_info, i)); #endif g_match_info_free(match_info); return true; } static bool peak_trc_read_packet_v2(wtap* wth, peak_trc_state_t* state, wtap_can_msg_t* peak_msg, char* line_buffer) { int i = 0; int column_i = 0; int column_start = 0; bool last_char_is_ws = true; peak_trc_column_type_t current_column = Col_Invalid; while (line_buffer[i] != 0) { bool is_whitespace = iswspace(line_buffer[i]); if (current_column == Col_Data) { // data always the last and could contain spaces is_whitespace = (line_buffer[i] == '\r' || line_buffer[i] == '\n'); } if (is_whitespace) { if (!last_char_is_ws) { // column closed -> process data gchar* column_text = g_utf8_substring(line_buffer, column_start, i); ws_debug("Column %d: %s\n", current_column, column_text); switch (current_column) { case Col_BusNumber: { char* interface_name = wmem_strdup_printf(NULL, "interface%s", column_text); peak_msg->interface_id = wtap_socketcan_find_or_create_new_interface(wth, interface_name); wmem_free(NULL, interface_name); break; } case Col_Direction: break; case Col_Data: { if (peak_msg->type == MSG_TYPE_ERR) { // FIXME fill data and msg id } else { gchar** bytes = g_strsplit(g_strstrip(column_text), " ", CANFD_MAX_DLEN); int byte_i = 0; while (byte_i < CANFD_MAX_DLEN) { if (bytes[byte_i] == 0) break; peak_msg->data.data[byte_i] = (uint8_t)g_ascii_strtoull(bytes[byte_i], NULL, 16); byte_i++; } } break; } case Col_CanId: peak_msg->id = (guint)g_ascii_strtoull(column_text, NULL, 16); break; case Col_DLC: peak_msg->data.length = (guint)g_ascii_strtoull(column_text, NULL, 10); break; case Col_TimeOffset: { double tsd = g_ascii_strtod(column_text, NULL) / 1000.0; // parse to seconds tsd += state->trace_start; peak_msg->ts.secs = (guint64)tsd; peak_msg->ts.nsecs = (int)((tsd - (guint64)tsd) * 1000000000); break; } case Col_MessageType: if (strcmp(column_text, "DT") == 0) { peak_msg->type = MSG_TYPE_STD; } else if (strcmp(column_text, "FD") == 0) { peak_msg->type = MSG_TYPE_STD_FD; } else if (strcmp(column_text, "FB") == 0) { // CAN FD data frame with BRS bit set (Bit Rate Switch). peak_msg->type = MSG_TYPE_STD_FD; // TODO } else if (strcmp(column_text, "FE") == 0) { // CAN FD data frame with ESI bit set (Error State Indicator). peak_msg->type = MSG_TYPE_ERR; } else if (strcmp(column_text, "BI") == 0) { // CAN FD data frame with both BRS and ESI bits set. peak_msg->type = MSG_TYPE_ERR; } else if (strcmp(column_text, "RR") == 0) { peak_msg->type = MSG_TYPE_STD_RTR; } else if (strcmp(column_text, "ST") == 0) { // TODO: Hardware Status change. // Currently not supported in Wireshark return false; } else if (strcmp(column_text, "ER") == 0) { peak_msg->type = MSG_TYPE_ERR; } else if (strcmp(column_text, "EC") == 0) { // TODO: Error Counter change // Currently not supported in Wireshark return false; } else if (strcmp(column_text, "EV") == 0) { // Event. User-defined text, begins directly after bus specifier // TODO add support for this event type return false; } break; case Col_MessageNumber: case Col_Reserved: case Col_Invalid: break; /* Version 1 column types ignored */ case Col_ErrorOrRtr_v1p0: case Col_BusNumber_v1: case Col_TimeOffset_v1_0: case Col_Data_v1_0: break; } g_free(column_text); } } else { // non whitespace read if (last_char_is_ws) { for (int lut_i = 0; lut_i < Col_Invalid; lut_i++) { if (state->column_positions[lut_i] == column_i) { current_column = lut_i; break; } } column_i++; column_start = i; } } last_char_is_ws = is_whitespace; i++; } return true; } static bool peak_trc_read_packet(wtap *wth, FILE_T fh, peak_trc_state_t* state, wtap_rec *rec, int *err, gchar **err_info, gint64 *data_offset) { wtap_can_msg_t peak_msg = {0}; char line_buffer[PEAK_TRC_MAX_LINE_SIZE]; while (!file_eof(fh)) { if (data_offset) *data_offset = file_tell(fh); /* Read a line */ if (file_gets(line_buffer, PEAK_TRC_MAX_LINE_SIZE, fh) == NULL) { *err = file_error(fh, err_info); return false; } /* Ignore empty or commented lines */ if ((strlen(line_buffer) == 0) || (line_buffer[0] == ';')) continue; //Start with unknown interface ID peak_msg.interface_id = WTAP_SOCKETCAN_INVALID_INTERFACE_ID; if (state->file_version_major >= 2) { if (!peak_trc_read_packet_v2(wth, state, &peak_msg, line_buffer)) continue; } else { if (!peak_trc_read_packet_v1(wth, state, &peak_msg, line_buffer)) continue; } if (peak_msg.id > 0x7FF) { //Translate from 11-bit to 32-bit IDs switch (peak_msg.type) { case MSG_TYPE_STD_FD: peak_msg.type = MSG_TYPE_EXT_FD; break; case MSG_TYPE_STD: peak_msg.type = MSG_TYPE_EXT; break; case MSG_TYPE_STD_RTR: peak_msg.type = MSG_TYPE_EXT_RTR; break; default: //Ignore other types break; } } return wtap_socketcan_gen_packet(wth, rec, &peak_msg, "peak", err, err_info); } return false; } static bool peak_trc_read(wtap *wth, wtap_rec *rec, int *err, char **err_info, int64_t *data_offset) { peak_trc_state_t* state = (peak_trc_state_t*)wtap_socketcan_get_private_data(wth); ws_debug("%s: Try reading at offset %" PRIi64 "\n", G_STRFUNC, file_tell(wth->fh)); return peak_trc_read_packet(wth, wth->fh, state, rec, err, err_info, data_offset); } static bool peak_trc_seek_read(wtap *wth, int64_t seek_off, wtap_rec *rec, int *err, char **err_info) { ws_debug("%s: Read at offset %" PRIi64 "\n", G_STRFUNC, seek_off); peak_trc_state_t* state = (peak_trc_state_t*)wtap_socketcan_get_private_data(wth); if (file_seek(wth->random_fh, seek_off, SEEK_SET, err) == -1) { *err = errno; *err_info = g_strdup(g_strerror(errno)); return false; } return peak_trc_read_packet(wth, wth->random_fh, state, rec, err, err_info, NULL); } static void clean_trc_state(peak_trc_state_t* state) { if (state != NULL) { if (state->data_matcher != NULL) g_regex_unref(state->data_matcher); g_free(state); } } static void peak_trc_close(void* tap_data) { clean_trc_state((peak_trc_state_t*)tap_data); } wtap_open_return_val peak_trc_open(wtap* wth, int* err, char** err_info) { gint64 data_offset = 0; peak_trc_state_t* trc_state = g_new0(peak_trc_state_t, 1); /* wth->priv stores a pointer to the general file properties. * It it updated when the header data is parsed */ wth->priv = trc_state; wtap_open_return_val open_val = peak_trc_parse(wth, trc_state, &data_offset, err, err_info); if (open_val != WTAP_OPEN_MINE) { clean_trc_state(trc_state); wth->priv = NULL; return open_val; } ws_debug("%s: This is our file\n", G_STRFUNC); /* Go to the start of the real packet data since header is now done */ if (file_seek(wth->fh, data_offset, SEEK_SET, err) == -1) { *err = errno; *err_info = g_strdup(g_strerror(errno)); return WTAP_OPEN_ERROR; } wtap_set_as_socketcan(wth, peak_trc_file_type_subtype, WTAP_TSPREC_USEC, trc_state, peak_trc_close); wth->subtype_read = peak_trc_read; wth->subtype_seek_read = peak_trc_seek_read; return WTAP_OPEN_MINE; } static const struct supported_block_type peak_trc_blocks_supported[] = { /* * We support packet blocks, with no comments or other options. */ { WTAP_BLOCK_PACKET, MULTIPLE_BLOCKS_SUPPORTED, NO_OPTIONS_SUPPORTED } }; static const struct file_type_subtype_info peak_trc_info = { "PEAK CAN TRC file", "peak-trc", "trc", NULL, false, BLOCKS_SUPPORTED(peak_trc_blocks_supported), NULL, NULL, NULL }; void register_peak_trc(void) { peak_trc_file_type_subtype = wtap_register_file_type_subtype(&peak_trc_info); } /* * Editor modelines - https://www.wireshark.org/tools/modelines.html * * Local variables: * c-basic-offset: 4 * tab-width: 8 * indent-tabs-mode: nil * End: * * vi: set shiftwidth=4 tabstop=8 expandtab: * :indentSize=4:tabSize=8:noTabs=true: */